@matthewfl/pi-contemplator 0.3.1 → 0.4.1

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/README.md CHANGED
@@ -157,7 +157,7 @@ The observer and summarizer provide the durable memory substrate. The contemplat
157
157
 
158
158
  ## Installation
159
159
 
160
- `pi-contemplator` 0.3.0 requires **Pi 0.87.x**.
160
+ `pi-contemplator` 0.4.0 requires **Pi 1.0.0** or later.
161
161
 
162
162
  Install the published package through Pi:
163
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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,20 +41,20 @@
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-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"
44
+ "test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-contemplator-active-time.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
- "@earendil-works/pi-agent-core": "^0.87.0",
48
- "@earendil-works/pi-ai": "^0.87.0",
49
- "@earendil-works/pi-coding-agent": "^0.87.0",
50
- "@earendil-works/pi-tui": "^0.87.0"
47
+ "@earendil-works/pi-agent-core": "^1.0.0",
48
+ "@earendil-works/pi-ai": "^1.0.0",
49
+ "@earendil-works/pi-coding-agent": "^1.0.0",
50
+ "@earendil-works/pi-tui": "^1.0.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-agent-core": "^0.87.0",
54
- "@earendil-works/pi-ai": "^0.87.0",
55
- "@earendil-works/pi-coding-agent": "^0.87.0",
56
- "@earendil-works/pi-server": "^0.87.0",
57
- "@earendil-works/pi-tui": "^0.87.0",
53
+ "@earendil-works/pi-agent-core": "^1.0.0",
54
+ "@earendil-works/pi-ai": "^1.0.0",
55
+ "@earendil-works/pi-coding-agent": "^1.0.0",
56
+ "@earendil-works/pi-server": "^1.0.0",
57
+ "@earendil-works/pi-tui": "^1.0.0",
58
58
  "@types/node": "^22.0.0",
59
59
  "typebox": "^1.1.38",
60
60
  "typescript": "^5.6.0",
@@ -1,5 +1,6 @@
1
1
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
2
2
  import { Type, type Message, type Model } from "@earendil-works/pi-ai";
3
+ import { isRetryableAssistantError } from "@earendil-works/pi-ai/utils/retry";
3
4
  import type { Static } from "typebox";
