@matthewfl/pi-contemplator 0.0.10 → 0.1.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.
Files changed (44) hide show
  1. package/README.md +12 -12
  2. package/package.json +6 -6
  3. package/src/agents/contemplator/agent.ts +325 -91
  4. package/src/agents/contemplator/prompts.ts +6 -6
  5. package/src/agents/observer/agent.ts +96 -39
  6. package/src/agents/observer/prompts.ts +19 -10
  7. package/src/agents/reviewer/agent.ts +24 -4
  8. package/src/agents/reviewer/prompts.ts +1 -1
  9. package/src/agents/reviewer/tools.ts +24 -9
  10. package/src/agents/stream-errors.ts +1 -1
  11. package/src/agents/summarizer/agent.ts +597 -0
  12. package/src/agents/summarizer/prompts.ts +46 -0
  13. package/src/agents/summarizer/sampling.ts +80 -0
  14. package/src/commands/contemplator-view.ts +22 -1
  15. package/src/commands/settings.ts +95 -70
  16. package/src/commands/status.ts +60 -36
  17. package/src/commands/summarizer-view.ts +58 -0
  18. package/src/commands/view.ts +22 -10
  19. package/src/config.ts +30 -37
  20. package/src/hooks/compaction-hook.ts +32 -17
  21. package/src/hooks/compaction-resume.ts +4 -4
  22. package/src/hooks/compaction-trigger.ts +33 -11
  23. package/src/hooks/consolidation-trigger.ts +245 -215
  24. package/src/memory-citations.ts +37 -0
  25. package/src/required-tool-choice.ts +28 -0
  26. package/src/runtime.ts +115 -32
  27. package/src/session-ledger/fold.ts +82 -53
  28. package/src/session-ledger/index.ts +1 -0
  29. package/src/session-ledger/pools.ts +77 -0
  30. package/src/session-ledger/progress.ts +8 -19
  31. package/src/session-ledger/projection.ts +45 -177
  32. package/src/session-ledger/recall.ts +129 -127
  33. package/src/session-ledger/render-summary.ts +20 -19
  34. package/src/session-ledger/search.ts +99 -115
  35. package/src/session-ledger/types.ts +103 -77
  36. package/src/tools/compact-context.ts +1 -1
  37. package/src/tools/recall-observation.ts +99 -459
  38. package/src/tools/search-memories.ts +31 -72
  39. package/src/agents/dropper/agent.ts +0 -291
  40. package/src/agents/dropper/coverage.ts +0 -128
  41. package/src/agents/dropper/pool.ts +0 -67
  42. package/src/agents/dropper/prompts.ts +0 -48
  43. package/src/agents/reflector/agent.ts +0 -213
  44. package/src/agents/reflector/prompts.ts +0 -81
@@ -1,31 +1,23 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { runDropper } from "../agents/dropper/agent.js";
3
- import { observationPoolMetrics } from "../agents/dropper/pool.js";
2
+ import { runSummarizer } from "../agents/summarizer/agent.js";
4
3
  import { runObserver } from "../agents/observer/agent.js";
5
- import { runReflector } from "../agents/reflector/agent.js";
6
4
  import { debugLog, withDebugLogContext } from "../debug-log.js";
7
5
  import { resolveObserverChunkMaxTokens } from "../config.js";
8
6
  import type { ResolveResult, Runtime } from "../runtime.js";
9
7
  import { serializeSourceAddressedBranchEntries } from "../serialize.js";
10
8
  import {
11
- OM_OBSERVATIONS_DROPPED,
9
+ OM_SUMMARIZER_COMMIT,
12
10
  OM_OBSERVATIONS_RECORDED,
13
- OM_REFLECTIONS_RECORDED,
14
- buildObservationsDroppedData,
15
11
  buildObservationsRecordedData,
16
- buildReflectionsRecordedData,
17
- earlierCoverageMarkerId,
18
12
  foldLedger,
19
13
  fullProjection,
20
14
  isSourceEntry,
21
15
  latestCoverageIndex,
22
- latestCoverageMarkerId,
23
16
  observationToSummaryLine,
17
+ partitionMemoryPools,
24
18
  rawTokensSinceObservationCoverage,
25
- rawTokensSinceReflectionCoverage,
26
- reflectionToSummaryLine,
19
+ summaryToSummaryLine,
27
20
  type Entry,
28
- type Reflection,
29
21
  } from "../session-ledger/index.js";
30
22
 
31
23
  type ResolvedModel = Extract<ResolveResult, { ok: true }>;
