pi-observational-memory 2.1.3 → 2.4.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 +40 -22
- package/package.json +10 -6
- package/src/branch.ts +395 -8
- package/src/commands/status.ts +37 -11
- package/src/commands/view.ts +6 -5
- package/src/compaction.ts +331 -45
- package/src/config.ts +21 -2
- package/src/hooks/compaction-hook.ts +31 -25
- package/src/hooks/compaction-trigger.ts +31 -23
- package/src/hooks/observer-trigger.ts +17 -10
- package/src/index.ts +2 -0
- package/src/observer.ts +42 -4
- package/src/prompts.ts +44 -15
- package/src/runtime.ts +6 -1
- package/src/serialize.ts +144 -31
- package/src/tools/recall-observation.ts +613 -0
- package/src/types.ts +101 -10
|
@@ -5,17 +5,18 @@ import {
|
|
|
5
5
|
gapRawEntries,
|
|
6
6
|
getMemoryState,
|
|
7
7
|
} from "../branch.js";
|
|
8
|
-
import { renderSummary, runPruner, runReflector } from "../compaction.js";
|
|
8
|
+
import { migrateLegacyReflections, renderSummary, runPruner, runReflector } from "../compaction.js";
|
|
9
9
|
import { observationsToPromptLines, runObserver } from "../observer.js";
|
|
10
10
|
import type { Runtime } from "../runtime.js";
|
|
11
|
-
import {
|
|
11
|
+
import { serializeSourceAddressedBranchEntries } from "../serialize.js";
|
|
12
12
|
import { estimateStringTokens } from "../tokens.js";
|
|
13
13
|
import {
|
|
14
14
|
OBSERVATION_CUSTOM_TYPE,
|
|
15
|
-
|
|
15
|
+
reflectionToPromptLine,
|
|
16
|
+
type MemoryDetailsV4,
|
|
17
|
+
type MemoryReflection,
|
|
16
18
|
type ObservationEntryData,
|
|
17
19
|
type ObservationRecord,
|
|
18
|
-
type Reflection,
|
|
19
20
|
} from "../types.js";
|
|
20
21
|
|
|
21
22
|
export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
|
|
@@ -33,9 +34,14 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
33
34
|
const { preparation, branchEntries, signal } = event;
|
|
34
35
|
const { firstKeptEntryId, tokensBefore } = preparation;
|
|
35
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
|
+
|
|
36
42
|
const resolved = await runtime.resolveModel(ctx as any);
|
|
37
43
|
if (!resolved.ok) {
|
|
38
|
-
if (
|
|
44
|
+
if (hasUI) ui?.notify(
|
|
39
45
|
`Observational memory: cannot compact — ${resolved.reason}. ` +
|
|
40
46
|
"Fix the model/API key and try /compact manually.",
|
|
41
47
|
"error",
|
|
@@ -58,8 +64,8 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
58
64
|
let gapObservationData: ObservationEntryData | null = null;
|
|
59
65
|
const gap = gapRawEntries(entries, firstKeptEntryId);
|
|
60
66
|
if (gap.length > 0) {
|
|
61
|
-
const gapChunk =
|
|
62
|
-
if (gapChunk.trim()) {
|
|
67
|
+
const { text: gapChunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(gap);
|
|
68
|
+
if (gapChunk.trim() && sourceEntryIds.length > 0) {
|
|
63
69
|
const gapFromId = gap[0].id;
|
|
64
70
|
const gapUpToId = gap[gap.length - 1].id;
|
|
65
71
|
const priorObservationLines = observationsToPromptLines([
|
|
@@ -67,7 +73,7 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
67
73
|
...memoryState.pendingObs,
|
|
68
74
|
]);
|
|
69
75
|
const gapTokenEstimate = estimateStringTokens(gapChunk);
|
|
70
|
-
if (
|
|
76
|
+
if (hasUI) ui?.notify(
|
|
71
77
|
`Observational memory: sync catch-up observer running on ~${gapTokenEstimate.toLocaleString()}-token gap`,
|
|
72
78
|
"info",
|
|
73
79
|
);
|
|
@@ -76,9 +82,10 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
76
82
|
model: resolved.model as any,
|
|
77
83
|
apiKey: resolved.apiKey,
|
|
78
84
|
headers: resolved.headers,
|
|
79
|
-
priorReflections: memoryState.reflections,
|
|
85
|
+
priorReflections: memoryState.reflections.map(reflectionToPromptLine),
|
|
80
86
|
priorObservations: priorObservationLines,
|
|
81
87
|
chunk: gapChunk,
|
|
88
|
+
allowedSourceEntryIds: sourceEntryIds,
|
|
82
89
|
signal,
|
|
83
90
|
});
|
|
84
91
|
const gapPromise: Promise<void> = gapCall.then(() => undefined, () => undefined);
|
|
@@ -94,19 +101,19 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
94
101
|
tokenCount: observationTokens,
|
|
95
102
|
};
|
|
96
103
|
pi.appendEntry(OBSERVATION_CUSTOM_TYPE, gapObservationData);
|
|
97
|
-
if (
|
|
104
|
+
if (hasUI && ui) ui.notify(
|
|
98
105
|
`Observational memory: sync catch-up recorded ${records.length} observation${records.length === 1 ? "" : "s"} (~${observationTokens.toLocaleString()} tokens)`,
|
|
99
106
|
"info",
|
|
100
107
|
);
|
|
101
|
-
} else if (
|
|
102
|
-
|
|
108
|
+
} else if (hasUI && ui) {
|
|
109
|
+
ui.notify(
|
|
103
110
|
"Observational memory: sync catch-up observer returned empty — proceeding with compaction",
|
|
104
111
|
"warning",
|
|
105
112
|
);
|
|
106
113
|
}
|
|
107
114
|
} catch (error) {
|
|
108
115
|
const msg = error instanceof Error ? error.message : String(error);
|
|
109
|
-
if (
|
|
116
|
+
if (hasUI && ui) ui.notify(
|
|
110
117
|
`Observational memory: sync catch-up observer failed: ${msg}. Cancelling compaction — ${gap.length} unobserved raw entries would be pruned without coverage. Try /compact again.`,
|
|
111
118
|
"warning",
|
|
112
119
|
);
|
|
@@ -124,11 +131,11 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
124
131
|
if (gapObservationData) deltaObservationData.push(gapObservationData);
|
|
125
132
|
|
|
126
133
|
if (deltaObservationData.length === 0) {
|
|
127
|
-
if (
|
|
134
|
+
if (hasUI) ui?.notify("Observational memory: nothing to compact yet", "warning");
|
|
128
135
|
return { cancel: true };
|
|
129
136
|
}
|
|
130
137
|
|
|
131
|
-
const workingReflections:
|
|
138
|
+
const workingReflections: MemoryReflection[] = migrateLegacyReflections(memoryState.reflections);
|
|
132
139
|
const workingObservations: ObservationRecord[] = [
|
|
133
140
|
...memoryState.committedObs,
|
|
134
141
|
...deltaObservationData.flatMap((d) => d.records),
|
|
@@ -140,14 +147,13 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
140
147
|
let finalObservations = workingObservations;
|
|
141
148
|
|
|
142
149
|
if (observationTokens >= runtime.config.reflectionThresholdTokens) {
|
|
143
|
-
if (
|
|
150
|
+
if (hasUI) ui?.notify("Observational memory: running reflector + pruner...", "info");
|
|
144
151
|
try {
|
|
145
|
-
|
|
152
|
+
finalReflections = await runReflector(
|
|
146
153
|
{ model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal },
|
|
147
154
|
workingReflections,
|
|
148
155
|
workingObservations,
|
|
149
156
|
);
|
|
150
|
-
finalReflections = [...workingReflections, ...newReflections];
|
|
151
157
|
|
|
152
158
|
const prunerResult = await runPruner(
|
|
153
159
|
{ model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal },
|
|
@@ -156,15 +162,15 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
156
162
|
runtime.config.reflectionThresholdTokens,
|
|
157
163
|
);
|
|
158
164
|
finalObservations = prunerResult.observations;
|
|
159
|
-
if (prunerResult.fellBack &&
|
|
160
|
-
|
|
165
|
+
if (prunerResult.fellBack && hasUI) {
|
|
166
|
+
ui?.notify(
|
|
161
167
|
"Observational memory: pruner run failed; kept observation set unchanged",
|
|
162
168
|
"warning",
|
|
163
169
|
);
|
|
164
170
|
}
|
|
165
171
|
} catch (error) {
|
|
166
172
|
const msg = error instanceof Error ? error.message : String(error);
|
|
167
|
-
if (
|
|
173
|
+
if (hasUI) ui?.notify(`Observational memory: reflect/prune failed: ${msg}`, "warning");
|
|
168
174
|
}
|
|
169
175
|
}
|
|
170
176
|
|
|
@@ -174,14 +180,14 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
174
180
|
throw new Error("invariant violated: finalObservations empty after delta guard");
|
|
175
181
|
}
|
|
176
182
|
|
|
177
|
-
const details:
|
|
183
|
+
const details: MemoryDetailsV4 = {
|
|
178
184
|
type: "observational-memory",
|
|
179
|
-
version:
|
|
185
|
+
version: 4,
|
|
180
186
|
observations: finalObservations,
|
|
181
187
|
reflections: finalReflections,
|
|
182
188
|
};
|
|
183
189
|
|
|
184
|
-
if (
|
|
190
|
+
if (hasUI) ui?.notify(
|
|
185
191
|
`Observational memory: compaction assembled — ${finalObservations.length} observation${finalObservations.length === 1 ? "" : "s"}, ${finalReflections.length} reflection${finalReflections.length === 1 ? "" : "s"}`,
|
|
186
192
|
"info",
|
|
187
193
|
);
|
|
@@ -198,4 +204,4 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
198
204
|
runtime.compactHookInFlight = false;
|
|
199
205
|
}
|
|
200
206
|
});
|
|
201
|
-
}
|
|
207
|
+
}
|
|
@@ -5,13 +5,19 @@ import type { Runtime } from "../runtime.js";
|
|
|
5
5
|
export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
|
|
6
6
|
pi.on("agent_end", (_event, ctx) => {
|
|
7
7
|
runtime.ensureConfig(ctx.cwd);
|
|
8
|
+
if (runtime.config.passive === true) return;
|
|
8
9
|
if (runtime.compactInFlight) return;
|
|
9
10
|
|
|
10
11
|
const entries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastCompaction>[0];
|
|
11
12
|
const tokens = rawTokensSinceLastCompaction(entries);
|
|
12
13
|
if (tokens < runtime.config.compactionThresholdTokens) return;
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
// Capture ctx properties synchronously — the setTimeout + async work below
|
|
16
|
+
// may outlive the extension ctx (stale after session replacement/reload).
|
|
17
|
+
const hasUI = ctx.hasUI;
|
|
18
|
+
const ui = ctx.ui;
|
|
19
|
+
|
|
20
|
+
if (hasUI) ui?.notify(
|
|
15
21
|
`Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
|
|
16
22
|
"info",
|
|
17
23
|
);
|
|
@@ -25,29 +31,31 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
25
31
|
// errors already surfaced via launchObserverTask
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (ctx.hasUI) ctx.ui.notify(
|
|
31
|
-
"Observational memory: compaction deferred — agent became busy after observer wait",
|
|
32
|
-
"info",
|
|
33
|
-
);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
const currentEntries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastCompaction>[0];
|
|
37
|
-
const currentTokens = rawTokensSinceLastCompaction(currentEntries);
|
|
38
|
-
if (currentTokens < runtime.config.compactionThresholdTokens) {
|
|
39
|
-
runtime.compactInFlight = false;
|
|
40
|
-
if (ctx.hasUI) ctx.ui.notify(
|
|
41
|
-
"Observational memory: compaction skipped — another compaction already ran during observer wait",
|
|
42
|
-
"info",
|
|
43
|
-
);
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
34
|
+
// After awaiting observerPromise, ctx may be stale.
|
|
35
|
+
// Use captured hasUI/ui for notification; wrap ctx access in try/catch.
|
|
46
36
|
try {
|
|
37
|
+
if (!ctx.isIdle()) {
|
|
38
|
+
runtime.compactInFlight = false;
|
|
39
|
+
if (hasUI) ui?.notify(
|
|
40
|
+
"Observational memory: compaction deferred — agent became busy after observer wait",
|
|
41
|
+
"info",
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const currentEntries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastCompaction>[0];
|
|
46
|
+
const currentTokens = rawTokensSinceLastCompaction(currentEntries);
|
|
47
|
+
if (currentTokens < runtime.config.compactionThresholdTokens) {
|
|
48
|
+
runtime.compactInFlight = false;
|
|
49
|
+
if (hasUI) ui?.notify(
|
|
50
|
+
"Observational memory: compaction skipped — another compaction already ran during observer wait",
|
|
51
|
+
"info",
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
47
55
|
ctx.compact({
|
|
48
56
|
onComplete: () => {
|
|
49
57
|
runtime.compactInFlight = false;
|
|
50
|
-
if (
|
|
58
|
+
if (hasUI) ui?.notify("Observational memory: compaction complete", "info");
|
|
51
59
|
},
|
|
52
60
|
onError: (error) => {
|
|
53
61
|
runtime.compactInFlight = false;
|
|
@@ -55,14 +63,14 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
55
63
|
// We already notified the user with the real reason before returning { cancel: true }.
|
|
56
64
|
return;
|
|
57
65
|
}
|
|
58
|
-
if (
|
|
66
|
+
if (hasUI) ui?.notify(`Observational memory: ${error.message}`, "error");
|
|
59
67
|
},
|
|
60
68
|
});
|
|
61
69
|
} catch (error) {
|
|
62
70
|
runtime.compactInFlight = false;
|
|
63
71
|
const msg = error instanceof Error ? error.message : String(error);
|
|
64
|
-
if (
|
|
72
|
+
if (hasUI) ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
|
|
65
73
|
}
|
|
66
74
|
}, 0);
|
|
67
75
|
});
|
|
68
|
-
}
|
|
76
|
+
}
|
|
@@ -8,13 +8,14 @@ import {
|
|
|
8
8
|
} from "../branch.js";
|
|
9
9
|
import { observationsToPromptLines, runObserver } from "../observer.js";
|
|
10
10
|
import type { Runtime } from "../runtime.js";
|
|
11
|
-
import {
|
|
11
|
+
import { serializeSourceAddressedBranchEntries } from "../serialize.js";
|
|
12
12
|
import { estimateStringTokens } from "../tokens.js";
|
|
13
|
-
import { OBSERVATION_CUSTOM_TYPE, type ObservationEntryData } from "../types.js";
|
|
13
|
+
import { OBSERVATION_CUSTOM_TYPE, reflectionToPromptLine, type ObservationEntryData } from "../types.js";
|
|
14
14
|
|
|
15
15
|
export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): void {
|
|
16
16
|
pi.on("turn_end", (_event, ctx) => {
|
|
17
17
|
runtime.ensureConfig(ctx.cwd);
|
|
18
|
+
if (runtime.config.passive === true) return;
|
|
18
19
|
if (runtime.observerInFlight) return;
|
|
19
20
|
|
|
20
21
|
const entries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastBound>[0];
|
|
@@ -34,19 +35,24 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
|
|
|
34
35
|
|
|
35
36
|
const chunkEntries = rawTailEntriesBetween(entries, coversFromId, coversUpToId);
|
|
36
37
|
if (chunkEntries.length === 0) return;
|
|
37
|
-
const chunk =
|
|
38
|
-
if (!chunk.trim()) return;
|
|
38
|
+
const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
|
|
39
|
+
if (!chunk.trim() || sourceEntryIds.length === 0) return;
|
|
39
40
|
|
|
40
41
|
if (ctx.hasUI) ctx.ui.notify(
|
|
41
42
|
`Observational memory: observer running on ~${tokens.toLocaleString()}-token chunk`,
|
|
42
43
|
"info",
|
|
43
44
|
);
|
|
44
45
|
|
|
46
|
+
// Capture ctx properties synchronously — the async work below may outlive
|
|
47
|
+
// the extension ctx (stale after session replacement/reload).
|
|
48
|
+
const hasUI = ctx.hasUI;
|
|
49
|
+
const ui = ctx.ui;
|
|
50
|
+
|
|
45
51
|
void runtime.launchObserverTask(ctx, "observer", async () => {
|
|
46
52
|
const resolved = await runtime.resolveModel(ctx as any);
|
|
47
53
|
if (!resolved.ok) {
|
|
48
|
-
if (!runtime.resolveFailureNotified &&
|
|
49
|
-
|
|
54
|
+
if (!runtime.resolveFailureNotified && hasUI && ui) {
|
|
55
|
+
ui.notify(
|
|
50
56
|
`Observational memory: observer skipped — ${resolved.reason}`,
|
|
51
57
|
"warning",
|
|
52
58
|
);
|
|
@@ -60,12 +66,13 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
|
|
|
60
66
|
model: resolved.model as any,
|
|
61
67
|
apiKey: resolved.apiKey,
|
|
62
68
|
headers: resolved.headers,
|
|
63
|
-
priorReflections: reflections,
|
|
69
|
+
priorReflections: reflections.map(reflectionToPromptLine),
|
|
64
70
|
priorObservations: priorObservationLines,
|
|
65
71
|
chunk,
|
|
72
|
+
allowedSourceEntryIds: sourceEntryIds,
|
|
66
73
|
});
|
|
67
74
|
if (!records || records.length === 0) {
|
|
68
|
-
if (
|
|
75
|
+
if (hasUI && ui) ui.notify(
|
|
69
76
|
"Observational memory: observer returned no observations",
|
|
70
77
|
"warning",
|
|
71
78
|
);
|
|
@@ -80,10 +87,10 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
|
|
|
80
87
|
tokenCount: observationTokens,
|
|
81
88
|
};
|
|
82
89
|
pi.appendEntry(OBSERVATION_CUSTOM_TYPE, data);
|
|
83
|
-
if (
|
|
90
|
+
if (hasUI && ui) ui.notify(
|
|
84
91
|
`Observational memory: ${records.length} observation${records.length === 1 ? "" : "s"} recorded (~${observationTokens.toLocaleString()} tokens)`,
|
|
85
92
|
"info",
|
|
86
93
|
);
|
|
87
94
|
});
|
|
88
95
|
});
|
|
89
|
-
}
|
|
96
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { registerCompactionHook } from "./hooks/compaction-hook.js";
|
|
|
5
5
|
import { registerCompactionTrigger } from "./hooks/compaction-trigger.js";
|
|
6
6
|
import { registerObserverTrigger } from "./hooks/observer-trigger.js";
|
|
7
7
|
import { Runtime } from "./runtime.js";
|
|
8
|
+
import { registerRecallTool } from "./tools/recall-observation.js";
|
|
8
9
|
|
|
9
10
|
export default function observationalMemory(pi: ExtensionAPI) {
|
|
10
11
|
const runtime = new Runtime();
|
|
@@ -15,4 +16,5 @@ export default function observationalMemory(pi: ExtensionAPI) {
|
|
|
15
16
|
|
|
16
17
|
registerStatusCommand(pi, runtime);
|
|
17
18
|
registerViewCommand(pi, runtime);
|
|
19
|
+
registerRecallTool(pi);
|
|
18
20
|
}
|
package/src/observer.ts
CHANGED
|
@@ -14,6 +14,7 @@ interface RunObserverArgs {
|
|
|
14
14
|
priorReflections: string[];
|
|
15
15
|
priorObservations: string[];
|
|
16
16
|
chunk: string;
|
|
17
|
+
allowedSourceEntryIds: string[];
|
|
17
18
|
signal?: AbortSignal;
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -36,6 +37,15 @@ const RecordObservationsSchema = Type.Object({
|
|
|
36
37
|
description: "Single-line plain prose. No markdown, no tags, no embedded timestamp.",
|
|
37
38
|
}),
|
|
38
39
|
relevance: RelevanceSchema,
|
|
40
|
+
sourceEntryIds: Type.Array(
|
|
41
|
+
Type.String({ minLength: 1 }),
|
|
42
|
+
{
|
|
43
|
+
minItems: 1,
|
|
44
|
+
description:
|
|
45
|
+
"Exact source entry ids from the chunk that directly support this observation. " +
|
|
46
|
+
"Use only ids shown in '[Source entry id: ...]' labels; never invent ids.",
|
|
47
|
+
},
|
|
48
|
+
),
|
|
39
49
|
}),
|
|
40
50
|
{ description: "Batch of new observations. May be empty only if the tool is not called at all." },
|
|
41
51
|
),
|
|
@@ -47,8 +57,25 @@ function joinOrEmpty(items: string[]): string {
|
|
|
47
57
|
return items.length ? items.join("\n") : "(none yet)";
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
export function normalizeSourceEntryIds(
|
|
61
|
+
sourceEntryIds: readonly string[] | undefined,
|
|
62
|
+
allowedSourceEntryIds: readonly string[],
|
|
63
|
+
): string[] | undefined {
|
|
64
|
+
if (!sourceEntryIds || sourceEntryIds.length === 0) return undefined;
|
|
65
|
+
const allowedOrder = new Map<string, number>();
|
|
66
|
+
for (let i = 0; i < allowedSourceEntryIds.length; i++) allowedOrder.set(allowedSourceEntryIds[i], i);
|
|
67
|
+
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
for (const id of sourceEntryIds) {
|
|
70
|
+
if (!allowedOrder.has(id)) return undefined;
|
|
71
|
+
seen.add(id);
|
|
72
|
+
}
|
|
73
|
+
if (seen.size === 0) return undefined;
|
|
74
|
+
return Array.from(seen).sort((a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0));
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
export async function runObserver(args: RunObserverArgs): Promise<ObservationRecord[] | undefined> {
|
|
51
|
-
const { model, apiKey, headers, priorReflections, priorObservations, chunk, signal } = args;
|
|
78
|
+
const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
|
|
52
79
|
const conversation = chunk.trim();
|
|
53
80
|
if (!conversation) return undefined;
|
|
54
81
|
|
|
@@ -65,7 +92,13 @@ export async function runObserver(args: RunObserverArgs): Promise<ObservationRec
|
|
|
65
92
|
execute: async (_id, params: RecordObservationsArgs) => {
|
|
66
93
|
let added = 0;
|
|
67
94
|
let duplicates = 0;
|
|
95
|
+
let rejected = 0;
|
|
68
96
|
for (const obs of params.observations) {
|
|
97
|
+
const sourceEntryIds = normalizeSourceEntryIds(obs.sourceEntryIds, allowedSourceEntryIds);
|
|
98
|
+
if (!sourceEntryIds) {
|
|
99
|
+
rejected++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
69
102
|
const content = truncateRecordContent(obs.content);
|
|
70
103
|
const id = hashId(content);
|
|
71
104
|
if (accumulated.has(id)) {
|
|
@@ -77,15 +110,20 @@ export async function runObserver(args: RunObserverArgs): Promise<ObservationRec
|
|
|
77
110
|
content,
|
|
78
111
|
timestamp: obs.timestamp,
|
|
79
112
|
relevance: obs.relevance as Relevance,
|
|
113
|
+
sourceEntryIds,
|
|
80
114
|
});
|
|
81
115
|
added++;
|
|
82
116
|
}
|
|
117
|
+
const rejectedPart = rejected > 0
|
|
118
|
+
? ` ${rejected} observation${rejected === 1 ? "" : "s"} rejected for missing or invalid sourceEntryIds.`
|
|
119
|
+
: "";
|
|
83
120
|
const ack =
|
|
84
121
|
`Recorded ${added} new observation${added === 1 ? "" : "s"} ` +
|
|
85
|
-
(duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped)
|
|
86
|
-
|
|
122
|
+
(duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped).` : ".") +
|
|
123
|
+
rejectedPart +
|
|
124
|
+
` Total so far this run: ${accumulated.size}. ` +
|
|
87
125
|
`Continue if the chunk still has uncovered content; otherwise stop calling the tool and emit a short plain-text confirmation.`;
|
|
88
|
-
return { content: [{ type: "text", text: ack }], details: { added, duplicates, total: accumulated.size } };
|
|
126
|
+
return { content: [{ type: "text", text: ack }], details: { added, duplicates, rejected, total: accumulated.size } };
|
|
89
127
|
},
|
|
90
128
|
};
|
|
91
129
|
|
package/src/prompts.ts
CHANGED
|
@@ -97,7 +97,7 @@ Your job is to compress a chunk of recent conversation into timestamped, rated o
|
|
|
97
97
|
You receive:
|
|
98
98
|
- Current reflections (long-lived facts already crystallized).
|
|
99
99
|
- Current observations (already-recorded observations, each shown as "[id] YYYY-MM-DD HH:MM [relevance] content").
|
|
100
|
-
- A new chunk of conversation with inline message timestamps formatted as "[User @ YYYY-MM-DD HH:MM]:", "[Assistant @ ...]:", "[Tool result for <name> @ ...]:".
|
|
100
|
+
- A new chunk of conversation with source entry labels and inline message timestamps. Each source block starts with "[Source entry id: <id>]" followed by content formatted as "[User @ YYYY-MM-DD HH:MM]:", "[Assistant @ ...]:", "[Tool result for <name> @ ...]:", custom messages, or branch summaries.
|
|
101
101
|
- A current local time fallback for observations that have no obvious message timestamp.
|
|
102
102
|
|
|
103
103
|
How you work:
|
|
@@ -110,6 +110,9 @@ How you work:
|
|
|
110
110
|
What to emit:
|
|
111
111
|
- Produce NEW observations for the new chunk only. Do not restate facts already present in reflections or current observations unless something has materially changed.
|
|
112
112
|
- Use the timestamp from the relevant conversation message. Fall back to current local time ONLY when no message timestamp applies.
|
|
113
|
+
- For every observation, include sourceEntryIds: the smallest exact set of "[Source entry id: ...]" ids that directly support the observation.
|
|
114
|
+
- Never invent source entry ids. Use only ids printed in the chunk. If an observation spans multiple turns or tool results, include every supporting source entry id.
|
|
115
|
+
- Observations with missing, empty, or invalid sourceEntryIds will be rejected and not recorded, so do not call record_observations until you can cite valid source ids.
|
|
113
116
|
- Group repeated similar tool calls into a single observation rather than one per call.
|
|
114
117
|
- Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, simply do not call the tool and end with a plain-text confirmation.
|
|
115
118
|
|
|
@@ -127,7 +130,7 @@ export const REFLECTOR_SYSTEM = `You are the reflection agent for a coding assis
|
|
|
127
130
|
|
|
128
131
|
${MEMORY_STAKES}
|
|
129
132
|
|
|
130
|
-
Your job is to crystallize stable, long-lived patterns from accumulated observations into
|
|
133
|
+
Your job is to crystallize stable, long-lived patterns from accumulated observations into reflections by calling the record_reflections tool. Reflections are the most durable layer of memory: once the pruner drops the observations behind them, the reflection is what remains.
|
|
131
134
|
|
|
132
135
|
You are operating on records produced by another part of the memory pipeline — the observer. To understand what you are reading and to produce reflections in the same voice, the observer was given these rules:
|
|
133
136
|
|
|
@@ -142,17 +145,23 @@ ${RELEVANCE_RUBRIC}
|
|
|
142
145
|
Your task is different from the observer's: you are not recording events, you are distilling stable patterns from them.
|
|
143
146
|
|
|
144
147
|
You receive:
|
|
145
|
-
- Current reflections (already-crystallized long-lived facts, one per line).
|
|
148
|
+
- Current reflections (already-crystallized long-lived facts, one per line). Newer reflections may begin with a bracketed id handle; treat that id as recall metadata, not as part of the reflection prose.
|
|
146
149
|
- Current observations (timestamped, relevance-tagged events accumulated over many turns). Each is shown as "[id] YYYY-MM-DD HH:MM [relevance] content".
|
|
147
150
|
|
|
148
151
|
How you work:
|
|
149
152
|
1. Read current reflections and observations to understand what is already crystallized and what new signal exists in the pool.
|
|
150
|
-
2. Identify new stable patterns worth crystallizing and call record_reflections with a batch of one or more new reflection
|
|
153
|
+
2. Identify new stable patterns worth crystallizing and call record_reflections with a batch of one or more new reflection proposals. Each proposal must include the reflection content and the exact supporting observation ids.
|
|
151
154
|
3. Read the receipt. If more reflections are warranted, call record_reflections again with another batch. You may call the tool many times.
|
|
152
155
|
4. When nothing more is stable enough to crystallize, STOP calling the tool and reply with a brief plain-text confirmation (one short sentence). That ends the run.
|
|
153
156
|
|
|
154
157
|
What to emit:
|
|
155
|
-
- Produce
|
|
158
|
+
- Produce new reflections when durable meaning is missing from the current reflections.
|
|
159
|
+
- To strengthen an existing reflection, emit the exact same reflection content with additional supportingObservationIds; the system will merge the supporting ids into the existing reflection.
|
|
160
|
+
- To promote a legacy/no-provenance reflection, emit the exact same reflection content with valid supportingObservationIds; the system will replace it with a provenance-backed reflection.
|
|
161
|
+
- When repeating exact existing content, emit only the reflection prose; omit any bracketed id handle.
|
|
162
|
+
- Do not lightly reword existing reflections. Rewording creates a separate reflection, so only use different wording when the durable meaning is materially different, more specific, or corrects/refines the existing reflection.
|
|
163
|
+
- For every reflection proposal, include supportingObservationIds: the smallest exact set of current observation ids that directly support the reflection.
|
|
164
|
+
- Never invent supporting observation ids. Use only ids printed in the current observations list. Reflection proposals with missing, empty, or invalid supportingObservationIds will be rejected and not recorded.
|
|
156
165
|
- Crystallize preferentially from "high" and "critical" observations; ignore "low" unless a pattern across many "low" observations is itself significant.
|
|
157
166
|
- Focus on:
|
|
158
167
|
- User identity, role, preferences, constraints.
|
|
@@ -193,10 +202,15 @@ ${RELEVANCE_RUBRIC}
|
|
|
193
202
|
</relevance-rubric>
|
|
194
203
|
|
|
195
204
|
You receive:
|
|
196
|
-
- Current reflections (long-lived facts; they survive regardless — treat them as already captured).
|
|
197
|
-
- Current observations (timestamped, relevance-tagged events to prune). Each is shown as "[id] YYYY-MM-DD HH:MM [relevance] content", where id is the 12-character hex handle you reference when dropping.
|
|
205
|
+
- Current reflections (long-lived facts; they survive regardless — treat them as already captured). Newer reflections may begin with a bracketed id handle; treat that id as recall metadata, not as part of the reflection prose.
|
|
206
|
+
- Current observations (timestamped, relevance-tagged events to prune). Each is shown as "[id] YYYY-MM-DD HH:MM [relevance] [coverage: tag] content", where id is the 12-character hex handle you reference when dropping.
|
|
198
207
|
- A pressure line stating pool size, target, tokens still to cut, and the current pass strategy.
|
|
199
208
|
|
|
209
|
+
Coverage tags are advisory pruning signals derived from current provenance-backed reflection support ids:
|
|
210
|
+
- [coverage: uncited] means no current provenance-backed reflection cites this observation. Prune cautiously, especially for medium/high/critical observations, because durable meaning may not be captured elsewhere.
|
|
211
|
+
- [coverage: cited] means 1-3 current provenance-backed reflections cite this observation. It is a better pruning candidate when the reflection preserves equivalent meaning, but it is not automatically safe to drop.
|
|
212
|
+
- [coverage: reinforced] means 4 or more current provenance-backed reflections cite this observation. Durable meaning is likely represented, but still preserve it if it carries exact errors, file paths, decisions, recent task state, user assertions, constraints, or nuance not captured with equivalent fidelity.
|
|
213
|
+
|
|
200
214
|
How you work:
|
|
201
215
|
1. Read reflections and the observation pool.
|
|
202
216
|
2. Identify ids that should be removed and call drop_observations with them. Pass multiple ids per call and call the tool multiple times as you work the pool down toward the target.
|
|
@@ -206,7 +220,7 @@ How you work:
|
|
|
206
220
|
This agent may be invoked again in a follow-up pass if the pool is still over budget — focus each run on your next-weakest drops rather than trying to do everything in one call.
|
|
207
221
|
|
|
208
222
|
What to drop (in priority order):
|
|
209
|
-
- Signal-captured: observations
|
|
223
|
+
- Signal-captured: observations tagged [coverage: cited] or [coverage: reinforced] whose durable meaning is captured by a reflection now in the reflections list. These are better pruning candidates, but still keep them when they contain exact details, user assertions, concrete completions, recent task state, or nuance not captured with equivalent fidelity.
|
|
210
224
|
- Superseded: directly contradicted or replaced by a newer observation.
|
|
211
225
|
- Redundant: near-duplicate of another observation (keep the higher-relevance or more recent one).
|
|
212
226
|
- Exhausted routine: tool-call acks, status updates, trivia that no longer affects the work.
|
|
@@ -240,7 +254,7 @@ If one of these categories is ALSO captured by an existing reflection with equiv
|
|
|
240
254
|
BAD: drop "[id] 2025-12-04 14:30 [medium] Build failed: TS2322 at src/auth.ts:47 — Type 'string | undefined' is not assignable to type 'string'" because it is only medium and the task moved on.
|
|
241
255
|
GOOD: keep that observation; it is a verbatim error the user hit, not captured in any reflection. Future debugging may need the exact code and location.
|
|
242
256
|
|
|
243
|
-
When in doubt,
|
|
257
|
+
When in doubt, prefer dropping cited or reinforced observations over uncited observations, but remember coverage tags are not commands. Reflections protect durable facts only when they preserve equivalent meaning. The only things you must preserve unconditionally are user assertions and concrete completions.
|
|
244
258
|
|
|
245
259
|
What you CANNOT do:
|
|
246
260
|
- You cannot merge observations. If two overlap, drop the weaker one.
|
|
@@ -251,12 +265,25 @@ It is valid to end a pass with zero drops if the pool genuinely has nothing more
|
|
|
251
265
|
|
|
252
266
|
Remember: every observation you drop is erased from the assistant's memory. A drop that looks reasonable at "low" becomes a mistake if the content was a user correction with a mis-labeled relevance. Read before you cut.`;
|
|
253
267
|
|
|
268
|
+
type ReflectorPassTier = 1 | 2 | 3;
|
|
269
|
+
|
|
270
|
+
const REFLECTOR_PASS_STRATEGIES: Record<ReflectorPassTier, string> = {
|
|
271
|
+
1: `Pass strategy — multi-observation synthesis. Find broad durable patterns, repeated preferences, recurring constraints, stable work style, and project-level themes supported by multiple observations. Every reflection recorded in this pass must cite at least 2 distinct supportingObservationIds. Do not create one-off event summaries; leave important single-observation facts for the atomic durable facts pass. If an existing reflection already captures the pattern, repeat the exact same content only when adding support ids materially strengthens it.`,
|
|
272
|
+
2: `Pass strategy — atomic durable facts. Capture important durable facts that may be supported by a single authoritative observation: explicit user preferences, hard constraints, corrections, decisions, completed milestones, project facts, release or rollback caveats, and other load-bearing facts future agents must not forget. Do not duplicate reflections created in earlier passes; repeat exact existing content only to add missing support or promote no-provenance legacy memory.`,
|
|
273
|
+
3: `Pass strategy — final safety review. Review the full observation pool against the current reflections, including reflections created in earlier passes, and catch durable information still missing. Look especially for high or critical observations, explicit user assertions, corrections, constraints, decisions, completed work, and important technical context. Do not create reflections just to increase coverage; only record a reflection if the durable meaning is not already captured with sufficient fidelity.`,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export function buildReflectorPassGuidance(pass: number, maxPasses: number): string {
|
|
277
|
+
const tier = (Math.min(3, Math.max(1, pass)) as ReflectorPassTier);
|
|
278
|
+
return `Pass ${pass} of up to ${maxPasses}. ${REFLECTOR_PASS_STRATEGIES[tier]}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
254
281
|
type PrunerPassTier = 1 | 2 | 3;
|
|
255
282
|
|
|
256
283
|
const PRUNER_PASS_STRATEGIES: Record<PrunerPassTier, string> = {
|
|
257
|
-
1: `Pass strategy — clear-cut drops only.
|
|
258
|
-
2: `Pass strategy — topic compression. Drop "low" observations that cover the same territory as recent "medium" or "high" observations. Drop older "medium" observations whose substance is now covered by a reflection. Collapse sequences of repeated tool-call observations by keeping the one that captures the learning and dropping the rest.`,
|
|
259
|
-
3: `Pass strategy — aggressive age compression. In the older half of the pool, drop all but the outcome-bearing "low" and "medium" observations. Keep the most recent ~30% of the pool at higher detail. Drop "high" observations only when a reflection clearly captures the same fact. NEVER drop "critical" items, user assertions, or concrete completions regardless of age.`,
|
|
284
|
+
1: `Pass strategy — clear-cut drops only. Prefer old low-value [coverage: cited] or [coverage: reinforced] observations when their durable meaning is represented by current reflections. Also remove exact duplicates, near-duplicates (keep the higher-relevance or more recent version), observations directly superseded by a newer one, and routine "low" tool-call acks. Do not touch ambiguous [coverage: uncited] cases on this pass — a follow-up pass will handle them if still needed.`,
|
|
285
|
+
2: `Pass strategy — topic compression. Drop "low" observations that cover the same territory as recent "medium" or "high" observations, especially when tagged [coverage: cited] or [coverage: reinforced]. Drop older "medium" observations whose substance is now covered by a reflection. Collapse sequences of repeated tool-call observations by keeping the one that captures the learning and dropping the rest.`,
|
|
286
|
+
3: `Pass strategy — aggressive age compression. In the older half of the pool, drop all but the outcome-bearing "low" and "medium" observations, preferring [coverage: cited] and [coverage: reinforced] over [coverage: uncited]. Keep the most recent ~30% of the pool at higher detail. Drop "high" observations only when a reflection clearly captures the same fact. NEVER drop "critical" items, user assertions, or concrete completions regardless of age.`,
|
|
260
287
|
};
|
|
261
288
|
|
|
262
289
|
export function buildPrunerPassGuidance(pass: number, maxPasses: number): string {
|
|
@@ -266,7 +293,9 @@ export function buildPrunerPassGuidance(pass: number, maxPasses: number): string
|
|
|
266
293
|
|
|
267
294
|
export const CONTEXT_USAGE_INSTRUCTIONS = `These are condensed memories from earlier in this session.
|
|
268
295
|
|
|
269
|
-
- Reflections: stable, long-lived facts about the user, project, decisions, and constraints.
|
|
270
|
-
- Observations: timestamped events from the conversation history, in chronological order.
|
|
296
|
+
- Reflections: stable, long-lived facts about the user, project, decisions, and constraints. New reflection lines may include ids in brackets.
|
|
297
|
+
- Observations: timestamped events from the conversation history, in chronological order. Observation lines include ids in brackets.
|
|
298
|
+
|
|
299
|
+
Treat these as past records. When entries conflict, the most recent observation reflects the latest known state. Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.
|
|
271
300
|
|
|
272
|
-
|
|
301
|
+
When exact source context is needed for precision or traceability, use the recall tool with the relevant observation or reflection id. This is especially useful when a reflection materially affects a decision or is too compressed to continue confidently. Do not use recall as broad search or inject raw source unless it is needed.`;
|
package/src/runtime.ts
CHANGED
|
@@ -58,13 +58,18 @@ export class Runtime {
|
|
|
58
58
|
|
|
59
59
|
launchObserverTask(ctx: LaunchCtx, label: string, work: () => Promise<void>): Promise<void> {
|
|
60
60
|
this.observerInFlight = true;
|
|
61
|
+
// Capture ctx properties synchronously — after `await work()` the extension ctx
|
|
62
|
+
// may be stale (e.g. after ctx.newSession/fork/switchSession/reload), and accessing
|
|
63
|
+
// ctx.hasUI or ctx.ui on a stale proxy throws.
|
|
64
|
+
const hasUI = ctx.hasUI;
|
|
65
|
+
const ui = ctx.ui;
|
|
61
66
|
let promise!: Promise<void>;
|
|
62
67
|
promise = (async () => {
|
|
63
68
|
try {
|
|
64
69
|
await work();
|
|
65
70
|
} catch (error) {
|
|
66
71
|
const msg = error instanceof Error ? error.message : String(error);
|
|
67
|
-
if (
|
|
72
|
+
if (hasUI && ui) ui.notify(`Observational memory: ${label} failed: ${msg}`, "warning");
|
|
68
73
|
} finally {
|
|
69
74
|
this.observerInFlight = false;
|
|
70
75
|
if (this.observerPromise === promise) this.observerPromise = null;
|