@matthewfl/pi-contemplator 0.0.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/package.json +60 -0
  4. package/src/agents/contemplator/agent.ts +718 -0
  5. package/src/agents/contemplator/prompts.ts +212 -0
  6. package/src/agents/dropper/agent.ts +291 -0
  7. package/src/agents/dropper/coverage.ts +128 -0
  8. package/src/agents/dropper/pool.ts +67 -0
  9. package/src/agents/dropper/prompts.ts +48 -0
  10. package/src/agents/observer/agent.ts +207 -0
  11. package/src/agents/observer/prompts.ts +119 -0
  12. package/src/agents/reflector/agent.ts +213 -0
  13. package/src/agents/reflector/prompts.ts +81 -0
  14. package/src/agents/reviewer/agent.ts +187 -0
  15. package/src/agents/reviewer/history-tools.ts +337 -0
  16. package/src/agents/reviewer/prompts.ts +135 -0
  17. package/src/agents/reviewer/tools.ts +84 -0
  18. package/src/agents/stream-errors.ts +22 -0
  19. package/src/clipboard.ts +63 -0
  20. package/src/commands/contemplator-view.ts +128 -0
  21. package/src/commands/reviewer-view.ts +89 -0
  22. package/src/commands/settings.ts +257 -0
  23. package/src/commands/status.ts +176 -0
  24. package/src/commands/view.ts +171 -0
  25. package/src/config.ts +284 -0
  26. package/src/debug-log.ts +72 -0
  27. package/src/hooks/compaction-hook.ts +99 -0
  28. package/src/hooks/compaction-resume.ts +124 -0
  29. package/src/hooks/compaction-trigger.ts +122 -0
  30. package/src/hooks/consolidation-trigger.ts +488 -0
  31. package/src/ids.ts +5 -0
  32. package/src/index.ts +32 -0
  33. package/src/model-budget.ts +16 -0
  34. package/src/runtime.ts +316 -0
  35. package/src/serialize.ts +274 -0
  36. package/src/session-ledger/fold.ts +115 -0
  37. package/src/session-ledger/index.ts +7 -0
  38. package/src/session-ledger/progress.ts +156 -0
  39. package/src/session-ledger/projection.ts +243 -0
  40. package/src/session-ledger/recall.ts +258 -0
  41. package/src/session-ledger/render-summary.ts +31 -0
  42. package/src/session-ledger/search.ts +184 -0
  43. package/src/session-ledger/types.ts +329 -0
  44. package/src/tokens.ts +27 -0
  45. package/src/tools/compact-context.ts +54 -0
  46. package/src/tools/recall-observation.ts +532 -0
  47. package/src/tools/search-memories.ts +131 -0
