pi-observational-memory 2.4.2 → 3.0.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.
@@ -0,0 +1,49 @@
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 "@earendil-works/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 debugLog(event: string, data: Record<string, unknown> = {}): void {
23
+ const context = storage.getStore();
24
+ if (context?.enabled !== true) return;
25
+
26
+ try {
27
+ const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
28
+ mkdirSync(dirname(path), { recursive: true });
29
+ rotateIfNeeded(path);
30
+ const payload = {
31
+ ts: new Date().toISOString(),
32
+ event,
33
+ cwd: context.cwd,
34
+ runId: context.runId,
35
+ data,
36
+ };
37
+ appendFileSync(path, `${JSON.stringify(payload)}\n`, "utf-8");
38
+ } catch {
39
+ // Debug logging must never affect memory behavior.
40
+ }
41
+ }
42
+
43
+ function rotateIfNeeded(path: string): void {
44
+ if (!existsSync(path)) return;
45
+ if (statSync(path).size < DEBUG_LOG_MAX_BYTES) return;
46
+ const backupPath = `${path}.1`;
47
+ if (existsSync(backupPath)) unlinkSync(backupPath);
48
+ renameSync(path, backupPath);
49
+ }
@@ -1,207 +1,51 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import {
3
- collectObservationsByCoverage,
4
- findLastCompactionIndex,
5
- gapRawEntries,
6
- getMemoryState,
7
- } from "../branch.js";
8
- import { migrateLegacyReflections, observationPoolTokens, renderSummary, runPruner, runReflector } from "../compaction.js";
9
- import { observationsToPromptLines, runObserver } from "../observer.js";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
10
3
  import type { Runtime } from "../runtime.js";
11
- import { serializeSourceAddressedBranchEntries } from "../serialize.js";
12
- import { estimateStringTokens } from "../tokens.js";
13
- import {
14
- OBSERVATION_CUSTOM_TYPE,
15
- reflectionToPromptLine,
16
- type MemoryDetailsV4,
17
- type MemoryReflection,
18
- type ObservationEntryData,
19
- type ObservationRecord,
20
- } from "../types.js";
4
+ import { buildCompactionProjection, renderSummary, type Entry } from "../session-ledger/index.js";
5
+
6
+ const DEFAULT_OBSERVATIONS_POOL_MAX_TOKENS = 20_000;
7
+
8
+ function observationsPoolMaxTokens(runtime: Runtime): number {
9
+ const value = (runtime.config as { observationsPoolMaxTokens?: unknown }).observationsPoolMaxTokens;
10
+ return typeof value === "number" && Number.isFinite(value) && value > 0
11
+ ? value
12
+ : DEFAULT_OBSERVATIONS_POOL_MAX_TOKENS;
13
+ }
21
14
 
22
15
  export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
