pi-observational-memory 2.1.3 → 2.3.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 +10 -0
- package/package.json +10 -6
- package/src/branch.ts +120 -2
- package/src/compaction.ts +1 -1
- package/src/hooks/compaction-hook.ts +4 -3
- package/src/hooks/observer-trigger.ts +4 -3
- package/src/index.ts +2 -0
- package/src/observer.ts +42 -4
- package/src/prompts.ts +8 -3
- package/src/serialize.ts +144 -31
- package/src/tools/recall-observation.ts +409 -0
- package/src/types.ts +13 -4
package/README.md
CHANGED
|
@@ -112,6 +112,16 @@ Settings live in Pi's `settings.json` — globally at `~/.pi/agent/settings.json
|
|
|
112
112
|
}
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
+
To run the background memory work (observer, reflector, pruner) on a cheaper / faster model than your main coding agent — often the single biggest cost lever the extension exposes — add `compactionModel`:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"observational-memory": {
|
|
120
|
+
"compactionModel": { "provider": "openrouter", "id": "google/gemma-4-31b-it" }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
115
125
|
The five settings most worth knowing:
|
|
116
126
|
|
|
117
127
|
| Setting | Default | What it controls |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-observational-memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Observational memory extension for pi — cache-friendly tiered compaction with observations and reflections.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,18 +30,22 @@
|
|
|
30
30
|
"README.md"
|
|
31
31
|
],
|
|
32
32
|
"scripts": {
|
|
33
|
-
"typecheck": "tsc --noEmit"
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "vitest run"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
|
-
"@mariozechner/pi-
|
|
37
|
+
"@mariozechner/pi-agent-core": "*",
|
|
37
38
|
"@mariozechner/pi-ai": "*",
|
|
38
|
-
"@mariozechner/pi-agent
|
|
39
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
40
|
+
"@mariozechner/pi-tui": "*"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
41
|
-
"@mariozechner/pi-ai": "^0.66.1",
|
|
42
43
|
"@mariozechner/pi-agent-core": "^0.66.1",
|
|
44
|
+
"@mariozechner/pi-ai": "^0.66.1",
|
|
43
45
|
"@mariozechner/pi-coding-agent": "^0.66.1",
|
|
46
|
+
"@mariozechner/pi-tui": "^0.66.1",
|
|
44
47
|
"@types/node": "^22.0.0",
|
|
45
|
-
"typescript": "^5.6.0"
|
|
48
|
+
"typescript": "^5.6.0",
|
|
49
|
+
"vitest": "^4.1.5"
|
|
46
50
|
}
|
|
47
51
|
}
|
package/src/branch.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "./types.js";
|
|
10
10
|
import { estimateEntryTokens } from "./tokens.js";
|
|
11
11
|
|
|
12
|
-
type Entry = {
|
|
12
|
+
export type Entry = {
|
|
13
13
|
type: string;
|
|
14
14
|
id: string;
|
|
15
15
|
timestamp?: string;
|
|
@@ -25,10 +25,50 @@ type Entry = {
|
|
|
25
25
|
|
|
26
26
|
const RAW_TYPES = new Set(["message", "custom_message", "branch_summary"]);
|
|
27
27
|
|
|
28
|
+
export function isSourceEntry(entry: Entry): boolean {
|
|
29
|
+
return RAW_TYPES.has(entry.type);
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
function isObservationEntry(entry: Entry): boolean {
|
|
29
33
|
return entry.type === "custom" && entry.customType === OBSERVATION_CUSTOM_TYPE;
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
export type RecallObservationMatch =
|
|
37
|
+
| {
|
|
38
|
+
status: "ok";
|
|
39
|
+
observation: ObservationRecord;
|
|
40
|
+
observationEntryId: string;
|
|
41
|
+
sourceEntryIds: string[];
|
|
42
|
+
sourceEntries: Entry[];
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
status: "no_source";
|
|
46
|
+
observation: ObservationRecord;
|
|
47
|
+
observationEntryId: string;
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
status: "source_unavailable";
|
|
51
|
+
observation: ObservationRecord;
|
|
52
|
+
observationEntryId: string;
|
|
53
|
+
sourceEntryIds: string[];
|
|
54
|
+
missingSourceEntryIds: string[];
|
|
55
|
+
nonSourceEntryIds: string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type RecallObservationSourcesResult =
|
|
59
|
+
| {
|
|
60
|
+
status: "not_found";
|
|
61
|
+
observationId: string;
|
|
62
|
+
matches: [];
|
|
63
|
+
collision: false;
|
|
64
|
+
}
|
|
65
|
+
| {
|
|
66
|
+
status: "found";
|
|
67
|
+
observationId: string;
|
|
68
|
+
matches: RecallObservationMatch[];
|
|
69
|
+
collision: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
32
72
|
export function findLastCompactionIndex(entries: Entry[]): number {
|
|
33
73
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
34
74
|
if (entries[i].type === "compaction") return i;
|
|
@@ -103,11 +143,89 @@ export function rawTailEntriesBetween(entries: Entry[], fromId: string, untilId:
|
|
|
103
143
|
|
|
104
144
|
const result: Entry[] = [];
|
|
105
145
|
for (let i = fromIdx; i <= untilIdx; i++) {
|
|
106
|
-
if (
|
|
146
|
+
if (isSourceEntry(entries[i])) result.push(entries[i]);
|
|
107
147
|
}
|
|
108
148
|
return result;
|
|
109
149
|
}
|
|
110
150
|
|
|
151
|
+
function uniqueIds(ids: string[]): string[] {
|
|
152
|
+
return Array.from(new Set(ids));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function resolveSourceEntries(entries: Entry[], sourceEntryIds: string[]): {
|
|
156
|
+
status: "ok" | "source_unavailable";
|
|
157
|
+
sourceEntryIds: string[];
|
|
158
|
+
sourceEntries: Entry[];
|
|
159
|
+
missingSourceEntryIds: string[];
|
|
160
|
+
nonSourceEntryIds: string[];
|
|
161
|
+
} {
|
|
162
|
+
const requested = uniqueIds(sourceEntryIds);
|
|
163
|
+
const requestedSet = new Set(requested);
|
|
164
|
+
const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
|
|
165
|
+
const missingSourceEntryIds = requested.filter((id) => !entriesById.has(id));
|
|
166
|
+
const nonSourceEntryIds = requested.filter((id) => {
|
|
167
|
+
const entry = entriesById.get(id);
|
|
168
|
+
return entry !== undefined && !isSourceEntry(entry);
|
|
169
|
+
});
|
|
170
|
+
if (missingSourceEntryIds.length > 0 || nonSourceEntryIds.length > 0) {
|
|
171
|
+
return {
|
|
172
|
+
status: "source_unavailable",
|
|
173
|
+
sourceEntryIds: requested,
|
|
174
|
+
sourceEntries: [],
|
|
175
|
+
missingSourceEntryIds,
|
|
176
|
+
nonSourceEntryIds,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sourceEntries = entries.filter((entry) => requestedSet.has(entry.id));
|
|
181
|
+
return {
|
|
182
|
+
status: "ok",
|
|
183
|
+
sourceEntryIds: sourceEntries.map((entry) => entry.id),
|
|
184
|
+
sourceEntries,
|
|
185
|
+
missingSourceEntryIds: [],
|
|
186
|
+
nonSourceEntryIds: [],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function recallObservationSources(entries: Entry[], observationId: string): RecallObservationSourcesResult {
|
|
191
|
+
const matches: RecallObservationMatch[] = [];
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (!isObservationEntry(entry)) continue;
|
|
194
|
+
if (!isObservationEntryData(entry.data)) continue;
|
|
195
|
+
for (const observation of entry.data.records) {
|
|
196
|
+
if (observation.id !== observationId) continue;
|
|
197
|
+
if (!observation.sourceEntryIds || observation.sourceEntryIds.length === 0) {
|
|
198
|
+
matches.push({ status: "no_source", observation, observationEntryId: entry.id });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const resolved = resolveSourceEntries(entries, observation.sourceEntryIds);
|
|
203
|
+
if (resolved.status === "source_unavailable") {
|
|
204
|
+
matches.push({
|
|
205
|
+
status: "source_unavailable",
|
|
206
|
+
observation,
|
|
207
|
+
observationEntryId: entry.id,
|
|
208
|
+
sourceEntryIds: resolved.sourceEntryIds,
|
|
209
|
+
missingSourceEntryIds: resolved.missingSourceEntryIds,
|
|
210
|
+
nonSourceEntryIds: resolved.nonSourceEntryIds,
|
|
211
|
+
});
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
matches.push({
|
|
216
|
+
status: "ok",
|
|
217
|
+
observation,
|
|
218
|
+
observationEntryId: entry.id,
|
|
219
|
+
sourceEntryIds: resolved.sourceEntryIds,
|
|
220
|
+
sourceEntries: resolved.sourceEntries,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (matches.length === 0) return { status: "not_found", observationId, matches: [], collision: false };
|
|
226
|
+
return { status: "found", observationId, matches, collision: matches.length > 1 };
|
|
227
|
+
}
|
|
228
|
+
|
|
111
229
|
function getPriorMemoryDetails(entries: Entry[]): MemoryDetails | undefined {
|
|
112
230
|
const idx = findLastCompactionIndex(entries);
|
|
113
231
|
if (idx === -1) return undefined;
|
package/src/compaction.ts
CHANGED
|
@@ -323,7 +323,7 @@ export function renderSummary(reflections: Reflection[], observations: Observati
|
|
|
323
323
|
parts.push(`## Reflections\n${reflections.join("\n")}`);
|
|
324
324
|
}
|
|
325
325
|
if (observations.length > 0) {
|
|
326
|
-
const body = observations
|
|
326
|
+
const body = observationsToPromptLines(observations).join("\n");
|
|
327
327
|
parts.push(`## Observations\n${body}`);
|
|
328
328
|
}
|
|
329
329
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { 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,
|
|
@@ -58,8 +58,8 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
58
58
|
let gapObservationData: ObservationEntryData | null = null;
|
|
59
59
|
const gap = gapRawEntries(entries, firstKeptEntryId);
|
|
60
60
|
if (gap.length > 0) {
|
|
61
|
-
const gapChunk =
|
|
62
|
-
if (gapChunk.trim()) {
|
|
61
|
+
const { text: gapChunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(gap);
|
|
62
|
+
if (gapChunk.trim() && sourceEntryIds.length > 0) {
|
|
63
63
|
const gapFromId = gap[0].id;
|
|
64
64
|
const gapUpToId = gap[gap.length - 1].id;
|
|
65
65
|
const priorObservationLines = observationsToPromptLines([
|
|
@@ -79,6 +79,7 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
79
79
|
priorReflections: memoryState.reflections,
|
|
80
80
|
priorObservations: priorObservationLines,
|
|
81
81
|
chunk: gapChunk,
|
|
82
|
+
allowedSourceEntryIds: sourceEntryIds,
|
|
82
83
|
signal,
|
|
83
84
|
});
|
|
84
85
|
const gapPromise: Promise<void> = gapCall.then(() => undefined, () => undefined);
|
|
@@ -8,7 +8,7 @@ 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
13
|
import { OBSERVATION_CUSTOM_TYPE, type ObservationEntryData } from "../types.js";
|
|
14
14
|
|
|
@@ -34,8 +34,8 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
|
|
|
34
34
|
|
|
35
35
|
const chunkEntries = rawTailEntriesBetween(entries, coversFromId, coversUpToId);
|
|
36
36
|
if (chunkEntries.length === 0) return;
|
|
37
|
-
const chunk =
|
|
38
|
-
if (!chunk.trim()) return;
|
|
37
|
+
const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
|
|
38
|
+
if (!chunk.trim() || sourceEntryIds.length === 0) return;
|
|
39
39
|
|
|
40
40
|
if (ctx.hasUI) ctx.ui.notify(
|
|
41
41
|
`Observational memory: observer running on ~${tokens.toLocaleString()}-token chunk`,
|
|
@@ -63,6 +63,7 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
|
|
|
63
63
|
priorReflections: reflections,
|
|
64
64
|
priorObservations: priorObservationLines,
|
|
65
65
|
chunk,
|
|
66
|
+
allowedSourceEntryIds: sourceEntryIds,
|
|
66
67
|
});
|
|
67
68
|
if (!records || records.length === 0) {
|
|
68
69
|
if (ctx.hasUI && ctx.ui) ctx.ui.notify(
|
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
|
|
|
@@ -267,6 +270,8 @@ export function buildPrunerPassGuidance(pass: number, maxPasses: number): string
|
|
|
267
270
|
export const CONTEXT_USAGE_INSTRUCTIONS = `These are condensed memories from earlier in this session.
|
|
268
271
|
|
|
269
272
|
- Reflections: stable, long-lived facts about the user, project, decisions, and constraints.
|
|
270
|
-
- Observations: timestamped events from the conversation history, in chronological order.
|
|
273
|
+
- Observations: timestamped events from the conversation history, in chronological order. Observation lines include ids in brackets.
|
|
271
274
|
|
|
272
|
-
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
|
|
275
|
+
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.
|
|
276
|
+
|
|
277
|
+
When exact source context is needed for precision or traceability, use the recall tool with the relevant observation id. Do not use recall as broad search or inject raw source unless it is needed.`;
|
package/src/serialize.ts
CHANGED
|
@@ -14,35 +14,78 @@ function formatTimestamp(v: number | string | undefined): string {
|
|
|
14
14
|
return Number.isNaN(d.getTime()) ? "????-??-?? ??:??" : fmtLocal(d);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function formatRecallTimestamp(...values: Array<number | string | undefined>): string {
|
|
18
|
+
for (const v of values) {
|
|
19
|
+
if (v === undefined) continue;
|
|
20
|
+
const d = new Date(v);
|
|
21
|
+
if (!Number.isNaN(d.getTime())) return fmtLocal(d);
|
|
22
|
+
}
|
|
23
|
+
return "Unknown time";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function textAndPlaceholders(
|
|
27
|
+
content: unknown,
|
|
28
|
+
options: { omitRedactedThinking?: boolean; includeThinking?: boolean } = {},
|
|
29
|
+
): string {
|
|
30
|
+
if (typeof content === "string") return content;
|
|
31
|
+
if (!Array.isArray(content)) return "[non-text content omitted]";
|
|
32
|
+
|
|
33
|
+
const parts: string[] = [];
|
|
34
|
+
for (const block of content as Array<Record<string, unknown>>) {
|
|
35
|
+
if (!block || typeof block !== "object") {
|
|
36
|
+
parts.push("[non-text content omitted]");
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
40
|
+
parts.push(block.text);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (block.type === "thinking") {
|
|
44
|
+
if (options.omitRedactedThinking && block.redacted === true) continue;
|
|
45
|
+
if (options.includeThinking && typeof block.thinking === "string") {
|
|
46
|
+
parts.push(`[thinking: ${block.thinking}]`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
parts.push("[non-text content omitted]");
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (block.type === "toolCall" && typeof block.name === "string") {
|
|
53
|
+
parts.push(`[${block.name}(${JSON.stringify(block.arguments ?? {})})]`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
parts.push("[non-text content omitted]");
|
|
57
|
+
}
|
|
58
|
+
return parts.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function textOnly(content: string | Array<{ type?: string; text?: string }>): string {
|
|
62
|
+
if (typeof content === "string") return content;
|
|
63
|
+
return content
|
|
64
|
+
.filter((b): b is TextContent => b?.type === "text" && typeof b.text === "string")
|
|
65
|
+
.map((b) => b.text)
|
|
66
|
+
.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
17
69
|
export function serializeConversation(messages: Message[]): string {
|
|
18
70
|
return messages
|
|
19
71
|
.map((msg): string | null => {
|
|
20
72
|
const time = formatTimestamp(msg.timestamp);
|
|
21
73
|
if (msg.role === "user") {
|
|
22
|
-
const text =
|
|
23
|
-
typeof msg.content === "string"
|
|
24
|
-
? msg.content
|
|
25
|
-
: msg.content
|
|
26
|
-
.filter((b): b is TextContent => b.type === "text")
|
|
27
|
-
.map((b) => b.text)
|
|
28
|
-
.join("\n");
|
|
74
|
+
const text = textOnly(msg.content);
|
|
29
75
|
return `[User @ ${time}]: ${text}`;
|
|
30
76
|
}
|
|
31
77
|
if (msg.role === "assistant") {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
78
|
+
const body = textAndPlaceholders(msg.content, {
|
|
79
|
+
includeThinking: true,
|
|
80
|
+
omitRedactedThinking: true,
|
|
81
|
+
})
|
|
82
|
+
.split("\n")
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join("\n");
|
|
39
85
|
if (!body) return null;
|
|
40
86
|
return `[Assistant @ ${time}]: ${body}`;
|
|
41
87
|
}
|
|
42
|
-
const text = msg.content
|
|
43
|
-
.filter((b): b is TextContent => b.type === "text")
|
|
44
|
-
.map((b) => b.text)
|
|
45
|
-
.join("\n");
|
|
88
|
+
const text = textOnly(msg.content);
|
|
46
89
|
return `[Tool result for ${(msg as ToolResultMessage).toolName} @ ${time}]: ${text}`;
|
|
47
90
|
})
|
|
48
91
|
.filter((line): line is string => line !== null)
|
|
@@ -62,8 +105,9 @@ export function truncateRecordContent(content: string): string {
|
|
|
62
105
|
return `${head} … [truncated ${dropped} chars]`;
|
|
63
106
|
}
|
|
64
107
|
|
|
65
|
-
type RenderableEntry = {
|
|
108
|
+
export type RenderableEntry = {
|
|
66
109
|
type: string;
|
|
110
|
+
id?: string;
|
|
67
111
|
timestamp?: string;
|
|
68
112
|
message?: unknown;
|
|
69
113
|
customType?: string;
|
|
@@ -71,6 +115,26 @@ type RenderableEntry = {
|
|
|
71
115
|
summary?: unknown;
|
|
72
116
|
};
|
|
73
117
|
|
|
118
|
+
function renderCustomMessage(entry: RenderableEntry, options: { recallFormat: boolean }): string {
|
|
119
|
+
const time = options.recallFormat ? formatRecallTimestamp(entry.timestamp) : formatTimestamp(entry.timestamp);
|
|
120
|
+
const text = options.recallFormat
|
|
121
|
+
? textAndPlaceholders(entry.content)
|
|
122
|
+
: typeof entry.content === "string"
|
|
123
|
+
? entry.content
|
|
124
|
+
: Array.isArray(entry.content)
|
|
125
|
+
? (entry.content as Array<{ type?: string; text?: string }>)
|
|
126
|
+
.filter((b) => b?.type === "text" && typeof b.text === "string")
|
|
127
|
+
.map((b) => b.text as string)
|
|
128
|
+
.join("\n")
|
|
129
|
+
: "";
|
|
130
|
+
if (options.recallFormat) {
|
|
131
|
+
const origin = entry.customType ? `Custom message (${entry.customType})` : "Custom message";
|
|
132
|
+
return `[${origin} @ ${time}]: ${text}`;
|
|
133
|
+
}
|
|
134
|
+
const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
|
|
135
|
+
return `[${tag} @ ${time}]: ${text}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
74
138
|
export function serializeBranchEntries(entries: RenderableEntry[]): string {
|
|
75
139
|
const blocks: string[] = [];
|
|
76
140
|
for (const entry of entries) {
|
|
@@ -80,18 +144,7 @@ export function serializeBranchEntries(entries: RenderableEntry[]): string {
|
|
|
80
144
|
continue;
|
|
81
145
|
}
|
|
82
146
|
if (entry.type === "custom_message") {
|
|
83
|
-
|
|
84
|
-
let text = "";
|
|
85
|
-
if (typeof entry.content === "string") {
|
|
86
|
-
text = entry.content;
|
|
87
|
-
} else if (Array.isArray(entry.content)) {
|
|
88
|
-
text = (entry.content as Array<{ type?: string; text?: string }>)
|
|
89
|
-
.filter((b) => b?.type === "text" && typeof b.text === "string")
|
|
90
|
-
.map((b) => b.text as string)
|
|
91
|
-
.join("\n");
|
|
92
|
-
}
|
|
93
|
-
const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
|
|
94
|
-
blocks.push(`[${tag} @ ${time}]: ${text}`);
|
|
147
|
+
blocks.push(renderCustomMessage(entry, { recallFormat: false }));
|
|
95
148
|
continue;
|
|
96
149
|
}
|
|
97
150
|
if (entry.type === "branch_summary" && typeof entry.summary === "string") {
|
|
@@ -101,3 +154,63 @@ export function serializeBranchEntries(entries: RenderableEntry[]): string {
|
|
|
101
154
|
}
|
|
102
155
|
return blocks.join("\n\n");
|
|
103
156
|
}
|
|
157
|
+
|
|
158
|
+
export type SourceAddressedSerialization = {
|
|
159
|
+
text: string;
|
|
160
|
+
sourceEntryIds: string[];
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
function isSourceRenderableEntry(entry: RenderableEntry): boolean {
|
|
164
|
+
return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function serializeSourceAddressedBranchEntries(entries: RenderableEntry[]): SourceAddressedSerialization {
|
|
168
|
+
const blocks: string[] = [];
|
|
169
|
+
const sourceEntryIds: string[] = [];
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
if (!entry.id || !isSourceRenderableEntry(entry)) continue;
|
|
172
|
+
const rendered = serializeBranchEntries([entry]);
|
|
173
|
+
if (!rendered.trim()) continue;
|
|
174
|
+
sourceEntryIds.push(entry.id);
|
|
175
|
+
blocks.push(`[Source entry id: ${entry.id}]\n${rendered}`);
|
|
176
|
+
}
|
|
177
|
+
return { text: blocks.join("\n\n"), sourceEntryIds };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderRecallMessage(entry: RenderableEntry): string | null {
|
|
181
|
+
if (!entry.message || typeof entry.message !== "object") return null;
|
|
182
|
+
const msg = entry.message as Message;
|
|
183
|
+
const time = formatRecallTimestamp(msg.timestamp, entry.timestamp);
|
|
184
|
+
if (msg.role === "user") {
|
|
185
|
+
return `[User @ ${time}]: ${textAndPlaceholders(msg.content)}`;
|
|
186
|
+
}
|
|
187
|
+
if (msg.role === "assistant") {
|
|
188
|
+
const body = textAndPlaceholders(msg.content, {
|
|
189
|
+
includeThinking: true,
|
|
190
|
+
omitRedactedThinking: true,
|
|
191
|
+
})
|
|
192
|
+
.split("\n")
|
|
193
|
+
.filter(Boolean)
|
|
194
|
+
.join("\n");
|
|
195
|
+
if (!body) return null;
|
|
196
|
+
return `[Assistant @ ${time}]: ${body}`;
|
|
197
|
+
}
|
|
198
|
+
return `[Tool result: ${(msg as ToolResultMessage).toolName} @ ${time}]: ${textAndPlaceholders(msg.content)}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function renderRecallSourceEntry(entry: RenderableEntry): string | null {
|
|
202
|
+
if (entry.type === "message") return renderRecallMessage(entry);
|
|
203
|
+
if (entry.type === "custom_message") return renderCustomMessage(entry, { recallFormat: true });
|
|
204
|
+
if (entry.type === "branch_summary" && typeof entry.summary === "string") {
|
|
205
|
+
const time = formatRecallTimestamp(entry.timestamp);
|
|
206
|
+
return `[Branch summary @ ${time}]: ${entry.summary}`;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function renderRecallSourceEntries(entries: RenderableEntry[]): string {
|
|
212
|
+
return entries
|
|
213
|
+
.map(renderRecallSourceEntry)
|
|
214
|
+
.filter((block): block is string => block !== null && block.trim().length > 0)
|
|
215
|
+
.join("\n\n");
|
|
216
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
2
|
+
import type { Message, ToolResultMessage } from "@mariozechner/pi-ai";
|
|
3
|
+
import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
5
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
6
|
+
import {
|
|
7
|
+
recallObservationSources,
|
|
8
|
+
type Entry,
|
|
9
|
+
type RecallObservationMatch,
|
|
10
|
+
type RecallObservationSourcesResult,
|
|
11
|
+
} from "../branch.js";
|
|
12
|
+
import { renderRecallSourceEntries, renderRecallSourceEntry } from "../serialize.js";
|
|
13
|
+
import { estimateEntryTokens } from "../tokens.js";
|
|
14
|
+
import type { ObservationRecord } from "../types.js";
|
|
15
|
+
|
|
16
|
+
export const RECALL_OBSERVATION_TOOL_NAME = "recall";
|
|
17
|
+
|
|
18
|
+
const OBSERVATION_ID_PATTERN = /^[a-f0-9]{12}$/;
|
|
19
|
+
|
|
20
|
+
type RecallObservationToolStatus =
|
|
21
|
+
| "ok"
|
|
22
|
+
| "invalid_id"
|
|
23
|
+
| "not_found"
|
|
24
|
+
| "no_source"
|
|
25
|
+
| "source_unavailable";
|
|
26
|
+
|
|
27
|
+
type ObservationDetails = Pick<ObservationRecord, "id" | "content" | "timestamp" | "relevance">;
|
|
28
|
+
|
|
29
|
+
export type RecallSourceEntryDetails = {
|
|
30
|
+
id: string;
|
|
31
|
+
origin: string;
|
|
32
|
+
timestamp: string;
|
|
33
|
+
tokens: number;
|
|
34
|
+
qualifiers: string[];
|
|
35
|
+
content?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type RecallObservationMatchDetails = {
|
|
39
|
+
status: RecallObservationMatch["status"];
|
|
40
|
+
observationEntryId: string;
|
|
41
|
+
observation: ObservationDetails;
|
|
42
|
+
sourceEntryIds?: string[];
|
|
43
|
+
sourceEntries?: RecallSourceEntryDetails[];
|
|
44
|
+
missingSourceEntryIds?: string[];
|
|
45
|
+
nonSourceEntryIds?: string[];
|
|
46
|
+
sourceCharacterCount?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type RecallObservationToolDetails = {
|
|
50
|
+
status: RecallObservationToolStatus;
|
|
51
|
+
observationId: string;
|
|
52
|
+
collision: boolean;
|
|
53
|
+
matches: RecallObservationMatchDetails[];
|
|
54
|
+
sourceCharacterCount?: number;
|
|
55
|
+
message?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function pad(n: number): string {
|
|
59
|
+
return n.toString().padStart(2, "0");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fmtLocal(d: Date): string {
|
|
63
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatDisplayTimestamp(...values: Array<number | string | undefined>): string {
|
|
67
|
+
for (const v of values) {
|
|
68
|
+
if (v === undefined) continue;
|
|
69
|
+
const d = new Date(v);
|
|
70
|
+
if (!Number.isNaN(d.getTime())) return fmtLocal(d);
|
|
71
|
+
}
|
|
72
|
+
return "Unknown time";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function textContentBlocks(content: unknown): Array<Record<string, unknown>> {
|
|
76
|
+
return Array.isArray(content) ? content.filter((block): block is Record<string, unknown> => !!block && typeof block === "object") : [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function uniqueStrings(items: string[]): string[] {
|
|
80
|
+
return Array.from(new Set(items));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sourceOriginAndQualifiers(entry: Entry): { origin: string; timestamp: string; qualifiers: string[] } {
|
|
84
|
+
if (entry.type === "message" && entry.message && typeof entry.message === "object") {
|
|
85
|
+
const msg = entry.message as Message;
|
|
86
|
+
const timestamp = formatDisplayTimestamp(msg.timestamp, entry.timestamp);
|
|
87
|
+
if (msg.role === "user") return { origin: "User", timestamp, qualifiers: [] };
|
|
88
|
+
if (msg.role === "assistant") {
|
|
89
|
+
const toolCalls = uniqueStrings(
|
|
90
|
+
textContentBlocks(msg.content)
|
|
91
|
+
.filter((block) => block.type === "toolCall" && typeof block.name === "string")
|
|
92
|
+
.map((block) => block.name as string),
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
origin: "Assistant",
|
|
96
|
+
timestamp,
|
|
97
|
+
qualifiers: toolCalls.length > 0 ? [`tool calls: ${toolCalls.join(", ")}`] : [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const toolName = (msg as ToolResultMessage).toolName;
|
|
101
|
+
return { origin: `Tool result: ${typeof toolName === "string" && toolName ? toolName : "unknown"}`, timestamp, qualifiers: [] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (entry.type === "custom_message") {
|
|
105
|
+
return {
|
|
106
|
+
origin: "Custom message",
|
|
107
|
+
timestamp: formatDisplayTimestamp(entry.timestamp),
|
|
108
|
+
qualifiers: typeof entry.customType === "string" && entry.customType ? [`custom: ${entry.customType}`] : [],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (entry.type === "branch_summary") {
|
|
113
|
+
return { origin: "Branch summary", timestamp: formatDisplayTimestamp(entry.timestamp), qualifiers: [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { origin: entry.type || "Entry", timestamp: formatDisplayTimestamp(entry.timestamp), qualifiers: [] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderSourceEntryContentOnly(entry: Entry): string | undefined {
|
|
120
|
+
const rendered = renderRecallSourceEntry(entry);
|
|
121
|
+
return rendered?.replace(/^\[[^\]]+\]:\s?/, "") || undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sourceEntryDetails(entry: Entry, includeContent: boolean): RecallSourceEntryDetails {
|
|
125
|
+
const { origin, timestamp, qualifiers } = sourceOriginAndQualifiers(entry);
|
|
126
|
+
const content = renderSourceEntryContentOnly(entry);
|
|
127
|
+
return {
|
|
128
|
+
id: entry.id,
|
|
129
|
+
origin,
|
|
130
|
+
timestamp,
|
|
131
|
+
tokens: estimateEntryTokens(entry),
|
|
132
|
+
qualifiers,
|
|
133
|
+
...(includeContent && content ? { content } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function observationDetails(observation: ObservationRecord): ObservationDetails {
|
|
138
|
+
return {
|
|
139
|
+
id: observation.id,
|
|
140
|
+
content: observation.content,
|
|
141
|
+
timestamp: observation.timestamp,
|
|
142
|
+
relevance: observation.relevance,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function matchDetails(match: RecallObservationMatch, sourceText?: string, includeSourceContent = true): RecallObservationMatchDetails {
|
|
147
|
+
if (match.status === "ok") {
|
|
148
|
+
return {
|
|
149
|
+
status: "ok",
|
|
150
|
+
observationEntryId: match.observationEntryId,
|
|
151
|
+
observation: observationDetails(match.observation),
|
|
152
|
+
sourceEntryIds: match.sourceEntryIds,
|
|
153
|
+
sourceEntries: match.sourceEntries.map((entry) => sourceEntryDetails(entry, includeSourceContent)),
|
|
154
|
+
sourceCharacterCount: sourceText?.length ?? 0,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (match.status === "source_unavailable") {
|
|
158
|
+
return {
|
|
159
|
+
status: "source_unavailable",
|
|
160
|
+
observationEntryId: match.observationEntryId,
|
|
161
|
+
observation: observationDetails(match.observation),
|
|
162
|
+
sourceEntryIds: match.sourceEntryIds,
|
|
163
|
+
missingSourceEntryIds: match.missingSourceEntryIds,
|
|
164
|
+
nonSourceEntryIds: match.nonSourceEntryIds,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
status: "no_source",
|
|
169
|
+
observationEntryId: match.observationEntryId,
|
|
170
|
+
observation: observationDetails(match.observation),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function textResult(text: string, details: RecallObservationToolDetails) {
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text" as const, text }],
|
|
177
|
+
details,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function aggregateStatus(matches: RecallObservationMatch[]): RecallObservationToolStatus {
|
|
182
|
+
if (matches.some((match) => match.status === "ok")) return "ok";
|
|
183
|
+
if (matches.some((match) => match.status === "source_unavailable")) return "source_unavailable";
|
|
184
|
+
return "no_source";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function friendlyNoSourceMessage(observationId: string): string {
|
|
188
|
+
return `Observation ${observationId} has no source entries associated with it. This can happen for legacy observations created before source recall was available.`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function friendlySourceUnavailableMessage(match: Extract<RecallObservationMatch, { status: "source_unavailable" }>): string {
|
|
192
|
+
const missing = match.missingSourceEntryIds.length > 0 ? ` missing: ${match.missingSourceEntryIds.join(", ")}` : "";
|
|
193
|
+
const nonSource = match.nonSourceEntryIds.length > 0 ? ` non-source: ${match.nonSourceEntryIds.join(", ")}` : "";
|
|
194
|
+
return `Observation ${match.observation.id} has source entries associated, but some are unavailable on the current branch or are not source-renderable.${missing}${nonSource}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderFoundResult(result: Extract<RecallObservationSourcesResult, { status: "found" }>): ReturnType<typeof textResult> {
|
|
198
|
+
const sections: string[] = [];
|
|
199
|
+
const detailsMatches: RecallObservationMatchDetails[] = [];
|
|
200
|
+
let sourceCharacterCount = 0;
|
|
201
|
+
|
|
202
|
+
if (result.collision) {
|
|
203
|
+
sections.push(`Multiple observations share id ${result.observationId}; returning all matching source results from the current branch.`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const match of result.matches) {
|
|
207
|
+
if (match.status === "ok") {
|
|
208
|
+
const sourceText = renderRecallSourceEntries(match.sourceEntries);
|
|
209
|
+
sourceCharacterCount += sourceText.length;
|
|
210
|
+
detailsMatches.push(matchDetails(match, sourceText));
|
|
211
|
+
if (sourceText.trim()) sections.push(sourceText);
|
|
212
|
+
else sections.push(`Observation ${match.observation.id} has source entries associated, but they rendered no text content.`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (match.status === "source_unavailable") {
|
|
217
|
+
detailsMatches.push(matchDetails(match));
|
|
218
|
+
sections.push(friendlySourceUnavailableMessage(match));
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
detailsMatches.push(matchDetails(match));
|
|
223
|
+
sections.push(friendlyNoSourceMessage(match.observation.id));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const text = sections.join("\n\n");
|
|
227
|
+
return textResult(text, {
|
|
228
|
+
status: aggregateStatus(result.matches),
|
|
229
|
+
observationId: result.observationId,
|
|
230
|
+
collision: result.collision,
|
|
231
|
+
matches: detailsMatches,
|
|
232
|
+
sourceCharacterCount,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
|
|
237
|
+
return `${n.toLocaleString()} ${n === 1 ? singular : pluralForm}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function sourceEntriesFromDetails(details: RecallObservationToolDetails): RecallSourceEntryDetails[] {
|
|
241
|
+
return details.matches.flatMap((match) => match.sourceEntries ?? []);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function tokenSummary(tokens: number): string {
|
|
245
|
+
return `~${tokens.toLocaleString()} ${tokens === 1 ? "token" : "tokens"}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function statusIcon(details: RecallObservationToolDetails): string {
|
|
249
|
+
if (details.status === "ok") return details.collision ? "⚠" : "✓";
|
|
250
|
+
return "×";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function statusSummary(details: RecallObservationToolDetails): string {
|
|
254
|
+
if (details.status === "invalid_id") return "invalid id";
|
|
255
|
+
if (details.status === "not_found") return "not found";
|
|
256
|
+
if (details.status === "source_unavailable") return "source unavailable";
|
|
257
|
+
if (details.status === "no_source") return "no source";
|
|
258
|
+
return details.collision ? "recalled · id collision" : "recalled";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function formatRecallHeaderForTui(details: RecallObservationToolDetails): string {
|
|
262
|
+
const parts = [`${statusIcon(details)} ${statusSummary(details)}`];
|
|
263
|
+
if (details.matches.length > 0) parts.push(plural(details.matches.length, "match", "matches"));
|
|
264
|
+
const sources = sourceEntriesFromDetails(details);
|
|
265
|
+
if (sources.length > 0) parts.push(plural(sources.length, "source entry", "source entries"));
|
|
266
|
+
const tokens = sources.reduce((sum, source) => sum + source.tokens, 0);
|
|
267
|
+
if (tokens > 0) parts.push(tokenSummary(tokens));
|
|
268
|
+
return parts.join(" · ");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function sourceLabel(source: RecallSourceEntryDetails): string {
|
|
272
|
+
return source.origin ? `${source.origin[0].toLowerCase()}${source.origin.slice(1)}` : "entry";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function sourceMetadataLine(source: RecallSourceEntryDetails): string {
|
|
276
|
+
const qualifiers = source.qualifiers.length > 0 ? ` · ${source.qualifiers.join(" · ")}` : "";
|
|
277
|
+
return `✓ ${sourceLabel(source)} · ${source.timestamp} · entry ${source.id} · ${tokenSummary(source.tokens)}${qualifiers}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function observationLine(observation: ObservationDetails): string {
|
|
281
|
+
return `✓ observation · ${observation.timestamp} · [${observation.relevance}] · ${observation.content}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function indentContent(content: string): string {
|
|
285
|
+
return content
|
|
286
|
+
.split("\n")
|
|
287
|
+
.map((line) => ` ${line}`)
|
|
288
|
+
.join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function unavailableSourceLine(match: RecallObservationMatchDetails): string {
|
|
292
|
+
const parts: string[] = [];
|
|
293
|
+
if (match.missingSourceEntryIds && match.missingSourceEntryIds.length > 0) {
|
|
294
|
+
parts.push(`missing: ${match.missingSourceEntryIds.join(", ")}`);
|
|
295
|
+
}
|
|
296
|
+
if (match.nonSourceEntryIds && match.nonSourceEntryIds.length > 0) {
|
|
297
|
+
parts.push(`non-source: ${match.nonSourceEntryIds.join(", ")}`);
|
|
298
|
+
}
|
|
299
|
+
return `× source unavailable${parts.length > 0 ? ` · ${parts.join(" · ")}` : ""}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function matchLines(match: RecallObservationMatchDetails, expanded: boolean): string[] {
|
|
303
|
+
const lines = [observationLine(match.observation), ""];
|
|
304
|
+
if (match.status === "ok") {
|
|
305
|
+
for (const source of match.sourceEntries ?? []) {
|
|
306
|
+
lines.push(sourceMetadataLine(source));
|
|
307
|
+
if (expanded && source.content) {
|
|
308
|
+
lines.push(indentContent(source.content));
|
|
309
|
+
lines.push("");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return lines;
|
|
313
|
+
}
|
|
314
|
+
if (match.status === "source_unavailable") return [...lines, unavailableSourceLine(match)];
|
|
315
|
+
return [...lines, "× no source · legacy/unattributed observation"];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function formatRecallResultForTui(result: AgentToolResult<RecallObservationToolDetails>, expanded: boolean): string {
|
|
319
|
+
const details = result.details;
|
|
320
|
+
if (!details) {
|
|
321
|
+
const text = result.content
|
|
322
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
|
|
323
|
+
.map((part) => part.text)
|
|
324
|
+
.join("\n");
|
|
325
|
+
return text || "recall";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const lines: string[] = [];
|
|
329
|
+
if (details.matches.length > 0) {
|
|
330
|
+
for (const match of details.matches) {
|
|
331
|
+
if (lines.length > 0) lines.push("");
|
|
332
|
+
lines.push(...matchLines(match, expanded));
|
|
333
|
+
}
|
|
334
|
+
} else if (details.message) {
|
|
335
|
+
lines.push(details.message);
|
|
336
|
+
}
|
|
337
|
+
if (!expanded && details.matches.some((match) => match.status === "ok" && (match.sourceEntries?.length ?? 0) > 0)) {
|
|
338
|
+
lines.push("", "(Ctrl+O to expand)");
|
|
339
|
+
}
|
|
340
|
+
return lines.join("\n").trimEnd();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function formatRecallCallForTui(id: string | undefined): string {
|
|
344
|
+
return `recall ${id ?? "..."}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function formatRecallRenderedResultForTui(result: AgentToolResult<RecallObservationToolDetails>, expanded: boolean): string {
|
|
348
|
+
const body = formatRecallResultForTui(result, expanded);
|
|
349
|
+
const header = result.details ? formatRecallHeaderForTui(result.details) : undefined;
|
|
350
|
+
if (header && body) return `\n${header}\n\n${body}`;
|
|
351
|
+
if (header) return `\n${header}`;
|
|
352
|
+
return body ? `\n${body}` : "";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export const recallObservationTool = defineTool({
|
|
356
|
+
name: RECALL_OBSERVATION_TOOL_NAME,
|
|
357
|
+
label: "Recall observation source",
|
|
358
|
+
description: "Recall exact source entries for an observational-memory observation id on the current branch.",
|
|
359
|
+
promptSnippet: "Recall exact source entries for a compacted observational-memory observation id.",
|
|
360
|
+
promptGuidelines: [
|
|
361
|
+
"Use recall when a compacted observation id needs exact source context or the user asks what supports a remembered claim.",
|
|
362
|
+
"This is not general search: pass a specific observation id from the compacted Observations list.",
|
|
363
|
+
"Do not call recall for broad transcript browsing or off-branch history.",
|
|
364
|
+
],
|
|
365
|
+
parameters: Type.Object({
|
|
366
|
+
id: Type.String({
|
|
367
|
+
pattern: "^[a-f0-9]{12}$",
|
|
368
|
+
description: "12-character lowercase hex observational-memory observation id.",
|
|
369
|
+
}),
|
|
370
|
+
}),
|
|
371
|
+
renderCall(args) {
|
|
372
|
+
return new Text(formatRecallCallForTui(args.id), 0, 0);
|
|
373
|
+
},
|
|
374
|
+
renderResult(result, options) {
|
|
375
|
+
return new Text(formatRecallRenderedResultForTui(result as AgentToolResult<RecallObservationToolDetails>, options.expanded), 0, 0);
|
|
376
|
+
},
|
|
377
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
378
|
+
const observationId = params.id;
|
|
379
|
+
if (!OBSERVATION_ID_PATTERN.test(observationId)) {
|
|
380
|
+
const message = `Observation id must be 12 lowercase hex characters. Received: ${observationId}`;
|
|
381
|
+
return textResult(message, {
|
|
382
|
+
status: "invalid_id",
|
|
383
|
+
observationId,
|
|
384
|
+
collision: false,
|
|
385
|
+
matches: [],
|
|
386
|
+
message,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const branchEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
391
|
+
const result = recallObservationSources(branchEntries, observationId);
|
|
392
|
+
if (result.status === "not_found") {
|
|
393
|
+
const message = `No observation with id ${observationId} was found on the current branch.`;
|
|
394
|
+
return textResult(message, {
|
|
395
|
+
status: "not_found",
|
|
396
|
+
observationId,
|
|
397
|
+
collision: false,
|
|
398
|
+
matches: [],
|
|
399
|
+
message,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return renderFoundResult(result);
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
export function registerRecallTool(pi: ExtensionAPI): void {
|
|
408
|
+
pi.registerTool(recallObservationTool);
|
|
409
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface ObservationRecord {
|
|
|
9
9
|
content: string;
|
|
10
10
|
timestamp: string;
|
|
11
11
|
relevance: Relevance;
|
|
12
|
+
sourceEntryIds?: string[];
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export type Reflection = string;
|
|
@@ -34,11 +35,19 @@ function isRelevance(v: unknown): v is Relevance {
|
|
|
34
35
|
function isObservationRecord(v: unknown): v is ObservationRecord {
|
|
35
36
|
if (!v || typeof v !== "object") return false;
|
|
36
37
|
const o = v as Record<string, unknown>;
|
|
38
|
+
if (
|
|
39
|
+
typeof o.id !== "string" ||
|
|
40
|
+
typeof o.content !== "string" ||
|
|
41
|
+
typeof o.timestamp !== "string" ||
|
|
42
|
+
!isRelevance(o.relevance)
|
|
43
|
+
) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (o.sourceEntryIds === undefined) return true;
|
|
37
47
|
return (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
isRelevance(o.relevance)
|
|
48
|
+
Array.isArray(o.sourceEntryIds) &&
|
|
49
|
+
o.sourceEntryIds.length > 0 &&
|
|
50
|
+
o.sourceEntryIds.every((id) => typeof id === "string" && id.length > 0)
|
|
42
51
|
);
|
|
43
52
|
}
|
|
44
53
|
|