Skip to content

Commit 9409506

Browse files
committed
fold: autoCaptureContextTurns rolling pair window onto current tips (PR 966, composed with master's reconcile-with-kept-texts and protected-prefix contracts)
1 parent 99b9bfa commit 9409506

10 files changed

Lines changed: 740 additions & 6 deletions

dist/index.js

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { buildFallbackCandidate, gateRegexFallbackCapture } from "./src/autocapt
4141
import { gateMappedReflectionEntries, resolveMappedRowAdmissionController } from "./src/reflection-mapped-admission.js";
4242
import { createMemoryCLI } from "./cli.js";
4343
import { isNoise } from "./src/noise-filter.js";
44-
import { buildConversationTurnsForExtraction, formatConversationTranscript, neutralizeSpeakerTagSpoof, nextAutoCaptureMessageId, normalizeAutoCaptureText, reconcileTurnsWithKeptTexts, } from "./src/auto-capture-cleanup.js";
44+
import { buildConversationTurnsForExtraction, dedupePairWindow, formatConversationTranscript, neutralizeSpeakerTagSpoof, nextAutoCaptureMessageId, normalizeAutoCaptureText, reconcileTurnsWithKeptTexts, trimTurnsToUserCap, } from "./src/auto-capture-cleanup.js";
4545
// Import smart extraction & lifecycle components
4646
import { SmartExtractor, createExtractionRateLimiter, stripEnvelopeMetadata } from "./src/smart-extractor.js";
4747
import { compressTexts, estimateConversationValue } from "./src/session-compressor.js";
@@ -2069,6 +2069,8 @@ function _initPluginState(api) {
20692069
const autoCaptureRecentTurns = new Map();
20702070
const autoCaptureDeferredFlushTurns = new Map();
20712071
const autoCaptureSessionIdToKey = new Map();
2072+
const autoCaptureSessionIds = new Map();
2073+
const autoCaptureRecentPairTurns = new Map();
20722074
const autoCaptureInFlightRuns = new Map();
20732075
return {
20742076
config,
@@ -2100,6 +2102,8 @@ function _initPluginState(api) {
21002102
autoCaptureRecentTurns,
21012103
autoCaptureDeferredFlushTurns,
21022104
autoCaptureSessionIdToKey,
2105+
autoCaptureSessionIds,
2106+
autoCaptureRecentPairTurns,
21032107
autoCaptureInFlightRuns,
21042108
captureAdmissionController,
21052109
captureAdmissionAudit,
@@ -2213,7 +2217,7 @@ const memoryLanceDBProPlugin = {
22132217
_registeredApisMap.delete(api); // dual-track rollback: Map un-claim
22142218
throw err;
22152219
}
2216-
const { config, resolvedDbPath, vectorDim, store, embedder, retriever, canonicalCorpusIndexer, dreamingEngine, dreamingScheduler, scopeManager, migrator, smartExtractor, mdMirror, decayEngine, tierManager, extractionRateLimiter, reflectionErrorStateBySession, reflectionDerivedBySession, reflectionDerivedSuppressionBySession, reflectionByAgentCache, reflectionByAgentCacheGeneration, recallHistory, turnCounter, autoCaptureSeenTextCount, autoCapturePendingIngressTexts, autoCaptureCountedPendingCount, autoCaptureRecentTurns, autoCaptureDeferredFlushTurns, autoCaptureSessionIdToKey, autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, admissionRejectionAuditWriter, } = singleton;
2220+
const { config, resolvedDbPath, vectorDim, store, embedder, retriever, canonicalCorpusIndexer, dreamingEngine, dreamingScheduler, scopeManager, migrator, smartExtractor, mdMirror, decayEngine, tierManager, extractionRateLimiter, reflectionErrorStateBySession, reflectionDerivedBySession, reflectionDerivedSuppressionBySession, reflectionByAgentCache, reflectionByAgentCacheGeneration, recallHistory, turnCounter, autoCaptureSeenTextCount, autoCapturePendingIngressTexts, autoCaptureCountedPendingCount, autoCaptureRecentTurns, autoCaptureDeferredFlushTurns, autoCaptureSessionIdToKey, autoCaptureSessionIds, autoCaptureRecentPairTurns, autoCaptureInFlightRuns, captureAdmissionController, captureAdmissionAudit, captureReflectionAdmissionController, admissionRejectionAuditWriter, } = singleton;
22172221
const learnAutoCaptureSessionAlias = (sessionId, sessionKey) => {
22182222
if (typeof sessionId !== "string" || !sessionId
22192223
|| typeof sessionKey !== "string" || !sessionKey
@@ -3590,7 +3594,36 @@ const memoryLanceDBProPlugin = {
35903594
// texts the selectors dropped back into extraction. Kept indices
35913595
// pin each surviving copy to its own turn; occurrence counting
35923596
// stays as the fallback when positional alignment is unavailable.
3593-
const finalConversationTurns = reconcileTurnsWithKeptTexts(thisCallTurns, cleanTexts, cleanTurnIndices);
3597+
let finalConversationTurns = reconcileTurnsWithKeptTexts(thisCallTurns, cleanTexts, cleanTurnIndices);
3598+
// Rolling PAIR window sized by autoCaptureContextTurns (0 =
3599+
// disabled: each extraction sees only its own call's turns, and
3600+
// nothing is retained between calls). When enabled, this call's
3601+
// reconciled pairs extend what earlier calls buffered, bounded
3602+
// to autoCaptureContextTurns user turns (or this call's own
3603+
// new-user count when larger, so unextracted user turns are
3604+
// never trimmed out of their own transcript). The buffer holds
3605+
// the FILTERED window, so selector-dropped texts can never
3606+
// re-enter a later transcript as retained context. A remember
3607+
// flow (prepended referent) bypasses the prepend for its own
3608+
// call: the extractor's protected-prefix contract counts
3609+
// referent turns from position zero.
3610+
const contextTurns = config.autoCaptureContextTurns ?? 0;
3611+
if (contextTurns > 0 && rememberPrependedTurns.length === 0) {
3612+
const priorPairTurns = autoCaptureRecentPairTurns.get(sessionKey) || [];
3613+
finalConversationTurns = trimTurnsToUserCap(dedupePairWindow([...priorPairTurns, ...finalConversationTurns]), Math.max(contextTurns, finalConversationTurns.filter((turn) => turn.role === "user").length));
3614+
}
3615+
if (contextTurns === 0) {
3616+
autoCaptureRecentPairTurns.delete(sessionKey);
3617+
}
3618+
else if (thisCallTurns.length > 0) {
3619+
// Deliberately retained across successful extractions:
3620+
// deleting it here would mean steady-state captures (one
3621+
// extraction per turn) always see a bare current pair. The
3622+
// set-time trim bounds it; the watermark keeps retained
3623+
// turns from re-becoming sources.
3624+
autoCaptureRecentPairTurns.set(sessionKey, finalConversationTurns);
3625+
pruneMapIfOver(autoCaptureRecentPairTurns, AUTO_CAPTURE_MAP_MAX_ENTRIES);
3626+
}
35943627
// The referent is the OLDEST turn of the prepended window, which is
35953628
// exactly what the extractor's newest-first budget walk sacrifices
35963629
// first, so it needs a guaranteed share. Only the referent RUN gets
@@ -5655,6 +5688,7 @@ export function parsePluginConfig(value) {
56555688
})()
56565689
: undefined,
56575690
extractMinMessages: parsePositiveInt(cfg.extractMinMessages) ?? 4,
5691+
autoCaptureContextTurns: Math.min(10, Math.max(0, Math.floor(Number(cfg.autoCaptureContextTurns)) || 0)),
56585692
extractMaxChars: parsePositiveInt(cfg.extractMaxChars) ?? 8000,
56595693
batchChunkSize: (() => { const raw = parsePositiveInt(cfg.batchChunkSize); return raw === undefined ? undefined : Math.min(50, raw); })(),
56605694
scopes: typeof cfg.scopes === "object" && cfg.scopes !== null ? cfg.scopes : undefined,

dist/src/auto-capture-cleanup.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,103 @@ function keepRenderedTail(blocks, rendered, start, end, budget) {
357357
}
358358
return kept;
359359
}
360+
/**
361+
* Bounds a rolling pair window to at most `maxUserTurns` user turns, keeping
362+
* the newest ones with their interleaved assistant replies, and never leaving
363+
* an orphan assistant turn ahead of the window's first user turn. The caller
364+
* passes max(autoCaptureContextTurns, this call's new user turns), so the
365+
* transcript always contains every not-yet-extracted user turn, padded with
366+
* earlier still-buffered pairs up to the configured window.
367+
*/
368+
export function trimTurnsToUserCap(turns, maxUserTurns) {
369+
const cap = Math.max(1, maxUserTurns);
370+
let userCount = 0;
371+
let start = turns.length;
372+
for (let i = turns.length - 1; i >= 0; i--) {
373+
if (turns[i].role === "user") {
374+
userCount++;
375+
if (userCount > cap)
376+
break;
377+
start = i;
378+
}
379+
}
380+
if (userCount === 0) {
381+
// All-assistant window (possible under captureAssistant=true when the
382+
// delta carries only assistant turns): no user anchor exists, so keep
383+
// the newest `cap` turns instead of silently dropping everything.
384+
return turns.slice(-cap);
385+
}
386+
return turns.slice(start);
387+
}
388+
/**
389+
* Repairs a pair window that double-preserved deferred turns. A below-threshold
390+
* deferral keeps content alive on two independent paths -- the rolling pair
391+
* buffer, and the watermark rollback (or pending-ingress re-queue) whose next
392+
* slice re-includes the same turns -- so the assembled window can carry the
393+
* same exchange twice. Collapse duplicates by user text at pair granularity:
394+
* a pair-shaped copy (user turn plus its replies) beats a flat re-queued copy,
395+
* copies of an identical exchange collapse to the latest, and a repeated user
396+
* text whose replies differ is a real conversation and is kept whole.
397+
*/
398+
export function dedupePairWindow(turns) {
399+
const groups = [];
400+
let current = null;
401+
for (const turn of turns) {
402+
if (turn.role === "user") {
403+
current = { turns: [turn], userText: turn.text, replies: "" };
404+
groups.push(current);
405+
}
406+
else if (current) {
407+
current.turns.push(turn);
408+
current.replies = JSON.stringify(current.turns.slice(1).map((t) => t.text));
409+
}
410+
else {
411+
groups.push({ turns: [turn], userText: null, replies: "" });
412+
}
413+
}
414+
const kept = [];
415+
for (const group of groups) {
416+
if (group.userText === null) {
417+
kept.push(group);
418+
continue;
419+
}
420+
let prevIndex = -1;
421+
for (let i = kept.length - 1; i >= 0; i--) {
422+
if (kept[i].userText === group.userText) {
423+
prevIndex = i;
424+
break;
425+
}
426+
}
427+
if (prevIndex < 0) {
428+
kept.push(group);
429+
continue;
430+
}
431+
const prev = kept[prevIndex];
432+
const prevPaired = prev.turns.length > 1;
433+
const currPaired = group.turns.length > 1;
434+
if (currPaired && prevPaired) {
435+
if (prev.replies === group.replies) {
436+
kept.splice(prevIndex, 1);
437+
kept.push(group);
438+
}
439+
else {
440+
kept.push(group);
441+
}
442+
}
443+
else if (currPaired && !prevPaired) {
444+
kept.splice(prevIndex, 1);
445+
kept.push(group);
446+
}
447+
else if (!currPaired && prevPaired) {
448+
continue;
449+
}
450+
else {
451+
kept.splice(prevIndex, 1);
452+
kept.push(group);
453+
}
454+
}
455+
return kept.flatMap((group) => group.turns);
456+
}
360457
/**
361458
* Assembles the ordered turn sequence for the extraction prompt's transcript
362459
* from this call's true message-loop order, without recomputing any

index.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,13 @@ import { isNoise } from "./src/noise-filter.js";
7474
import {
7575
type ConversationTurn,
7676
buildConversationTurnsForExtraction,
77+
dedupePairWindow,
7778
formatConversationTranscript,
7879
neutralizeSpeakerTagSpoof,
7980
nextAutoCaptureMessageId,
8081
normalizeAutoCaptureText,
8182
reconcileTurnsWithKeptTexts,
83+
trimTurnsToUserCap,
8284
} from "./src/auto-capture-cleanup.js";
8385

8486
// Import smart extraction & lifecycle components
@@ -277,6 +279,8 @@ interface PluginConfig {
277279
thinkLevel?: string;
278280
};
279281
extractMinMessages?: number;
282+
/** Rolling extraction context window in retained user turns (0 = disabled, max 10). */
283+
autoCaptureContextTurns?: number;
280284
extractMaxChars?: number;
281285
batchChunkSize?: number;
282286
scopes?: {
@@ -2489,6 +2493,9 @@ interface PluginSingletonState {
24892493
autoCaptureRecentTurns: Map<string, ConversationTurn[]>;
24902494
autoCaptureDeferredFlushTurns: Map<string, ConversationTurn[]>;
24912495
autoCaptureSessionIdToKey: Map<string, string>;
2496+
2497+
autoCaptureSessionIds: Map<string, string>;
2498+
autoCaptureRecentPairTurns: Map<string, ConversationTurn[]>;
24922499
autoCaptureInFlightRuns: Map<string, Set<Promise<void>>>;
24932500
captureAdmissionController: () => AdmissionController | null;
24942501
captureAdmissionAudit: () => boolean;
@@ -2797,6 +2804,9 @@ function _initPluginState(api: OpenClawPluginApi): PluginSingletonState {
27972804
const autoCaptureRecentTurns = new Map<string, ConversationTurn[]>();
27982805
const autoCaptureDeferredFlushTurns = new Map<string, ConversationTurn[]>();
27992806
const autoCaptureSessionIdToKey = new Map<string, string>();
2807+
2808+
const autoCaptureSessionIds = new Map<string, string>();
2809+
const autoCaptureRecentPairTurns = new Map<string, ConversationTurn[]>();
28002810
const autoCaptureInFlightRuns = new Map<string, Set<Promise<void>>>();
28012811

28022812
return {
@@ -2829,6 +2839,9 @@ function _initPluginState(api: OpenClawPluginApi): PluginSingletonState {
28292839
autoCaptureRecentTurns,
28302840
autoCaptureDeferredFlushTurns,
28312841
autoCaptureSessionIdToKey,
2842+
2843+
autoCaptureSessionIds,
2844+
autoCaptureRecentPairTurns,
28322845
autoCaptureInFlightRuns,
28332846
captureAdmissionController,
28342847
captureAdmissionAudit,
@@ -2988,7 +3001,9 @@ const memoryLanceDBProPlugin = {
29883001
autoCaptureCountedPendingCount,
29893002
autoCaptureRecentTurns,
29903003
autoCaptureDeferredFlushTurns,
2991-
autoCaptureSessionIdToKey,
3004+
autoCaptureSessionIdToKey,
3005+
autoCaptureSessionIds,
3006+
autoCaptureRecentPairTurns,
29923007
autoCaptureInFlightRuns,
29933008
captureAdmissionController,
29943009
captureAdmissionAudit,
@@ -4651,7 +4666,38 @@ const memoryLanceDBProPlugin = {
46514666
// texts the selectors dropped back into extraction. Kept indices
46524667
// pin each surviving copy to its own turn; occurrence counting
46534668
// stays as the fallback when positional alignment is unavailable.
4654-
const finalConversationTurns = reconcileTurnsWithKeptTexts(thisCallTurns, cleanTexts, cleanTurnIndices);
4669+
let finalConversationTurns = reconcileTurnsWithKeptTexts(thisCallTurns, cleanTexts, cleanTurnIndices);
4670+
// Rolling PAIR window sized by autoCaptureContextTurns (0 =
4671+
// disabled: each extraction sees only its own call's turns, and
4672+
// nothing is retained between calls). When enabled, this call's
4673+
// reconciled pairs extend what earlier calls buffered, bounded
4674+
// to autoCaptureContextTurns user turns (or this call's own
4675+
// new-user count when larger, so unextracted user turns are
4676+
// never trimmed out of their own transcript). The buffer holds
4677+
// the FILTERED window, so selector-dropped texts can never
4678+
// re-enter a later transcript as retained context. A remember
4679+
// flow (prepended referent) bypasses the prepend for its own
4680+
// call: the extractor's protected-prefix contract counts
4681+
// referent turns from position zero.
4682+
const contextTurns = config.autoCaptureContextTurns ?? 0;
4683+
if (contextTurns > 0 && rememberPrependedTurns.length === 0) {
4684+
const priorPairTurns = autoCaptureRecentPairTurns.get(sessionKey) || [];
4685+
finalConversationTurns = trimTurnsToUserCap(
4686+
dedupePairWindow([...priorPairTurns, ...finalConversationTurns]),
4687+
Math.max(contextTurns, finalConversationTurns.filter((turn) => turn.role === "user").length),
4688+
);
4689+
}
4690+
if (contextTurns === 0) {
4691+
autoCaptureRecentPairTurns.delete(sessionKey);
4692+
} else if (thisCallTurns.length > 0) {
4693+
// Deliberately retained across successful extractions:
4694+
// deleting it here would mean steady-state captures (one
4695+
// extraction per turn) always see a bare current pair. The
4696+
// set-time trim bounds it; the watermark keeps retained
4697+
// turns from re-becoming sources.
4698+
autoCaptureRecentPairTurns.set(sessionKey, finalConversationTurns);
4699+
pruneMapIfOver(autoCaptureRecentPairTurns, AUTO_CAPTURE_MAP_MAX_ENTRIES);
4700+
}
46554701
// The referent is the OLDEST turn of the prepended window, which is
46564702
// exactly what the extractor's newest-first budget walk sacrifices
46574703
// first, so it needs a guaranteed share. Only the referent RUN gets
@@ -7065,6 +7111,7 @@ export function parsePluginConfig(value: unknown): PluginConfig {
70657111
})()
70667112
: undefined,
70677113
extractMinMessages: parsePositiveInt(cfg.extractMinMessages) ?? 4,
7114+
autoCaptureContextTurns: Math.min(10, Math.max(0, Math.floor(Number(cfg.autoCaptureContextTurns)) || 0)),
70687115
extractMaxChars: parsePositiveInt(cfg.extractMaxChars) ?? 8000,
70697116
batchChunkSize: (() => { const raw = parsePositiveInt(cfg.batchChunkSize); return raw === undefined ? undefined : Math.min(50, raw); })(),
70707117
scopes: typeof cfg.scopes === "object" && cfg.scopes !== null ? cfg.scopes as any : undefined,

openclaw.plugin.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,13 @@
438438
"default": 4,
439439
"description": "Minimum conversation messages required before smart extraction runs."
440440
},
441+
"autoCaptureContextTurns": {
442+
"type": "integer",
443+
"minimum": 0,
444+
"maximum": 10,
445+
"default": 0,
446+
"description": "Rolling context window for auto-capture extraction: how many recent user turns (with their assistant replies) stay in the transcript across extractions as context. 0 disables retention; each extraction then sees only its own call's turns."
447+
},
441448
"batchChunkSize": {
442449
"type": "integer",
443450
"minimum": 1,
@@ -1939,6 +1946,11 @@
19391946
"help": "Minimum conversation messages before smart extraction triggers",
19401947
"advanced": true
19411948
},
1949+
"autoCaptureContextTurns": {
1950+
"label": "Auto-Capture Context Turns",
1951+
"help": "Rolling window of recent user turns (with replies) kept as extraction context; 0 = off",
1952+
"advanced": true
1953+
},
19421954
"extractMaxChars": {
19431955
"label": "Max Chars for Extraction",
19441956
"help": "Maximum conversation characters to process for extraction",

0 commit comments

Comments
 (0)