@matthewfl/pi-contemplator 0.1.4 → 0.1.6

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.4",
3
+ "version": "0.1.6",
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",
@@ -229,9 +229,11 @@ export class Contemplator {
229
229
  private deliveredProbeIds = new Set<string>();
230
230
  /** Probe ids passed to pi.sendMessage by this live extension runtime. */
231
231
  private queuedProbeIds = new Set<string>();
232
+ /** Probes whose provider-context delivery will establish the next response-spacing anchor. */
233
+ private probeCooldownPendingIds = new Set<string>();
232
234
  private sessionGeneration = 0;
233
235
  private latestCtx: MemoryUpdateCtx | undefined;
234
- /** Completed primary-model responses since the previous contemplator run. */
236
+ /** Completed primary-model responses since the current completion/probe-delivery spacing anchor. */
235
237
  private turnsSinceRun = 0;
236
238
  /** Used to avoid counting the final turn_end after its assistant message_end. */
237
239
  private assistantResponsesInCurrentTurn = 0;
@@ -311,6 +313,7 @@ export class Contemplator {
311
313
  this.reviewerSessions.clear();
312
314
  this.deliveredProbeIds.clear();
313
315
  this.queuedProbeIds.clear();
316
+ this.probeCooldownPendingIds.clear();
314
317
  this.latestCtx = undefined;
315
318
  this.turnsSinceRun = 0;
316
319
  this.assistantResponsesInCurrentTurn = 0;
@@ -359,9 +362,12 @@ export class Contemplator {
359
362
  });
360
363
  this.pi.on("context", (event: any, ctx: ExtensionContext) => {
361
364
  const deliveredMessages = event.messages?.filter((message: any) => message?.role === "custom" && message.customType === CONTEMPLATOR_SUGGESTION && typeof message.details?.probeId === "string") ?? [];
365
+ let cooldownAnchored = false;
362
366
  for (const delivered of deliveredMessages) {
363
367
  if (this.deliveredProbeIds.has(delivered.details.probeId)) continue;
364
368
  this.deliveredProbeIds.add(delivered.details.probeId);
369
+ this.probeCooldownPendingIds.delete(delivered.details.probeId);
370
+ cooldownAnchored = true;
365
371
  // Once Pi includes the probe in a provider context it is no longer in
366
372
  // either in-memory delivery queue. Keeping this id indefinitely caused
367
373
  // later tree restores to suppress a genuinely needed requeue.
@@ -375,6 +381,12 @@ export class Contemplator {
375
381
  this.markTipPersisted(ctx);
376
382
  debugLog("contemplator.suggestion_delivered", { probeId: delivered.details.probeId });
377
383
  }
384
+ if (cooldownAnchored) {
385
+ // Probe spacing begins only once Pi proves the probe reached an actual
386
+ // provider context. Responses generated before this point do not count.
387
+ this.turnsSinceRun = 0;
388
+ this.withDebugContext(ctx, () => this.observeTurn(ctx));
389
+ }
378
390
  });
379
391
  this.pi.on("turn_end", (_event: any, ctx: ExtensionContext) => {
380
392
  this.persistAgentActivity(ctx);
@@ -437,6 +449,7 @@ export class Contemplator {
437
449
  let resetProjection: ReturnType<typeof fullProjection> | undefined;
438
450
  if (resetTracking) {
439
451
  this.deliveredProbeIds.clear();
452
+ this.probeCooldownPendingIds.clear();
440
453
  if (!retainQueuedIds) this.queuedProbeIds.clear();
441
454
  this.inFlightReviewIds.clear();
442
455
  this.resolvingReviewIds.clear();
@@ -545,6 +558,9 @@ export class Contemplator {
545
558
  }
546
559
  this.restoredTipId = tipId;
547
560
  for (const [probeId, question] of undeliveredSuggestions) {
561
+ // An undelivered durable probe remains the cooldown anchor even when Pi's
562
+ // live queue survived an extension reload and must not be duplicated.
563
+ this.probeCooldownPendingIds.add(probeId);
548
564
  // A durable custom_message proves only that Pi inserted the probe at some
549
565
  // point; it does not prove an in-memory queue still owns it, and compaction
550
566
  // may have removed it from active model context. Suppress requeue only for
@@ -570,18 +586,21 @@ export class Contemplator {
570
586
  }
571
587
  const branchEntries = ctx.sessionManager.getBranch() as Entry[];
572
588
  const observerBacklogTokens = rawTokensSinceObservationCoverage(branchEntries);
573
- if (
589
+ const waitingForCapturedObserverBacklog = this.runtime.observerBacklogBlocking || (
590
+ !this.runtime.consolidationInFlight &&
574
591
  observerBacklogTokens >= this.runtime.config.observeAfterTokens &&
575
- (this.runtime.consolidationInFlight || this.runtime.lastObserverError === undefined)
576
- ) {
592
+ this.runtime.lastObserverError === undefined
593
+ );
594
+ if (waitingForCapturedObserverBacklog) {
577
595
  // A catch-up observer pipeline may append several partial batches while it
578
- // drains old source. Do not let primary-agent checkpoints feed those
579
- // fragments to the contemplator one at a time. The pipeline emits one
580
- // memory update after the backlog falls below the observer trigger.
596
+ // drains the finite source snapshot captured at launch. Do not feed those
597
+ // fragments to the contemplator one at a time. Source appended concurrently
598
+ // belongs to the next snapshot and must not extend this waiting period.
581
599
  this.publishState("observer");
582
600
  debugLog("contemplator.waiting", {
583
601
  reason: "observer_backlog",
584
602
  observerBacklogTokens,
603
+ observerBacklogBlocking: this.runtime.observerBacklogBlocking,
585
604
  observeAfterTokens: this.runtime.config.observeAfterTokens,
586
605
  });
587
606
  return;
@@ -620,7 +639,7 @@ export class Contemplator {
620
639
  };
621
640
  }
622
641
  if (!this.pending) {
623
- this.publishState(this.running ? "running" : "idle");
642
+ this.publishState(this.running ? "running" : this.probeCooldownPendingIds.size > 0 ? "probe" : "idle");
624
643
  return;
625
644
  }
626
645
  // Activity values are cumulative send-time snapshots, not values frozen when
@@ -630,6 +649,11 @@ export class Contemplator {
630
649
  this.pending.mainAgentToolCalls = assistantToolCallCount(branchEntries);
631
650
  this.pending.mainAgentActiveTimeMs = agentActiveTimeMs(branchEntries);
632
651
  const enoughMemories = this.pending.reviews.length > 0 || this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations || this.pending.summaries.length >= this.runtime.config.contemplatorMinNewSummaries;
652
+ if (this.probeCooldownPendingIds.size > 0) {
653
+ this.publishState("probe");
654
+ debugLog("contemplator.waiting", { reason: "probe_delivery", pendingProbeCount: this.probeCooldownPendingIds.size });
655
+ return;
656
+ }
633
657
  if (!enoughMemories || this.turnsSinceRun < this.runtime.config.contemplatorMinTurns) {
634
658
  this.publishState(!enoughMemories ? "memories" : "responses");
635
659
  debugLog("contemplator.waiting", {
@@ -676,6 +700,7 @@ export class Contemplator {
676
700
  let failureMessage: string | undefined;
677
701
  let workerNotified = false;
678
702
  let promptPersisted = false;
703
+ let emittedProbeId: string | undefined;
679
704
  let workerWatchdog: ReturnType<typeof createWorkerStallWatchdog> | undefined;
680
705
  this.publishState("running", { lastStartedAt: startedAt, lastError: undefined });
681
706
  debugLog("contemplator.start", {
@@ -863,7 +888,7 @@ export class Contemplator {
863
888
  this.markTipPersisted(ctx);
864
889
  }
865
890
  }
866
- if (intervention?.kind === "probe" && sessionGeneration === this.sessionGeneration) this.queueProbe(ctx, intervention.question, "send_probe");
891
+ if (intervention?.kind === "probe" && sessionGeneration === this.sessionGeneration) emittedProbeId = this.queueProbe(ctx, intervention.question, "send_probe");
867
892
  if (intervention?.kind === "review" && this.runtime.config.reviewerEnabled && sessionGeneration === this.sessionGeneration) {
868
893
  const reviewerModel = await this.runtime.resolveModel({
869
894
  model: ctx.model,
@@ -917,13 +942,21 @@ export class Contemplator {
917
942
  if (flushEpoch !== this.flushEpoch) return;
918
943
  this.running = false;
919
944
  if (!failed) this.consecutiveFlushFailures = 0;
945
+ // Normal runs establish their spacing anchor at completion. A probe run
946
+ // instead anchors at provider-context delivery: if delivery already occurred
947
+ // during this run, retain responses counted since it; otherwise the pending
948
+ // probe gate blocks launches until the context event resets the counter.
949
+ if (emittedProbeId === undefined) this.turnsSinceRun = 0;
950
+ const waitingForProbe = this.probeCooldownPendingIds.size > 0;
920
951
  const pendingHasEnoughMemories = this.pending !== undefined && (
921
952
  this.pending.reviews.length > 0 ||
922
953
  this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations ||
923
954
  this.pending.summaries.length >= this.runtime.config.contemplatorMinNewSummaries
924
955
  );
925
- const waitingFor = !this.pending
926
- ? "idle"
956
+ const waitingFor = waitingForProbe
957
+ ? "probe"
958
+ : !this.pending
959
+ ? "idle"
927
960
  : !pendingHasEnoughMemories
928
961
  ? "memories"
929
962
  : this.turnsSinceRun < this.runtime.config.contemplatorMinTurns
@@ -948,7 +981,7 @@ export class Contemplator {
948
981
  }
949
982
  }
950
983
 
951
- private queueProbe(ctx: MemoryUpdateCtx, question: string, source: "send_probe" | "restore", existingProbeId?: string): void {
984
+ private queueProbe(ctx: MemoryUpdateCtx, question: string, source: "send_probe" | "restore", existingProbeId?: string): string {
952
985
  const probeId = existingProbeId ?? `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
953
986
  // Persist intent before touching Pi's in-memory queue. A crash in between
954
987
  // leaves a recoverable pending probe rather than an invisible lost one.
@@ -967,6 +1000,7 @@ export class Contemplator {
967
1000
  // unrelated observer update or compaction callback cannot restore and enqueue
968
1001
  // a duplicate while the original idle steer is still pending.
969
1002
  this.queuedProbeIds.add(probeId);
1003
+ this.probeCooldownPendingIds.add(probeId);
970
1004
  this.pi.sendMessage({
971
1005
  customType: CONTEMPLATOR_SUGGESTION,
972
1006
  content: `Background contemplator probe (advisory):\n${question}\n\nReferenced memories can be reviewed using the recall tool.`,
@@ -981,6 +1015,7 @@ export class Contemplator {
981
1015
  triggerTurn: "omitted",
982
1016
  source,
983
1017
  });
1018
+ return probeId;
984
1019
  }
985
1020
 
986
1021
  private queueStructuralReview(options: QueueStructuralReviewOptions): void {
@@ -65,6 +65,8 @@ function liveStateLine(state: ContemplatorRunState): string {
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"
67
67
  ? "waiting for observer backlog"
68
+ : state.waitingFor === "probe"
69
+ ? "waiting for queued probe delivery"
68
70
  : state.waitingFor === "memories"
69
71
  ? "waiting for memory threshold"
70
72
  : state.waitingFor === "responses"
@@ -76,7 +78,7 @@ function liveStateLine(state: ContemplatorRunState): string {
76
78
  : state.waitingFor === "passive"
77
79
  ? "passive mode"
78
80
  : "idle";
79
- return `LIVE · ${reason} · ${pending} · ${state.responsesSinceRun} primary responses since last run\n${timing}${error}`;
81
+ return `LIVE · ${reason} · ${pending} · ${state.responsesSinceRun} primary responses since cooldown anchor\n${timing}${error}`;
80
82
  }
81
83
 
82
84
  export function renderContemplator(entries: Entry[], state?: ContemplatorRunState): string {
@@ -43,6 +43,7 @@ function formatRunAge(timestamp: number): string {
43
43
  function contemplatorWaitingLabel(waitingFor: Runtime["contemplatorState"]["waitingFor"]): string {
44
44
  switch (waitingFor) {
45
45
  case "observer": return "waiting for observer backlog";
46
+ case "probe": return "waiting for queued probe delivery";
46
47
  case "memories": return "waiting for memory threshold";
47
48
  case "responses": return "waiting for response spacing";
48
49
  case "ready": return "ready to launch";
package/src/config.ts CHANGED
@@ -61,7 +61,7 @@ export interface Config {
61
61
  reviewerModel?: ConfiguredModel;
62
62
  contemplatorMinNewObservations: number;
63
63
  contemplatorMinNewSummaries: number;
64
- /** Minimum completed primary-model responses between contemplator runs. */
64
+ /** Minimum primary-model responses after contemplator completion, or after delivery of its probe, before the next run. */
65
65
  contemplatorMinTurns: number;
66
66
  /** Stateless loss-aware summarizer for the old memory pool. */
67
67
  summarizerEnabled: boolean;
@@ -18,6 +18,7 @@ import {
18
18
  observationToSummaryLine,
19
19
  partitionMemoryPools,
20
20
  rawTokensSinceObservationCoverage,
21
+ rawTokensSinceObservationCoverageThrough,
21
22
  summaryToSummaryLine,
22
23
  type Entry,
23
24
  } from "../session-ledger/index.js";
@@ -51,8 +52,12 @@ export function createSummarizerStallWatchdog(
51
52
  return watchdog;
52
53
  }
53
54
 
54
- function sourceEntriesAfter(entries: Entry[], index: number): Entry[] {
55
- return entries.slice(index + 1).filter(isSourceEntry);
55
+ function sourceEntriesAfter(entries: Entry[], index: number, throughEntryId?: string): Entry[] {
56
+ const throughIndex = throughEntryId === undefined
57
+ ? entries.length - 1
58
+ : entries.findIndex((entry) => entry.id === throughEntryId);
59
+ if (throughIndex < 0 || throughIndex <= index) return [];
60
+ return entries.slice(index + 1, throughIndex + 1).filter(isSourceEntry);
56
61
  }
57
62
 
58
63
  function appendEntry(pi: ExtensionAPI, customType: string, data: unknown): void {
@@ -161,6 +166,7 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
161
166
  };
162
167
 
163
168
  const sessionMetadata = debugSessionMetadata(ctx);
169
+ const launchGeneration = runtime.getContextGeneration();
164
170
  const task = runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
165
171
  enabled: runtime.config.debugLog === true,
166
172
  cwd: ctx.cwd,
@@ -174,7 +180,15 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
174
180
  // degraded mode rather than leaving all advisory work gated forever. Future
175
181
  // primary activity still retries the observer.
176
182
  void task.then(() => {
177
- if (runtime.lastObserverError) runtime.notifyMemoryUpdate(ctx);
183
+ if (launchGeneration !== runtime.getContextGeneration()) return;
184
+ if (runtime.lastObserverError) {
185
+ runtime.notifyMemoryUpdate(ctx);
186
+ return;
187
+ }
188
+ // Source appended while the finite catch-up snapshot was running belongs
189
+ // to a later snapshot. Recheck after the lock is released; the completed
190
+ // pipeline has already given the contemplator its memory-update opportunity.
191
+ maybeLaunchConsolidation(pi, runtime, ctx);
178
192
  });
179
193
  }
180
194
 
@@ -225,19 +239,25 @@ export async function runConsolidationPipeline(
225
239
  const resolveModel = makeModelResolver(runtime, ctx);
226
240
  const contextGeneration = runtime.getContextGeneration();
227
241
 
242
+ const pipelineEntries = options.observerEntries ?? (ctx.sessionManager.getBranch() as Entry[]);
243
+ const initialCoverage = latestCoverageIndex(pipelineEntries, OM_OBSERVATIONS_RECORDED);
244
+ const catchUpThroughId = sourceEntriesAfter(pipelineEntries, initialCoverage).at(-1)?.id;
228
245
  const beforeFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
229
246
  runtime.consolidationPhase = "observer";
247
+ runtime.observerBacklogBlocking = catchUpThroughId !== undefined;
230
248
  try {
231
- // A large backlog is drained in bounded, oldest-first chunks. The normal
232
- // trigger threshold controls when the batch stops; a static compaction
233
- // snapshot is intentionally processed only once. Coverage must advance on
234
- // every iteration, otherwise stop rather than spin on a failed chunk.
249
+ // Drain only the finite source snapshot captured at pipeline launch, using
250
+ // bounded oldest-first chunks. Concurrent source belongs to a later pipeline,
251
+ // so a fast primary agent cannot indefinitely extend this blocking backlog.
252
+ // A static compaction snapshot is intentionally processed only once. Coverage
253
+ // must advance on every iteration, otherwise stop rather than spin.
235
254
  while (true) {
236
255
  const beforeEntries = ctx.sessionManager.getBranch() as Entry[];
237
256
  const beforeCoverage = latestCoverageIndex(beforeEntries, OM_OBSERVATIONS_RECORDED);
238
257
  const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
239
258
  force: options.forceObserver === true,
240
259
  entries: options.observerEntries,
260
+ throughSourceEntryId: options.observerEntries ? undefined : catchUpThroughId,
241
261
  contextGeneration,
242
262
  });
243
263
  if (observerOutcome === "abort") return;
@@ -245,13 +265,19 @@ export async function runConsolidationPipeline(
245
265
 
246
266
  const afterEntries = ctx.sessionManager.getBranch() as Entry[];
247
267
  const afterCoverage = latestCoverageIndex(afterEntries, OM_OBSERVATIONS_RECORDED);
248
- const remainingTokens = rawTokensSinceObservationCoverage(afterEntries);
268
+ const remainingTokens = catchUpThroughId === undefined
269
+ ? 0
270
+ : rawTokensSinceObservationCoverageThrough(afterEntries, catchUpThroughId);
249
271
  if (afterCoverage <= beforeCoverage || remainingTokens < runtime.config.observeAfterTokens) break;
250
272
  debugLog("observer.backlog_continue", { remainingTokens, afterCoverage });
251
273
  }
252
274
  } catch (error) {
253
275
  debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
254
276
  return;
277
+ } finally {
278
+ // New source appended after catchUpThroughId was never part of this
279
+ // pipeline's blocking backlog. Clear before notifying the contemplator.
280
+ if (contextGeneration === runtime.getContextGeneration()) runtime.observerBacklogBlocking = false;
255
281
  }
256
282
  const afterFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
257
283
  const beforeIds = new Set(beforeFold.observations.map((item) => item.id));
@@ -408,10 +434,12 @@ async function runObserverStage(
408
434
  runtime: Runtime,
409
435
  ctx: ConsolidationCtx,
410
436
  resolveModel: (stage: "observer") => Promise<ResolvedModel | undefined>,
411
- options: { force?: boolean; entries?: Entry[]; contextGeneration?: number } = {},
437
+ options: { force?: boolean; entries?: Entry[]; throughSourceEntryId?: string; contextGeneration?: number } = {},
412
438
  ): Promise<StageOutcome> {
413
439
  const entries = options.entries ?? (ctx.sessionManager.getBranch() as Entry[]);
414
- const tokens = rawTokensSinceObservationCoverage(entries);
440
+ const tokens = options.throughSourceEntryId === undefined
441
+ ? rawTokensSinceObservationCoverage(entries)
442
+ : rawTokensSinceObservationCoverageThrough(entries, options.throughSourceEntryId);
415
443
  if (!options.force && tokens < runtime.config.observeAfterTokens) return "continue";
416
444
 
417
445
  // Resolve the model before building the chunk: the default chunk cap
@@ -424,7 +452,7 @@ async function runObserverStage(
424
452
  }
425
453
 
426
454
  const lastCoverageIdx = latestCoverageIndex(entries, OM_OBSERVATIONS_RECORDED);
427
- const backlogEntries = sourceEntriesAfter(entries, lastCoverageIdx);
455
+ const backlogEntries = sourceEntriesAfter(entries, lastCoverageIdx, options.throughSourceEntryId);
428
456
 
429
457
  // Budget the text that is actually sent to the observer, including source
430
458
  // labels and rendered message content. Complete entries are kept intact.
package/src/runtime.ts CHANGED
@@ -82,9 +82,9 @@ export interface ContemplatorRunState {
82
82
  pendingObservations: number;
83
83
  pendingSummaries: number;
84
84
  pendingReviews: number;
85
- /** Completed primary-model responses since the previous contemplator run. */
85
+ /** Completed primary-model responses since the current completion/probe-delivery spacing anchor. */
86
86
  responsesSinceRun: number;
87
- waitingFor: "disabled" | "passive" | "observer" | "memories" | "responses" | "ready" | "running" | "idle";
87
+ waitingFor: "disabled" | "passive" | "observer" | "probe" | "memories" | "responses" | "ready" | "running" | "idle";
88
88
  lastStartedAt?: number;
89
89
  lastCompletedAt?: number;
90
90
  lastError?: string;
@@ -182,6 +182,8 @@ export class Runtime {
182
182
  private settingsUpdateListener: ((ctx: MemoryUpdateCtx, settings: SettingsUpdate) => void) | undefined;
183
183
  private contextGeneration = 0;
184
184
  consolidationPhase: ConsolidationPhase | undefined;
185
+ /** True only while the observer is draining the finite source snapshot captured at pipeline start. */
186
+ observerBacklogBlocking = false;
185
187
  compactInFlight = false;
186
188
  compactRequested = false;
187
189
  /** Agent-authored instructions to deliver after an explicit compact_context request. */
@@ -274,6 +276,7 @@ export class Runtime {
274
276
  this.consolidationInFlight = false;
275
277
  this.consolidationPromise = null;
276
278
  this.consolidationPhase = undefined;
279
+ this.observerBacklogBlocking = false;
277
280
  this.summarizerInFlight = false;
278
281
  this.summarizerPromise = null;
279
282
  this.reviewInFlight = false;
@@ -138,6 +138,23 @@ export function rawTokensSinceObservationCoverage(entries: Entry[]): number {
138
138
  return rawTokensSinceCoverage(entries, OM_OBSERVATIONS_RECORDED);
139
139
  }
140
140
 
141
+ /**
142
+ * Sum uncovered observer source only through a fixed branch entry. Source
143
+ * appended after that boundary is intentionally excluded so one catch-up pass
144
+ * has a finite backlog even while the primary agent keeps producing output.
145
+ */
146
+ export function rawTokensSinceObservationCoverageThrough(entries: Entry[], throughEntryId: string): number {
147
+ const throughIndex = entryIndexForId(entries, throughEntryId);
148
+ if (throughIndex < 0) return 0;
149
+ const coverageIndex = latestCoverageIndex(entries, OM_OBSERVATIONS_RECORDED);
150
+ if (coverageIndex >= throughIndex) return 0;
151
+ let total = 0;
152
+ for (let i = Math.max(0, coverageIndex + 1); i <= throughIndex; i++) {
153
+ if (isSourceEntry(entries[i])) total += estimateEntryTokens(entries[i]);
154
+ }
155
+ return total;
156
+ }
157
+
141
158
  export function findLastCompactionIndex(entries: Entry[]): number {
142
159
  for (let i = entries.length - 1; i >= 0; i--) {
143
160
  if (entries[i].type === "compaction") return i;