pi-observational-memory 2.4.2 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import type { ModelThinkingLevel } from "@mariozechner/pi-ai";
3
4
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
5
 
5
6
  export interface Config {
@@ -7,7 +8,20 @@ export interface Config {
7
8
  compactionThresholdTokens: number;
8
9
  reflectionThresholdTokens: number;
9
10
  passive: boolean;
11
+ debugLog: boolean;
10
12
  compactionModel?: { provider: string; id: string };
13
+ observerMaxTurnsPerRun?: number;
14
+ reflectorMaxTurnsPerPass?: number;
15
+ prunerMaxTurnsPerPass?: number;
16
+ thinkingLevel: ModelThinkingLevel;
17
+ /** @deprecated Use reflectorMaxTurnsPerPass and prunerMaxTurnsPerPass. */
18
+ compactionMaxToolCalls?: number;
19
+ }
20
+
21
+ export interface EffectiveTurnLimits {
22
+ observerMaxTurnsPerRun: number;
23
+ reflectorMaxTurnsPerPass: number;
24
+ prunerMaxTurnsPerPass: number;
11
25
  }
12
26
 
13
27
  export const DEFAULTS: Config = {
@@ -15,17 +29,59 @@ export const DEFAULTS: Config = {
15
29
  compactionThresholdTokens: 50_000,
16
30
  reflectionThresholdTokens: 30_000,
17
31
  passive: false,
32
+ debugLog: false,
33
+ thinkingLevel: "low",
18
34
  };
19
35
 
36
+ export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
37
+
20
38
  const SETTINGS_KEY = "observational-memory";
21
39
  const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
40
+ const DEFAULT_MAX_TURNS = 16;
41
+
42
+ function positiveIntegerOrUndefined(value: unknown): number | undefined {
43
+ return Number.isInteger(value) && typeof value === "number" && value > 0 ? value : undefined;
44
+ }
45
+
46
+ function normalizeTurnLimit<K extends keyof Config>(normalized: Partial<Config>, key: K): void {
47
+ if (!(key in normalized)) return;
48
+ const value = positiveIntegerOrUndefined(normalized[key]);
49
+ if (value === undefined) {
50
+ delete normalized[key];
51
+ } else {
52
+ (normalized as Record<K, number>)[key] = value;
53
+ }
54
+ }
55
+
56
+ function isThinkingLevel(value: unknown): value is ModelThinkingLevel {
57
+ return typeof value === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(value);
58
+ }
59
+
60
+ function normalizeThinkingLevel(normalized: Partial<Config>): void {
61
+ if (!("thinkingLevel" in normalized)) return;
62
+ if (!isThinkingLevel(normalized.thinkingLevel)) delete normalized.thinkingLevel;
63
+ }
22
64
 
23
65
  function normalizeSettingsConfig(value: Partial<Config>): Partial<Config> {
24
66
  const normalized = { ...value };
25
67
  if ("passive" in normalized && typeof normalized.passive !== "boolean") delete normalized.passive;
68
+ if ("debugLog" in normalized && typeof normalized.debugLog !== "boolean") delete normalized.debugLog;
69
+ normalizeTurnLimit(normalized, "observerMaxTurnsPerRun");
70
+ normalizeTurnLimit(normalized, "reflectorMaxTurnsPerPass");
71
+ normalizeTurnLimit(normalized, "prunerMaxTurnsPerPass");
72
+ normalizeTurnLimit(normalized, "compactionMaxToolCalls");
73
+ normalizeThinkingLevel(normalized);
26
74
  return normalized;
27
75
  }
28
76
 
77
+ export function resolveTurnLimits(config: Config): EffectiveTurnLimits {
78
+ return {
79
+ observerMaxTurnsPerRun: config.observerMaxTurnsPerRun ?? DEFAULT_MAX_TURNS,
80
+ reflectorMaxTurnsPerPass: config.reflectorMaxTurnsPerPass ?? config.compactionMaxToolCalls ?? DEFAULT_MAX_TURNS,
81
+ prunerMaxTurnsPerPass: config.prunerMaxTurnsPerPass ?? config.compactionMaxToolCalls ?? DEFAULT_MAX_TURNS,
82
+ };
83
+ }
84
+
29
85
  export function readEnvConfig(env: NodeJS.ProcessEnv = process.env): Partial<Config> {
30
86
  const rawPassive = env[PASSIVE_ENV];
31
87
  if (rawPassive === undefined) return {};
@@ -0,0 +1,53 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, appendFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
5
+
6
+ export const DEBUG_LOG_MAX_BYTES = 10 * 1024 * 1024;
7
+ export const DEBUG_LOG_RELATIVE_PATH = join("observational-memory", "debug.ndjson");
8
+
9
+ interface DebugLogContext {
10
+ enabled: boolean;
11
+ cwd?: string;
12
+ runId?: string;
13
+ }
14
+
15
+ const storage = new AsyncLocalStorage<DebugLogContext>();
16
+
17
+ export function withDebugLogContext<T>(context: DebugLogContext, fn: () => T): T {
18
+ const parent = storage.getStore();
19
+ return storage.run({ ...parent, ...context }, fn);
20
+ }
21
+
22
+ export function isDebugLogEnabled(): boolean {
23
+ return storage.getStore()?.enabled === true;
24
+ }
25
+
26
+ export function debugLog(event: string, data: Record<string, unknown> = {}): void {
27
+ const context = storage.getStore();
28
+ if (context?.enabled !== true) return;
29
+
30
+ try {
31
+ const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ rotateIfNeeded(path);
34
+ const payload = {
35
+ ts: new Date().toISOString(),
36
+ event,
37
+ cwd: context.cwd,
38
+ runId: context.runId,
39
+ data,
40
+ };
41
+ appendFileSync(path, `${JSON.stringify(payload)}\n`, "utf-8");
42
+ } catch {
43
+ // Debug logging must never affect memory behavior.
44
+ }
45
+ }
46
+
47
+ function rotateIfNeeded(path: string): void {
48
+ if (!existsSync(path)) return;
49
+ if (statSync(path).size < DEBUG_LOG_MAX_BYTES) return;
50
+ const backupPath = `${path}.1`;
51
+ if (existsSync(backupPath)) unlinkSync(backupPath);
52
+ renameSync(path, backupPath);
53
+ }
@@ -1,12 +1,27 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import { debugLog, withDebugLogContext } from "../debug-log.js";
4
+ import { resolveTurnLimits } from "../config.js";
2
5
  import {
3
6
  collectObservationsByCoverage,
4
7
  findLastCompactionIndex,
5
8
  gapRawEntries,
6
9
  getMemoryState,
7
10
  } from "../branch.js";
8
- import { migrateLegacyReflections, observationPoolTokens, renderSummary, runPruner, runReflector } from "../compaction.js";
11
+ import {
12
+ REFLECTOR_MAX_PASSES,
13
+ coverageTagCounts,
14
+ migrateLegacyReflections,
15
+ observationPoolTokens,
16
+ renderSummary,
17
+ runPruner,
18
+ runReflector,
19
+ type CoverageTagCounts,
20
+ type PrunerResult,
21
+ type ReflectorStats,
22
+ } from "../compaction.js";
9
23
  import { observationsToPromptLines, runObserver } from "../observer.js";
24
+ import { CompactionProgressTracker } from "../progress.js";
10
25
  import type { Runtime } from "../runtime.js";
11
26
  import { serializeSourceAddressedBranchEntries } from "../serialize.js";
12
27
  import { estimateStringTokens } from "../tokens.js";
@@ -19,6 +34,23 @@ import {
19
34
  type ObservationRecord,
20
35
  } from "../types.js";
21
36
 
37
+ function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
38
+ return `${count.toLocaleString()} ${count === 1 ? singular : pluralForm}`;
39
+ }
40
+
41
+ function formatCoverageCounts(counts: CoverageTagCounts): string {
42
+ return `${counts.uncited.toLocaleString()}/${counts.cited.toLocaleString()}/${counts.reinforced.toLocaleString()} uncited/cited/reinforced`;
43
+ }
44
+
45
+ function formatReflectorStats(stats: ReflectorStats): string {
46
+ const failed = stats.failedPass === undefined ? "" : `, failed pass ${stats.failedPass}`;
47
+ return `reflector ${plural(stats.toolCalls, "tool call")}, +${stats.added.toLocaleString()} added, ${stats.merged.toLocaleString()} merged, ${stats.promoted.toLocaleString()} promoted, ${stats.duplicates.toLocaleString()} duplicate/no-op, ${stats.unsupported.toLocaleString()} unsupported${failed}`;
48
+ }
49
+
50
+ function formatPrunerStats(result: PrunerResult): string {
51
+ return `pruner dropped ${plural(result.droppedIds.length, "observation")} in ${plural(result.passes.length, "pass", "passes")}, stop: ${result.stopReason}`;
52
+ }
53
+
22
54
  export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
23
55
  pi.on("session_before_compact", async (event, ctx) => {
24
56
  if (runtime.compactHookInFlight) {
@@ -29,8 +61,13 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
29
61
  return { cancel: true };
30
62
  }
31
63
  runtime.compactHookInFlight = true;
64
+ const progress = new CompactionProgressTracker();
65
+ const WIDGET_NAME = "om_compact_progress";
66
+ let clearWidget = () => {};
32
67
  try {
33
68
  runtime.ensureConfig(ctx.cwd);
69
+ const runId = `compaction-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
70
+ return await withDebugLogContext({ enabled: runtime.config.debugLog === true, cwd: ctx.cwd, runId }, async () => {
34
71
  const { preparation, branchEntries, signal } = event;
35
72
  const { firstKeptEntryId, tokensBefore } = preparation;
36
73
 
@@ -38,9 +75,19 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
38
75
  // the extension ctx may become stale (e.g. after session replacement/reload).
39
76
  const hasUI = ctx.hasUI;
40
77
  const ui = ctx.ui;
78
+ const turnLimits = resolveTurnLimits(runtime.config);
79
+ debugLog("compaction.start", {
80
+ firstKeptEntryId,
81
+ tokensBefore,
82
+ branchEntryCount: branchEntries.length,
83
+ reflectionThresholdTokens: runtime.config.reflectionThresholdTokens,
84
+ turnLimits,
85
+ legacyCompactionMaxToolCalls: runtime.config.compactionMaxToolCalls,
86
+ });
41
87
 
42
88
  const resolved = await runtime.resolveModel(ctx as any);
43
89
  if (!resolved.ok) {
90
+ debugLog("compaction.model_unavailable", { reason: resolved.reason });
44
91
  if (hasUI) ui?.notify(
45
92
  `Observational memory: cannot compact — ${resolved.reason}. ` +
46
93
  "Fix the model/API key and try /compact manually.",
@@ -50,6 +97,23 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
50
97
  }
51
98
  runtime.resolveFailureNotified = false;
52
99
 
100
+ const updateWidget = () => {
101
+ if (!hasUI || !ui) return;
102
+ if (!progress.getPhase()) {
103
+ ui.setWidget(WIDGET_NAME, undefined);
104
+ return;
105
+ }
106
+ ui.setWidget(WIDGET_NAME, (_tui: any, theme: any) => {
107
+ return new Text(
108
+ progress.formatWidget(theme),
109
+ 0, 0,
110
+ );
111
+ });
112
+ };
113
+ clearWidget = () => {
114
+ if (hasUI && ui) ui.setWidget(WIDGET_NAME, undefined);
115
+ };
116
+
53
117
  let entries = branchEntries as Parameters<typeof getMemoryState>[0];
54
118
 
55
119
  if (runtime.observerPromise) {
@@ -60,6 +124,11 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
60
124
  }
61
125
 
62
126
  const memoryState = getMemoryState(entries);
127
+ debugLog("compaction.memory_state", {
128
+ committedObservations: memoryState.committedObs.length,
129
+ pendingObservations: memoryState.pendingObs.length,
130
+ reflections: memoryState.reflections.length,
131
+ });
63
132
 
64
133
  let gapObservationData: ObservationEntryData | null = null;
65
134
  const gap = gapRawEntries(entries, firstKeptEntryId);
@@ -73,10 +142,19 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
73
142
  ...memoryState.pendingObs,
74
143
  ]);
75
144
  const gapTokenEstimate = estimateStringTokens(gapChunk);
145
+ debugLog("compaction.sync_catchup.start", {
146
+ gapEntryCount: gap.length,
147
+ sourceEntryIds,
148
+ gapFromId,
149
+ gapUpToId,
150
+ tokenEstimate: gapTokenEstimate,
151
+ });
76
152
  if (hasUI) ui?.notify(
77
153
  `Observational memory: sync catch-up observer running on ~${gapTokenEstimate.toLocaleString()}-token gap`,
78
154
  "info",
79
155
  );
156
+ progress.setPhase("observer", 1, 1);
157
+ updateWidget();
80
158
  runtime.observerInFlight = true;
81
159
  const gapCall = runObserver({
82
160
  model: resolved.model as any,
@@ -87,6 +165,8 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
87
165
  chunk: gapChunk,
88
166
  allowedSourceEntryIds: sourceEntryIds,
89
167
  signal,
168
+ maxTurns: turnLimits.observerMaxTurnsPerRun,
169
+ thinkingLevel: runtime.config.thinkingLevel,
90
170
  });
91
171
  const gapPromise: Promise<void> = gapCall.then(() => undefined, () => undefined);
92
172
  runtime.observerPromise = gapPromise;
@@ -100,12 +180,20 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
100
180
  coversUpToId: gapUpToId,
101
181
  tokenCount: observationTokens,
102
182
  };
183
+ debugLog("compaction.sync_catchup.records", {
184
+ count: records.length,
185
+ observationTokens,
186
+ coversFromId: gapFromId,
187
+ coversUpToId: gapUpToId,
188
+ records,
189
+ });
103
190
  pi.appendEntry(OBSERVATION_CUSTOM_TYPE, gapObservationData);
104
191
  if (hasUI && ui) ui.notify(
105
192
  `Observational memory: sync catch-up recorded ${records.length} observation${records.length === 1 ? "" : "s"} (~${observationTokens.toLocaleString()} tokens)`,
106
193
  "info",
107
194
  );
108
195
  } else if (hasUI && ui) {
196
+ debugLog("compaction.sync_catchup.empty", { gapEntryCount: gap.length });
109
197
  ui.notify(
110
198
  "Observational memory: sync catch-up observer returned empty — proceeding with compaction",
111
199
  "warning",
@@ -113,6 +201,7 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
113
201
  }
114
202
  } catch (error) {
115
203
  const msg = error instanceof Error ? error.message : String(error);
204
+ debugLog("compaction.sync_catchup.error", { gapEntryCount: gap.length, errorMessage: msg });
116
205
  if (hasUI && ui) ui.notify(
117
206
  `Observational memory: sync catch-up observer failed: ${msg}. Cancelling compaction — ${gap.length} unobserved raw entries would be pruned without coverage. Try /compact again.`,
118
207
  "warning",
@@ -129,10 +218,58 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
129
218
  const priorFirstKeptEntryId = priorCompactionIdx >= 0 ? entries[priorCompactionIdx].firstKeptEntryId : undefined;
130
219
  const deltaObservationData = collectObservationsByCoverage(entries, priorFirstKeptEntryId, firstKeptEntryId);
131
220
  if (gapObservationData) deltaObservationData.push(gapObservationData);
221
+ debugLog("compaction.delta", {
222
+ priorFirstKeptEntryId,
223
+ firstKeptEntryId,
224
+ deltaObservationEntries: deltaObservationData.length,
225
+ deltaObservationRecords: deltaObservationData.reduce((sum, data) => sum + data.records.length, 0),
226
+ gapObservationRecords: gapObservationData?.records.length ?? 0,
227
+ });
132
228
 
133
229
  if (deltaObservationData.length === 0) {
134
- if (hasUI) ui?.notify("Observational memory: nothing to compact yet", "warning");
135
- return { cancel: true };
230
+ // No new observations since last compaction. If we have existing memory,
231
+ // carry it forward in a no-op compaction so it survives Pi's compaction.
232
+ // If there is truly nothing (no prior memory either), cancel.
233
+ if (memoryState.committedObs.length === 0 && memoryState.reflections.length === 0) {
234
+ debugLog("compaction.no_delta_cancel", {
235
+ committedObservations: memoryState.committedObs.length,
236
+ pendingObservations: memoryState.pendingObs.length,
237
+ reflections: memoryState.reflections.length,
238
+ });
239
+ if (hasUI) {
240
+ ui?.notify(
241
+ `Observational memory: nothing to compact yet — ${plural(memoryState.committedObs.length, "committed observation")} and ${plural(memoryState.pendingObs.length, "pending observation")}; no eligible delta before compact boundary`,
242
+ "warning",
243
+ );
244
+ }
245
+ return { cancel: true };
246
+ }
247
+
248
+ // Carry forward existing memory without running reflector/pruner
249
+ const workingReflections: MemoryReflection[] = migrateLegacyReflections(memoryState.reflections);
250
+ debugLog("compaction.no_delta_carry_forward", {
251
+ observations: memoryState.committedObs.length,
252
+ reflections: workingReflections.length,
253
+ });
254
+ const summary = renderSummary(workingReflections, memoryState.committedObs);
255
+ const details: MemoryDetailsV4 = {
256
+ type: "observational-memory",
257
+ version: 4,
258
+ observations: memoryState.committedObs,
259
+ reflections: workingReflections,
260
+ };
261
+ if (hasUI) ui?.notify(
262
+ `Observational memory: no new observations — carrying forward ${memoryState.committedObs.length} observation${memoryState.committedObs.length === 1 ? "" : "s"}, ${workingReflections.length} reflection${workingReflections.length === 1 ? "" : "s"}`,
263
+ "info",
264
+ );
265
+ return {
266
+ compaction: {
267
+ summary,
268
+ firstKeptEntryId,
269
+ tokensBefore,
270
+ details,
271
+ },
272
+ };
136
273
  }
137
274
 
138
275
  const workingReflections: MemoryReflection[] = migrateLegacyReflections(memoryState.reflections);
@@ -142,26 +279,68 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
142
279
  ];
143
280
 
144
281
  const observationTokens = observationPoolTokens(workingObservations);
282
+ debugLog("compaction.reflect_prune.gate", {
283
+ observationTokens,
284
+ reflectionThresholdTokens: runtime.config.reflectionThresholdTokens,
285
+ willRun: observationTokens >= runtime.config.reflectionThresholdTokens,
286
+ workingObservations: workingObservations.length,
287
+ workingReflections: workingReflections.length,
288
+ });
145
289
 
146
290
  let finalReflections = workingReflections;
147
291
  let finalObservations = workingObservations;
148
292
 
149
293
  if (observationTokens >= runtime.config.reflectionThresholdTokens) {
150
- if (hasUI) ui?.notify("Observational memory: running reflector + pruner...", "info");
151
294
  try {
152
- finalReflections = await runReflector(
153
- { model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal },
295
+ debugLog("compaction.reflect_prune.start", {
296
+ workingObservations: workingObservations.length,
297
+ workingReflections: workingReflections.length,
298
+ observationTokens,
299
+ });
300
+ if (hasUI) ui?.notify("Observational memory: running reflector + pruner...", "info");
301
+ progress.setPhase("reflector", 1, REFLECTOR_MAX_PASSES);
302
+ progress.setStartingCounts(workingReflections.length, workingObservations.length);
303
+ updateWidget();
304
+ const coverageBefore = coverageTagCounts(workingReflections, workingObservations);
305
+ const reflectorResult = await runReflector(
306
+ { model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal, onEvent: (event) => { progress.onEvent(event); updateWidget(); }, maxTurns: turnLimits.reflectorMaxTurnsPerPass, thinkingLevel: runtime.config.thinkingLevel },
154
307
  workingReflections,
155
308
  workingObservations,
309
+ (pass, max) => { progress.setPhase("reflector", pass, max); updateWidget(); },
156
310
  );
311
+ finalReflections = reflectorResult.reflections;
312
+ const coverageAfter = coverageTagCounts(finalReflections, workingObservations);
313
+ debugLog("compaction.reflector.result", {
314
+ stats: reflectorResult.stats,
315
+ coverageBefore,
316
+ coverageAfter,
317
+ beforeReflections: workingReflections.length,
318
+ afterReflections: finalReflections.length,
319
+ });
157
320
 
158
321
  const prunerResult = await runPruner(
159
- { model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal },
322
+ { model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal, onEvent: (event) => { progress.onEvent(event); updateWidget(); }, maxTurns: turnLimits.prunerMaxTurnsPerPass, thinkingLevel: runtime.config.thinkingLevel },
160
323
  finalReflections,
161
324
  workingObservations,
162
325
  runtime.config.reflectionThresholdTokens,
326
+ (pass, max) => { progress.setPhase("pruner", pass, max); updateWidget(); },
163
327
  );
164
328
  finalObservations = prunerResult.observations;
329
+ debugLog("compaction.pruner.result", {
330
+ stopReason: prunerResult.stopReason,
331
+ fellBack: prunerResult.fellBack,
332
+ droppedIds: prunerResult.droppedIds,
333
+ passes: prunerResult.passes,
334
+ beforeObservations: workingObservations.length,
335
+ afterObservations: finalObservations.length,
336
+ });
337
+ updateWidget();
338
+ if (hasUI) {
339
+ ui?.notify(
340
+ `Observational memory: diagnostics — ${formatReflectorStats(reflectorResult.stats)}; coverage ${formatCoverageCounts(coverageBefore)} → ${formatCoverageCounts(coverageAfter)}; ${formatPrunerStats(prunerResult)}`,
341
+ "info",
342
+ );
343
+ }
165
344
  if (prunerResult.fellBack && hasUI) {
166
345
  ui?.notify(
167
346
  "Observational memory: pruner run failed; kept observation set unchanged",
@@ -170,6 +349,7 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
170
349
  }
171
350
  } catch (error) {
172
351
  const msg = error instanceof Error ? error.message : String(error);
352
+ debugLog("compaction.reflect_prune.error", { errorMessage: msg });
173
353
  if (hasUI) ui?.notify(`Observational memory: reflect/prune failed: ${msg}`, "warning");
174
354
  }
175
355
  }
@@ -186,6 +366,12 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
186
366
  observations: finalObservations,
187
367
  reflections: finalReflections,
188
368
  };
369
+ debugLog("compaction.result", {
370
+ finalObservations: finalObservations.length,
371
+ finalReflections: finalReflections.length,
372
+ firstKeptEntryId,
373
+ tokensBefore,
374
+ });
189
375
 
190
376
  if (hasUI) ui?.notify(
191
377
  `Observational memory: compaction assembled — ${finalObservations.length} observation${finalObservations.length === 1 ? "" : "s"}, ${finalReflections.length} reflection${finalReflections.length === 1 ? "" : "s"}`,
@@ -200,8 +386,11 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
200
386
  details,
201
387
  },
202
388
  };
389
+ });
203
390
  } finally {
204
391
  runtime.compactHookInFlight = false;
392
+ progress.clear();
393
+ clearWidget();
205
394
  }
206
395
  });
207
396
  }
@@ -2,12 +2,35 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { rawTokensSinceLastCompaction } from "../branch.js";
3
3
  import type { Runtime } from "../runtime.js";
4
4
 
5
+ /**
6
+ * Regex matching Pi's internal retryable error detection.
7
+ * When the last assistant message in agent_end has stopReason "error" matching this pattern,
8
+ * Pi will auto-retry — we must not trigger compaction between attempts.
9
+ */
10
+ const RETRYABLE_ERROR_RE =
11
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
12
+
5
13
  export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
6
- pi.on("agent_end", (_event, ctx) => {
14
+ pi.on("agent_end", (event, ctx) => {
7
15
  runtime.ensureConfig(ctx.cwd);
8
16
  if (runtime.config.passive === true) return;
9
17
  if (runtime.compactInFlight) return;
10
18
 
19
+ // Don't trigger compaction if Pi will auto-retry — the agent hasn't truly finished.
20
+ // Pi emits agent_end before its own retry check, so we must detect this ourselves.
21
+ // The next agent_end (after retry succeeds or exhausts attempts) will re-evaluate.
22
+ const lastAssistant = [...event.messages].reverse().find(
23
+ (m): m is Extract<typeof m, { role: "assistant" }> => m.role === "assistant",
24
+ );
25
+ if (
26
+ lastAssistant
27
+ && lastAssistant.stopReason === "error"
28
+ && lastAssistant.errorMessage
29
+ && RETRYABLE_ERROR_RE.test(lastAssistant.errorMessage)
30
+ ) {
31
+ return;
32
+ }
33
+
11
34
  const entries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastCompaction>[0];
12
35
  const tokens = rawTokensSinceLastCompaction(entries);
13
36
  if (tokens < runtime.config.compactionThresholdTokens) return;
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { debugLog, withDebugLogContext } from "../debug-log.js";
3
+ import { resolveTurnLimits } from "../config.js";
2
4
  import {
3
5
  firstRawIdAfter,
4
6
  getMemoryState,
@@ -32,6 +34,7 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
32
34
 
33
35
  const { reflections, committedObs, pendingObs } = getMemoryState(entries);
34
36
  const priorObservationLines = observationsToPromptLines([...committedObs, ...pendingObs]);
37
+ const turnLimits = resolveTurnLimits(runtime.config);
35
38
 
36
39
  const chunkEntries = rawTailEntriesBetween(entries, coversFromId, coversUpToId);
37
40
  if (chunkEntries.length === 0) return;
@@ -42,55 +45,85 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
42
45
  `Observational memory: observer running on ~${tokens.toLocaleString()}-token chunk`,
43
46
  "info",
44
47
  );
48
+ const runId = `observer-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
45
49
 
46
50
  // Capture ctx properties synchronously — the async work below may outlive
47
51
  // the extension ctx (stale after session replacement/reload).
48
52
  const hasUI = ctx.hasUI;
49
53
  const ui = ctx.ui;
54
+ const model = ctx.model;
55
+ const modelRegistry = ctx.modelRegistry;
56
+ const cwd = ctx.cwd;
50
57
 
51
- void runtime.launchObserverTask(ctx, "observer", async () => {
52
- const resolved = await runtime.resolveModel(ctx as any);
53
- if (!resolved.ok) {
54
- if (!runtime.resolveFailureNotified && hasUI && ui) {
55
- ui.notify(
56
- `Observational memory: observer skipped — ${resolved.reason}`,
58
+ void runtime.launchObserverTask(ctx, "observer", async () => withDebugLogContext({ enabled: runtime.config.debugLog === true, cwd, runId }, async () => {
59
+ try {
60
+ debugLog("observer.start", {
61
+ tokens,
62
+ coversFromId,
63
+ coversUpToId,
64
+ sourceEntryIds,
65
+ sourceEntryCount: sourceEntryIds.length,
66
+ priorReflections: reflections.length,
67
+ priorObservations: priorObservationLines.length,
68
+ });
69
+ const resolved = await runtime.resolveModel({ model, modelRegistry, hasUI, ui });
70
+ if (!resolved.ok) {
71
+ debugLog("observer.model_unavailable", { reason: resolved.reason });
72
+ if (!runtime.resolveFailureNotified && hasUI && ui) {
73
+ ui.notify(
74
+ `Observational memory: observer skipped — ${resolved.reason}`,
75
+ "warning",
76
+ );
77
+ runtime.resolveFailureNotified = true;
78
+ }
79
+ return;
80
+ }
81
+ runtime.resolveFailureNotified = false;
82
+
83
+ const records = await runObserver({
84
+ model: resolved.model as any,
85
+ apiKey: resolved.apiKey,
86
+ headers: resolved.headers,
87
+ priorReflections: reflections.map(reflectionToPromptLine),
88
+ priorObservations: priorObservationLines,
89
+ chunk,
90
+ allowedSourceEntryIds: sourceEntryIds,
91
+ maxTurns: turnLimits.observerMaxTurnsPerRun,
92
+ thinkingLevel: runtime.config.thinkingLevel,
93
+ });
94
+ if (!records || records.length === 0) {
95
+ debugLog("observer.empty", { coversFromId, coversUpToId });
96
+ if (hasUI && ui) ui.notify(
97
+ "Observational memory: observer returned no observations",
57
98
  "warning",
58
99
  );
59
- runtime.resolveFailureNotified = true;
100
+ return;
60
101
  }
61
- return;
62
- }
63
- runtime.resolveFailureNotified = false;
64
102
 
65
- const records = await runObserver({
66
- model: resolved.model as any,
67
- apiKey: resolved.apiKey,
68
- headers: resolved.headers,
69
- priorReflections: reflections.map(reflectionToPromptLine),
70
- priorObservations: priorObservationLines,
71
- chunk,
72
- allowedSourceEntryIds: sourceEntryIds,
73
- });
74
- if (!records || records.length === 0) {
103
+ const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
104
+ const data: ObservationEntryData = {
105
+ records,
106
+ coversFromId,
107
+ coversUpToId,
108
+ tokenCount: observationTokens,
109
+ };
110
+ debugLog("observer.records", {
111
+ count: records.length,
112
+ observationTokens,
113
+ coversFromId,
114
+ coversUpToId,
115
+ records,
116
+ });
117
+ pi.appendEntry(OBSERVATION_CUSTOM_TYPE, data);
118
+ debugLog("observer.appended", { count: records.length, tokenCount: observationTokens, coversFromId, coversUpToId });
75
119
  if (hasUI && ui) ui.notify(
76
- "Observational memory: observer returned no observations",
77
- "warning",
120
+ `Observational memory: ${records.length} observation${records.length === 1 ? "" : "s"} recorded (~${observationTokens.toLocaleString()} tokens)`,
121
+ "info",
78
122
  );
79
- return;
123
+ } catch (error) {
124
+ debugLog("observer.error", { errorMessage: error instanceof Error ? error.message : String(error) });
125
+ throw error;
80
126
  }
81
-
82
- const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
83
- const data: ObservationEntryData = {
84
- records,
85
- coversFromId,
86
- coversUpToId,
87
- tokenCount: observationTokens,
88
- };
89
- pi.appendEntry(OBSERVATION_CUSTOM_TYPE, data);
90
- if (hasUI && ui) ui.notify(
91
- `Observational memory: ${records.length} observation${records.length === 1 ? "" : "s"} recorded (~${observationTokens.toLocaleString()} tokens)`,
92
- "info",
93
- );
94
- });
127
+ }));
95
128
  });
96
129
  }
@@ -0,0 +1,9 @@
1
+ import type { Model } from "@mariozechner/pi-ai";
2
+
3
+ export const AGENT_LOOP_MAX_TOKENS = 32_000;
4
+
5
+ export function boundedMaxTokens(model: Model<any>, requested: number = AGENT_LOOP_MAX_TOKENS): number {
6
+ return typeof model.maxTokens === "number" && model.maxTokens > 0
7
+ ? Math.min(model.maxTokens, requested)
8
+ : requested;
9
+ }