@matthewfl/pi-contemplator 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/agents/contemplator/agent.ts +193 -20
- package/src/hooks/consolidation-trigger.ts +48 -17
- package/src/runtime.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthewfl/pi-contemplator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "A Pi extension that keeps long-running agentic sessions on track with background memory, contemplation, and structural review.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
42
|
"test": "npm run test:unit && npm run test:e2e",
|
|
43
43
|
"test:unit": "vitest run",
|
|
44
|
-
"test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
|
|
44
|
+
"test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-contemplator-history.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@earendil-works/pi-agent-core": "*",
|
|
@@ -2,7 +2,7 @@ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage,
|
|
|
2
2
|
import { Type, type Message, type Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { Static } from "typebox";
|
|
4
4
|
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
5
|
-
import { generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { estimateTokens as estimateAgentMessageTokens, generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
8
8
|
import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, rawTokensSinceObservationCoverage, recallMemorySources, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
|
|
@@ -19,6 +19,66 @@ import { buildContemplatorSystemPrompt } from "./prompts.js";
|
|
|
19
19
|
import { runStructuralReview } from "../reviewer/agent.js";
|
|
20
20
|
import { createWorkerStallWatchdog } from "../../worker-watchdog.js";
|
|
21
21
|
|
|
22
|
+
const CONTEMPLATOR_HISTORY_FALLBACK_TRIGGER_TOKENS = 20_000;
|
|
23
|
+
const CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS = 40_000;
|
|
24
|
+
const CONTEMPLATOR_HISTORY_CONTEXT_FRACTION = 0.25;
|
|
25
|
+
const CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS = 12_000;
|
|
26
|
+
const CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS = 16_000;
|
|
27
|
+
const CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS = "This is an older prefix of a private contemplator transcript. Be concise. Preserve durable user intent, decisions, evidence, unresolved reasoning gaps, and review/probe outcomes. Newer transcript messages are retained verbatim after this checkpoint.";
|
|
28
|
+
|
|
29
|
+
function contemplatorMessageTokens(message: AgentMessage): number {
|
|
30
|
+
try {
|
|
31
|
+
return Math.max(1, estimateAgentMessageTokens(message));
|
|
32
|
+
} catch {
|
|
33
|
+
return Math.max(1, Math.ceil(JSON.stringify(message).length / 4));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function contemplatorHistoryTokens(history: readonly AgentMessage[]): number {
|
|
38
|
+
return history.reduce((total, message) => total + contemplatorMessageTokens(message), 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function contemplatorHistoryTriggerTokens(model: Model<any>): number {
|
|
42
|
+
const contextWindow = Number(model.contextWindow);
|
|
43
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return CONTEMPLATOR_HISTORY_FALLBACK_TRIGGER_TOKENS;
|
|
44
|
+
return Math.max(1, Math.min(CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS, Math.floor(contextWindow * CONTEMPLATOR_HISTORY_CONTEXT_FRACTION)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Return a user-message boundary that leaves approximately targetTokens recent. */
|
|
48
|
+
function recentHistoryStart(history: readonly AgentMessage[], entryIds: readonly (string | undefined)[], targetTokens: number): number | undefined {
|
|
49
|
+
let tokens = 0;
|
|
50
|
+
let start = history.length;
|
|
51
|
+
while (start > 0 && tokens < targetTokens) {
|
|
52
|
+
start--;
|
|
53
|
+
tokens += contemplatorMessageTokens(history[start]);
|
|
54
|
+
}
|
|
55
|
+
while (start > 0 && history[start].role !== "user") start--;
|
|
56
|
+
// Retained messages are durable pointers, not copied payloads. If a legacy
|
|
57
|
+
// snapshot supplied messages without entry ids, retain only the newer complete
|
|
58
|
+
// updates whose original om.contemplator.message entries can be referenced.
|
|
59
|
+
let lastMissingId = -1;
|
|
60
|
+
for (let index = start; index < entryIds.length; index++) {
|
|
61
|
+
if (entryIds[index] === undefined) lastMissingId = index;
|
|
62
|
+
}
|
|
63
|
+
if (lastMissingId >= 0) {
|
|
64
|
+
start = lastMissingId + 1;
|
|
65
|
+
while (start < history.length && history[start].role !== "user") start++;
|
|
66
|
+
}
|
|
67
|
+
return start > 0 && start < history.length ? start : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Pick a smaller complete oldest prefix after a length-truncated first attempt. */
|
|
71
|
+
function smallerPrefixEnd(history: readonly AgentMessage[], initialEnd: number): number | undefined {
|
|
72
|
+
const target = Math.max(1, Math.floor(contemplatorHistoryTokens(history.slice(0, initialEnd)) / 2));
|
|
73
|
+
let tokens = 0;
|
|
74
|
+
for (let index = 0; index < initialEnd; index++) {
|
|
75
|
+
tokens += contemplatorMessageTokens(history[index]);
|
|
76
|
+
const next = index + 1;
|
|
77
|
+
if (tokens >= target && next < initialEnd && history[next].role === "user") return next;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
22
82
|
interface PendingUpdate {
|
|
23
83
|
observations: string[];
|
|
24
84
|
reviews: string[];
|
|
@@ -211,6 +271,8 @@ export function createRequestReviewTool(
|
|
|
211
271
|
|
|
212
272
|
export class Contemplator {
|
|
213
273
|
private history: AgentMessage[] = [];
|
|
274
|
+
/** Ledger entry id for each private-history message; used by compact checkpoints to retain a suffix without copying it. */
|
|
275
|
+
private historyEntryIds: Array<string | undefined> = [];
|
|
214
276
|
private pending: PendingUpdate | undefined;
|
|
215
277
|
private running = false;
|
|
216
278
|
/** Invalidates stale/hard-timed-out flush finalizers across session changes. */
|
|
@@ -300,6 +362,7 @@ export class Contemplator {
|
|
|
300
362
|
this.consecutiveFlushFailures = 0;
|
|
301
363
|
this.agentActiveSince = undefined;
|
|
302
364
|
this.history = [];
|
|
365
|
+
this.historyEntryIds = [];
|
|
303
366
|
this.pending = undefined;
|
|
304
367
|
this.seenObservationIds.clear();
|
|
305
368
|
this.seenReviewIds.clear();
|
|
@@ -441,6 +504,8 @@ export class Contemplator {
|
|
|
441
504
|
if (this.running && !resetTracking) return;
|
|
442
505
|
if (tipId === this.restoredTipId && !resetTracking) return;
|
|
443
506
|
this.history = [];
|
|
507
|
+
this.historyEntryIds = [];
|
|
508
|
+
const historyMessagesByEntryId = new Map<string, AgentMessage>();
|
|
444
509
|
let resetProjection: ReturnType<typeof fullProjection> | undefined;
|
|
445
510
|
if (resetTracking) {
|
|
446
511
|
this.deliveredProbeIds.clear();
|
|
@@ -468,14 +533,27 @@ export class Contemplator {
|
|
|
468
533
|
for (const entry of entries) {
|
|
469
534
|
if (entry.customType === CONTEMPLATOR_STATE && entry.data && typeof entry.data === "object") {
|
|
470
535
|
const state = entry.data as { history?: unknown };
|
|
471
|
-
if (Array.isArray(state.history))
|
|
536
|
+
if (Array.isArray(state.history)) {
|
|
537
|
+
this.history = state.history.filter((message): message is AgentMessage => !!message && typeof message === "object");
|
|
538
|
+
this.historyEntryIds = this.history.map(() => undefined);
|
|
539
|
+
}
|
|
472
540
|
}
|
|
473
541
|
if (entry.customType === CONTEMPLATOR_MESSAGE && entry.data && typeof entry.data === "object") {
|
|
474
|
-
const data = entry.data as { message?: unknown; compacted?: unknown };
|
|
542
|
+
const data = entry.data as { message?: unknown; compacted?: unknown; retainedMessageEntryIds?: unknown };
|
|
475
543
|
const message = data.message;
|
|
476
544
|
if (message && typeof message === "object") {
|
|
477
|
-
|
|
478
|
-
|
|
545
|
+
const typedMessage = message as AgentMessage;
|
|
546
|
+
historyMessagesByEntryId.set(entry.id, typedMessage);
|
|
547
|
+
if (data.compacted === true) {
|
|
548
|
+
const retainedIds = Array.isArray(data.retainedMessageEntryIds)
|
|
549
|
+
? data.retainedMessageEntryIds.filter((id): id is string => typeof id === "string" && historyMessagesByEntryId.has(id))
|
|
550
|
+
: [];
|
|
551
|
+
this.history = [typedMessage, ...retainedIds.map((id) => historyMessagesByEntryId.get(id)!)];
|
|
552
|
+
this.historyEntryIds = [entry.id, ...retainedIds];
|
|
553
|
+
} else {
|
|
554
|
+
this.history.push(typedMessage);
|
|
555
|
+
this.historyEntryIds.push(entry.id);
|
|
556
|
+
}
|
|
479
557
|
}
|
|
480
558
|
}
|
|
481
559
|
if (entry.customType === OM_REVIEWER_STATE && entry.data && typeof entry.data === "object") {
|
|
@@ -862,7 +940,7 @@ export class Contemplator {
|
|
|
862
940
|
for (const message of runMessages) {
|
|
863
941
|
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
864
942
|
this.history.push(message);
|
|
865
|
-
this.
|
|
943
|
+
this.historyEntryIds.push(this.appendContemplatorHistoryEntry(ctx, { version: 1, message }));
|
|
866
944
|
promptPersisted = true;
|
|
867
945
|
this.markTipPersisted(ctx);
|
|
868
946
|
}
|
|
@@ -892,7 +970,13 @@ export class Contemplator {
|
|
|
892
970
|
}
|
|
893
971
|
if (sessionGeneration === this.sessionGeneration) {
|
|
894
972
|
workerWatchdog.progress();
|
|
895
|
-
|
|
973
|
+
try {
|
|
974
|
+
await workerWatchdog.race(this.compactHistory(ctx, resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch, workerWatchdog.signal));
|
|
975
|
+
} catch (compactionError) {
|
|
976
|
+
// The intervention and its durable messages are already complete. Private
|
|
977
|
+
// history maintenance must never relabel that successful work as failed.
|
|
978
|
+
debugLog("contemplator.compaction_postponed", { reason: compactionError instanceof Error ? compactionError.message : String(compactionError) });
|
|
979
|
+
}
|
|
896
980
|
}
|
|
897
981
|
} catch (error) {
|
|
898
982
|
failed = true;
|
|
@@ -1134,6 +1218,24 @@ export class Contemplator {
|
|
|
1134
1218
|
}
|
|
1135
1219
|
}
|
|
1136
1220
|
|
|
1221
|
+
/**
|
|
1222
|
+
* Append one private-history entry and attribute its synchronous ledger id.
|
|
1223
|
+
* Pi does not return the id from appendEntry(), so prefer the exact data
|
|
1224
|
+
* object and accept a cloned payload only when exactly one matching entry was
|
|
1225
|
+
* appended. Ambiguity returns undefined rather than pointing at another
|
|
1226
|
+
* writer's message.
|
|
1227
|
+
*/
|
|
1228
|
+
private appendContemplatorHistoryEntry(ctx: MemoryUpdateCtx, data: Record<string, unknown>): string | undefined {
|
|
1229
|
+
const branch = ctx.sessionManager.getBranch() as readonly Entry[];
|
|
1230
|
+
const before = branch.length;
|
|
1231
|
+
this.pi.appendEntry(CONTEMPLATOR_MESSAGE, data);
|
|
1232
|
+
const candidates = (ctx.sessionManager.getBranch() as readonly Entry[])
|
|
1233
|
+
.slice(before)
|
|
1234
|
+
.filter((entry) => entry.customType === CONTEMPLATOR_MESSAGE);
|
|
1235
|
+
return candidates.find((entry) => entry.data === data)?.id
|
|
1236
|
+
?? (candidates.length === 1 ? candidates[0].id : undefined);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1137
1239
|
private markTipPersisted(ctx: MemoryUpdateCtx): string | undefined {
|
|
1138
1240
|
this.restoredTipId = (ctx.sessionManager.getBranch() as Entry[]).at(-1)?.id;
|
|
1139
1241
|
return this.restoredTipId;
|
|
@@ -1157,27 +1259,73 @@ export class Contemplator {
|
|
|
1157
1259
|
return own?.id;
|
|
1158
1260
|
}
|
|
1159
1261
|
|
|
1160
|
-
private async compactHistory(model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number): Promise<void> {
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
const
|
|
1262
|
+
private async compactHistory(ctx: MemoryUpdateCtx, model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number, signal?: AbortSignal): Promise<void> {
|
|
1263
|
+
const history = this.history.slice();
|
|
1264
|
+
const historyEntryIds = this.historyEntryIds.slice();
|
|
1265
|
+
const historyTokens = contemplatorHistoryTokens(history);
|
|
1266
|
+
const triggerTokens = contemplatorHistoryTriggerTokens(model);
|
|
1267
|
+
if (history.length < 12 || historyTokens < triggerTokens) return;
|
|
1268
|
+
|
|
1269
|
+
const keepRecentTokens = Math.min(CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS, Math.max(1, Math.floor(triggerTokens * 0.5)));
|
|
1270
|
+
const initialPrefixEnd = recentHistoryStart(history, historyEntryIds, keepRecentTokens);
|
|
1271
|
+
if (initialPrefixEnd === undefined) return;
|
|
1272
|
+
const previousMessageCount = history.length;
|
|
1164
1273
|
debugLog("contemplator.compaction_start", {
|
|
1165
1274
|
historyMessageCount: previousMessageCount,
|
|
1166
|
-
|
|
1275
|
+
historyTokens,
|
|
1276
|
+
triggerTokens,
|
|
1277
|
+
keepRecentTokens,
|
|
1278
|
+
prefixMessageCount: initialPrefixEnd,
|
|
1279
|
+
retainedMessageCount: history.length - initialPrefixEnd,
|
|
1167
1280
|
});
|
|
1168
|
-
|
|
1169
|
-
const
|
|
1281
|
+
|
|
1282
|
+
const summarizePrefix = async (prefixEnd: number) => generateSummaryWithUsage(
|
|
1283
|
+
history.slice(0, prefixEnd),
|
|
1284
|
+
model,
|
|
1285
|
+
CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS,
|
|
1286
|
+
apiKey,
|
|
1287
|
+
headers,
|
|
1288
|
+
signal,
|
|
1289
|
+
CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS,
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
let prefixEnd = initialPrefixEnd;
|
|
1293
|
+
let summaryWithUsage: Awaited<ReturnType<typeof generateSummaryWithUsage>>;
|
|
1294
|
+
try {
|
|
1295
|
+
summaryWithUsage = await summarizePrefix(prefixEnd);
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
const failure = error instanceof Error ? error.message : String(error);
|
|
1298
|
+
const fallbackEnd = /generation hit the token cap/i.test(failure)
|
|
1299
|
+
? smallerPrefixEnd(history, initialPrefixEnd)
|
|
1300
|
+
: undefined;
|
|
1301
|
+
if (fallbackEnd === undefined) {
|
|
1302
|
+
debugLog("contemplator.compaction_postponed", { reason: failure, historyTokens, prefixMessageCount: prefixEnd });
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
prefixEnd = fallbackEnd;
|
|
1306
|
+
debugLog("contemplator.compaction_retry_smaller_prefix", { reason: failure, prefixMessageCount: prefixEnd, retainedMessageCount: history.length - prefixEnd });
|
|
1307
|
+
try {
|
|
1308
|
+
summaryWithUsage = await summarizePrefix(prefixEnd);
|
|
1309
|
+
} catch (fallbackError) {
|
|
1310
|
+
debugLog("contemplator.compaction_postponed", {
|
|
1311
|
+
reason: fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
|
|
1312
|
+
historyTokens,
|
|
1313
|
+
prefixMessageCount: prefixEnd,
|
|
1314
|
+
});
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1170
1319
|
this.runtime.recordAgentUsage(summaryWithUsage.usage);
|
|
1171
1320
|
if (sessionGeneration !== this.sessionGeneration || flushEpoch !== this.flushEpoch) {
|
|
1172
1321
|
debugLog("contemplator.compaction_stale", { reason: "session_or_branch_changed" });
|
|
1173
1322
|
return;
|
|
1174
1323
|
}
|
|
1175
|
-
const summary = summaryWithUsage.text;
|
|
1176
1324
|
const summaryModel = model as Model<any> & { api?: unknown; provider?: string; id?: string };
|
|
1177
1325
|
const summaryUsage = summaryWithUsage.usage;
|
|
1178
|
-
|
|
1326
|
+
const summaryMessage = {
|
|
1179
1327
|
role: "assistant",
|
|
1180
|
-
content: [{ type: "text", text: `Previous contemplator context summary:\n${
|
|
1328
|
+
content: [{ type: "text", text: `Previous contemplator context summary:\n${summaryWithUsage.text}` }],
|
|
1181
1329
|
api: summaryModel.api,
|
|
1182
1330
|
provider: summaryModel.provider ?? "unknown",
|
|
1183
1331
|
model: summaryModel.id ?? "contemplator",
|
|
@@ -1191,12 +1339,37 @@ export class Contemplator {
|
|
|
1191
1339
|
},
|
|
1192
1340
|
stopReason: "stop",
|
|
1193
1341
|
timestamp: Date.now(),
|
|
1194
|
-
} as AgentMessage
|
|
1195
|
-
|
|
1342
|
+
} as AgentMessage;
|
|
1343
|
+
// A flush currently owns private-history mutation while this awaits the
|
|
1344
|
+
// model, and session/tree movement invalidates the generation above. Still,
|
|
1345
|
+
// preserve any future append-only writer rather than replacing a stale
|
|
1346
|
+
// snapshot and silently dropping messages. Non-append mutation is unsafe to
|
|
1347
|
+
// merge, so postpone and let a later pass compact the live transcript.
|
|
1348
|
+
const liveHistory = this.history;
|
|
1349
|
+
const liveHistoryEntryIds = this.historyEntryIds;
|
|
1350
|
+
const snapshotIsLivePrefix = liveHistory.length >= history.length
|
|
1351
|
+
&& history.every((message, index) => liveHistory[index] === message && liveHistoryEntryIds[index] === historyEntryIds[index]);
|
|
1352
|
+
if (!snapshotIsLivePrefix) {
|
|
1353
|
+
debugLog("contemplator.compaction_postponed", { reason: "private history changed during compaction" });
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const retainedMessages = liveHistory.slice(prefixEnd);
|
|
1357
|
+
const retainedMessageEntryIds = liveHistoryEntryIds.slice(prefixEnd).filter((id): id is string => typeof id === "string");
|
|
1358
|
+
if (retainedMessageEntryIds.length !== retainedMessages.length) {
|
|
1359
|
+
debugLog("contemplator.compaction_postponed", { reason: "retained history lacked durable entry ids", retainedMessageCount: retainedMessages.length, retainedReferenceCount: retainedMessageEntryIds.length });
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const checkpoint = { version: 2, compacted: true, message: summaryMessage, retainedMessageEntryIds };
|
|
1363
|
+
const checkpointEntryId = this.appendContemplatorHistoryEntry(ctx, checkpoint);
|
|
1364
|
+
this.history = [summaryMessage, ...retainedMessages];
|
|
1365
|
+
this.historyEntryIds = [checkpointEntryId, ...retainedMessageEntryIds];
|
|
1366
|
+
this.markTipPersisted(ctx);
|
|
1196
1367
|
debugLog("contemplator.compaction_complete", {
|
|
1197
1368
|
previousMessageCount,
|
|
1198
1369
|
newMessageCount: this.history.length,
|
|
1199
|
-
|
|
1370
|
+
prefixMessageCount: prefixEnd,
|
|
1371
|
+
retainedMessageCount: retainedMessages.length,
|
|
1372
|
+
summaryLength: summaryWithUsage.text.length,
|
|
1200
1373
|
});
|
|
1201
1374
|
}
|
|
1202
1375
|
}
|
|
@@ -242,7 +242,6 @@ export async function runConsolidationPipeline(
|
|
|
242
242
|
const pipelineEntries = options.observerEntries ?? (ctx.sessionManager.getBranch() as Entry[]);
|
|
243
243
|
const initialCoverage = latestCoverageIndex(pipelineEntries, OM_OBSERVATIONS_RECORDED);
|
|
244
244
|
const catchUpThroughId = sourceEntriesAfter(pipelineEntries, initialCoverage).at(-1)?.id;
|
|
245
|
-
const beforeFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
|
|
246
245
|
runtime.consolidationPhase = "observer";
|
|
247
246
|
runtime.observerBacklogBlocking = catchUpThroughId !== undefined;
|
|
248
247
|
try {
|
|
@@ -254,6 +253,7 @@ export async function runConsolidationPipeline(
|
|
|
254
253
|
while (true) {
|
|
255
254
|
const beforeEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
256
255
|
const beforeCoverage = latestCoverageIndex(beforeEntries, OM_OBSERVATIONS_RECORDED);
|
|
256
|
+
const beforeObservationIds = new Set(foldLedger(beforeEntries).observations.map((memory) => memory.id));
|
|
257
257
|
const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
|
|
258
258
|
force: options.forceObserver === true,
|
|
259
259
|
entries: options.observerEntries,
|
|
@@ -261,9 +261,18 @@ export async function runConsolidationPipeline(
|
|
|
261
261
|
contextGeneration,
|
|
262
262
|
});
|
|
263
263
|
if (observerOutcome === "abort") return;
|
|
264
|
-
if (options.observerEntries) break;
|
|
265
264
|
|
|
266
265
|
const afterEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
266
|
+
if (shouldScheduleSummarizerFromObserver(options)) {
|
|
267
|
+
const passAddedObservations = foldLedger(afterEntries).observations.some((memory) => !beforeObservationIds.has(memory.id));
|
|
268
|
+
// A finite observer backlog can take hours to drain while the primary
|
|
269
|
+
// agent is idle. Let each completed chunk feed the independent
|
|
270
|
+
// summarizer; waiting for the entire observer pipeline can otherwise
|
|
271
|
+
// leave an oversized old pool untouched indefinitely.
|
|
272
|
+
if (passAddedObservations) scheduleSummarizer(pi, runtime, ctx);
|
|
273
|
+
}
|
|
274
|
+
if (options.observerEntries) break;
|
|
275
|
+
|
|
267
276
|
const afterCoverage = latestCoverageIndex(afterEntries, OM_OBSERVATIONS_RECORDED);
|
|
268
277
|
const remainingTokens = catchUpThroughId === undefined
|
|
269
278
|
? 0
|
|
@@ -279,10 +288,6 @@ export async function runConsolidationPipeline(
|
|
|
279
288
|
// pipeline's blocking backlog. Clear before notifying the contemplator.
|
|
280
289
|
if (contextGeneration === runtime.getContextGeneration()) runtime.observerBacklogBlocking = false;
|
|
281
290
|
}
|
|
282
|
-
const afterFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
|
|
283
|
-
const beforeIds = new Set(beforeFold.observations.map((item) => item.id));
|
|
284
|
-
const added = afterFold.observations.filter((item) => !beforeIds.has(item.id));
|
|
285
|
-
if (added.length > 0 && shouldScheduleSummarizerFromObserver(options)) scheduleSummarizer(pi, runtime, ctx);
|
|
286
291
|
if (contextGeneration === runtime.getContextGeneration()) runtime.notifyMemoryUpdate?.(ctx);
|
|
287
292
|
}
|
|
288
293
|
|
|
@@ -312,10 +317,14 @@ export function summarizerTriggerAfterRun(
|
|
|
312
317
|
targetTokens: number,
|
|
313
318
|
postRunOldTokens: number,
|
|
314
319
|
retriggerTokens: number,
|
|
320
|
+
failedAttemptStartOldTokens?: number,
|
|
315
321
|
): number | undefined {
|
|
316
|
-
return
|
|
317
|
-
|
|
318
|
-
|
|
322
|
+
if (successfullyCompleted) return nextSummarizerTriggerTokens(targetTokens, postRunOldTokens, retriggerTokens);
|
|
323
|
+
if (failedAttemptStartOldTokens === undefined) return currentTriggerTokens;
|
|
324
|
+
// Back off a model pass from the pool it actually received. Growth that
|
|
325
|
+
// arrived while it was running must count toward the retry rather than being
|
|
326
|
+
// swallowed by a threshold based on the larger post-run pool.
|
|
327
|
+
return Math.max(currentTriggerTokens ?? targetTokens, failedAttemptStartOldTokens + retriggerTokens);
|
|
319
328
|
}
|
|
320
329
|
|
|
321
330
|
function syncAndScheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
|
|
@@ -324,14 +333,27 @@ function syncAndScheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
|
|
|
324
333
|
}
|
|
325
334
|
|
|
326
335
|
export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
|
|
327
|
-
if (runtime.config.passive || !runtime.config.summarizerEnabled
|
|
336
|
+
if (runtime.config.passive || !runtime.config.summarizerEnabled) return;
|
|
337
|
+
if (runtime.summarizerInFlight) {
|
|
338
|
+
// Do not lose observer/activity checkpoints that arrive during a long run.
|
|
339
|
+
// The tracked task rechecks once its single-flight lock has been released.
|
|
340
|
+
runtime.summarizerRecheckPending = true;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
runtime.summarizerRecheckPending = false;
|
|
328
344
|
const entries = ctx.sessionManager.getBranch() as Entry[];
|
|
329
345
|
const pools = currentMemoryPools(runtime, entries);
|
|
330
|
-
|
|
346
|
+
const targetTokens = runtime.config.oldMemoryPoolTargetTokens;
|
|
347
|
+
const nextTriggerTokens = summarizerTriggerTokens(runtime);
|
|
348
|
+
// Initial/healthy eligibility strictly exceeds the advisory target. Once a
|
|
349
|
+
// growth backoff is installed, reaching that +N threshold is sufficient; it
|
|
350
|
+
// must not require an accidental extra token beyond the configured amount.
|
|
351
|
+
if (pools.oldTokens <= targetTokens || nextTriggerTokens > targetTokens && pools.oldTokens < nextTriggerTokens) return;
|
|
352
|
+
const runStartOldTokens = pools.oldTokens;
|
|
331
353
|
const generation = runtime.getContextGeneration();
|
|
332
354
|
const runId = `summarizer-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
333
355
|
const sessionMetadata = debugSessionMetadata(ctx);
|
|
334
|
-
|
|
356
|
+
const task = runtime.launchSummarizerTask(ctx, async () => withDebugLogContext({
|
|
335
357
|
enabled: runtime.config.debugLog === true,
|
|
336
358
|
cwd: ctx.cwd,
|
|
337
359
|
...sessionMetadata,
|
|
@@ -415,19 +437,28 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
|
|
|
415
437
|
const postRunPools = currentMemoryPools(runtime, ctx.sessionManager.getBranch() as Entry[]);
|
|
416
438
|
const target = runtime.config.oldMemoryPoolTargetTokens;
|
|
417
439
|
runtime.summarizerNextTriggerTokens = summarizerTriggerAfterRun(
|
|
418
|
-
|
|
419
|
-
// identical pool at every primary-agent checkpoint. Require fresh
|
|
420
|
-
// old-memory growth before trying another (potentially different)
|
|
421
|
-
// sample. Model-resolution failures spend no tokens and stay eligible.
|
|
422
|
-
successfullyCompleted || modelRunAttempted,
|
|
440
|
+
successfullyCompleted,
|
|
423
441
|
runtime.summarizerNextTriggerTokens,
|
|
424
442
|
target,
|
|
425
443
|
postRunPools.oldTokens,
|
|
426
444
|
runtime.config.summarizerRetriggerTokens,
|
|
445
|
+
// A failed/no-progress model pass should not retry an identical
|
|
446
|
+
// prompt at every checkpoint. Anchor the growth backoff to the pool
|
|
447
|
+
// seen at launch so concurrent growth is not accidentally erased.
|
|
448
|
+
modelRunAttempted ? runStartOldTokens : undefined,
|
|
427
449
|
);
|
|
428
450
|
}
|
|
429
451
|
}
|
|
430
452
|
}));
|
|
453
|
+
void task?.then(() => {
|
|
454
|
+
if (generation !== runtime.getContextGeneration()) return;
|
|
455
|
+
if (!runtime.summarizerRecheckPending) return;
|
|
456
|
+
// launchTrackedTask has released summarizerInFlight before resolving.
|
|
457
|
+
// Coalesce every checkpoint received during the old run into one fresh
|
|
458
|
+
// ledger/threshold evaluation.
|
|
459
|
+
runtime.summarizerRecheckPending = false;
|
|
460
|
+
scheduleSummarizer(pi, runtime, ctx);
|
|
461
|
+
});
|
|
431
462
|
}
|
|
432
463
|
|
|
433
464
|
async function runObserverStage(
|
package/src/runtime.ts
CHANGED
|
@@ -176,6 +176,8 @@ export class Runtime {
|
|
|
176
176
|
summarizerPromise: Promise<void> | null = null;
|
|
177
177
|
/** Old-pool token threshold for the next pass; undefined means configured target. */
|
|
178
178
|
summarizerNextTriggerTokens: number | undefined;
|
|
179
|
+
/** A scheduling checkpoint arrived while the single-flight summarizer lock was held. */
|
|
180
|
+
summarizerRecheckPending = false;
|
|
179
181
|
private memoryUpdateListener: ((ctx: MemoryUpdateCtx) => void) | undefined;
|
|
180
182
|
private agentActivityListener: ((ctx: MemoryUpdateCtx) => void) | undefined;
|
|
181
183
|
private settingsUpdateListener: ((ctx: MemoryUpdateCtx, settings: SettingsUpdate) => void) | undefined;
|
|
@@ -277,6 +279,7 @@ export class Runtime {
|
|
|
277
279
|
this.observerBacklogBlocking = false;
|
|
278
280
|
this.summarizerInFlight = false;
|
|
279
281
|
this.summarizerPromise = null;
|
|
282
|
+
this.summarizerRecheckPending = false;
|
|
280
283
|
this.reviewInFlight = false;
|
|
281
284
|
this.reviewPromise = null;
|
|
282
285
|
this.compactInFlight = false;
|