4
5
  import { estimateTokens as estimateAgentMessageTokens, generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
5
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -23,6 +24,8 @@ const CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS = 40_000;
23
24
  const CONTEMPLATOR_HISTORY_CONTEXT_FRACTION = 0.25;
24
25
  const CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS = 12_000;
25
26
  const CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS = 16_000;
27
+ const CONTEMPLATOR_UPDATE_MAX_TOKENS = 20_000;
28
+ const CONTEMPLATOR_UPDATE_CONTEXT_FRACTION = 0.2;
26
29
  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.";
27
30
 
28
31
  function contemplatorMessageTokens(message: AgentMessage): number {
@@ -84,6 +87,8 @@ interface PendingUpdate {
84
87
  mainAgentOutputTokens: number;
85
88
  mainAgentToolCalls: number;
86
89
  mainAgentActiveTimeMs: number;
90
+ /** True when cumulative active work, rather than memory volume, forced this update. */
91
+ activeTimeFallback: boolean;
87
92
  }
88
93
 
89
94
  type Intervention =
@@ -143,6 +148,72 @@ function reviewSummaryLine(review: ReviewResult): string {
143
148
  : `[${review.id}] ${review.scope} review concluded with no proposal — ${review.reason}`;
144
149
  }
145
150
 
151
+ function memoryLineId(line: string): string | undefined {
152
+ return line.match(/^\[([0-9a-f]{12})\]/)?.[1];
153
+ }
154
+
155
+ function contemplatorUpdateBudgetTokens(model: Model<any>): number {
156
+ const contextWindow = Number(model.contextWindow);
157
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return CONTEMPLATOR_UPDATE_MAX_TOKENS;
158
+ return Math.max(1_024, Math.min(CONTEMPLATOR_UPDATE_MAX_TOKENS, Math.floor(contextWindow * CONTEMPLATOR_UPDATE_CONTEXT_FRACTION)));
159
+ }
160
+
161
+ /**
162
+ * Keep the newest pending records within a bounded request budget. The complete
163
+ * ids are persisted as coverage separately, so intentionally omitted old
164
+ * records do not reappear after reload even though their bodies are not copied
165
+ * into the private contemplator transcript.
166
+ */
167
+ function boundedContemplatorUpdate(observations: string[], reviews: string[], budgetTokens: number): {
168
+ observations: string[];
169
+ reviews: string[];
170
+ omitted: number;
171
+ } {
172
+ type Candidate = { kind: "observation" | "review"; index: number; line: string };
173
+ // Reviews are rare terminal outcomes and get first claim on the budget. Within
174
+ // each section, newest records are selected first and rendered chronologically.
175
+ const candidates: Candidate[] = [
176
+ ...reviews.map((line, index) => ({ kind: "review" as const, index, line })).reverse(),
177
+ ...observations.map((line, index) => ({ kind: "observation" as const, index, line })).reverse(),
178
+ ];
179
+ const selected = new Map<Candidate, string>();
180
+ let remaining = budgetTokens;
181
+ for (const candidate of candidates) {
182
+ const tokens = Math.max(1, Math.ceil(candidate.line.length / 4));
183
+ if (tokens <= remaining) {
184
+ selected.set(candidate, candidate.line);
185
+ remaining -= tokens;
186
+ continue;
187
+ }
188
+ // Always expose at least the newest record. An individually enormous memory
189
+ // is excerpted rather than defeating the safety cap; recall retains the full
190
+ // source under the id shown at the beginning of the excerpt.
191
+ if (selected.size === 0 && remaining > 0) {
192
+ const maxChars = Math.max(256, remaining * 4);
193
+ selected.set(candidate, `${candidate.line.slice(0, Math.max(1, maxChars - 96))}\n… [memory excerpt capped; use recall for the full record]`);
194
+ remaining = 0;
195
+ }
196
+ }
197
+ const selectedObservations = candidates
198
+ .filter((candidate) => candidate.kind === "observation" && selected.has(candidate))
199
+ .sort((left, right) => left.index - right.index)
200
+ .map((candidate) => selected.get(candidate)!);
201
+ const selectedReviews = candidates
202
+ .filter((candidate) => candidate.kind === "review" && selected.has(candidate))
203
+ .sort((left, right) => left.index - right.index)
204
+ .map((candidate) => selected.get(candidate)!);
205
+ return {
206
+ observations: selectedObservations,
207
+ reviews: selectedReviews,
208
+ omitted: observations.length + reviews.length - selected.size,
209
+ };
210
+ }
211
+
212
+ function retryableContemplatorFailure(message: string): boolean {
213
+ return isRetryableAssistantError({ stopReason: "error", errorMessage: message } as any)
214
+ || /produced no progress|runtime ceiling/i.test(message);
215
+ }
216
+
146
217
  function reviewRequestKey(request: RequestReviewArgs): string {
147
218
  return `${request.scope}:${hashId(`${request.evidence}\n${request.concern}`)}`;
148
219
  }
@@ -171,6 +242,8 @@ function coarseAgentTime(durationMs: number): string {
171
242
  }
172
243
  const CONTEMPLATOR_MESSAGE = "om.contemplator.message";
173
244
  const CONTEMPLATOR_STATE = "om.contemplator.state";
245
+ /** Durable per-branch baseline for the cumulative active-work fallback trigger. */
246
+ const CONTEMPLATOR_RUN = "om.contemplator.run";
174
247
  const CONTEMPLATOR_SUGGESTION = "om.contemplator.suggestion";
175
248
  const REVIEW_PROPOSAL_MESSAGE = "om.review.proposal";
176
249
  const SendProbeSchema = Type.Object({ question: Type.String({ minLength: 1, description: "One concise, memory-grounded probing question, optionally preceded by one short sentence of context. Cite relevant memory identifiers." }) });
@@ -292,6 +365,8 @@ export class Contemplator {
292
365
  private queuedProbeIds = new Set<string>();
293
366
  /** Probes whose provider-context delivery will establish the next response-spacing anchor. */
294
367
  private probeCooldownPendingIds = new Set<string>();
368
+ /** Preserve the configured response gap after a probe actually reaches provider context. */
369
+ private probeDeliverySpacingActive = false;
295
370
  private sessionGeneration = 0;
296
371
  private latestCtx: MemoryUpdateCtx | undefined;
297
372
  /** Completed primary-model responses since the current completion/probe-delivery spacing anchor. */
@@ -301,6 +376,10 @@ export class Contemplator {
301
376
  private restoredTipId: string | undefined;
302
377
  /** Start of the unpersisted portion of the current main-agent run. */
303
378
  private agentActiveSince: number | undefined;
379
+ /** Cumulative active time at the latest contemplator attempt on this branch. */
380
+ private lastContemplatorActiveTimeMs = 0;
381
+ /** Fires while the primary agent remains active, including inside one long tool call. */
382
+ private activeTimeFallbackTimer: ReturnType<typeof setTimeout> | undefined;
304
383
 
305
384
  constructor(private readonly pi: ExtensionAPI, private readonly runtime: Runtime) {}
306
385
 
@@ -322,12 +401,14 @@ export class Contemplator {
322
401
  return box;
323
402
  });
324
403
  this.runtime.setMemoryUpdateListener((ctx) => this.withDebugContext(ctx, () => this.observeTurn(ctx)));
325
- this.pi.on("agent_start", () => {
404
+ this.pi.on("agent_start", (_event: unknown, ctx: ExtensionContext) => {
326
405
  this.agentActiveSince = Date.now();
406
+ this.withDebugContext(ctx, () => this.scheduleActiveTimeFallback(ctx));
327
407
  });
328
408
  this.pi.on("agent_end", (_event: unknown, ctx: ExtensionContext) => {
329
409
  this.persistAgentActivity(ctx);
330
410
  this.agentActiveSince = undefined;
411
+ this.clearActiveTimeFallbackTimer();
331
412
  });
332
413
  this.pi.on("session_start", (event: any, ctx: ExtensionContext) => {
333
414
  this.sessionGeneration++;
@@ -336,11 +417,20 @@ export class Contemplator {
336
417
  this.consecutiveFlushFailures = 0;
337
418
  const generation = this.sessionGeneration;
338
419
  this.agentActiveSince = undefined;
420
+ this.clearActiveTimeFallbackTimer();
339
421
  // AgentSession preserves its steering queue across extension reloads. An
340
422
  // undelivered tracking entry therefore still has a live queued message;
341
423
  // restoring it here would enqueue the same probe a second time.
342
424
  const reload = event?.reason === "reload";
343
425
  this.restore(ctx, true, reload, reload);
426
+ // Reload replaces the extension runtime without restarting an already-active
427
+ // agent run, so no new agent_start event is guaranteed. Recover the clock
428
+ // from Pi's live state; elapsed time before reload may be unavailable, but
429
+ // the current run remains monitored rather than going unarmed indefinitely.
430
+ if (!ctx.isIdle()) {
431
+ this.agentActiveSince = Date.now();
432
+ this.withDebugContext(ctx, () => this.scheduleActiveTimeFallback(ctx));
433
+ }
344
434
  // Reconstruct and schedule durable memory immediately. In particular, a
345
435
  // reload after a failed run must not silently mark its pending backlog seen.
346
436
  queueMicrotask(() => {
@@ -353,15 +443,22 @@ export class Contemplator {
353
443
  this.running = false;
354
444
  this.consecutiveFlushFailures = 0;
355
445
  this.agentActiveSince = undefined;
446
+ this.clearActiveTimeFallbackTimer();
356
447
  // Pending steering messages remain queued while navigating the tree.
357
448
  this.restore(ctx, true, true);
358
449
  });
359
- this.pi.on("session_shutdown", () => {
450
+ this.pi.on("session_shutdown", (_event: unknown, ctx: ExtensionContext) => {
451
+ // On extension reload Pi shuts down the old runtime before starting the
452
+ // replacement. Persist its final live interval without launching workers,
453
+ // so the replacement can continue from the exact cumulative clock.
454
+ this.persistAgentActivity(ctx, false, false);
360
455
  this.sessionGeneration++;
361
456
  this.flushEpoch++;
362
457
  this.running = false;
363
458
  this.consecutiveFlushFailures = 0;
364
459
  this.agentActiveSince = undefined;
460
+ this.clearActiveTimeFallbackTimer();
461
+ this.lastContemplatorActiveTimeMs = 0;
365
462
  this.history = [];
366
463
  this.historyEntryIds = [];
367
464
  this.pending = undefined;
@@ -375,6 +472,7 @@ export class Contemplator {
375
472
  this.deliveredProbeIds.clear();
376
473
  this.queuedProbeIds.clear();
377
474
  this.probeCooldownPendingIds.clear();
475
+ this.probeDeliverySpacingActive = false;
378
476
  this.latestCtx = undefined;
379
477
  this.turnsSinceRun = 0;
380
478
  this.assistantResponsesInCurrentTurn = 0;
@@ -384,6 +482,7 @@ export class Contemplator {
384
482
  pendingObservations: 0,
385
483
  pendingReviews: 0,
386
484
  responsesSinceRun: 0,
485
+ activeTimeSinceRunMs: 0,
387
486
  waitingFor: "idle",
388
487
  };
389
488
  });
@@ -419,6 +518,9 @@ export class Contemplator {
419
518
  // This records one wall-clock interval regardless of how many tools were
420
519
  // running concurrently; persistAgentActivity restarts the shared clock.
421
520
  this.persistAgentActivity(ctx);
521
+ // A long autonomous turn can perform many tools without ending a model
522
+ // turn. Recheck the active-work fallback at each completed tool batch.
523
+ this.withDebugContext(ctx, () => this.observeTurn(ctx));
422
524
  });
423
525
  this.pi.on("context", (event: any, ctx: ExtensionContext) => {
424
526
  const deliveredMessages = event.messages?.filter((message: any) => message?.role === "custom" && message.customType === CONTEMPLATOR_SUGGESTION && typeof message.details?.probeId === "string") ?? [];
@@ -444,6 +546,7 @@ export class Contemplator {
444
546
  if (cooldownAnchored) {
445
547
  // Probe spacing begins only once Pi proves the probe reached an actual
446
548
  // provider context. Responses generated before this point do not count.
549
+ this.probeDeliverySpacingActive = true;
447
550
  this.turnsSinceRun = 0;
448
551
  this.withDebugContext(ctx, () => this.observeTurn(ctx));
449
552
  }
@@ -458,18 +561,46 @@ export class Contemplator {
458
561
  });
459
562
  }
460
563
 
461
- private persistAgentActivity(ctx: MemoryUpdateCtx): void {
564
+ private clearActiveTimeFallbackTimer(): void {
565
+ if (this.activeTimeFallbackTimer !== undefined) clearTimeout(this.activeTimeFallbackTimer);
566
+ this.activeTimeFallbackTimer = undefined;
567
+ }
568
+
569
+ private scheduleActiveTimeFallback(ctx: MemoryUpdateCtx): void {
570
+ this.clearActiveTimeFallbackTimer();
571
+ if (this.agentActiveSince === undefined || !this.runtime.config.contemplatorEnabled || this.runtime.config.passive) return;
572
+ const persistedActiveMs = agentActiveTimeMs(ctx.sessionManager.getBranch() as Entry[]);
573
+ const liveActiveMs = Math.max(0, Date.now() - this.agentActiveSince);
574
+ const elapsedMs = Math.max(0, persistedActiveMs + liveActiveMs - this.lastContemplatorActiveTimeMs);
575
+ const thresholdMs = this.runtime.config.contemplatorMaxActiveMinutes * 60_000;
576
+ const delayMs = Math.max(1, thresholdMs - elapsedMs);
577
+ this.activeTimeFallbackTimer = setTimeout(() => {
578
+ this.activeTimeFallbackTimer = undefined;
579
+ if (this.agentActiveSince === undefined) return;
580
+ this.withDebugContext(ctx, () => {
581
+ // Materialize the live interval before observeTurn reads the ledger. This
582
+ // lets a sleep/long tool trigger contemplation while it is still running.
583
+ this.persistAgentActivity(ctx, false);
584
+ this.observeTurn(ctx);
585
+ });
586
+ }, delayMs);
587
+ (this.activeTimeFallbackTimer as { unref?: () => void }).unref?.();
588
+ }
589
+
590
+ private persistAgentActivity(ctx: MemoryUpdateCtx, reschedule = true, notify = true): void {
462
591
  const startedAt = this.agentActiveSince;
463
592
  if (startedAt === undefined) return;
464
593
  const endedAt = Date.now();
465
594
  this.agentActiveSince = endedAt;
466
595
  const durationMs = Math.max(0, endedAt - startedAt);
467
- if (durationMs === 0) return;
468
- this.pi.appendEntry(OM_AGENT_ACTIVITY, { version: 1, durationMs, endedAt });
469
- // Notify only after appendEntry so active-time schedulers always observe the
470
- // checkpoint, regardless of Pi's ordering between independent event handlers.
471
- this.runtime.notifyAgentActivity(ctx);
472
- debugLog("agent.activity_recorded", { durationMs });
596
+ if (durationMs > 0) {
597
+ this.pi.appendEntry(OM_AGENT_ACTIVITY, { version: 1, durationMs, endedAt });
598
+ // Notify only after appendEntry so active-time schedulers always observe the
599
+ // checkpoint, regardless of Pi's ordering between independent event handlers.
600
+ if (notify) this.runtime.notifyAgentActivity(ctx);
601
+ debugLog("agent.activity_recorded", { durationMs });
602
+ }
603
+ if (reschedule) this.scheduleActiveTimeFallback(ctx);
473
604
  }
474
605
 
475
606
  private withDebugContext<T>(ctx: MemoryUpdateCtx, fn: () => T): T {
@@ -493,6 +624,7 @@ export class Contemplator {
493
624
  pendingObservations: this.pending?.observations.length ?? 0,
494
625
  pendingReviews: this.pending?.reviews.length ?? 0,
495
626
  responsesSinceRun: this.turnsSinceRun,
627
+ activeTimeSinceRunMs: this.runtime.contemplatorState.activeTimeSinceRunMs,
496
628
  waitingFor,
497
629
  ...overrides,
498
630
  };
@@ -509,10 +641,13 @@ export class Contemplator {
509
641
  const historyMessagesByEntryId = new Map<string, AgentMessage>();
510
642
  const checkpointCoveredObservationIds = new Set<string>();
511
643
  const checkpointCoveredReviewIds = new Set<string>();
644
+ let latestRunActiveTimeMs: number | undefined;
645
+ let probeDeliveredAfterLatestRun = false;
512
646
  let resetProjection: ReturnType<typeof fullProjection> | undefined;
513
647
  if (resetTracking) {
514
648
  this.deliveredProbeIds.clear();
515
649
  this.probeCooldownPendingIds.clear();
650
+ this.probeDeliverySpacingActive = false;
516
651
  if (!retainQueuedIds) this.queuedProbeIds.clear();
517
652
  this.inFlightReviewIds.clear();
518
653
  this.resolvingReviewIds.clear();
@@ -529,11 +664,19 @@ export class Contemplator {
529
664
  pendingObservations: 0,
530
665
  pendingReviews: 0,
531
666
  responsesSinceRun: 0,
667
+ activeTimeSinceRunMs: 0,
532
668
  waitingFor: "idle",
533
669
  };
534
670
  }
535
671
  const undeliveredSuggestions = new Map<string, string>();
536
672
  for (const entry of entries) {
673
+ if (entry.customType === CONTEMPLATOR_RUN && entry.data && typeof entry.data === "object") {
674
+ const activeTimeMs = (entry.data as { mainAgentActiveTimeMs?: unknown }).mainAgentActiveTimeMs;
675
+ if (typeof activeTimeMs === "number" && Number.isFinite(activeTimeMs) && activeTimeMs >= 0) {
676
+ latestRunActiveTimeMs = activeTimeMs;
677
+ probeDeliveredAfterLatestRun = false;
678
+ }
679
+ }
537
680
  if (entry.customType === CONTEMPLATOR_STATE && entry.data && typeof entry.data === "object") {
538
681
  const state = entry.data as { history?: unknown };
539
682
  if (Array.isArray(state.history)) {
@@ -543,6 +686,11 @@ export class Contemplator {
543
686
  }
544
687
  if (entry.customType === CONTEMPLATOR_MESSAGE && entry.data && typeof entry.data === "object") {
545
688
  const data = entry.data as { message?: unknown; compacted?: unknown; retainedMessageEntryIds?: unknown; coveredObservationIds?: unknown; coveredReviewIds?: unknown };
689
+ // Coverage may be broader than the rendered prompt when a long-dead
690
+ // contemplator returns to a large backlog. Persisting compact id lists
691
+ // avoids copying every old memory body while keeping restore stable.
692
+ if (Array.isArray(data.coveredObservationIds)) for (const id of data.coveredObservationIds) if (typeof id === "string") checkpointCoveredObservationIds.add(id);
693
+ if (Array.isArray(data.coveredReviewIds)) for (const id of data.coveredReviewIds) if (typeof id === "string") checkpointCoveredReviewIds.add(id);
546
694
  const message = data.message;
547
695
  if (message && typeof message === "object") {
548
696
  const typedMessage = message as AgentMessage;
@@ -553,8 +701,6 @@ export class Contemplator {
553
701
  : [];
554
702
  this.history = [typedMessage, ...retainedIds.map((id) => historyMessagesByEntryId.get(id)!)];
555
703
  this.historyEntryIds = [entry.id, ...retainedIds];
556
- if (Array.isArray(data.coveredObservationIds)) for (const id of data.coveredObservationIds) if (typeof id === "string") checkpointCoveredObservationIds.add(id);
557
- if (Array.isArray(data.coveredReviewIds)) for (const id of data.coveredReviewIds) if (typeof id === "string") checkpointCoveredReviewIds.add(id);
558
704
  } else {
559
705
  this.history.push(typedMessage);
560
706
  this.historyEntryIds.push(entry.id);
@@ -607,12 +753,19 @@ export class Contemplator {
607
753
  if (typeof data.probeId !== "string") continue;
608
754
  if (data.delivered === true) {
609
755
  this.deliveredProbeIds.add(data.probeId);
756
+ probeDeliveredAfterLatestRun = true;
610
757
  undeliveredSuggestions.delete(data.probeId);
611
758
  } else if (typeof data.suggestion === "string") {
612
759
  undeliveredSuggestions.set(data.probeId, data.suggestion);
613
760
  }
614
761
  }
615
762
  }
763
+ // A malformed/foreign marker must not regress a valid live baseline during
764
+ // an ordinary tip refresh. A true session/tree restore with no valid marker
765
+ // intentionally starts this branch's cumulative baseline at zero.
766
+ if (latestRunActiveTimeMs !== undefined) this.lastContemplatorActiveTimeMs = latestRunActiveTimeMs;
767
+ else if (resetTracking) this.lastContemplatorActiveTimeMs = 0;
768
+ this.probeDeliverySpacingActive = probeDeliveredAfterLatestRun;
616
769
  if (resetTracking && resetProjection) {
617
770
  // Successful update prompts and private-history compaction checkpoints are
618
771
  // the durable coverage record. Checkpoints carry ids from prompts replaced
@@ -681,6 +834,10 @@ export class Contemplator {
681
834
  });
682
835
  return;
683
836
  }
837
+ const cumulativeActiveTimeMs = agentActiveTimeMs(branchEntries) + (this.agentActiveSince === undefined ? 0 : Math.max(0, Date.now() - this.agentActiveSince));
838
+ const activeTimeSinceRunMs = Math.max(0, cumulativeActiveTimeMs - this.lastContemplatorActiveTimeMs);
839
+ const activeTimeThresholdMs = this.runtime.config.contemplatorMaxActiveMinutes * 60_000;
840
+ const activeTimeDue = activeTimeSinceRunMs >= activeTimeThresholdMs;
684
841
  const projection = fullProjection(branchEntries);
685
842
  const observations = projection.observations.map((item) => `[${item.id}] ${item.content}`);
686
843
  const reviews = projection.reviews ?? [];
@@ -704,11 +861,25 @@ export class Contemplator {
704
861
  reviews: mergeMemoryLines(this.pending?.reviews ?? [], newReviews),
705
862
  mainAgentOutputTokens: assistantOutputTokens(branchEntries),
706
863
  mainAgentToolCalls: assistantToolCallCount(branchEntries),
707
- mainAgentActiveTimeMs: agentActiveTimeMs(branchEntries),
864
+ mainAgentActiveTimeMs: cumulativeActiveTimeMs,
865
+ activeTimeFallback: this.pending?.activeTimeFallback ?? false,
866
+ };
867
+ }
868
+ if (activeTimeDue && !this.pending) {
869
+ this.pending = {
870
+ observations: [],
871
+ reviews: [],
872
+ mainAgentOutputTokens: assistantOutputTokens(branchEntries),
873
+ mainAgentToolCalls: assistantToolCallCount(branchEntries),
874
+ mainAgentActiveTimeMs: cumulativeActiveTimeMs,
875
+ activeTimeFallback: true,
708
876
  };
877
+ } else if (activeTimeDue && this.pending) {
878
+ this.pending.activeTimeFallback = true;
709
879
  }
710
880
  if (!this.pending) {
711
- this.publishState(this.running ? "running" : this.probeCooldownPendingIds.size > 0 ? "probe" : "idle");
881
+ this.scheduleActiveTimeFallback(ctx);
882
+ this.publishState(this.running ? "running" : this.probeCooldownPendingIds.size > 0 ? "probe" : "idle", { activeTimeSinceRunMs });
712
883
  return;
713
884
  }
714
885
  // Activity values are cumulative send-time snapshots, not values frozen when
@@ -716,15 +887,17 @@ export class Contemplator {
716
887
  // while that batch waits for its memory/turn thresholds.
717
888
  this.pending.mainAgentOutputTokens = assistantOutputTokens(branchEntries);
718
889
  this.pending.mainAgentToolCalls = assistantToolCallCount(branchEntries);
719
- this.pending.mainAgentActiveTimeMs = agentActiveTimeMs(branchEntries);
890
+ this.pending.mainAgentActiveTimeMs = cumulativeActiveTimeMs;
720
891
  const enoughMemories = this.pending.reviews.length > 0 || this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations;
892
+ const forcedByActiveTime = this.pending.activeTimeFallback;
721
893
  if (this.probeCooldownPendingIds.size > 0) {
722
- this.publishState("probe");
894
+ this.publishState("probe", { activeTimeSinceRunMs });
723
895
  debugLog("contemplator.waiting", { reason: "probe_delivery", pendingProbeCount: this.probeCooldownPendingIds.size });
724
896
  return;
725
897
  }
726
- if (!enoughMemories || this.turnsSinceRun < this.runtime.config.contemplatorMinTurns) {
727
- this.publishState(!enoughMemories ? "memories" : "responses");
898
+ const waitingForDeliveredProbeResponses = this.probeDeliverySpacingActive && this.turnsSinceRun < this.runtime.config.contemplatorMinTurns;
899
+ if (waitingForDeliveredProbeResponses || (!forcedByActiveTime && (!enoughMemories || this.turnsSinceRun < this.runtime.config.contemplatorMinTurns))) {
900
+ this.publishState(waitingForDeliveredProbeResponses || enoughMemories ? "responses" : "memories", { activeTimeSinceRunMs });
728
901
  debugLog("contemplator.waiting", {
729
902
  enoughMemories,
730
903
  turnsSinceRun: this.turnsSinceRun,
@@ -733,11 +906,13 @@ export class Contemplator {
733
906
  });
734
907
  return;
735
908
  }
736
- this.publishState(this.running ? "running" : "ready");
909
+ this.publishState(this.running ? "running" : "ready", { activeTimeSinceRunMs });
737
910
  debugLog("contemplator.triggered", {
738
911
  pendingObservationCount: this.pending.observations.length,
739
912
  pendingReviewCount: this.pending.reviews.length,
740
913
  turnsSinceRun: this.turnsSinceRun,
914
+ forcedByActiveTime,
915
+ activeTimeSinceRunMs,
741
916
  });
742
917
  void this.flush(ctx);
743
918
  }
@@ -756,6 +931,19 @@ export class Contemplator {
756
931
  update.mainAgentOutputTokens = assistantOutputTokens(branchEntriesAtStart);
757
932
  update.mainAgentToolCalls = assistantToolCallCount(branchEntriesAtStart);
758
933
  update.mainAgentActiveTimeMs = agentActiveTimeMs(branchEntriesAtStart);
934
+ const activeTimeSinceLastRunMs = Math.max(0, update.mainAgentActiveTimeMs - this.lastContemplatorActiveTimeMs);
935
+ // Reset the active-work interval at attempt start, including failed attempts,
936
+ // so a broken provider cannot create an immediate background retry loop. The
937
+ // tiny durable marker makes reloads and tree navigation preserve that backoff.
938
+ this.lastContemplatorActiveTimeMs = update.mainAgentActiveTimeMs;
939
+ this.probeDeliverySpacingActive = false;
940
+ this.scheduleActiveTimeFallback(ctx);
941
+ this.pi.appendEntry(CONTEMPLATOR_RUN, {
942
+ version: 1,
943
+ mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
944
+ startedAt: Date.now(),
945
+ });
946
+ this.markTipPersisted(ctx);
759
947
  this.pending = undefined;
760
948
  const turnsBeforeRun = this.turnsSinceRun;
761
949
  const sessionGeneration = this.sessionGeneration;
@@ -764,6 +952,7 @@ export class Contemplator {
764
952
  this.turnsSinceRun = 0;
765
953
  const startedAt = Date.now();
766
954
  let failed = false;
955
+ let transientFailure = false;
767
956
  let failureMessage: string | undefined;
768
957
  let workerNotified = false;
769
958
  let promptPersisted = false;
@@ -797,6 +986,7 @@ export class Contemplator {
797
986
  mainAgentOutputTokens: update.mainAgentOutputTokens,
798
987
  mainAgentToolCalls: update.mainAgentToolCalls,
799
988
  mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
989
+ activeTimeFallback: update.activeTimeFallback,
800
990
  };
801
991
  } else {
802
992
  debugLog("contemplator.poisoned_update_released", { reason: resolved.reason, observationCount: update.observations.length, reviewCount: update.reviews.length });
@@ -823,17 +1013,41 @@ export class Contemplator {
823
1013
  workerNotified = true;
824
1014
  }
825
1015
  const reviewerEnabled = this.runtime.config.reviewerEnabled;
1016
+ const boundedUpdate = boundedContemplatorUpdate(
1017
+ update.observations,
1018
+ update.reviews,
1019
+ contemplatorUpdateBudgetTokens(resolved.model as Model<any>),
1020
+ );
826
1021
  const updateSections: string[] = [];
827
- if (update.observations.length > 0) updateSections.push(`OBSERVATIONS:\n${update.observations.join("\n")}`);
828
- if (update.reviews.length > 0) updateSections.push(`REVIEWS:\n${update.reviews.join("\n")}`);
1022
+ if (boundedUpdate.observations.length > 0) updateSections.push(`OBSERVATIONS:\n${boundedUpdate.observations.join("\n")}`);
1023
+ if (boundedUpdate.reviews.length > 0) updateSections.push(`REVIEWS:\n${boundedUpdate.reviews.join("\n")}`);
1024
+ if (boundedUpdate.omitted > 0) updateSections.push(`BACKLOG SAFETY CAP: ${boundedUpdate.omitted} older pending memory record${boundedUpdate.omitted === 1 ? " was" : "s were"} omitted from this update. They remain searchable and recallable; focus on the newest records shown here.`);
829
1025
  const updateBody = updateSections.length > 0 ? updateSections.join("\n\n") : "(no new memories)";
1026
+ const coveredObservationIds = update.observations.map(memoryLineId).filter((id): id is string => id !== undefined);
1027
+ const coveredReviewIds = update.reviews.map(memoryLineId).filter((id): id is string => id !== undefined);
1028
+ debugLog("contemplator.update_bounded", {
1029
+ observationCount: update.observations.length,
1030
+ reviewCount: update.reviews.length,
1031
+ renderedObservationCount: boundedUpdate.observations.length,
1032
+ renderedReviewCount: boundedUpdate.reviews.length,
1033
+ omittedCount: boundedUpdate.omitted,
1034
+ });
1035
+ const sparseMemoryDescription = update.observations.length + update.reviews.length === 0
1036
+ ? "No new memories were created during this interval."
1037
+ : `Only ${update.observations.length + update.reviews.length} new memory record${update.observations.length + update.reviews.length === 1 ? " was" : "s were"} created during this interval.`;
1038
+ const reviewerHistoryHint = reviewerEnabled
1039
+ ? " If a recurring concern warrants structural review, remember that the reviewer can inspect the primary agent's recorded chat directly with its chat-history tools; it is not limited to these sparse memories."
1040
+ : "";
1041
+ const activeTimeNotice = update.activeTimeFallback
1042
+ ? `\n\nACTIVE-WORK FALLBACK: ${sparseMemoryDescription} The primary agent has been actively working for ${coarseAgentTime(activeTimeSinceLastRunMs)} since the last contemplator run. Check whether it is productively making progress rather than being stuck in an unproductive loop.${reviewerHistoryHint} Prefer no_intervention if there is no grounded, useful action.`
1043
+ : "";
830
1044
  const finalActionNames = reviewerEnabled
831
1045
  ? "send_probe, request_review, or no_intervention"
832
1046
  : "send_probe or no_intervention";
833
1047
  const interventionInstruction = reviewerEnabled
834
1048
  ? `You must end this update by calling exactly one final-action tool: ${finalActionNames}. The tool requirement is bookkeeping, not a reason to intervene. Prefer the argument-free no_intervention whenever no specific, grounded, materially useful intervention is clearly warranted or usefulness is uncertain. Use send_probe only for one unusually useful focused question, and request_review only when a deeper workflow or software review is justified. Never send a probe merely to satisfy the final-action requirement. If a tool warns about a bad memory citation, use search_memories and recall, then call a final-action tool again to replace it.`
835
1049
  : `You must end this update by calling exactly one final-action tool: ${finalActionNames}. The tool requirement is bookkeeping, not a reason to intervene. Prefer the argument-free no_intervention whenever no specific, grounded, materially useful probe is clearly warranted or usefulness is uncertain. Use send_probe only for one unusually useful focused question. Never send a probe merely to satisfy the final-action requirement. If send_probe warns about a bad memory citation, use search_memories and recall, then call a final-action tool again to replace it.`;
836
- const prompt: Message = { role: "user", content: [{ type: "text", text: `NEW MEMORY UPDATE\n\n${updateBody}\n\nCUMULATIVE ACTIVITY: ${update.mainAgentOutputTokens} generated tokens; ${update.mainAgentToolCalls} tool calls; ${coarseAgentTime(update.mainAgentActiveTimeMs)} active.\n\nConsider these updates in the context of the accumulated memories. Prioritize reasoning gaps, contradictions, user-intent alignment, relevant overlooked alternatives, well-supported loops, and recurring structural patterns. ${interventionInstruction}` }], timestamp: Date.now() };
1050
+ const prompt: Message = { role: "user", content: [{ type: "text", text: `NEW MEMORY UPDATE\n\n${updateBody}${activeTimeNotice}\n\nCUMULATIVE ACTIVITY: ${update.mainAgentOutputTokens} generated tokens; ${update.mainAgentToolCalls} tool calls; ${coarseAgentTime(update.mainAgentActiveTimeMs)} active.\n\nConsider these updates in the context of the accumulated memories. Prioritize reasoning gaps, contradictions, user-intent alignment, relevant overlooked alternatives, well-supported loops, and recurring structural patterns. ${interventionInstruction}` }], timestamp: Date.now() };
837
1051
  let intervention: Intervention | undefined;
838
1052
  let finalActionWarned = false;
839
1053
  const branchEntries = ctx.sessionManager.getBranch() as Entry[];
@@ -935,6 +1149,7 @@ export class Contemplator {
935
1149
  const errorMessage = "errorMessage" in assistant && typeof assistant.errorMessage === "string"
936
1150
  ? assistant.errorMessage
937
1151
  : `Contemplator model ${assistant.stopReason}`;
1152
+ transientFailure = assistant.stopReason === "error" && retryableContemplatorFailure(errorMessage);
938
1153
  throw new Error(errorMessage);
939
1154
  }
940
1155
  if (!intervention && invocation < CONTEMPLATOR_MAX_INVOCATIONS) {
@@ -949,7 +1164,9 @@ export class Contemplator {
949
1164
  for (const message of runMessages) {
950
1165
  if (message.role !== "user" && message.role !== "assistant") continue;
951
1166
  this.history.push(message);
952
- this.historyEntryIds.push(this.appendContemplatorHistoryEntry(ctx, { version: 1, message }));
1167
+ this.historyEntryIds.push(this.appendContemplatorHistoryEntry(ctx, message === prompt
1168
+ ? { version: 2, message, coveredObservationIds, coveredReviewIds }
1169
+ : { version: 1, message }));
953
1170
  promptPersisted = true;
954
1171
  this.markTipPersisted(ctx);
955
1172
  }
@@ -989,10 +1206,12 @@ export class Contemplator {
989
1206
  } catch (error) {
990
1207
  failed = true;
991
1208
  failureMessage = error instanceof Error ? error.message : String(error);
992
- debugLog("contemplator.error", { errorMessage: failureMessage });
1209
+ transientFailure ||= retryableContemplatorFailure(failureMessage);
1210
+ debugLog("contemplator.error", { errorMessage: failureMessage, transientFailure });
993
1211
  if (sessionGeneration === this.sessionGeneration && !promptPersisted) {
994
- this.consecutiveFlushFailures++;
995
- if (this.consecutiveFlushFailures < 2) {
1212
+ if (transientFailure) this.consecutiveFlushFailures = 0;
1213
+ else this.consecutiveFlushFailures++;
1214
+ if (transientFailure || this.consecutiveFlushFailures < 2) {
996
1215
  const pending = this.pending as PendingUpdate | undefined;
997
1216
  this.pending = {
998
1217
  observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
@@ -1000,6 +1219,7 @@ export class Contemplator {
1000
1219
  mainAgentOutputTokens: update.mainAgentOutputTokens,
1001
1220
  mainAgentToolCalls: update.mainAgentToolCalls,
1002
1221
  mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
1222
+ activeTimeFallback: update.activeTimeFallback,
1003
1223
  };
1004
1224
  } else {
1005
1225
  debugLog("contemplator.poisoned_update_released", { reason: failureMessage, observationCount: update.observations.length, reviewCount: update.reviews.length });
@@ -1031,8 +1251,14 @@ export class Contemplator {
1031
1251
  : this.turnsSinceRun < this.runtime.config.contemplatorMinTurns
1032
1252
  ? "responses"
1033
1253
  : "ready";
1254
+ const activeTimeSinceRunMs = Math.max(
1255
+ 0,
1256
+ agentActiveTimeMs(ctx.sessionManager.getBranch() as Entry[]) - this.lastContemplatorActiveTimeMs,
1257
+ );
1258
+ const finishedAt = Date.now();
1034
1259
  this.publishState(waitingFor, {
1035
- lastCompletedAt: Date.now(),
1260
+ activeTimeSinceRunMs,
1261
+ ...(failed ? { lastFailedAt: finishedAt } : { lastCompletedAt: finishedAt, lastFailedAt: undefined }),
1036
1262
  lastError: failureMessage,
1037
1263
  });
1038
1264
  debugLog("contemplator.complete", {
@@ -1378,13 +1604,24 @@ export class Contemplator {
1378
1604
  // per checkpoint, and restore unions checkpoints, avoiding cumulative O(n²)
1379
1605
  // id duplication across a very long session.
1380
1606
  const prefixCoveredIds = new Set<string>();
1381
- for (const message of history.slice(0, prefixEnd)) {
1382
- if (message.role !== "user") continue;
1383
- const text = customMessageText(message.content);
1384
- if (!text.includes("NEW MEMORY UPDATE")) continue;
1385
- for (const id of memoryReferenceIds(text)) prefixCoveredIds.add(id);
1607
+ const branchEntries = ctx.sessionManager.getBranch() as Entry[];
1608
+ const branchById = new Map(branchEntries.map((entry) => [entry.id, entry]));
1609
+ for (let index = 0; index < prefixEnd; index++) {
1610
+ const message = history[index];
1611
+ if (message.role === "user") {
1612
+ const text = customMessageText(message.content);
1613
+ if (text.includes("NEW MEMORY UPDATE")) for (const id of memoryReferenceIds(text)) prefixCoveredIds.add(id);
1614
+ }
1615
+ // A safety-capped prompt intentionally covers records whose bodies were
1616
+ // omitted. Carry those compact id references into the replacement
1617
+ // checkpoint before its original message entry becomes unreachable.
1618
+ const entryId = historyEntryIds[index];
1619
+ const data = entryId === undefined ? undefined : branchById.get(entryId)?.data as { coveredObservationIds?: unknown; coveredReviewIds?: unknown } | undefined;
1620
+ for (const ids of [data?.coveredObservationIds, data?.coveredReviewIds]) {
1621
+ if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") prefixCoveredIds.add(id);
1622
+ }
1386
1623
  }
1387
- const currentProjection = fullProjection(ctx.sessionManager.getBranch() as Entry[]);
1624
+ const currentProjection = fullProjection(branchEntries);
1388
1625
  const checkpoint = {
1389
1626
  version: 2,
1390
1627
  compacted: true,
@@ -60,7 +60,7 @@ function renderMessage(message: StoredMessage, compacted: boolean): string {
60
60
 
61
61
  function liveStateLine(state: ContemplatorRunState): string {
62
62
  const pending = `${state.pendingObservations} observations / ${state.pendingReviews} reviews pending`;
63
- const timing = `Last start: ${state.lastStartedAt === undefined ? "not run this launch" : new Date(state.lastStartedAt).toISOString()} · Last end: ${state.lastCompletedAt === undefined ? "not completed this launch" : new Date(state.lastCompletedAt).toISOString()}`;
63
+ const timing = `Last start: ${state.lastStartedAt === undefined ? "not run this launch" : new Date(state.lastStartedAt).toISOString()} · Last success: ${state.lastCompletedAt === undefined ? "none this launch" : new Date(state.lastCompletedAt).toISOString()} · Last failure: ${state.lastFailedAt === undefined ? "none this launch" : new Date(state.lastFailedAt).toISOString()}`;
64
64
  const error = state.lastError ? `\nLast error: ${state.lastError}` : "";
65
65
  if (state.running) return `LIVE · running for ${Math.max(0, Math.floor((Date.now() - (state.lastStartedAt ?? Date.now())) / 60_000))}m · ${pending}\n${timing}${error}`;
66
66
  const reason = state.waitingFor === "observer"
@@ -10,7 +10,7 @@ type ModelRegistryLike = {
10
10
  getAll(): Array<{ provider: string; id: string }>;
11
11
  find?(provider: string, id: string): { contextWindow?: number } | undefined;
12
12
  };
13
- type NumberSetting = "observeAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinTurns" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens";
13
+ type NumberSetting = "observeAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinTurns" | "contemplatorMaxActiveMinutes" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens";
14
14
  type BooleanSetting = "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled" | "summarizerEnabled" | "compactionObserverEnabled" | "showWorkerNotifications" | "passive" | "debugLog";
15
15
 
16
16
  function modelLabel(model: ConfiguredModel | undefined): string {
@@ -239,6 +239,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
239
239
  `Show contemplator messages: ${scalarLabel(runtime, "showContemplatorMessages")}`,
240
240
  `Contemplator new-observation trigger (count): ${scalarLabel(runtime, "contemplatorMinNewObservations")}`,
241
241
  `Contemplator response spacing (count): ${scalarLabel(runtime, "contemplatorMinTurns")}`,
242
+ `Contemplator maximum active-work interval (minutes): ${scalarLabel(runtime, "contemplatorMaxActiveMinutes")}`,
242
243
  `Summarizer enabled: ${scalarLabel(runtime, "summarizerEnabled")}`,
243
244
  `Summarizer model: ${memoryWorkerModelSettingLabel(runtime, "summarizer")}`,
244
245
  `New memory pool protection budget (tokens): ${scalarLabel(runtime, "newMemoryPoolMaxTokens")}`,
@@ -298,6 +299,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
298
299
  ["Old memory pool target (tokens, advisory):", "oldMemoryPoolTargetTokens", "Old memory pool target (tokens, advisory)"],
299
300
  ["Contemplator new-observation trigger (count):", "contemplatorMinNewObservations", "Contemplator new-observation trigger (count)"],
300
301
  ["Contemplator response spacing (count):", "contemplatorMinTurns", "Contemplator response spacing (count)"],
302
+ ["Contemplator maximum active-work interval (minutes):", "contemplatorMaxActiveMinutes", "Contemplator maximum active-work interval (minutes)"],
301
303
  ["Summarizer old-pool retrigger growth (tokens):", "summarizerRetriggerTokens", "Summarizer old-pool retrigger growth (tokens)"],
302
304
  ["Summarizer input cap before sampling (tokens):", "summarizerSamplingThresholdTokens", "Summarizer input cap before sampling (tokens)"],
303
305
  ];
@@ -129,7 +129,7 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
129
129
  `Cumulative agent time: ${formatDuration(agentActiveTimeMs(entries))}`,
130
130
  `Observe source during compaction: ${runtime.config.compactionObserverEnabled === false ? "disabled" : "enabled"}`,
131
131
  `Contemplator: ${runtime.config.contemplatorEnabled ? "enabled" : "disabled"}`,
132
- `Contemplator trigger: ${runtime.contemplatorState.pendingObservations} observations / ${runtime.contemplatorState.pendingReviews} reviews pending; ${runtime.contemplatorState.responsesSinceRun} / ${runtime.config.contemplatorMinTurns} primary responses; ${contemplatorWaitingLabel(runtime.contemplatorState.waitingFor)}`,
132
+ `Contemplator trigger: ${runtime.contemplatorState.pendingObservations} observations / ${runtime.contemplatorState.pendingReviews} reviews pending; ${runtime.contemplatorState.responsesSinceRun} / ${runtime.config.contemplatorMinTurns} primary responses; ${formatDuration(runtime.contemplatorState.activeTimeSinceRunMs)} / ${formatDuration(runtime.config.contemplatorMaxActiveMinutes * 60_000)} active-work fallback; ${contemplatorWaitingLabel(runtime.contemplatorState.waitingFor)}`,
133
133
  `Contemplator model: ${runtime.config.contemplatorModel ? `${runtime.config.contemplatorModel.provider}/${runtime.config.contemplatorModel.id}` : "current session model"}`,
134
134
  `Contemplator messages: ${runtime.config.showContemplatorMessages ? "visible" : "hidden"}`,
135
135
  `Structural reviewer: ${runtime.config.reviewerEnabled === false ? "disabled" : "enabled"}`,
@@ -147,7 +147,8 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
147
147
  lines.push(`Last summarizer start: ${runtime.lastSummarizerStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.lastSummarizerStartedAt)}`);
148
148
  lines.push(`Last summarizer end: ${runtime.lastSummarizerCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.lastSummarizerCompletedAt)}`);
149
149
  lines.push(`Last contemplator start: ${runtime.contemplatorState.lastStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.contemplatorState.lastStartedAt)}`);
150
- lines.push(`Last contemplator end: ${runtime.contemplatorState.lastCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.contemplatorState.lastCompletedAt)}`);
150
+ lines.push(`Last contemplator success: ${runtime.contemplatorState.lastCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.contemplatorState.lastCompletedAt)}`);
151
+ lines.push(`Last contemplator failure: ${runtime.contemplatorState.lastFailedAt === undefined ? "none this launch" : formatRunAge(runtime.contemplatorState.lastFailedAt)}`);
151
152
 
152
153
  lines.push("", "── Interventions ──");
153
154
  // Probe stats come from the branch ledger (like /om:view contemplator):
package/src/config.ts CHANGED
@@ -67,6 +67,8 @@ export interface Config {
67
67
  contemplatorMinNewObservations: number;
68
68
  /** Minimum primary-model responses after contemplator completion, or after delivery of its probe, before the next run. */
69
69
  contemplatorMinTurns: number;
70
+ /** Maximum cumulative main-agent active time between contemplator attempts, excluding time spent waiting for the user. */
71
+ contemplatorMaxActiveMinutes: number;
70
72
  /** Stateless loss-aware summarizer for the old memory pool. */
71
73
  summarizerEnabled: boolean;
72
74
  /** Additional old-pool tokens required before retrying an above-target pool. */
@@ -92,6 +94,7 @@ export const DEFAULTS: Config = {
92
94
  reviewerEnabled: true,
93
95
  contemplatorMinNewObservations: 8,
94
96
  contemplatorMinTurns: 10,
97
+ contemplatorMaxActiveMinutes: 60,
95
98
  summarizerEnabled: true,
96
99
  summarizerRetriggerTokens: 2_000,
97
100
  summarizerSamplingThresholdTokens: 60_000,
@@ -214,6 +217,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
214
217
  "agentMaxTurns",
215
218
  "contemplatorMinNewObservations",
216
219
  "contemplatorMinTurns",
220
+ "contemplatorMaxActiveMinutes",
217
221
  "summarizerRetriggerTokens",
218
222
  "summarizerSamplingThresholdTokens",
219
223
  ] as const;
@@ -131,6 +131,11 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
131
131
  scheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
132
132
  });
133
133
  runtime.setSettingsUpdateListener((ctx, settings) => {
134
+ if (settings.contemplatorMaxActiveMinutes !== undefined) {
135
+ // Re-evaluate immediately when the user lowers the maximum interval;
136
+ // otherwise it would wait for an unrelated primary-agent checkpoint.
137
+ runtime.notifyMemoryUpdate(ctx);
138
+ }
134
139
  const affectsScheduling = settings.summarizerEnabled !== undefined ||
135
140
  settings.newMemoryPoolMaxTokens !== undefined ||
136
141
  settings.oldMemoryPoolTargetTokens !== undefined ||
package/src/runtime.ts CHANGED
@@ -32,7 +32,7 @@ export type SessionSettings = Partial<Pick<Config,
32
32
  | "compactAfterTokensMode" | "compactAfterTokensRatio"
33
33
  | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns"
34
34
  | "showWorkerNotifications" | "passive" | "compactionObserverEnabled" | "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled"
35
- | "contemplatorMinNewObservations" | "contemplatorMinTurns"
35
+ | "contemplatorMinNewObservations" | "contemplatorMinTurns" | "contemplatorMaxActiveMinutes"
36
36
  | "summarizerEnabled" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens"
37
37
  | "debugLog"
38
38
  >> & {
@@ -92,9 +92,14 @@ export interface ContemplatorRunState {
92
92
  pendingReviews: number;
93
93
  /** Completed primary-model responses since the current completion/probe-delivery spacing anchor. */
94
94
  responsesSinceRun: number;
95
+ /** Cumulative main-agent active time since the latest contemplator attempt. */
96
+ activeTimeSinceRunMs: number;
95
97
  waitingFor: "disabled" | "passive" | "observer" | "probe" | "memories" | "responses" | "ready" | "running" | "idle";
96
98
  lastStartedAt?: number;
99
+ /** Most recent successful final-action completion. */
97
100
  lastCompletedAt?: number;
101
+ /** Most recent failed attempt; distinct so a timeout is never shown as success. */
102
+ lastFailedAt?: number;
98
103
  lastError?: string;
99
104
  }
100
105
 
@@ -148,7 +153,7 @@ export function computeSessionSettings(entries: readonly unknown[]): SessionSett
148
153
  const numberKeys = [
149
154
  "observeAfterTokens", "observerChunkMaxTokens", "compactAfterTokens",
150
155
  "newMemoryPoolMaxTokens", "oldMemoryPoolTargetTokens", "agentMaxTurns",
151
- "contemplatorMinNewObservations", "contemplatorMinTurns",
156
+ "contemplatorMinNewObservations", "contemplatorMinTurns", "contemplatorMaxActiveMinutes",
152
157
  "summarizerRetriggerTokens", "summarizerSamplingThresholdTokens",
153
158
  ] as const;
154
159
  for (const key of booleanKeys) if (typeof data[key] === "boolean") restored[key] = data[key];
@@ -225,6 +230,7 @@ export class Runtime {
225
230
  pendingObservations: 0,
226
231
  pendingReviews: 0,
227
232
  responsesSinceRun: 0,
233
+ activeTimeSinceRunMs: 0,
228
234
  waitingFor: "idle",
229
235
  };
230
236
  agentUsage: LlmUsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, runs: 0 };
@@ -325,6 +331,7 @@ export class Runtime {
325
331
  pendingObservations: 0,
326
332
  pendingReviews: 0,
327
333
  responsesSinceRun: 0,
334
+ activeTimeSinceRunMs: 0,
328
335
  waitingFor: "idle",
329
336
  };
330
337
  }