@@ -45,11 +37,35 @@ export type ConsolidationCtx = {
45
37
 
46
38
  type StageOutcome = "continue" | "abort";
47
39
 
48
- type ReflectorStageResult = {
49
- outcome: StageOutcome;
50
- sameRunReflections: Reflection[];
51
- effectiveReflectionCoverageId?: string;
52
- };
40
+ /** Abort only when a summarizer produces no stream/message progress for this long. */
41
+ export const SUMMARIZER_STALL_TIMEOUT_MS = 15 * 60_000;
42
+
43
+ export function createSummarizerStallWatchdog(
44
+ timeoutMs: number,
45
+ onStall: (signal: AbortSignal) => void,
46
+ ): { signal: AbortSignal; progress: () => void; dispose: () => void } {
47
+ const controller = new AbortController();
48
+ let timer: ReturnType<typeof setTimeout> | undefined;
49
+ const progress = () => {
50
+ if (controller.signal.aborted) return;
51
+ if (timer !== undefined) clearTimeout(timer);
52
+ timer = setTimeout(() => {
53
+ timer = undefined;
54
+ controller.abort(new Error(`summarizer produced no progress for ${Math.round(timeoutMs / 60_000)} minutes`));
55
+ onStall(controller.signal);
56
+ }, timeoutMs);
57
+ (timer as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
58
+ };
59
+ progress();
60
+ return {
61
+ signal: controller.signal,
62
+ progress,
63
+ dispose: () => {
64
+ if (timer !== undefined) clearTimeout(timer);
65
+ timer = undefined;
66
+ },
67
+ };
68
+ }
53
69
 
54
70
  function sourceEntriesAfter(entries: Entry[], index: number): Entry[] {
55
71
  return entries.slice(index + 1).filter(isSourceEntry);
@@ -59,27 +75,15 @@ function appendEntry(pi: ExtensionAPI, customType: string, data: unknown): void
59
75
  pi.appendEntry(customType, data);
60
76
  }
61
77
 
62
- function mergeReflections(existing: Reflection[], additional: Reflection[]): Reflection[] {
63
- const seen = new Set(existing.map((reflection) => reflection.id));
64
- const merged = [...existing];
65
- for (const reflection of additional) {
66
- if (seen.has(reflection.id)) continue;
67
- seen.add(reflection.id);
68
- merged.push(reflection);
69
- }
70
- return merged;
71
- }
72
-
73
78
  function anyStageDue(entries: Entry[], runtime: Runtime): boolean {
74
- return rawTokensSinceObservationCoverage(entries) >= runtime.config.observeAfterTokens
75
- || rawTokensSinceReflectionCoverage(entries) >= runtime.config.reflectAfterTokens;
79
+ return rawTokensSinceObservationCoverage(entries) >= runtime.config.observeAfterTokens;
76
80
  }
77
81
 
78
82
  function shouldNotifyWorker(runtime: Runtime, ctx: ConsolidationCtx): boolean {
79
83
  return runtime.config.showWorkerNotifications && ctx.hasUI;
80
84
  }
81
85
 
82
- function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer" | "reflector" | "dropper") => Promise<ResolvedModel | undefined> {
86
+ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer") => Promise<ResolvedModel | undefined> {
83
87
  let cached: ResolveResult | undefined;
84
88
  return async (stage) => {
85
89
  cached ??= await runtime.resolveModel({
@@ -94,7 +98,7 @@ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "ob
94
98
  }
95
99
  debugLog(`${stage}.model_unavailable`, { reason: cached.reason });
96
100
  if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
97
- ctx.ui.notify(`Observational memory: ${stage} skipped — ${cached.reason}`, "warning");
101
+ ctx.ui.notify(`pi-contemplator: ${stage} skipped — ${cached.reason}`, "warning");
98
102
  runtime.resolveFailureNotified = true;
99
103
  }
100
104
  return undefined;
@@ -105,8 +109,31 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
105
109
  const launch = (_event: unknown, ctx: ConsolidationCtx) => {
106
110
  maybeLaunchConsolidation(pi, runtime, ctx);
107
111
  };
108
- pi.on("agent_start", launch);
109
- pi.on("turn_end", launch);
112
+ pi.on("agent_start", (event, ctx) => {
113
+ launch(event, ctx);
114
+ syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
115
+ });
116
+ pi.on("turn_end", (event, ctx) => {
117
+ launch(event, ctx);
118
+ syncAndScheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
119
+ });
120
+ runtime.setAgentActivityListener((ctx) => {
121
+ runtime.ensureConfig(ctx.cwd);
122
+ // Token pools are re-evaluated at every primary-agent progress checkpoint;
123
+ // idle wall-clock time has no scheduling meaning.
124
+ scheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
125
+ });
126
+ runtime.setSettingsUpdateListener((ctx, settings) => {
127
+ const affectsScheduling = settings.summarizerEnabled !== undefined ||
128
+ settings.newMemoryPoolMaxTokens !== undefined ||
129
+ settings.oldMemoryPoolTargetTokens !== undefined ||
130
+ settings.summarizerRetriggerTokens !== undefined;
131
+ if (!affectsScheduling) return;
132
+ runtime.ensureConfig(ctx.cwd);
133
+ // A changed pool boundary starts a fresh threshold cycle.
134
+ runtime.summarizerNextTriggerTokens = undefined;
135
+ scheduleSummarizer(pi, runtime, ctx as ConsolidationCtx);
136
+ });
110
137
  }
111
138
 
112
139
  function debugSessionMetadata(ctx: ConsolidationCtx): { sessionId?: string; sessionFile?: string } {
@@ -183,6 +210,10 @@ export type ConsolidationPipelineOptions = {
183
210
  observerOnly?: boolean;
184
211
  };
185
212
 
213
+ export function shouldScheduleSummarizerFromObserver(options: ConsolidationPipelineOptions): boolean {
214
+ return options.observerOnly !== true;
215
+ }
216
+
186
217
  export async function runConsolidationPipeline(
187
218
  pi: ExtensionAPI,
188
219
  runtime: Runtime,
@@ -192,41 +223,177 @@ export async function runConsolidationPipeline(
192
223
  const resolveModel = makeModelResolver(runtime, ctx);
193
224
  const contextGeneration = runtime.getContextGeneration();
194
225
 
226
+ const beforeFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
195
227
  runtime.consolidationPhase = "observer";
228
+ runtime.lastObserverStartedAt = Date.now();
196
229
  try {
197
- const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
198
- force: options.forceObserver === true,
199
- entries: options.observerEntries,
200
- contextGeneration,
201
- });
202
- if (observerOutcome === "abort") return;
230
+ // A large backlog is drained in bounded, oldest-first chunks. The normal
231
+ // trigger threshold controls when the batch stops; a static compaction
232
+ // snapshot is intentionally processed only once. Coverage must advance on
233
+ // every iteration, otherwise stop rather than spin on a failed chunk.
234
+ while (true) {
235
+ const beforeEntries = ctx.sessionManager.getBranch() as Entry[];
236
+ const beforeCoverage = latestCoverageIndex(beforeEntries, OM_OBSERVATIONS_RECORDED);
237
+ const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
238
+ force: options.forceObserver === true,
239
+ entries: options.observerEntries,
240
+ contextGeneration,
241
+ });
242
+ if (observerOutcome === "abort") return;
243
+ if (options.observerEntries) break;
244
+
245
+ const afterEntries = ctx.sessionManager.getBranch() as Entry[];
246
+ const afterCoverage = latestCoverageIndex(afterEntries, OM_OBSERVATIONS_RECORDED);
247
+ const remainingTokens = rawTokensSinceObservationCoverage(afterEntries);
248
+ if (afterCoverage <= beforeCoverage || remainingTokens < runtime.config.observeAfterTokens) break;
249
+ debugLog("observer.backlog_continue", { remainingTokens, afterCoverage });
250
+ }
203
251
  } catch (error) {
204
252
  debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
205
253
  return;
254
+ } finally {
255
+ runtime.lastObserverCompletedAt = Date.now();
206
256
  }
207
- if (options.observerOnly === true) {
208
- runtime.notifyMemoryUpdate?.(ctx);
209
- return;
210
- }
257
+ const afterFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
258
+ const beforeIds = new Set(beforeFold.observations.map((item) => item.id));
259
+ const added = afterFold.observations.filter((item) => !beforeIds.has(item.id));
260
+ if (added.length > 0 && shouldScheduleSummarizerFromObserver(options)) scheduleSummarizer(pi, runtime, ctx);
261
+ if (contextGeneration === runtime.getContextGeneration()) runtime.notifyMemoryUpdate?.(ctx);
262
+ }
211
263
 
