@matthewfl/pi-contemplator 0.0.10 → 0.1.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.
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 +14 -6
  6. package/src/agents/observer/prompts.ts +16 -7
  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 +73 -69
  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 +25 -32
  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 +213 -196
  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 +7 -18
  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 +102 -75
  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,7 +223,9 @@ 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
230
  const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
198
231
  force: options.forceObserver === true,
@@ -203,30 +236,149 @@ export async function runConsolidationPipeline(
203
236
  } catch (error) {
204
237
  debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
205
238
  return;
239
+ } finally {
240
+ runtime.lastObserverCompletedAt = Date.now();
206
241
  }
207
- if (options.observerOnly === true) {
208
- runtime.notifyMemoryUpdate?.(ctx);
209
- return;
210
- }
242
+ const afterFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
243
+ const beforeIds = new Set(beforeFold.observations.map((item) => item.id));
244
+ const added = afterFold.observations.filter((item) => !beforeIds.has(item.id));
245
+ if (added.length > 0 && shouldScheduleSummarizerFromObserver(options)) scheduleSummarizer(pi, runtime, ctx);
246
+ if (contextGeneration === runtime.getContextGeneration()) runtime.notifyMemoryUpdate?.(ctx);
247
+ }
211
248
 
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
- }
249
+ export function currentMemoryPools(runtime: Runtime, entries: Entry[]) {
250
+ const folded = foldLedger(entries);
251
+ return partitionMemoryPools(
252
+ folded.activeObservations,
253
+ folded.activeSummaries,
254
+ runtime.config.newMemoryPoolMaxTokens,
255
+ );
256
+ }
221
257
 
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);
258
+ export function summarizerTriggerTokens(runtime: Runtime): number {
259
+ return runtime.summarizerNextTriggerTokens ?? runtime.config.oldMemoryPoolTargetTokens;
260
+ }
261
+
262
+ export function nextSummarizerTriggerTokens(targetTokens: number, postRunOldTokens: number, retriggerTokens: number): number {
263
+ return postRunOldTokens <= targetTokens
264
+ ? targetTokens
265
+ : Math.max(targetTokens, postRunOldTokens + retriggerTokens);
266
+ }
267
+
268
+ /** Failed/incomplete launches must remain eligible at the prior threshold. */
269
+ export function summarizerTriggerAfterRun(
270
+ successfullyCompleted: boolean,
271
+ currentTriggerTokens: number | undefined,
272
+ targetTokens: number,
273
+ postRunOldTokens: number,
274
+ retriggerTokens: number,
275
+ ): number | undefined {
276
+ return successfullyCompleted
277
+ ? nextSummarizerTriggerTokens(targetTokens, postRunOldTokens, retriggerTokens)
278
+ : currentTriggerTokens;
279
+ }
280
+
281
+ function syncAndScheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
282
+ runtime.ensureConfig(ctx.cwd);
283
+ scheduleSummarizer(pi, runtime, ctx);
284
+ }
285
+
286
+ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
287
+ if (runtime.config.passive || !runtime.config.summarizerEnabled || runtime.summarizerInFlight) return;
288
+ const entries = ctx.sessionManager.getBranch() as Entry[];
289
+ const pools = currentMemoryPools(runtime, entries);
290
+ if (pools.oldTokens <= summarizerTriggerTokens(runtime)) return;
291
+ const generation = runtime.getContextGeneration();
292
+ const runId = `summarizer-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
293
+ const sessionMetadata = debugSessionMetadata(ctx);
294
+ void runtime.launchSummarizerTask(ctx, async () => withDebugLogContext({
295
+ enabled: runtime.config.debugLog === true,
296
+ cwd: ctx.cwd,
297
+ ...sessionMetadata,
298
+ runId,
299
+ }, async () => {
300
+ let stalled = false;
301
+ let successfullyCompleted = false;
302
+ let disposeStallWatchdog = () => {};
303
+ const startedAt = Date.now();
304
+ runtime.lastSummarizerStartedAt = startedAt;
305
+ runtime.lastSummarizerRun = { startedAt, status: "running", messages: [] };
306
+ try {
307
+ const resolved = await runtime.resolveModel({ model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui });
308
+ if (!resolved.ok) {
309
+ debugLog("summarizer.model_unavailable", { reason: resolved.reason });
310
+ runtime.lastSummarizerRun = { startedAt, status: "failed", messages: [], error: resolved.reason };
311
+ return;
312
+ }
313
+ if (generation !== runtime.getContextGeneration()) return;
314
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify("pi-contemplator: summarizer running", "info");
315
+ const watchdog = createSummarizerStallWatchdog(SUMMARIZER_STALL_TIMEOUT_MS, (signal) => {
316
+ stalled = true;
317
+ const reason = signal.reason instanceof Error ? signal.reason.message : "summarizer stalled";
318
+ debugLog("summarizer.stalled", { reason });
319
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(`pi-contemplator: ${reason}; cancelling and leaving the backlog eligible for retry`, "warning");
320
+ });
321
+ disposeStallWatchdog = watchdog.dispose;
322
+ const result = await runSummarizer({
323
+ signal: watchdog.signal,
324
+ model: resolved.model as any,
325
+ apiKey: resolved.apiKey,
326
+ headers: resolved.headers,
327
+ getBranch: () => ctx.sessionManager.getBranch() as Entry[],
328
+ targetTokens: runtime.config.oldMemoryPoolTargetTokens,
329
+ newPoolMaxTokens: runtime.config.newMemoryPoolMaxTokens,
330
+ samplingThresholdTokens: runtime.config.summarizerSamplingThresholdTokens,
331
+ maxTurns: runtime.config.agentMaxTurns,
332
+ thinkingLevel: runtime.config.model?.thinking ?? "minimal",
333
+ recordUsage: (usage) => runtime.recordAgentUsage(usage),
334
+ onMessages: (messages) => {
335
+ watchdog.progress();
336
+ if (generation === runtime.getContextGeneration()) runtime.lastSummarizerRun = { startedAt, status: "running", messages: messages.slice() };
337
+ },
338
+ });
339
+ if (generation !== runtime.getContextGeneration()) return;
340
+ if (!result.completed) {
341
+ runtime.lastSummarizerRun = stalled
342
+ ? { ...runtime.lastSummarizerRun!, status: "failed", error: "Summarizer stalled and was cancelled; memory remains eligible for retry." }
343
+ : { ...runtime.lastSummarizerRun!, status: "incomplete" };
344
+ return;
345
+ }
346
+ if (result.commit) {
347
+ pi.appendEntry(OM_SUMMARIZER_COMMIT, result.commit);
348
+ successfullyCompleted = true;
349
+ const summary = `${result.commit.summaries.length} summaries consumed ${result.commit.metrics.consumedMemoryCount} memories, reducing visible memory by ~${result.commit.metrics.estimatedTokenReduction.toLocaleString()} tokens.`;
350
+ runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun!, status: "completed", summary };
351
+ debugLog("summarizer.appended", { summaries: result.commit.summaries.length, consumed: result.commit.metrics.consumedMemoryCount, sampled: result.sample?.sampled ?? false });
352
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(`pi-contemplator: summarizer completed — ${summary}`, "info");
353
+ runtime.notifyMemoryUpdate(ctx);
354
+ } else {
355
+ successfullyCompleted = true;
356
+ runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun!, status: "completed", summary: "No safe summaries were created." };
357
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify("pi-contemplator: summarizer completed — no safe summaries", "info");
358
+ }
359
+ } catch (error) {
360
+ if (generation === runtime.getContextGeneration() && runtime.lastSummarizerRun) {
361
+ runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun, status: "failed", error: error instanceof Error ? error.message : String(error) };
362
+ }
363
+ throw error;
364
+ } finally {
365
+ disposeStallWatchdog();
366
+ if (generation === runtime.getContextGeneration()) {
367
+ const completedAt = Date.now();
368
+ runtime.lastSummarizerCompletedAt = completedAt;
369
+ if (runtime.lastSummarizerRun) runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun, completedAt };
370
+ const postRunPools = currentMemoryPools(runtime, ctx.sessionManager.getBranch() as Entry[]);
371
+ const target = runtime.config.oldMemoryPoolTargetTokens;
372
+ runtime.summarizerNextTriggerTokens = summarizerTriggerAfterRun(
373
+ successfullyCompleted,
374
+ runtime.summarizerNextTriggerTokens,
375
+ target,
376
+ postRunPools.oldTokens,
377
+ runtime.config.summarizerRetriggerTokens,
378
+ );
379
+ }
380
+ }
381
+ }));
230
382
  }
231
383
 
232
384
  async function runObserverStage(
@@ -280,11 +432,11 @@ async function runObserverStage(
280
432
  }
281
433
 
282
434
  const memory = fullProjection(entries);
283
- const priorReflections = memory.reflections.map(reflectionToSummaryLine);
435
+ const priorSummaries = memory.summaries.map(summaryToSummaryLine);
284
436
  const priorObservations = memory.observations.map(observationToSummaryLine);
285
437
 
286
438
  if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
287
- `Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk`,
439
+ `pi-contemplator: observer running on ~${chunkTokens.toLocaleString()}-token chunk`,
288
440
  "info",
289
441
  );
290
442
  debugLog("observer.start", {
@@ -293,7 +445,7 @@ async function runObserverStage(
293
445
  coversUpToId,
294
446
  sourceEntryIds,
295
447
  sourceEntryCount: sourceEntryIds.length,
296
- priorReflections: priorReflections.length,
448
+ priorSummaries: priorSummaries.length,
297
449
  priorObservations: priorObservations.length,
298
450
  });
299
451
 
@@ -301,7 +453,7 @@ async function runObserverStage(
301
453
  model: resolved.model as any,
302
454
  apiKey: resolved.apiKey,
303
455
  headers: resolved.headers,
304
- priorReflections,
456
+ priorSummaries,
305
457
  priorObservations,
306
458
  chunk,
307
459
  allowedSourceEntryIds: sourceEntryIds,
@@ -316,7 +468,7 @@ async function runObserverStage(
316
468
  if (!observations || observations.length === 0) {
317
469
  debugLog("observer.empty", { coversUpToId });
318
470
  if (ctx.hasUI) ctx.ui?.notify(
319
- "Observational memory: observer returned no observations",
471
+ "pi-contemplator: observer returned no observations",
320
472
  "warning",
321
473
  );
322
474
  return "continue";
@@ -346,143 +498,8 @@ async function runObserverStage(
346
498
  appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
347
499
  debugLog("observer.appended", { count: observations.length, coversUpToId: effectiveCoversUpToId });
348
500
  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
-
369
- if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
370
- `Observational memory: reflector running (~${reflectionTokens.toLocaleString()} tokens)`,
371
- "info",
372
- );
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()}%)`,
501
+ `pi-contemplator: ${observations.length} observation${observations.length === 1 ? "" : "s"} recorded`,
453
502
  "info",
454
503
  );
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
504
  return "continue";
488
505
  }
@@ -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
+ }