@matthewfl/pi-contemplator 0.1.5 → 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.5",
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",
@@ -586,18 +586,21 @@ export class Contemplator {
586
586
  }
587
587
  const branchEntries = ctx.sessionManager.getBranch() as Entry[];
588
588
  const observerBacklogTokens = rawTokensSinceObservationCoverage(branchEntries);
589
- if (
589
+ const waitingForCapturedObserverBacklog = this.runtime.observerBacklogBlocking || (
590
+ !this.runtime.consolidationInFlight &&
590
591
  observerBacklogTokens >= this.runtime.config.observeAfterTokens &&
591
- (this.runtime.consolidationInFlight || this.runtime.lastObserverError === undefined)
592
- ) {
592
+ this.runtime.lastObserverError === undefined
593
+ );
594
+ if (waitingForCapturedObserverBacklog) {
593
595
  // A catch-up observer pipeline may append several partial batches while it
594
- // drains old source. Do not let primary-agent checkpoints feed those
595
- // fragments to the contemplator one at a time. The pipeline emits one
596
- // 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.
597
599
  this.publishState("observer");
598
600
  debugLog("contemplator.waiting", {
599
601
  reason: "observer_backlog",
600
602
  observerBacklogTokens,
603
+ observerBacklogBlocking: this.runtime.observerBacklogBlocking,
601
604
  observeAfterTokens: this.runtime.config.observeAfterTokens,
602
605
  });
603
606
  return;
@@ -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
@@ -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;