23
- pi.on("session_before_compact", async (event, ctx) => {
16
+ pi.on("session_before_compact", async (event: any, ctx: any) => {
24
17
  if (runtime.compactHookInFlight) {
25
- if (ctx.hasUI) ctx.ui.notify(
26
- "Observational memory: another compaction is already in progress; cancelling duplicate",
27
- "warning",
28
- );
18
+ if (ctx.hasUI) {
19
+ ctx.ui.notify(
20
+ "Observational memory: another compaction is already in progress; cancelling duplicate",
21
+ "warning",
22
+ );
23
+ }
29
24
  return { cancel: true };
30
25
  }
26
+
31
27
  runtime.compactHookInFlight = true;
32
28
  try {
33
29
  runtime.ensureConfig(ctx.cwd);
34
- const { preparation, branchEntries, signal } = event;
30
+ const { preparation, branchEntries } = event;
35
31
  const { firstKeptEntryId, tokensBefore } = preparation;
36
-
37
- // Capture ctx properties synchronously — after multiple awaits below,
38
- // the extension ctx may become stale (e.g. after session replacement/reload).
39
- const hasUI = ctx.hasUI;
40
- const ui = ctx.ui;
41
-
42
- const resolved = await runtime.resolveModel(ctx as any);
43
- if (!resolved.ok) {
44
- if (hasUI) ui?.notify(
45
- `Observational memory: cannot compact — ${resolved.reason}. ` +
46
- "Fix the model/API key and try /compact manually.",
47
- "error",
48
- );
49
- return { cancel: true };
50
- }
51
- runtime.resolveFailureNotified = false;
52
-
53
- let entries = branchEntries as Parameters<typeof getMemoryState>[0];
54
-
55
- if (runtime.observerPromise) {
56
- try { await runtime.observerPromise; } catch { /* already notified via launchObserverTask */ }
57
- // In-flight observer may have appended a new observation entry during the await;
58
- // refresh from sessionManager so gap computation and coverage collection see it
59
- entries = ctx.sessionManager.getBranch() as typeof entries;
60
- }
61
-
62
- const memoryState = getMemoryState(entries);
63
-
64
- let gapObservationData: ObservationEntryData | null = null;
65
- const gap = gapRawEntries(entries, firstKeptEntryId);
66
- if (gap.length > 0) {
67
- const { text: gapChunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(gap);
68
- if (gapChunk.trim() && sourceEntryIds.length > 0) {
69
- const gapFromId = gap[0].id;
70
- const gapUpToId = gap[gap.length - 1].id;
71
- const priorObservationLines = observationsToPromptLines([
72
- ...memoryState.committedObs,
73
- ...memoryState.pendingObs,
74
- ]);
75
- const gapTokenEstimate = estimateStringTokens(gapChunk);
76
- if (hasUI) ui?.notify(
77
- `Observational memory: sync catch-up observer running on ~${gapTokenEstimate.toLocaleString()}-token gap`,
78
- "info",
79
- );
80
- runtime.observerInFlight = true;
81
- const gapCall = runObserver({
82
- model: resolved.model as any,
83
- apiKey: resolved.apiKey,
84
- headers: resolved.headers,
85
- priorReflections: memoryState.reflections.map(reflectionToPromptLine),
86
- priorObservations: priorObservationLines,
87
- chunk: gapChunk,
88
- allowedSourceEntryIds: sourceEntryIds,
89
- signal,
90
- });
91
- const gapPromise: Promise<void> = gapCall.then(() => undefined, () => undefined);
92
- runtime.observerPromise = gapPromise;
93
- try {
94
- const records = await gapCall;
95
- if (records && records.length > 0) {
96
- const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
97
- gapObservationData = {
98
- records,
99
- coversFromId: gapFromId,
100
- coversUpToId: gapUpToId,
101
- tokenCount: observationTokens,
102
- };
103
- pi.appendEntry(OBSERVATION_CUSTOM_TYPE, gapObservationData);
104
- if (hasUI && ui) ui.notify(
105
- `Observational memory: sync catch-up recorded ${records.length} observation${records.length === 1 ? "" : "s"} (~${observationTokens.toLocaleString()} tokens)`,
106
- "info",
107
- );
108
- } else if (hasUI && ui) {
109
- ui.notify(
110
- "Observational memory: sync catch-up observer returned empty — proceeding with compaction",
111
- "warning",
112
- );
113
- }
114
- } catch (error) {
115
- const msg = error instanceof Error ? error.message : String(error);
116
- if (hasUI && ui) ui.notify(
117
- `Observational memory: sync catch-up observer failed: ${msg}. Cancelling compaction — ${gap.length} unobserved raw entries would be pruned without coverage. Try /compact again.`,
118
- "warning",
119
- );
120
- return { cancel: true };
121
- } finally {
122
- runtime.observerInFlight = false;
123
- if (runtime.observerPromise === gapPromise) runtime.observerPromise = null;
124
- }
125
- }
126
- }
127
-
128
- const priorCompactionIdx = findLastCompactionIndex(entries);
129
- const priorFirstKeptEntryId = priorCompactionIdx >= 0 ? entries[priorCompactionIdx].firstKeptEntryId : undefined;
130
- const deltaObservationData = collectObservationsByCoverage(entries, priorFirstKeptEntryId, firstKeptEntryId);
131
- if (gapObservationData) deltaObservationData.push(gapObservationData);
132
-
133
- if (deltaObservationData.length === 0) {
134
- if (hasUI) ui?.notify("Observational memory: nothing to compact yet", "warning");
135
- return { cancel: true };
136
- }
137
-
138
- const workingReflections: MemoryReflection[] = migrateLegacyReflections(memoryState.reflections);
139
- const workingObservations: ObservationRecord[] = [
140
- ...memoryState.committedObs,
141
- ...deltaObservationData.flatMap((d) => d.records),
142
- ];
143
-
144
- const observationTokens = observationPoolTokens(workingObservations);
145
-
146
- let finalReflections = workingReflections;
147
- let finalObservations = workingObservations;
148
-
149
- if (observationTokens >= runtime.config.reflectionThresholdTokens) {
150
- if (hasUI) ui?.notify("Observational memory: running reflector + pruner...", "info");
151
- try {
152
- finalReflections = await runReflector(
153
- { model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal },
154
- workingReflections,
155
- workingObservations,
156
- );
157
-
158
- const prunerResult = await runPruner(
159
- { model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal },
160
- finalReflections,
161
- workingObservations,
162
- runtime.config.reflectionThresholdTokens,
163
- );
164
- finalObservations = prunerResult.observations;
165
- if (prunerResult.fellBack && hasUI) {
166
- ui?.notify(
167
- "Observational memory: pruner run failed; kept observation set unchanged",
168
- "warning",
169
- );
170
- }
171
- } catch (error) {
172
- const msg = error instanceof Error ? error.message : String(error);
173
- if (hasUI) ui?.notify(`Observational memory: reflect/prune failed: ${msg}`, "warning");
174
- }
175
- }
176
-
177
- const summary = renderSummary(finalReflections, finalObservations);
178
-
179
- if (finalObservations.length === 0) {
180
- throw new Error("invariant violated: finalObservations empty after delta guard");
181
- }
182
-
183
- const details: MemoryDetailsV4 = {
184
- type: "observational-memory",
185
- version: 4,
186
- observations: finalObservations,
187
- reflections: finalReflections,
188
- };
189
-
190
- if (hasUI) ui?.notify(
191
- `Observational memory: compaction assembled — ${finalObservations.length} observation${finalObservations.length === 1 ? "" : "s"}, ${finalReflections.length} reflection${finalReflections.length === 1 ? "" : "s"}`,
192
- "info",
32
+ const projection = buildCompactionProjection(
33
+ branchEntries as Entry[],
34
+ firstKeptEntryId,
35
+ { observationsPoolMaxTokens: observationsPoolMaxTokens(runtime) },
193
36
  );
37
+ const summary = renderSummary(projection.reflections, projection.observations);
194
38
 
195
39
  return {
196
40
  compaction: {
197
41
  summary,
198
42
  firstKeptEntryId,
199
43
  tokensBefore,
200
- details,
44
+ details: projection.details,
201
45
  },
202
46
  };
203
47
  } finally {
204
48
  runtime.compactHookInFlight = false;
205
49
  }
206
50
  });
207
- }
51
+ }
@@ -1,16 +1,39 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { rawTokensSinceLastCompaction } from "../branch.js";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { rawTokensSinceLastCompaction, type Entry } from "../session-ledger/index.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: any, ctx: any) => {
7
15
  runtime.ensureConfig(ctx.cwd);
8
16
  if (runtime.config.passive === true) return;
9
17
  if (runtime.compactInFlight) return;
10
18
 
11
- const entries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastCompaction>[0];
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
+
34
+ const entries = ctx.sessionManager.getBranch() as Entry[];
12
35
  const tokens = rawTokensSinceLastCompaction(entries);
13
- if (tokens < runtime.config.compactionThresholdTokens) return;
36
+ if (tokens < runtime.config.compactAfterTokens) return;
14
37
 
15
38
  // Capture ctx properties synchronously — the setTimeout + async work below
16
39
  // may outlive the extension ctx (stale after session replacement/reload).
@@ -23,31 +46,22 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
23
46
  );
24
47
 
25
48
  runtime.compactInFlight = true;
26
- setTimeout(async () => {
27
- if (runtime.observerPromise) {
28
- try {
29
- await runtime.observerPromise;
30
- } catch {
31
- // errors already surfaced via launchObserverTask
32
- }
33
- }
34
- // After awaiting observerPromise, ctx may be stale.
35
- // Use captured hasUI/ui for notification; wrap ctx access in try/catch.
49
+ setTimeout(() => {
36
50
  try {
37
51
  if (!ctx.isIdle()) {
38
52
  runtime.compactInFlight = false;
39
53
  if (hasUI) ui?.notify(
40
- "Observational memory: compaction deferred — agent became busy after observer wait",
54
+ "Observational memory: compaction deferred — agent became busy before compaction",
41
55
  "info",
42
56
  );
43
57
  return;
44
58
  }
45
- const currentEntries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastCompaction>[0];
59
+ const currentEntries = ctx.sessionManager.getBranch() as Entry[];
46
60
  const currentTokens = rawTokensSinceLastCompaction(currentEntries);
47
- if (currentTokens < runtime.config.compactionThresholdTokens) {
61
+ if (currentTokens < runtime.config.compactAfterTokens) {
48
62
  runtime.compactInFlight = false;
49
63
  if (hasUI) ui?.notify(
50
- "Observational memory: compaction skipped — another compaction already ran during observer wait",
64
+ "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
51
65
  "info",
52
66
  );
53
67
  return;
@@ -57,7 +71,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
57
71
  runtime.compactInFlight = false;
58
72
  if (hasUI) ui?.notify("Observational memory: compaction complete", "info");
59
73
  },
60
- onError: (error) => {
74
+ onError: (error: { message: string }) => {
61
75
  runtime.compactInFlight = false;
62
76
  if (error.message === "Compaction cancelled") {
63
77
  // We already notified the user with the real reason before returning { cancel: true }.
@@ -73,4 +87,4 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
73
87
  }
74
88
  }, 0);
75
89
  });
76
- }
90
+ }