212
- runtime.consolidationPhase = "reflector";
213
- let reflectorResult: ReflectorStageResult;
214
- try {
215
- reflectorResult = await runReflectorStage(pi, runtime, ctx, resolveModel, contextGeneration);
216
- if (reflectorResult.outcome === "abort") return;
217
- } catch (error) {
218
- debugLog("reflector.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "reflector", error) });
219
- return;
220
- }
264
+ export function currentMemoryPools(runtime: Runtime, entries: Entry[]) {
265
+ const folded = foldLedger(entries);
266
+ return partitionMemoryPools(
267
+ folded.activeObservations,
268
+ folded.activeSummaries,
269
+ runtime.config.newMemoryPoolMaxTokens,
270
+ );
271
+ }
221
272
 
222
- runtime.consolidationPhase = "dropper";
223
- try {
224
- const dropperOutcome = await runDropperStage(pi, runtime, ctx, resolveModel, reflectorResult.sameRunReflections, reflectorResult.effectiveReflectionCoverageId, contextGeneration);
225
- if (dropperOutcome === "abort") return;
226
- } catch (error) {
227
- debugLog("dropper.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "dropper", error) });
228
- }
229
- if (contextGeneration === runtime.getContextGeneration()) runtime.notifyMemoryUpdate?.(ctx);
273
+ export function summarizerTriggerTokens(runtime: Runtime): number {
274
+ return runtime.summarizerNextTriggerTokens ?? runtime.config.oldMemoryPoolTargetTokens;
275
+ }
276
+
277
+ export function nextSummarizerTriggerTokens(targetTokens: number, postRunOldTokens: number, retriggerTokens: number): number {
278
+ return postRunOldTokens <= targetTokens
279
+ ? targetTokens
280
+ : Math.max(targetTokens, postRunOldTokens + retriggerTokens);
281
+ }
282
+
283
+ /** Failed/incomplete launches must remain eligible at the prior threshold. */
284
+ export function summarizerTriggerAfterRun(
285
+ successfullyCompleted: boolean,
286
+ currentTriggerTokens: number | undefined,
287
+ targetTokens: number,
288
+ postRunOldTokens: number,
289
+ retriggerTokens: number,
290
+ ): number | undefined {
291
+ return successfullyCompleted
292
+ ? nextSummarizerTriggerTokens(targetTokens, postRunOldTokens, retriggerTokens)
293
+ : currentTriggerTokens;
294
+ }
295
+
296
+ function syncAndScheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
297
+ runtime.ensureConfig(ctx.cwd);
298
+ scheduleSummarizer(pi, runtime, ctx);
299
+ }
300
+
301
+ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
302
+ if (runtime.config.passive || !runtime.config.summarizerEnabled || runtime.summarizerInFlight) return;
303
+ const entries = ctx.sessionManager.getBranch() as Entry[];
304
+ const pools = currentMemoryPools(runtime, entries);
305
+ if (pools.oldTokens <= summarizerTriggerTokens(runtime)) return;
306
+ const generation = runtime.getContextGeneration();
307
+ const runId = `summarizer-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
308
+ const sessionMetadata = debugSessionMetadata(ctx);
309
+ void runtime.launchSummarizerTask(ctx, async () => withDebugLogContext({
310
+ enabled: runtime.config.debugLog === true,
311
+ cwd: ctx.cwd,
312
+ ...sessionMetadata,
313
+ runId,
314
+ }, async () => {
315
+ let stalled = false;
316
+ let successfullyCompleted = false;
317
+ let disposeStallWatchdog = () => {};
318
+ const startedAt = Date.now();
319
+ runtime.lastSummarizerStartedAt = startedAt;
320
+ runtime.lastSummarizerRun = { startedAt, status: "running", messages: [] };
321
+ try {
322
+ const resolved = await runtime.resolveModel({ model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui });
323
+ if (!resolved.ok) {
324
+ debugLog("summarizer.model_unavailable", { reason: resolved.reason });
325
+ runtime.lastSummarizerRun = { startedAt, status: "failed", messages: [], error: resolved.reason };
326
+ return;
327
+ }
328
+ if (generation !== runtime.getContextGeneration()) return;
329
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify("pi-contemplator: summarizer running", "info");
330
+ const watchdog = createSummarizerStallWatchdog(SUMMARIZER_STALL_TIMEOUT_MS, (signal) => {
331
+ stalled = true;
332
+ const reason = signal.reason instanceof Error ? signal.reason.message : "summarizer stalled";
333
+ debugLog("summarizer.stalled", { reason });
334
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(`pi-contemplator: ${reason}; cancelling and leaving the backlog eligible for retry`, "warning");
335
+ });
336
+ disposeStallWatchdog = watchdog.dispose;
337
+ const result = await runSummarizer({
338
+ signal: watchdog.signal,
339
+ model: resolved.model as any,
340
+ apiKey: resolved.apiKey,
341
+ headers: resolved.headers,
342
+ getBranch: () => ctx.sessionManager.getBranch() as Entry[],
343
+ targetTokens: runtime.config.oldMemoryPoolTargetTokens,
344
+ newPoolMaxTokens: runtime.config.newMemoryPoolMaxTokens,
345
+ samplingThresholdTokens: runtime.config.summarizerSamplingThresholdTokens,
346
+ maxTurns: runtime.config.agentMaxTurns,
347
+ thinkingLevel: runtime.config.model?.thinking ?? "minimal",
348
+ recordUsage: (usage) => runtime.recordAgentUsage(usage),
349
+ onMessages: (messages) => {
350
+ watchdog.progress();
351
+ if (generation === runtime.getContextGeneration()) runtime.lastSummarizerRun = { startedAt, status: "running", messages: messages.slice() };
352
+ },
353
+ });
354
+ if (generation !== runtime.getContextGeneration()) return;
355
+ if (!result.completed) {
356
+ runtime.lastSummarizerRun = stalled
357
+ ? { ...runtime.lastSummarizerRun!, status: "failed", error: "Summarizer stalled and was cancelled; memory remains eligible for retry." }
358
+ : { ...runtime.lastSummarizerRun!, status: "incomplete" };
359
+ return;
360
+ }
361
+ if (result.commit) {
362
+ pi.appendEntry(OM_SUMMARIZER_COMMIT, result.commit);
363
+ successfullyCompleted = true;
364
+ const summary = `${result.commit.summaries.length} summaries consumed ${result.commit.metrics.consumedMemoryCount} memories, reducing visible memory by ~${result.commit.metrics.estimatedTokenReduction.toLocaleString()} tokens.`;
365
+ runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun!, status: "completed", summary };
366
+ debugLog("summarizer.appended", { summaries: result.commit.summaries.length, consumed: result.commit.metrics.consumedMemoryCount, sampled: result.sample?.sampled ?? false });
367
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(`pi-contemplator: summarizer completed — ${summary}`, "info");
368
+ runtime.notifyMemoryUpdate(ctx);
369
+ } else {
370
+ successfullyCompleted = true;
371
+ runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun!, status: "completed", summary: "No safe summaries were created." };
372
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify("pi-contemplator: summarizer completed — no safe summaries", "info");
373
+ }
374
+ } catch (error) {
375
+ if (generation === runtime.getContextGeneration() && runtime.lastSummarizerRun) {
376
+ runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun, status: "failed", error: error instanceof Error ? error.message : String(error) };
377
+ }
378
+ throw error;
379
+ } finally {
380
+ disposeStallWatchdog();
381
+ if (generation === runtime.getContextGeneration()) {
382
+ const completedAt = Date.now();
383
+ runtime.lastSummarizerCompletedAt = completedAt;
384
+ if (runtime.lastSummarizerRun) runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun, completedAt };
385
+ const postRunPools = currentMemoryPools(runtime, ctx.sessionManager.getBranch() as Entry[]);
386
+ const target = runtime.config.oldMemoryPoolTargetTokens;
387
+ runtime.summarizerNextTriggerTokens = summarizerTriggerAfterRun(
388
+ successfullyCompleted,
389
+ runtime.summarizerNextTriggerTokens,
390
+ target,
391
+ postRunPools.oldTokens,
392
+ runtime.config.summarizerRetriggerTokens,
393
+ );
394
+ }
395
+ }
396
+ }));
230
397
  }
231
398
 
232
399
  async function runObserverStage(
@@ -280,11 +447,11 @@ async function runObserverStage(
280
447
  }
281
448
 
282
449
  const memory = fullProjection(entries);
283
- const priorReflections = memory.reflections.map(reflectionToSummaryLine);
450
+ const priorSummaries = memory.summaries.map(summaryToSummaryLine);
284
451
  const priorObservations = memory.observations.map(observationToSummaryLine);
285
452
 
286
453
  if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
287
- `Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk`,
454
+ `pi-contemplator: observer running on ~${chunkTokens.toLocaleString()}-token chunk`,
288
455
  "info",
289
456
  );
290
457
  debugLog("observer.start", {
@@ -293,7 +460,7 @@ async function runObserverStage(
293
460
  coversUpToId,
294
461
  sourceEntryIds,
295
462
  sourceEntryCount: sourceEntryIds.length,
296
- priorReflections: priorReflections.length,
463
+ priorSummaries: priorSummaries.length,
297
464
  priorObservations: priorObservations.length,
298
465
  });
299
466
 
@@ -301,7 +468,7 @@ async function runObserverStage(
301
468
  model: resolved.model as any,
302
469
  apiKey: resolved.apiKey,
303
470
  headers: resolved.headers,
304
- priorReflections,
471
+ priorSummaries,
305
472
  priorObservations,
306
473
  chunk,
307
474
  allowedSourceEntryIds: sourceEntryIds,
@@ -313,15 +480,6 @@ async function runObserverStage(
313
480
  debugLog("observer.stale", { reason: "session_or_branch_changed" });
314
481
  return "abort";
315
482
  }
316
- if (!observations || observations.length === 0) {
317
- debugLog("observer.empty", { coversUpToId });
318
- if (ctx.hasUI) ctx.ui?.notify(
319
- "Observational memory: observer returned no observations",
320
- "warning",
321
- );
322
- return "continue";
323
- }
324
-
325
483
  const currentEntries = ctx.sessionManager.getBranch() as Entry[];
326
484
  let effectiveCoversUpToId = coversUpToId;
327
485
  if (!currentEntries.some((entry) => entry.id === coversUpToId)) {
@@ -336,153 +494,25 @@ async function runObserverStage(
336
494
  compactionId: compaction?.id,
337
495
  });
338
496
  }
339
- const data = buildObservationsRecordedData(observations, effectiveCoversUpToId);
497
+ const accepted = observations ?? [];
498
+ const data = buildObservationsRecordedData(accepted, effectiveCoversUpToId);
340
499
  if (!data) return "continue";
341
- debugLog("observer.records", {
342
- count: observations.length,
343
- observationTokens: observations.reduce((sum, observation) => sum + observation.tokenCount, 0),
500
+ debugLog(accepted.length > 0 ? "observer.records" : "observer.coverage_only", {
501
+ count: accepted.length,
502
+ observationTokens: accepted.reduce((sum, observation) => sum + observation.tokenCount, 0),
344
503
  coversUpToId: effectiveCoversUpToId,
345
504
  });
505
+ // A clean zero-observation verdict is still successful coverage. Persist an
506
+ // empty batch so the next bounded pass starts after this chunk instead of
507
+ // retrying the same low-information source forever. Failures throw above and
508
+ // therefore never reach this coverage commit.
346
509
  appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
347
- debugLog("observer.appended", { count: observations.length, coversUpToId: effectiveCoversUpToId });
348
- if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
349
- `Observational memory: ${observations.length} observation${observations.length === 1 ? "" : "s"} recorded`,
350
- "info",
351
- );
352
- return "continue";
353
- }
354
-
355
- async function runReflectorStage(
356
- pi: ExtensionAPI,
357
- runtime: Runtime,
358
- ctx: ConsolidationCtx,
359
- resolveModel: (stage: "reflector") => Promise<ResolvedModel | undefined>,
360
- contextGeneration: number,
361
- ): Promise<ReflectorStageResult> {
362
- const entries = ctx.sessionManager.getBranch() as Entry[];
363
- const reflectionTokens = rawTokensSinceReflectionCoverage(entries);
364
- if (reflectionTokens < runtime.config.reflectAfterTokens) return { outcome: "continue", sameRunReflections: [] };
365
-
366
- const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
367
- if (!observationCoverageId) return { outcome: "continue", sameRunReflections: [] };
368
-
510
+ debugLog("observer.appended", { count: accepted.length, coversUpToId: effectiveCoversUpToId });
369
511
  if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
370
- `Observational memory: reflector running (~${reflectionTokens.toLocaleString()} tokens)`,
512
+ accepted.length > 0
513
+ ? `pi-contemplator: ${accepted.length} observation${accepted.length === 1 ? "" : "s"} recorded`
514
+ : "pi-contemplator: observer found no new information; processed chunk marked covered",
371
515
  "info",
372
516
  );
373
- const resolved = await resolveModel("reflector");
374
- if (!resolved) return { outcome: "abort", sameRunReflections: [] };
375
- if (contextGeneration !== runtime.getContextGeneration()) {
376
- debugLog("reflector.stale", { reason: "session_or_branch_changed" });
377
- return { outcome: "abort", sameRunReflections: [] };
378
- }
379
-
380
- const folded = foldLedger(entries);
381
- const reflections = await runReflector({
382
- model: resolved.model as any,
383
- apiKey: resolved.apiKey,
384
- headers: resolved.headers,
385
- reflections: folded.reflections,
386
- observations: folded.activeObservations,
387
- maxTurns: runtime.config.agentMaxTurns,
388
- thinkingLevel: runtime.config.model?.thinking ?? "low",
389
- recordUsage: (usage) => runtime.recordAgentUsage(usage),
390
- });
391
- if (contextGeneration !== runtime.getContextGeneration()) {
392
- debugLog("reflector.stale", { reason: "session_or_branch_changed" });
393
- return { outcome: "abort", sameRunReflections: [] };
394
- }
395
- if (!reflections) return { outcome: "continue", sameRunReflections: [] };
396
-
397
- const data = buildReflectionsRecordedData(reflections, observationCoverageId);
398
- if (!data) return { outcome: "continue", sameRunReflections: [] };
399
- appendEntry(pi, OM_REFLECTIONS_RECORDED, data);
400
- return {
401
- outcome: "continue",
402
- sameRunReflections: reflections,
403
- effectiveReflectionCoverageId: data.coversUpToId,
404
- };
405
- }
406
-
407
- async function runDropperStage(
408
- pi: ExtensionAPI,
409
- runtime: Runtime,
410
- ctx: ConsolidationCtx,
411
- resolveModel: (stage: "dropper") => Promise<ResolvedModel | undefined>,
412
- sameRunReflections: Reflection[],
413
- sameRunReflectionCoverageId: string | undefined,
414
- contextGeneration: number,
415
- ): Promise<StageOutcome> {
416
- if (!sameRunReflectionCoverageId || sameRunReflections.length === 0) {
417
- debugLog("dropper.waiting_for_reflection", { sameRunReflections: sameRunReflections.length });
418
- return "continue";
419
- }
420
-
421
- const entries = ctx.sessionManager.getBranch() as Entry[];
422
- const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
423
- if (!observationCoverageId) return "continue";
424
-
425
- const folded = foldLedger(entries);
426
- const metrics = observationPoolMetrics(folded.activeObservations, runtime.config.observationsPoolTargetTokens);
427
- if (!metrics.ready) {
428
- debugLog("dropper.not_ready", {
429
- observationTokens: metrics.observationTokens,
430
- targetTokens: metrics.targetTokens,
431
- tokensOverTarget: metrics.tokensOverTarget,
432
- fullness: metrics.fullness,
433
- activeObservationCount: metrics.activeObservationCount,
434
- droppableCount: metrics.droppableCount,
435
- maxDropsAllowed: metrics.maxDropsAllowed,
436
- });
437
- return "continue";
438
- }
439
- debugLog("dropper.stage_start", {
440
- observationCoverageId,
441
- sameRunReflectionCoverageId,
442
- sameRunReflectionCount: sameRunReflections.length,
443
- activeObservationCount: metrics.activeObservationCount,
444
- observationTokens: metrics.observationTokens,
445
- targetTokens: metrics.targetTokens,
446
- tokensOverTarget: metrics.tokensOverTarget,
447
- fullness: metrics.fullness,
448
- maxDropsAllowed: metrics.maxDropsAllowed,
449
- });
450
-
451
- if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
452
- `Observational memory: dropper running after reflection — active observation pool ~${metrics.observationTokens.toLocaleString()} / ${metrics.targetTokens.toLocaleString()} target tokens (${Math.round(metrics.fullness * 100).toLocaleString()}%)`,
453
- "info",
454
- );
455
- const resolved = await resolveModel("dropper");
456
- if (!resolved) return "abort";
457
- if (contextGeneration !== runtime.getContextGeneration()) {
458
- debugLog("dropper.stale", { reason: "session_or_branch_changed" });
459
- return "abort";
460
- }
461
-
462
- const reflectionsForDropper = mergeReflections(folded.reflections, sameRunReflections);
463
- const droppedIds = await runDropper({
464
- model: resolved.model as any,
465
- apiKey: resolved.apiKey,
466
- headers: resolved.headers,
467
- reflections: reflectionsForDropper,
468
- observations: folded.activeObservations,
469
- targetTokens: runtime.config.observationsPoolTargetTokens,
470
- maxTurns: runtime.config.agentMaxTurns,
471
- thinkingLevel: runtime.config.model?.thinking ?? "low",
472
- recordUsage: (usage) => runtime.recordAgentUsage(usage),
473
- });
474
- if (contextGeneration !== runtime.getContextGeneration()) {
475
- debugLog("dropper.stale", { reason: "session_or_branch_changed" });
476
- return "abort";
477
- }
478
- const coversUpToId = earlierCoverageMarkerId(entries, observationCoverageId, sameRunReflectionCoverageId);
479
- const data = coversUpToId && droppedIds ? buildObservationsDroppedData(droppedIds, coversUpToId) : undefined;
480
- debugLog("dropper.append", {
481
- droppedIdsCount: droppedIds?.length ?? 0,
482
- coversUpToId,
483
- dataBuilt: data !== undefined,
484
- appended: data !== undefined,
485
- });
486
- if (data) appendEntry(pi, OM_OBSERVATIONS_DROPPED, data);
487
517
  return "continue";
488
518
  }
@@ -0,0 +1,37 @@
1
+ const MEMORY_ID_RE = /(?<![0-9A-Za-z])([0-9a-f]{7,16})(?![0-9A-Za-z])/g;
2
+ const DELIMITED_LIST_RES = [/\[([^\]]*)\]/g, /\(([^)]*)\)/g, /\{([^}]*)\}/g];
3
+ const MEMORY_ID_LIST_RE = /^\s*[0-9a-f]{7,16}(?:\s*,\s*[0-9a-f]{7,16})*\s*$/;
4
+
5
+ /**
6
+ * Extract likely memory or entry references in textual order.
7
+ *
8
+ * IDs inside [], (), or {} may be comma-separated. A bare candidate must
9
+ * contain both a digit and a letter so ordinary words made only from a-f and
10
+ * decimal numbers are not mistaken for references. Delimited IDs do not need
11
+ * that mix because the delimiters provide an explicit citation signal.
12
+ */
13
+ export function memoryReferenceIds(text: string): string[] {
14
+ const delimitedRanges: Array<[number, number]> = [];
15
+ for (const expression of DELIMITED_LIST_RES) {
16
+ for (const match of text.matchAll(expression)) {
17
+ const contents = match[1];
18
+ if (contents !== undefined && MEMORY_ID_LIST_RE.test(contents)) {
19
+ const start = (match.index ?? 0) + 1;
20
+ delimitedRanges.push([start, start + contents.length]);
21
+ }
22
+ }
23
+ }
24
+
25
+ const ids: string[] = [];
26
+ const seen = new Set<string>();
27
+ for (const match of text.matchAll(MEMORY_ID_RE)) {
28
+ const id = match[1];
29
+ const index = match.index ?? -1;
30
+ const explicitlyDelimited = delimitedRanges.some(([start, end]) => index >= start && index < end);
31
+ const hashLike = /[0-9]/.test(id) && /[a-f]/.test(id);
32
+ if ((!explicitlyDelimited && !hashLike) || seen.has(id)) continue;
33
+ seen.add(id);
34
+ ids.push(id);
35
+ }
36
+ return ids;
37
+ }
@@ -0,0 +1,28 @@
1
+ /** Agent-loop tool-choice value accepted by each provider family. */
2
+ export function requiredToolChoice(api: string | undefined): "any" | "required" {
3
+ if (api === "anthropic-messages" || api === "google-generative-ai" || api === "google-vertex" || api === "bedrock-converse-stream") return "any";
4
+ return "required";
5
+ }
6
+
7
+ /** Apply provider-native required-tool controls at the final API payload layer. */
8
+ export function forceRequiredToolPayload(payload: unknown, api: string | undefined): unknown {
9
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
10
+ const record = payload as Record<string, unknown>;
11
+ if (api === "anthropic-messages") return { ...record, tool_choice: { type: "any" } };
12
+ if (api === "google-generative-ai" || api === "google-vertex") {
13
+ const config = record.config && typeof record.config === "object" && !Array.isArray(record.config) ? record.config as Record<string, unknown> : {};
14
+ const toolConfig = config.toolConfig && typeof config.toolConfig === "object" && !Array.isArray(config.toolConfig) ? config.toolConfig as Record<string, unknown> : {};
15
+ const functionCallingConfig = toolConfig.functionCallingConfig && typeof toolConfig.functionCallingConfig === "object" && !Array.isArray(toolConfig.functionCallingConfig) ? toolConfig.functionCallingConfig as Record<string, unknown> : {};
16
+ return { ...record, config: { ...config, toolConfig: { ...toolConfig, functionCallingConfig: { ...functionCallingConfig, mode: "ANY" } } } };
17
+ }
18
+ if (api === "bedrock-converse-stream") {
19
+ const toolConfig = record.toolConfig && typeof record.toolConfig === "object" && !Array.isArray(record.toolConfig) ? record.toolConfig as Record<string, unknown> : {};
20
+ return { ...record, toolConfig: { ...toolConfig, toolChoice: { any: {} } } };
21
+ }
22
+ if (api === "mistral-conversations") return { ...record, toolChoice: "required" };
23
+ if (api === "pi-messages") {
24
+ const options = record.options && typeof record.options === "object" && !Array.isArray(record.options) ? record.options as Record<string, unknown> : {};
25
+ return { ...record, options: { ...options, toolChoice: "required" } };
26
+ }
27
+ return { ...record, tool_choice: "required" };
28
+ }