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.
- package/README.md +316 -79
- package/package.json +10 -9
- package/src/agents/dropper/agent.ts +152 -0
- package/src/agents/dropper/pool.ts +67 -0
- package/src/agents/dropper/prompts.ts +43 -0
- package/src/{observer.ts → agents/observer/agent.ts} +30 -16
- package/src/agents/observer/prompts.ts +119 -0
- package/src/agents/reflector/agent.ts +134 -0
- package/src/agents/reflector/prompts.ts +77 -0
- package/src/clipboard.ts +63 -0
- package/src/commands/status.ts +79 -78
- package/src/commands/view.ts +58 -66
- package/src/config.ts +95 -16
- package/src/debug-log.ts +49 -0
- package/src/hooks/compaction-hook.ts +28 -184
- package/src/hooks/compaction-trigger.ts +35 -21
- package/src/hooks/consolidation-trigger.ts +331 -0
- package/src/index.ts +3 -3
- package/src/model-budget.ts +9 -0
- package/src/runtime.ts +46 -19
- package/src/serialize.ts +1 -1
- package/src/session-ledger/fold.ts +100 -0
- package/src/session-ledger/index.ts +6 -0
- package/src/session-ledger/progress.ts +129 -0
- package/src/session-ledger/projection.ts +220 -0
- package/src/session-ledger/recall.ts +237 -0
- package/src/session-ledger/render-summary.ts +31 -0
- package/src/session-ledger/types.ts +200 -0
- package/src/tokens.ts +1 -1
- package/src/tools/recall-observation.ts +84 -214
- package/src/branch.ts +0 -577
- package/src/compaction.ts +0 -617
- package/src/hooks/observer-trigger.ts +0 -96
- package/src/prompts.ts +0 -301
- package/src/relevance.ts +0 -15
- package/src/types.ts +0 -155
package/src/debug-log.ts
ADDED
|
@@ -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 "@
|
|
2
|
-
|
|
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 {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
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)
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
30
|
+
const { preparation, branchEntries } = event;
|
|
35
31
|
const { firstKeptEntryId, tokensBefore } = preparation;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 "@
|
|
2
|
-
import { rawTokensSinceLastCompaction } from "../
|
|
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", (
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
|
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
|
|
59
|
+
const currentEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
46
60
|
const currentTokens = rawTokensSinceLastCompaction(currentEntries);
|
|
47
|
-
if (currentTokens < runtime.config.
|
|
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
|
|
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
|
+
}
|