@matthewfl/pi-contemplator 0.3.1 → 0.4.0

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.0",
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",
@@ -84,6 +84,8 @@ interface PendingUpdate {
84
84
  mainAgentOutputTokens: number;
85
85
  mainAgentToolCalls: number;
86
86
  mainAgentActiveTimeMs: number;
87
+ /** True when cumulative active work, rather than memory volume, forced this update. */
88
+ activeTimeFallback: boolean;
87
89
  }
88
90
 
89
91
  type Intervention =
@@ -171,6 +173,8 @@ function coarseAgentTime(durationMs: number): string {
171
173
  }
172
174
  const CONTEMPLATOR_MESSAGE = "om.contemplator.message";
173
175
  const CONTEMPLATOR_STATE = "om.contemplator.state";
176
+ /** Durable per-branch baseline for the cumulative active-work fallback trigger. */
177
+ const CONTEMPLATOR_RUN = "om.contemplator.run";
174
178
  const CONTEMPLATOR_SUGGESTION = "om.contemplator.suggestion";
175
179
  const REVIEW_PROPOSAL_MESSAGE = "om.review.proposal";
176
180
  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 +296,8 @@ export class Contemplator {
292
296
  private queuedProbeIds = new Set<string>();
293
297
  /** Probes whose provider-context delivery will establish the next response-spacing anchor. */
294
298
  private probeCooldownPendingIds = new Set<string>();
299
+ /** Preserve the configured response gap after a probe actually reaches provider context. */
300
+ private probeDeliverySpacingActive = false;
295
301
  private sessionGeneration = 0;
296
302
  private latestCtx: MemoryUpdateCtx | undefined;
297
303
  /** Completed primary-model responses since the current completion/probe-delivery spacing anchor. */
@@ -301,6 +307,10 @@ export class Contemplator {
301
307
  private restoredTipId: string | undefined;
302
308
  /** Start of the unpersisted portion of the current main-agent run. */
303
309
  private agentActiveSince: number | undefined;
310
+ /** Cumulative active time at the latest contemplator attempt on this branch. */
311
+ private lastContemplatorActiveTimeMs = 0;
312
+ /** Fires while the primary agent remains active, including inside one long tool call. */
313
+ private activeTimeFallbackTimer: ReturnType<typeof setTimeout> | undefined;
304
314
 
305
315
  constructor(private readonly pi: ExtensionAPI, private readonly runtime: Runtime) {}
306
316
 
@@ -322,12 +332,14 @@ export class Contemplator {
322
332
  return box;
323
333
  });
324
334
  this.runtime.setMemoryUpdateListener((ctx) => this.withDebugContext(ctx, () => this.observeTurn(ctx)));
325
- this.pi.on("agent_start", () => {
335
+ this.pi.on("agent_start", (_event: unknown, ctx: ExtensionContext) => {
326
336
  this.agentActiveSince = Date.now();
337
+ this.withDebugContext(ctx, () => this.scheduleActiveTimeFallback(ctx));
327
338
  });
328
339
  this.pi.on("agent_end", (_event: unknown, ctx: ExtensionContext) => {
329
340
  this.persistAgentActivity(ctx);
330
341
  this.agentActiveSince = undefined;
342
+ this.clearActiveTimeFallbackTimer();
331
343
  });
332
344
  this.pi.on("session_start", (event: any, ctx: ExtensionContext) => {
333
345
  this.sessionGeneration++;
@@ -336,11 +348,20 @@ export class Contemplator {
336
348
  this.consecutiveFlushFailures = 0;
337
349
  const generation = this.sessionGeneration;
338
350
  this.agentActiveSince = undefined;
351
+ this.clearActiveTimeFallbackTimer();
339
352
  // AgentSession preserves its steering queue across extension reloads. An
340
353
  // undelivered tracking entry therefore still has a live queued message;
341
354
  // restoring it here would enqueue the same probe a second time.
342
355
  const reload = event?.reason === "reload";
343
356
  this.restore(ctx, true, reload, reload);
357
+ // Reload replaces the extension runtime without restarting an already-active
358
+ // agent run, so no new agent_start event is guaranteed. Recover the clock
359
+ // from Pi's live state; elapsed time before reload may be unavailable, but
360
+ // the current run remains monitored rather than going unarmed indefinitely.
361
+ if (!ctx.isIdle()) {
362
+ this.agentActiveSince = Date.now();
363
+ this.withDebugContext(ctx, () => this.scheduleActiveTimeFallback(ctx));
364
+ }
344
365
  // Reconstruct and schedule durable memory immediately. In particular, a
345
366
  // reload after a failed run must not silently mark its pending backlog seen.
346
367
  queueMicrotask(() => {
@@ -353,15 +374,22 @@ export class Contemplator {
353
374
  this.running = false;
354
375
  this.consecutiveFlushFailures = 0;
355
376
  this.agentActiveSince = undefined;
377
+ this.clearActiveTimeFallbackTimer();
356
378
  // Pending steering messages remain queued while navigating the tree.
357
379
  this.restore(ctx, true, true);
358
380
  });
359
- this.pi.on("session_shutdown", () => {
381
+ this.pi.on("session_shutdown", (_event: unknown, ctx: ExtensionContext) => {
382
+ // On extension reload Pi shuts down the old runtime before starting the
383
+ // replacement. Persist its final live interval without launching workers,
384
+ // so the replacement can continue from the exact cumulative clock.
385
+ this.persistAgentActivity(ctx, false, false);
360
386
  this.sessionGeneration++;
361
387
  this.flushEpoch++;
362
388
  this.running = false;
363
389
  this.consecutiveFlushFailures = 0;
364
390
  this.agentActiveSince = undefined;
391
+ this.clearActiveTimeFallbackTimer();
392
+ this.lastContemplatorActiveTimeMs = 0;
365
393
  this.history = [];
366
394
  this.historyEntryIds = [];
367
395
  this.pending = undefined;
@@ -375,6 +403,7 @@ export class Contemplator {
375
403
  this.deliveredProbeIds.clear();
376
404
  this.queuedProbeIds.clear();
377
405
  this.probeCooldownPendingIds.clear();
406
+ this.probeDeliverySpacingActive = false;
378
407
  this.latestCtx = undefined;
379
408
  this.turnsSinceRun = 0;
380
409
  this.assistantResponsesInCurrentTurn = 0;
@@ -384,6 +413,7 @@ export class Contemplator {
384
413
  pendingObservations: 0,
385
414
  pendingReviews: 0,
386
415
  responsesSinceRun: 0,
416
+ activeTimeSinceRunMs: 0,
387
417
  waitingFor: "idle",
388
418
  };
389
419
  });
@@ -419,6 +449,9 @@ export class Contemplator {
419
449
  // This records one wall-clock interval regardless of how many tools were
420
450
  // running concurrently; persistAgentActivity restarts the shared clock.
421
451
  this.persistAgentActivity(ctx);
452
+ // A long autonomous turn can perform many tools without ending a model
453
+ // turn. Recheck the active-work fallback at each completed tool batch.
454
+ this.withDebugContext(ctx, () => this.observeTurn(ctx));
422
455
  });
423
456
  this.pi.on("context", (event: any, ctx: ExtensionContext) => {
424
457
  const deliveredMessages = event.messages?.filter((message: any) => message?.role === "custom" && message.customType === CONTEMPLATOR_SUGGESTION && typeof message.details?.probeId === "string") ?? [];
@@ -444,6 +477,7 @@ export class Contemplator {
444
477
  if (cooldownAnchored) {
445
478
  // Probe spacing begins only once Pi proves the probe reached an actual
446
479
  // provider context. Responses generated before this point do not count.
480
+ this.probeDeliverySpacingActive = true;
447
481
  this.turnsSinceRun = 0;
448
482
  this.withDebugContext(ctx, () => this.observeTurn(ctx));
449
483
  }
@@ -458,18 +492,46 @@ export class Contemplator {
458
492
  });
459
493
  }
460
494
 
461
- private persistAgentActivity(ctx: MemoryUpdateCtx): void {
495
+ private clearActiveTimeFallbackTimer(): void {
496
+ if (this.activeTimeFallbackTimer !== undefined) clearTimeout(this.activeTimeFallbackTimer);
497
+ this.activeTimeFallbackTimer = undefined;
498
+ }
499
+
500
+ private scheduleActiveTimeFallback(ctx: MemoryUpdateCtx): void {
501
+ this.clearActiveTimeFallbackTimer();
502
+ if (this.agentActiveSince === undefined || !this.runtime.config.contemplatorEnabled || this.runtime.config.passive) return;
503
+ const persistedActiveMs = agentActiveTimeMs(ctx.sessionManager.getBranch() as Entry[]);
504
+ const liveActiveMs = Math.max(0, Date.now() - this.agentActiveSince);
505
+ const elapsedMs = Math.max(0, persistedActiveMs + liveActiveMs - this.lastContemplatorActiveTimeMs);
506
+ const thresholdMs = this.runtime.config.contemplatorMaxActiveMinutes * 60_000;
507
+ const delayMs = Math.max(1, thresholdMs - elapsedMs);
508
+ this.activeTimeFallbackTimer = setTimeout(() => {
509
+ this.activeTimeFallbackTimer = undefined;
510
+ if (this.agentActiveSince === undefined) return;
511
+ this.withDebugContext(ctx, () => {
512
+ // Materialize the live interval before observeTurn reads the ledger. This
513
+ // lets a sleep/long tool trigger contemplation while it is still running.
514
+ this.persistAgentActivity(ctx, false);
515
+ this.observeTurn(ctx);
516
+ });
517
+ }, delayMs);
518
+ (this.activeTimeFallbackTimer as { unref?: () => void }).unref?.();
519
+ }
520
+
521
+ private persistAgentActivity(ctx: MemoryUpdateCtx, reschedule = true, notify = true): void {
462
522
  const startedAt = this.agentActiveSince;
463
523
  if (startedAt === undefined) return;
464
524
  const endedAt = Date.now();
465
525
  this.agentActiveSince = endedAt;
466
526
  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 });
527
+ if (durationMs > 0) {
528
+ this.pi.appendEntry(OM_AGENT_ACTIVITY, { version: 1, durationMs, endedAt });
529
+ // Notify only after appendEntry so active-time schedulers always observe the
530
+ // checkpoint, regardless of Pi's ordering between independent event handlers.
531
+ if (notify) this.runtime.notifyAgentActivity(ctx);
532
+ debugLog("agent.activity_recorded", { durationMs });
533
+ }
534
+ if (reschedule) this.scheduleActiveTimeFallback(ctx);
473
535
  }
474
536
 
475
537
  private withDebugContext<T>(ctx: MemoryUpdateCtx, fn: () => T): T {
@@ -493,6 +555,7 @@ export class Contemplator {
493
555
  pendingObservations: this.pending?.observations.length ?? 0,
494
556
  pendingReviews: this.pending?.reviews.length ?? 0,
495
557
  responsesSinceRun: this.turnsSinceRun,
558
+ activeTimeSinceRunMs: this.runtime.contemplatorState.activeTimeSinceRunMs,
496
559
  waitingFor,
497
560
  ...overrides,
498
561
  };
@@ -509,10 +572,13 @@ export class Contemplator {
509
572
  const historyMessagesByEntryId = new Map<string, AgentMessage>();
510
573
  const checkpointCoveredObservationIds = new Set<string>();
511
574
  const checkpointCoveredReviewIds = new Set<string>();
575
+ let latestRunActiveTimeMs: number | undefined;
576
+ let probeDeliveredAfterLatestRun = false;
512
577
  let resetProjection: ReturnType<typeof fullProjection> | undefined;
513
578
  if (resetTracking) {
514
579
  this.deliveredProbeIds.clear();
515
580
  this.probeCooldownPendingIds.clear();
581
+ this.probeDeliverySpacingActive = false;
516
582
  if (!retainQueuedIds) this.queuedProbeIds.clear();
517
583
  this.inFlightReviewIds.clear();
518
584
  this.resolvingReviewIds.clear();
@@ -529,11 +595,19 @@ export class Contemplator {
529
595
  pendingObservations: 0,
530
596
  pendingReviews: 0,
531
597
  responsesSinceRun: 0,
598
+ activeTimeSinceRunMs: 0,
532
599
  waitingFor: "idle",
533
600
  };
534
601
  }
535
602
  const undeliveredSuggestions = new Map<string, string>();
536
603
  for (const entry of entries) {
604
+ if (entry.customType === CONTEMPLATOR_RUN && entry.data && typeof entry.data === "object") {
605
+ const activeTimeMs = (entry.data as { mainAgentActiveTimeMs?: unknown }).mainAgentActiveTimeMs;
606
+ if (typeof activeTimeMs === "number" && Number.isFinite(activeTimeMs) && activeTimeMs >= 0) {
607
+ latestRunActiveTimeMs = activeTimeMs;
608
+ probeDeliveredAfterLatestRun = false;
609
+ }
610
+ }
537
611
  if (entry.customType === CONTEMPLATOR_STATE && entry.data && typeof entry.data === "object") {
538
612
  const state = entry.data as { history?: unknown };
539
613
  if (Array.isArray(state.history)) {
@@ -607,12 +681,19 @@ export class Contemplator {
607
681
  if (typeof data.probeId !== "string") continue;
608
682
  if (data.delivered === true) {
609
683
  this.deliveredProbeIds.add(data.probeId);
684
+ probeDeliveredAfterLatestRun = true;
610
685
  undeliveredSuggestions.delete(data.probeId);
611
686
  } else if (typeof data.suggestion === "string") {
612
687
  undeliveredSuggestions.set(data.probeId, data.suggestion);
613
688
  }
614
689
  }
615
690
  }
691
+ // A malformed/foreign marker must not regress a valid live baseline during
692
+ // an ordinary tip refresh. A true session/tree restore with no valid marker
693
+ // intentionally starts this branch's cumulative baseline at zero.
694
+ if (latestRunActiveTimeMs !== undefined) this.lastContemplatorActiveTimeMs = latestRunActiveTimeMs;
695
+ else if (resetTracking) this.lastContemplatorActiveTimeMs = 0;
696
+ this.probeDeliverySpacingActive = probeDeliveredAfterLatestRun;
616
697
  if (resetTracking && resetProjection) {
617
698
  // Successful update prompts and private-history compaction checkpoints are
618
699
  // the durable coverage record. Checkpoints carry ids from prompts replaced
@@ -681,6 +762,10 @@ export class Contemplator {
681
762
  });
682
763
  return;
683
764
  }
765
+ const cumulativeActiveTimeMs = agentActiveTimeMs(branchEntries) + (this.agentActiveSince === undefined ? 0 : Math.max(0, Date.now() - this.agentActiveSince));
766
+ const activeTimeSinceRunMs = Math.max(0, cumulativeActiveTimeMs - this.lastContemplatorActiveTimeMs);
767
+ const activeTimeThresholdMs = this.runtime.config.contemplatorMaxActiveMinutes * 60_000;
768
+ const activeTimeDue = activeTimeSinceRunMs >= activeTimeThresholdMs;
684
769
  const projection = fullProjection(branchEntries);
685
770
  const observations = projection.observations.map((item) => `[${item.id}] ${item.content}`);
686
771
  const reviews = projection.reviews ?? [];
@@ -704,11 +789,25 @@ export class Contemplator {
704
789
  reviews: mergeMemoryLines(this.pending?.reviews ?? [], newReviews),
705
790
  mainAgentOutputTokens: assistantOutputTokens(branchEntries),
706
791
  mainAgentToolCalls: assistantToolCallCount(branchEntries),
707
- mainAgentActiveTimeMs: agentActiveTimeMs(branchEntries),
792
+ mainAgentActiveTimeMs: cumulativeActiveTimeMs,
793
+ activeTimeFallback: this.pending?.activeTimeFallback ?? false,
708
794
  };
709
795
  }
796
+ if (activeTimeDue && !this.pending) {
797
+ this.pending = {
798
+ observations: [],
799
+ reviews: [],
800
+ mainAgentOutputTokens: assistantOutputTokens(branchEntries),
801
+ mainAgentToolCalls: assistantToolCallCount(branchEntries),
802
+ mainAgentActiveTimeMs: cumulativeActiveTimeMs,
803
+ activeTimeFallback: true,
804
+ };
805
+ } else if (activeTimeDue && this.pending) {
806
+ this.pending.activeTimeFallback = true;
807
+ }
710
808
  if (!this.pending) {
711
- this.publishState(this.running ? "running" : this.probeCooldownPendingIds.size > 0 ? "probe" : "idle");
809
+ this.scheduleActiveTimeFallback(ctx);
810
+ this.publishState(this.running ? "running" : this.probeCooldownPendingIds.size > 0 ? "probe" : "idle", { activeTimeSinceRunMs });
712
811
  return;
713
812
  }
714
813
  // Activity values are cumulative send-time snapshots, not values frozen when
@@ -716,15 +815,17 @@ export class Contemplator {
716
815
  // while that batch waits for its memory/turn thresholds.
717
816
  this.pending.mainAgentOutputTokens = assistantOutputTokens(branchEntries);
718
817
  this.pending.mainAgentToolCalls = assistantToolCallCount(branchEntries);
719
- this.pending.mainAgentActiveTimeMs = agentActiveTimeMs(branchEntries);
818
+ this.pending.mainAgentActiveTimeMs = cumulativeActiveTimeMs;
720
819
  const enoughMemories = this.pending.reviews.length > 0 || this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations;
820
+ const forcedByActiveTime = this.pending.activeTimeFallback;
721
821
  if (this.probeCooldownPendingIds.size > 0) {
722
- this.publishState("probe");
822
+ this.publishState("probe", { activeTimeSinceRunMs });
723
823
  debugLog("contemplator.waiting", { reason: "probe_delivery", pendingProbeCount: this.probeCooldownPendingIds.size });
724
824
  return;
725
825
  }
726
- if (!enoughMemories || this.turnsSinceRun < this.runtime.config.contemplatorMinTurns) {
727
- this.publishState(!enoughMemories ? "memories" : "responses");
826
+ const waitingForDeliveredProbeResponses = this.probeDeliverySpacingActive && this.turnsSinceRun < this.runtime.config.contemplatorMinTurns;
827
+ if (waitingForDeliveredProbeResponses || (!forcedByActiveTime && (!enoughMemories || this.turnsSinceRun < this.runtime.config.contemplatorMinTurns))) {
828
+ this.publishState(waitingForDeliveredProbeResponses || enoughMemories ? "responses" : "memories", { activeTimeSinceRunMs });
728
829
  debugLog("contemplator.waiting", {
729
830
  enoughMemories,
730
831
  turnsSinceRun: this.turnsSinceRun,
@@ -733,11 +834,13 @@ export class Contemplator {
733
834
  });
734
835
  return;
735
836
  }
736
- this.publishState(this.running ? "running" : "ready");
837
+ this.publishState(this.running ? "running" : "ready", { activeTimeSinceRunMs });
737
838
  debugLog("contemplator.triggered", {
738
839
  pendingObservationCount: this.pending.observations.length,
739
840
  pendingReviewCount: this.pending.reviews.length,
740
841
  turnsSinceRun: this.turnsSinceRun,
842
+ forcedByActiveTime,
843
+ activeTimeSinceRunMs,
741
844
  });
742
845
  void this.flush(ctx);
743
846
  }
@@ -756,6 +859,19 @@ export class Contemplator {
756
859
  update.mainAgentOutputTokens = assistantOutputTokens(branchEntriesAtStart);
757
860
  update.mainAgentToolCalls = assistantToolCallCount(branchEntriesAtStart);
758
861
  update.mainAgentActiveTimeMs = agentActiveTimeMs(branchEntriesAtStart);
862
+ const activeTimeSinceLastRunMs = Math.max(0, update.mainAgentActiveTimeMs - this.lastContemplatorActiveTimeMs);
863
+ // Reset the active-work interval at attempt start, including failed attempts,
864
+ // so a broken provider cannot create an immediate background retry loop. The
865
+ // tiny durable marker makes reloads and tree navigation preserve that backoff.
866
+ this.lastContemplatorActiveTimeMs = update.mainAgentActiveTimeMs;
867
+ this.probeDeliverySpacingActive = false;
868
+ this.scheduleActiveTimeFallback(ctx);
869
+ this.pi.appendEntry(CONTEMPLATOR_RUN, {
870
+ version: 1,
871
+ mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
872
+ startedAt: Date.now(),
873
+ });
874
+ this.markTipPersisted(ctx);
759
875
  this.pending = undefined;
760
876
  const turnsBeforeRun = this.turnsSinceRun;
761
877
  const sessionGeneration = this.sessionGeneration;
@@ -797,6 +913,7 @@ export class Contemplator {
797
913
  mainAgentOutputTokens: update.mainAgentOutputTokens,
798
914
  mainAgentToolCalls: update.mainAgentToolCalls,
799
915
  mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
916
+ activeTimeFallback: update.activeTimeFallback,
800
917
  };
801
918
  } else {
802
919
  debugLog("contemplator.poisoned_update_released", { reason: resolved.reason, observationCount: update.observations.length, reviewCount: update.reviews.length });
@@ -827,13 +944,22 @@ export class Contemplator {
827
944
  if (update.observations.length > 0) updateSections.push(`OBSERVATIONS:\n${update.observations.join("\n")}`);
828
945
  if (update.reviews.length > 0) updateSections.push(`REVIEWS:\n${update.reviews.join("\n")}`);
829
946
  const updateBody = updateSections.length > 0 ? updateSections.join("\n\n") : "(no new memories)";
947
+ const sparseMemoryDescription = update.observations.length + update.reviews.length === 0
948
+ ? "No new memories were created during this interval."
949
+ : `Only ${update.observations.length + update.reviews.length} new memory record${update.observations.length + update.reviews.length === 1 ? " was" : "s were"} created during this interval.`;
950
+ const reviewerHistoryHint = reviewerEnabled
951
+ ? " 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."
952
+ : "";
953
+ const activeTimeNotice = update.activeTimeFallback
954
+ ? `\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.`
955
+ : "";
830
956
  const finalActionNames = reviewerEnabled
831
957
  ? "send_probe, request_review, or no_intervention"
832
958
  : "send_probe or no_intervention";
833
959
  const interventionInstruction = reviewerEnabled
834
960
  ? `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
961
  : `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() };
962
+ 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
963
  let intervention: Intervention | undefined;
838
964
  let finalActionWarned = false;
839
965
  const branchEntries = ctx.sessionManager.getBranch() as Entry[];
@@ -1000,6 +1126,7 @@ export class Contemplator {
1000
1126
  mainAgentOutputTokens: update.mainAgentOutputTokens,
1001
1127
  mainAgentToolCalls: update.mainAgentToolCalls,
1002
1128
  mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
1129
+ activeTimeFallback: update.activeTimeFallback,
1003
1130
  };
1004
1131
  } else {
1005
1132
  debugLog("contemplator.poisoned_update_released", { reason: failureMessage, observationCount: update.observations.length, reviewCount: update.reviews.length });
@@ -1031,7 +1158,12 @@ export class Contemplator {
1031
1158
  : this.turnsSinceRun < this.runtime.config.contemplatorMinTurns
1032
1159
  ? "responses"
1033
1160
  : "ready";
1161
+ const activeTimeSinceRunMs = Math.max(
1162
+ 0,
1163
+ agentActiveTimeMs(ctx.sessionManager.getBranch() as Entry[]) - this.lastContemplatorActiveTimeMs,
1164
+ );
1034
1165
  this.publishState(waitingFor, {
1166
+ activeTimeSinceRunMs,
1035
1167
  lastCompletedAt: Date.now(),
1036
1168
  lastError: failureMessage,
1037
1169
  });
@@ -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"}`,
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,6 +92,8 @@ 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;
97
99
  lastCompletedAt?: number;
@@ -148,7 +150,7 @@ export function computeSessionSettings(entries: readonly unknown[]): SessionSett
148
150
  const numberKeys = [
149
151
  "observeAfterTokens", "observerChunkMaxTokens", "compactAfterTokens",
150
152
  "newMemoryPoolMaxTokens", "oldMemoryPoolTargetTokens", "agentMaxTurns",
151
- "contemplatorMinNewObservations", "contemplatorMinTurns",
153
+ "contemplatorMinNewObservations", "contemplatorMinTurns", "contemplatorMaxActiveMinutes",
152
154
  "summarizerRetriggerTokens", "summarizerSamplingThresholdTokens",
153
155
  ] as const;
154
156
  for (const key of booleanKeys) if (typeof data[key] === "boolean") restored[key] = data[key];
@@ -225,6 +227,7 @@ export class Runtime {
225
227
  pendingObservations: 0,
226
228
  pendingReviews: 0,
227
229
  responsesSinceRun: 0,
230
+ activeTimeSinceRunMs: 0,
228
231
  waitingFor: "idle",
229
232
  };
230
233
  agentUsage: LlmUsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, runs: 0 };
@@ -325,6 +328,7 @@ export class Runtime {
325
328
  pendingObservations: 0,
326
329
  pendingReviews: 0,
327
330
  responsesSinceRun: 0,
331
+ activeTimeSinceRunMs: 0,
328
332
  waitingFor: "idle",
329
333
  };
330
334
  }