@@ -0,0 +1,122 @@
1
+ import { isContextOverflow } from "@earendil-works/pi-ai/compat";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { resolveCompactAfterTokens } from "../config.js";
4
+ import { rawTokensSinceLastCompaction, type Entry } from "../session-ledger/index.js";
5
+ import type { Runtime } from "../runtime.js";
6
+ import {
7
+ registerCompactionResumeAcknowledgement,
8
+ resumeAfterCompaction,
9
+ } from "./compaction-resume.js";
10
+
11
+ const COMPACTION_STATUS_KEY = "observational-memory-compaction";
12
+
13
+ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
14
+ registerCompactionResumeAcknowledgement(pi, runtime);
15
+ pi.on("agent_end", (event: any, ctx: any) => {
16
+ runtime.ensureConfig(ctx.cwd);
17
+ if (runtime.compactInFlight) return;
18
+
19
+ const agentRequested = runtime.compactRequested;
20
+ if (agentRequested) runtime.compactRequested = false;
21
+ else if (runtime.config.passive === true) return;
22
+
23
+ const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : 0;
24
+ let threshold: number | undefined;
25
+ if (!agentRequested) {
26
+ // agent_end fires before Pi decides whether to retry or compact-and-retry an
27
+ // interrupted request. Starting ctx.compact() here turns it into a manual
28
+ // compaction (willRetry=false) and can consume Pi's overflow recovery, leaving
29
+ // the agent idle. Let Pi handle every failed/aborted/overflow response; OM's
30
+ // session_before_compact hook still supplies the actual memory compaction.
31
+ const lastAssistant = [...event.messages].reverse().find(
32
+ (m): m is Extract<typeof m, { role: "assistant" }> => m.role === "assistant",
33
+ );
34
+ if (
35
+ lastAssistant
36
+ && (
37
+ lastAssistant.stopReason === "error"
38
+ || lastAssistant.stopReason === "aborted"
39
+ || isContextOverflow(lastAssistant, contextWindow)
40
+ )
41
+ ) return;
42
+
43
+ const entries = ctx.sessionManager.getBranch() as Entry[];
44
+ const tokens = rawTokensSinceLastCompaction(entries);
45
+ // Resolve the proactive-compaction threshold from the active model's context
46
+ // window when ratio mode is configured. ctx.model is the current session model
47
+ // (Model<any> | undefined per ExtensionContext).
48
+ threshold = resolveCompactAfterTokens(runtime.config, contextWindow > 0 ? contextWindow : undefined);
49
+ if (tokens < threshold) return;
50
+ }
51
+
52
+ // Capture ctx properties synchronously — the setTimeout + async work below
53
+ // may outlive the extension ctx (stale after session replacement/reload).
54
+ const hasUI = ctx.hasUI;
55
+ const ui = ctx.ui;
56
+ const origin = agentRequested ? "agent-requested" : "proactive";
57
+
58
+ runtime.compactInFlight = true;
59
+ runtime.compactOrigin = origin;
60
+ setTimeout(() => {
61
+ try {
62
+ if (!ctx.isIdle()) {
63
+ runtime.compactInFlight = false;
64
+ runtime.compactOrigin = undefined;
65
+ if (agentRequested) runtime.compactRequested = true;
66
+ if (hasUI) ui?.notify(
67
+ "Observational memory: compaction deferred — agent became busy before compaction",
68
+ "info",
69
+ );
70
+ return;
71
+ }
72
+ const currentEntries = ctx.sessionManager.getBranch() as Entry[];
73
+ const currentTokens = rawTokensSinceLastCompaction(currentEntries);
74
+ if (threshold !== undefined && currentTokens < threshold) {
75
+ runtime.compactInFlight = false;
76
+ runtime.compactOrigin = undefined;
77
+ if (hasUI) ui?.notify(
78
+ "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
79
+ "info",
80
+ );
81
+ return;
82
+ }
83
+ if (hasUI) {
84
+ ui?.setStatus?.(COMPACTION_STATUS_KEY, `OM compaction: running (${origin}, resume pending)`);
85
+ const reason = agentRequested ? "agent-requested, " : "";
86
+ ui?.notify(
87
+ `Observational memory: compaction started (${reason}~${currentTokens.toLocaleString()} tokens); the agent will resume automatically`,
88
+ "info",
89
+ );
90
+ }
91
+ ctx.compact({
92
+ onComplete: () => {
93
+ runtime.compactInFlight = false;
94
+ runtime.compactOrigin = undefined;
95
+ // Both explicit and proactive OM compactions are manual from Pi's
96
+ // perspective (willRetry=false), so Pi will not continue either one.
97
+ // Always enqueue a hidden continuation after OM finishes compacting.
98
+ resumeAfterCompaction(pi, runtime, { hasUI, ui });
99
+ },
100
+ onError: (error: { message: string }) => {
101
+ runtime.compactInFlight = false;
102
+ runtime.compactOrigin = undefined;
103
+ if (hasUI) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
104
+ if (error.message !== "Compaction cancelled" && hasUI) {
105
+ ui?.notify(`Observational memory: ${error.message}`, "error");
106
+ }
107
+ resumeAfterCompaction(pi, runtime, { hasUI, ui }, true);
108
+ },
109
+ });
110
+ } catch (error) {
111
+ runtime.compactInFlight = false;
112
+ runtime.compactOrigin = undefined;
113
+ const msg = error instanceof Error ? error.message : String(error);
114
+ if (hasUI) {
115
+ ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
116
+ ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
117
+ }
118
+ resumeAfterCompaction(pi, runtime, { hasUI, ui }, true);
119
+ }
120
+ }, 0);
121
+ });
122
+ }
@@ -0,0 +1,488 @@
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";
4
+ import { runObserver } from "../agents/observer/agent.js";
5
+ import { runReflector } from "../agents/reflector/agent.js";
6
+ import { debugLog, withDebugLogContext } from "../debug-log.js";
7
+ import { resolveObserverChunkMaxTokens } from "../config.js";
8
+ import type { ResolveResult, Runtime } from "../runtime.js";
9
+ import { serializeSourceAddressedBranchEntries } from "../serialize.js";
10
+ import {
11
+ OM_OBSERVATIONS_DROPPED,
12
+ OM_OBSERVATIONS_RECORDED,
13
+ OM_REFLECTIONS_RECORDED,
14
+ buildObservationsDroppedData,
15
+ buildObservationsRecordedData,
16
+ buildReflectionsRecordedData,
17
+ earlierCoverageMarkerId,
18
+ foldLedger,
19
+ fullProjection,
20
+ isSourceEntry,
21
+ latestCoverageIndex,
22
+ latestCoverageMarkerId,
23
+ observationToSummaryLine,
24
+ rawTokensSinceObservationCoverage,
25
+ rawTokensSinceReflectionCoverage,
26
+ reflectionToSummaryLine,
27
+ type Entry,
28
+ type Reflection,
29
+ } from "../session-ledger/index.js";
30
+
31
+ type ResolvedModel = Extract<ResolveResult, { ok: true }>;
32
+
33
+ export type ConsolidationCtx = {
34
+ cwd: string;
35
+ hasUI: boolean;
36
+ ui?: { notify: (message: string, type?: "warning" | "info" | "error") => void };
37
+ model: unknown;
38
+ modelRegistry: any;
39
+ sessionManager: {
40
+ getBranch: () => readonly unknown[];
41
+ getSessionId?: () => string;
42
+ getSessionFile?: () => string | undefined;
43
+ };
44
+ };
45
+
46
+ type StageOutcome = "continue" | "abort";
47
+
48
+ type ReflectorStageResult = {
49
+ outcome: StageOutcome;
50
+ sameRunReflections: Reflection[];
51
+ effectiveReflectionCoverageId?: string;
52
+ };
53
+
54
+ function sourceEntriesAfter(entries: Entry[], index: number): Entry[] {
55
+ return entries.slice(index + 1).filter(isSourceEntry);
56
+ }
57
+
58
+ function appendEntry(pi: ExtensionAPI, customType: string, data: unknown): void {
59
+ pi.appendEntry(customType, data);
60
+ }
61
+
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
+ function anyStageDue(entries: Entry[], runtime: Runtime): boolean {
74
+ return rawTokensSinceObservationCoverage(entries) >= runtime.config.observeAfterTokens
75
+ || rawTokensSinceReflectionCoverage(entries) >= runtime.config.reflectAfterTokens;
76
+ }
77
+
78
+ function shouldNotifyWorker(runtime: Runtime, ctx: ConsolidationCtx): boolean {
79
+ return runtime.config.showWorkerNotifications && ctx.hasUI;
80
+ }
81
+
82
+ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer" | "reflector" | "dropper") => Promise<ResolvedModel | undefined> {
83
+ let cached: ResolveResult | undefined;
84
+ return async (stage) => {
85
+ cached ??= await runtime.resolveModel({
86
+ model: ctx.model,
87
+ modelRegistry: ctx.modelRegistry,
88
+ hasUI: ctx.hasUI,
89
+ ui: ctx.ui,
90
+ });
91
+ if (cached.ok) {
92
+ runtime.resolveFailureNotified = false;
93
+ return cached;
94
+ }
95
+ debugLog(`${stage}.model_unavailable`, { reason: cached.reason });
96
+ if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
97
+ ctx.ui.notify(`Observational memory: ${stage} skipped — ${cached.reason}`, "warning");
98
+ runtime.resolveFailureNotified = true;
99
+ }
100
+ return undefined;
101
+ };
102
+ }
103
+
104
+ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime): void {
105
+ const launch = (_event: unknown, ctx: ConsolidationCtx) => {
106
+ maybeLaunchConsolidation(pi, runtime, ctx);
107
+ };
108
+ pi.on("agent_start", launch);
109
+ pi.on("turn_end", launch);
110
+ }
111
+
112
+ function debugSessionMetadata(ctx: ConsolidationCtx): { sessionId?: string; sessionFile?: string } {
113
+ try {
114
+ return {
115
+ sessionId: ctx.sessionManager.getSessionId?.(),
116
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
117
+ };
118
+ } catch {
119
+ return {};
120
+ }
121
+ }
122
+
123
+ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
124
+ runtime.ensureConfig(ctx.cwd);
125
+ if (runtime.config.passive === true) return;
126
+ if (runtime.consolidationInFlight) return;
127
+
128
+ const entries = ctx.sessionManager.getBranch() as Entry[];
129
+ if (!anyStageDue(entries, runtime)) return;
130
+
131
+ const runId = `consolidation-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
132
+ const consolidationCtx: ConsolidationCtx = {
133
+ cwd: ctx.cwd,
134
+ hasUI: ctx.hasUI,
135
+ ui: ctx.ui,
136
+ model: ctx.model,
137
+ modelRegistry: ctx.modelRegistry,
138
+ sessionManager: ctx.sessionManager,
139
+ };
140
+
141
+ const sessionMetadata = debugSessionMetadata(ctx);
142
+ void runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
143
+ enabled: runtime.config.debugLog === true,
144
+ cwd: ctx.cwd,
145
+ ...sessionMetadata,
146
+ runId,
147
+ }, async () => {
148
+ await runConsolidationPipeline(pi, runtime, consolidationCtx);
149
+ }));
150
+ }
151
+
152
+ export function launchCompactionObserver(
153
+ pi: ExtensionAPI,
154
+ runtime: Runtime,
155
+ ctx: ConsolidationCtx,
156
+ branchEntries: Entry[],
157
+ ): void {
158
+ runtime.ensureConfig(ctx.cwd);
159
+ if (runtime.config.passive === true || runtime.consolidationInFlight) return;
160
+
161
+ const lastCoverageIdx = latestCoverageIndex(branchEntries, OM_OBSERVATIONS_RECORDED);
162
+ if (sourceEntriesAfter(branchEntries, lastCoverageIdx).length === 0) return;
163
+
164
+ const runId = `compaction-observer-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
165
+ const sessionMetadata = debugSessionMetadata(ctx);
166
+ void runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
167
+ enabled: runtime.config.debugLog === true,
168
+ cwd: ctx.cwd,
169
+ ...sessionMetadata,
170
+ runId,
171
+ }, async () => {
172
+ await runConsolidationPipeline(pi, runtime, ctx, {
173
+ forceObserver: true,
174
+ observerEntries: branchEntries,
175
+ observerOnly: true,
176
+ });
177
+ }));
178
+ }
179
+
180
+ export type ConsolidationPipelineOptions = {
181
+ forceObserver?: boolean;
182
+ observerEntries?: Entry[];
183
+ observerOnly?: boolean;
184
+ };
185
+
186
+ export async function runConsolidationPipeline(
187
+ pi: ExtensionAPI,
188
+ runtime: Runtime,
189
+ ctx: ConsolidationCtx,
190
+ options: ConsolidationPipelineOptions = {},
191
+ ): Promise<void> {
192
+ const resolveModel = makeModelResolver(runtime, ctx);
193
+ const contextGeneration = runtime.getContextGeneration();
194
+
195
+ runtime.consolidationPhase = "observer";
196
+ 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;
203
+ } catch (error) {
204
+ debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
205
+ return;
206
+ }
207
+ if (options.observerOnly === true) {
208
+ runtime.notifyMemoryUpdate?.(ctx);
209
+ return;
210
+ }
211
+
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
+ }
221
+
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);
230
+ }
231
+
232
+ async function runObserverStage(
233
+ pi: ExtensionAPI,
234
+ runtime: Runtime,
235
+ ctx: ConsolidationCtx,
236
+ resolveModel: (stage: "observer") => Promise<ResolvedModel | undefined>,
237
+ options: { force?: boolean; entries?: Entry[]; contextGeneration?: number } = {},
238
+ ): Promise<StageOutcome> {
239
+ const entries = options.entries ?? (ctx.sessionManager.getBranch() as Entry[]);
240
+ const tokens = rawTokensSinceObservationCoverage(entries);
241
+ if (!options.force && tokens < runtime.config.observeAfterTokens) return "continue";
242
+
243
+ // Resolve the model before building the chunk: the default chunk cap
244
+ // derives from the resolved model's context window.
245
+ const resolved = await resolveModel("observer");
246
+ if (!resolved) return "abort";
247
+ if (options.contextGeneration !== undefined && options.contextGeneration !== runtime.getContextGeneration()) {
248
+ debugLog("observer.stale", { reason: "session_or_branch_changed" });
249
+ return "abort";
250
+ }
251
+
252
+ const lastCoverageIdx = latestCoverageIndex(entries, OM_OBSERVATIONS_RECORDED);
253
+ const backlogEntries = sourceEntriesAfter(entries, lastCoverageIdx);
254
+
255
+ // Budget the text that is actually sent to the observer, including source
256
+ // labels and rendered message content. Complete entries are kept intact.
257
+ // Only a first entry that cannot fit by itself is represented by a clearly
258
+ // marked head/tail excerpt; the original ledger entry remains untouched.
259
+ const contextWindow = (resolved.model as { contextWindow?: number }).contextWindow;
260
+ const maxChunkTokens = resolveObserverChunkMaxTokens(runtime.config, contextWindow);
261
+ const {
262
+ text: chunk,
263
+ sourceEntryIds,
264
+ estimatedTokens: chunkTokens,
265
+ truncatedSourceEntryIds,
266
+ } = serializeSourceAddressedBranchEntries(backlogEntries, { maxTokens: maxChunkTokens });
267
+ if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
268
+ const coversUpToId = sourceEntryIds.at(-1);
269
+ if (!coversUpToId) return "continue";
270
+
271
+ if (sourceEntryIds.length < backlogEntries.length || truncatedSourceEntryIds.length > 0) {
272
+ debugLog("observer.chunk_capped", {
273
+ maxChunkTokens,
274
+ backlogEntries: backlogEntries.length,
275
+ backlogTokens: tokens,
276
+ chunkEntries: sourceEntryIds.length,
277
+ chunkTokens,
278
+ truncatedSourceEntryIds,
279
+ });
280
+ }
281
+
282
+ const memory = fullProjection(entries);
283
+ const priorReflections = memory.reflections.map(reflectionToSummaryLine);
284
+ const priorObservations = memory.observations.map(observationToSummaryLine);
285
+
286
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
287
+ `Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk`,
288
+ "info",
289
+ );
290
+ debugLog("observer.start", {
291
+ tokens,
292
+ chunkTokens,
293
+ coversUpToId,
294
+ sourceEntryIds,
295
+ sourceEntryCount: sourceEntryIds.length,
296
+ priorReflections: priorReflections.length,
297
+ priorObservations: priorObservations.length,
298
+ });
299
+
300
+ const observations = await runObserver({
301
+ model: resolved.model as any,
302
+ apiKey: resolved.apiKey,
303
+ headers: resolved.headers,
304
+ priorReflections,
305
+ priorObservations,
306
+ chunk,
307
+ allowedSourceEntryIds: sourceEntryIds,
308
+ maxTurns: runtime.config.agentMaxTurns,
309
+ thinkingLevel: runtime.config.model?.thinking ?? "low",
310
+ recordUsage: (usage) => runtime.recordAgentUsage(usage),
311
+ });
312
+ if (options.contextGeneration !== undefined && options.contextGeneration !== runtime.getContextGeneration()) {
313
+ debugLog("observer.stale", { reason: "session_or_branch_changed" });
314
+ return "abort";
315
+ }
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
+ const currentEntries = ctx.sessionManager.getBranch() as Entry[];
326
+ let effectiveCoversUpToId = coversUpToId;
327
+ if (!currentEntries.some((entry) => entry.id === coversUpToId)) {
328
+ // A fire-and-forget compaction observer may finish after compaction has
329
+ // folded its source target away. Never append an unresolvable marker: it
330
+ // would make these observations invisible to projection and recall.
331
+ const compaction = [...currentEntries].reverse().find((entry) => entry.type === "compaction");
332
+ effectiveCoversUpToId = compaction?.id ?? currentEntries.at(-1)?.id ?? coversUpToId;
333
+ debugLog("observer.coverage_target_folded", {
334
+ requestedCoversUpToId: coversUpToId,
335
+ effectiveCoversUpToId,
336
+ compactionId: compaction?.id,
337
+ });
338
+ }
339
+ const data = buildObservationsRecordedData(observations, effectiveCoversUpToId);
340
+ if (!data) return "continue";
341
+ debugLog("observer.records", {
342
+ count: observations.length,
343
+ observationTokens: observations.reduce((sum, observation) => sum + observation.tokenCount, 0),
344
+ coversUpToId: effectiveCoversUpToId,
345
+ });
346
+ 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
+
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()}%)`,
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
+ return "continue";
488
+ }
package/src/ids.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function hashId(content: string): string {
4
+ return createHash("sha256").update(content).digest("hex").slice(0, 12);
5
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerSettingsCommand } from "./commands/settings.js";
3
+ import { registerStatusCommand } from "./commands/status.js";
4
+ import { registerViewCommand } from "./commands/view.js";
5
+ import { registerCompactionHook } from "./hooks/compaction-hook.js";
6
+ import { registerCompactionTrigger } from "./hooks/compaction-trigger.js";
7
+ import { registerConsolidationTrigger } from "./hooks/consolidation-trigger.js";
8
+ import { Runtime } from "./runtime.js";
9
+ import { registerCompactContextTool } from "./tools/compact-context.js";
10
+ import { registerRecallTool } from "./tools/recall-observation.js";
11
+ import { registerSearchMemoriesTool } from "./tools/search-memories.js";
12
+ import { Contemplator } from "./agents/contemplator/agent.js";
13
+
14
+ export default function observationalMemory(pi: ExtensionAPI) {
15
+ const runtime = new Runtime();
16
+ pi.on("session_start", () => runtime.advanceContextGeneration());
17
+ pi.on("session_tree", () => runtime.advanceContextGeneration());
18
+ pi.on("session_shutdown", () => runtime.advanceContextGeneration());
19
+
20
+ registerSettingsCommand(pi, runtime);
21
+ new Contemplator(pi, runtime).register();
22
+
23
+ registerConsolidationTrigger(pi, runtime);
24
+ registerCompactionTrigger(pi, runtime);
25
+ registerCompactionHook(pi, runtime);
26
+
27
+ registerStatusCommand(pi, runtime);
28
+ registerViewCommand(pi, runtime);
29
+ registerCompactContextTool(pi, runtime);
30
+ registerRecallTool(pi);
31
+ registerSearchMemoriesTool(pi);
32
+ }
@@ -0,0 +1,16 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+
3
+ export const AGENT_LOOP_MAX_TOKENS = 32_000;
4
+
5
+ /**
6
+ * Lifetime output-token budget for one structural review request.
7
+ * Persisted reviewer transcripts carry usage across keep-going iterations,
8
+ * internal tool turns, and later session/tree resumptions.
9
+ */
10
+ export const REVIEWER_TOTAL_TOKEN_LIMIT = 1_000_000;
11
+
12
+ export function boundedMaxTokens(model: Model<any>, requested: number = AGENT_LOOP_MAX_TOKENS): number {
13
+ return typeof model.maxTokens === "number" && model.maxTokens > 0
14
+ ? Math.min(model.maxTokens, requested)
15
+ : requested;
16
+ }