@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.1.7",
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)) this.history = state.history.filter((message): message is AgentMessage => !!message && typeof message === "object");
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
- if (data.compacted === true) this.history = [message as AgentMessage];
478
- else this.history.push(message as AgentMessage);
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.pi.appendEntry(CONTEMPLATOR_MESSAGE, { version: 1, message });
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
- await workerWatchdog.race(this.compactHistory(resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch));
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 serializedLength = this.history.reduce((total, message) => total + JSON.stringify(message).length, 0);
1162
- if (this.history.length < 12 || serializedLength < 60_000) return;
1163
- const previousMessageCount = this.history.length;
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
- serializedLength,
1275
+ historyTokens,
1276
+ triggerTokens,
1277
+ keepRecentTokens,
1278
+ prefixMessageCount: initialPrefixEnd,
1279
+ retainedMessageCount: history.length - initialPrefixEnd,
1167
1280
  });
1168
- const history = this.history.slice();
1169
- const summaryWithUsage = await generateSummaryWithUsage(history as AgentMessage[], model, 4_000, apiKey, headers);
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
- this.history = [{
1326
+ const summaryMessage = {
1179
1327
  role: "assistant",
1180
- content: [{ type: "text", text: `Previous contemplator context summary:\n${summary}` }],
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
- this.pi.appendEntry(CONTEMPLATOR_MESSAGE, { version: 1, compacted: true, message: this.history[0] });
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
- summaryLength: summary.length,
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 successfullyCompleted
317
- ? nextSummarizerTriggerTokens(targetTokens, postRunOldTokens, retriggerTokens)
318
- : currentTriggerTokens;
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 || runtime.summarizerInFlight) return;
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
- if (pools.oldTokens <= summarizerTriggerTokens(runtime)) return;
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
- void runtime.launchSummarizerTask(ctx, async () => withDebugLogContext({
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
- // A failed/no-progress model pass must not be retried with the
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;