talon-agent 4.4.0 → 4.6.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/package.json +1 -1
- package/prompts/system/memory-core-view.md +7 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/memory.ts +31 -0
- package/src/core/memory/core-view.ts +158 -0
- package/src/core/memory/flag.ts +16 -0
- package/src/core/memory/import.ts +380 -0
- package/src/core/memory/render.ts +243 -0
- package/src/core/prompt/assemble.ts +69 -16
- package/src/core/prompt/embedded-prompts.ts +20 -18
- package/src/core/prompt/memory-view.ts +8 -8
- package/src/frontend/native/handlers.ts +5 -0
- package/src/frontend/native/memory.ts +112 -0
- package/src/frontend/native/protocol.ts +45 -0
- package/src/frontend/native/routes/host.ts +9 -0
- package/src/frontend/native/routes/index.ts +2 -0
- package/src/frontend/native/routes/memory.ts +34 -0
- package/src/frontend/native/routes/table.ts +7 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +3 -0
- package/src/frontend/telegram/commands/info.ts +3 -0
- package/src/frontend/telegram/commands/memory.ts +112 -0
- package/src/storage/memory.ts +8 -0
- package/src/storage/metrics.ts +26 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed memory rows → a `memory.md`-shaped document.
|
|
3
|
+
*
|
|
4
|
+
* The inverse of import.ts, and the second half of "markdown becomes a
|
|
5
|
+
* view" (plan §3.1): the store holds the claims, the file is a rendered
|
|
6
|
+
* projection of them — still human-readable, still `Read`-able, still
|
|
7
|
+
* editable, but no longer authoritative.
|
|
8
|
+
*
|
|
9
|
+
* Two properties matter more than prettiness:
|
|
10
|
+
*
|
|
11
|
+
* - **Deterministic.** The same rows render to the same bytes, every
|
|
12
|
+
* time. A file that churns on every render is a file nobody can diff
|
|
13
|
+
* and a prompt prefix that never caches.
|
|
14
|
+
* - **A fixed point with import.** Chunked rows are regrouped under
|
|
15
|
+
* their base subject and keyed state renders under its key, so
|
|
16
|
+
* importing this document back produces exactly the rows it came
|
|
17
|
+
* from and reports zero inserts. That round trip is what lets a
|
|
18
|
+
* hand-edit be folded back instead of duplicated.
|
|
19
|
+
*
|
|
20
|
+
* Truncation is selection, not slicing: sections are dropped whole, from
|
|
21
|
+
* the bottom of the ranking, and the tail is named so the reader knows
|
|
22
|
+
* to ask the store for the rest.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import writeFileAtomic from "write-file-atomic";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
listMemories,
|
|
31
|
+
type MemoryKind,
|
|
32
|
+
type MemoryRow,
|
|
33
|
+
} from "../../storage/memory.js";
|
|
34
|
+
import { dirs, files } from "../../util/paths.js";
|
|
35
|
+
import { toYMD } from "../../util/time.js";
|
|
36
|
+
import {
|
|
37
|
+
MEMORY_INJECT_MAX_CHARS,
|
|
38
|
+
headingTitle,
|
|
39
|
+
} from "../prompt/memory-view.js";
|
|
40
|
+
|
|
41
|
+
// ── Tunables ────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Kind order in the document, most durable first. `reflection` is absent
|
|
45
|
+
* on purpose: the diary is the persona layer and never a fact source, so
|
|
46
|
+
* it is not part of the memory projection (plan §3.1).
|
|
47
|
+
*/
|
|
48
|
+
const KIND_ORDER: readonly MemoryKind[] = [
|
|
49
|
+
"directive",
|
|
50
|
+
"relationship",
|
|
51
|
+
"fact",
|
|
52
|
+
"state",
|
|
53
|
+
"episode",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** How many rows per kind the default render pulls from the store. */
|
|
57
|
+
const KIND_ROW_LIMIT = 500;
|
|
58
|
+
|
|
59
|
+
/** The preamble. Import ignores everything above the first `## `. */
|
|
60
|
+
const HEADER =
|
|
61
|
+
"# Memory\n\n" +
|
|
62
|
+
"_Rendered from the typed memory store. Edit freely — " +
|
|
63
|
+
"`talon memory import` folds changes back._\n";
|
|
64
|
+
|
|
65
|
+
/** The ` (n/N)` marker import appends when a section outgrew the text cap. */
|
|
66
|
+
const CHUNK_SUFFIX = /^(.*) \((\d+)\/(\d+)\)$/;
|
|
67
|
+
|
|
68
|
+
// ── Ordering ────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** One row placed under its section heading. */
|
|
71
|
+
type Piece = { label: string; index: number; row: MemoryRow };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A keyed row renders under its key, which is the more specific label and
|
|
75
|
+
* the one import reads back. Chunk `n > 1` carries a `.n` key suffix so
|
|
76
|
+
* each chunk owns a distinct key; the heading drops it again.
|
|
77
|
+
*/
|
|
78
|
+
function stateLabel(row: MemoryRow, index: number): string {
|
|
79
|
+
const key = row.key ?? row.subject;
|
|
80
|
+
const suffix = `.${index}`;
|
|
81
|
+
return index > 1 && key.endsWith(suffix) ? key.slice(0, -suffix.length) : key;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pieceOf(row: MemoryRow): Piece {
|
|
85
|
+
const chunk = CHUNK_SUFFIX.exec(row.subject);
|
|
86
|
+
const index = chunk ? Number(chunk[2]) : 1;
|
|
87
|
+
const base = chunk?.[1] ?? row.subject;
|
|
88
|
+
return {
|
|
89
|
+
label: row.kind === "state" ? stateLabel(row, index) : base,
|
|
90
|
+
index,
|
|
91
|
+
row,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Pinned first, then kind, then salience, then recency; id breaks ties. */
|
|
96
|
+
function compareRows(a: MemoryRow, b: MemoryRow): number {
|
|
97
|
+
return (
|
|
98
|
+
Number(b.pinned) - Number(a.pinned) ||
|
|
99
|
+
KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) ||
|
|
100
|
+
b.salience - a.salience ||
|
|
101
|
+
b.lastSeenAt - a.lastSeenAt ||
|
|
102
|
+
a.id - b.id
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** One heading plus its rows, blank-line separated, chunks back in order. */
|
|
109
|
+
function renderSection(label: string, pieces: readonly Piece[]): string {
|
|
110
|
+
const body = [...pieces]
|
|
111
|
+
.sort((a, b) => a.index - b.index)
|
|
112
|
+
.map((piece) => piece.row.text)
|
|
113
|
+
.join("\n\n");
|
|
114
|
+
return `## ${headingTitle(label)}\n\n${body}\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Group ranked rows into sections. A section takes the position of its
|
|
119
|
+
* best-ranked row, so the ranking survives the grouping.
|
|
120
|
+
*/
|
|
121
|
+
function sectionsFrom(rows: readonly MemoryRow[]): string[] {
|
|
122
|
+
const groups = new Map<string, Piece[]>();
|
|
123
|
+
const ranked = rows
|
|
124
|
+
.filter((row) => KIND_ORDER.includes(row.kind))
|
|
125
|
+
.sort(compareRows);
|
|
126
|
+
for (const row of ranked) {
|
|
127
|
+
const piece = pieceOf(row);
|
|
128
|
+
const held = groups.get(piece.label);
|
|
129
|
+
if (held) held.push(piece);
|
|
130
|
+
else groups.set(piece.label, [piece]);
|
|
131
|
+
}
|
|
132
|
+
return [...groups].map(([label, pieces]) => renderSection(label, pieces));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The pointer that replaces whatever the budget could not carry. */
|
|
136
|
+
function moreLine(omitted: number): string {
|
|
137
|
+
const plural = omitted === 1 ? "section" : "sections";
|
|
138
|
+
return `_… ${omitted} more ${plural} in the store — \`talon memory list\`_\n`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Take whole sections until the budget is spent, then give back sections
|
|
143
|
+
* until the "N more" pointer fits too — the document as a whole stays
|
|
144
|
+
* inside the budget, and every cut lands on a section boundary. The
|
|
145
|
+
* header plus that pointer is the floor: a budget smaller than those two
|
|
146
|
+
* still gets them, because a document that cannot say what it dropped is
|
|
147
|
+
* worse than one slightly over budget.
|
|
148
|
+
*/
|
|
149
|
+
function selectSections(
|
|
150
|
+
sections: readonly string[],
|
|
151
|
+
budget: number,
|
|
152
|
+
): { taken: readonly string[]; omitted: number } {
|
|
153
|
+
let spent = HEADER.length;
|
|
154
|
+
let take = 0;
|
|
155
|
+
for (const section of sections) {
|
|
156
|
+
if (spent + section.length + 1 > budget) break;
|
|
157
|
+
spent += section.length + 1;
|
|
158
|
+
take += 1;
|
|
159
|
+
}
|
|
160
|
+
let omitted = sections.length - take;
|
|
161
|
+
while (
|
|
162
|
+
take > 0 &&
|
|
163
|
+
omitted > 0 &&
|
|
164
|
+
spent + moreLine(omitted).length + 1 > budget
|
|
165
|
+
) {
|
|
166
|
+
take -= 1;
|
|
167
|
+
spent -= sections[take]!.length + 1;
|
|
168
|
+
omitted += 1;
|
|
169
|
+
}
|
|
170
|
+
return { taken: sections.slice(0, take), omitted };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Options shared by the render and the file write. */
|
|
174
|
+
export type RenderOptions = {
|
|
175
|
+
/** Char budget for the whole document. Defaults to the inject cap. */
|
|
176
|
+
budget?: number;
|
|
177
|
+
/**
|
|
178
|
+
* Rows to render instead of the live store — the seam the tests and
|
|
179
|
+
* (from PR 9) the pre-ranked core view use.
|
|
180
|
+
*/
|
|
181
|
+
rows?: readonly MemoryRow[];
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
function liveRows(): MemoryRow[] {
|
|
185
|
+
return KIND_ORDER.flatMap((kind) =>
|
|
186
|
+
listMemories({ kind, limit: KIND_ROW_LIMIT }),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Render the live store as a `memory.md` document. Same rows in, same
|
|
192
|
+
* bytes out.
|
|
193
|
+
*/
|
|
194
|
+
export function renderMemoryMarkdown(opts: RenderOptions = {}): string {
|
|
195
|
+
const sections = sectionsFrom(opts.rows ?? liveRows());
|
|
196
|
+
const { taken, omitted } = selectSections(
|
|
197
|
+
sections,
|
|
198
|
+
opts.budget ?? MEMORY_INJECT_MAX_CHARS,
|
|
199
|
+
);
|
|
200
|
+
const parts = [HEADER, ...taken];
|
|
201
|
+
if (omitted > 0) parts.push(moreLine(omitted));
|
|
202
|
+
return parts.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Writing ─────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/** Where a render went, and what it displaced. */
|
|
208
|
+
export type RenderWrite = {
|
|
209
|
+
path: string;
|
|
210
|
+
bytes: number;
|
|
211
|
+
/** The archived copy of the previous file, when one was taken. */
|
|
212
|
+
backup?: string;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Copy the file aside before it is replaced — once per day, so a render
|
|
217
|
+
* loop can't bury the archive, and never overwriting an existing backup,
|
|
218
|
+
* so the first render of the day keeps the hand-written original.
|
|
219
|
+
*/
|
|
220
|
+
function backUp(path: string, archiveDir: string): string | undefined {
|
|
221
|
+
if (!existsSync(path)) return undefined;
|
|
222
|
+
const dest = join(archiveDir, `memory-before-render-${toYMD(new Date())}.md`);
|
|
223
|
+
if (existsSync(dest)) return undefined;
|
|
224
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
225
|
+
copyFileSync(path, dest);
|
|
226
|
+
return dest;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Render the store over `memory.md`, keeping a dated copy of what was there. */
|
|
230
|
+
export function writeRenderedMemory(
|
|
231
|
+
path: string = files.memory,
|
|
232
|
+
opts: RenderOptions & { archiveDir?: string } = {},
|
|
233
|
+
): RenderWrite {
|
|
234
|
+
const markdown = renderMemoryMarkdown(opts);
|
|
235
|
+
const backup = backUp(path, opts.archiveDir ?? dirs.memoryArchive);
|
|
236
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
237
|
+
writeFileAtomic.sync(path, markdown);
|
|
238
|
+
return {
|
|
239
|
+
path,
|
|
240
|
+
bytes: markdown.length,
|
|
241
|
+
...(backup !== undefined ? { backup } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* 4. Persistent memory (ranked, capped) prompts/system/persistent-memory.md
|
|
20
20
|
* wrapping ~/.talon/workspace/memory/memory.md
|
|
21
21
|
* via memory-view.ts
|
|
22
|
+
* — or, with TALON_MEMORY_STORE=1 and a
|
|
23
|
+
* non-empty store, the typed store's core
|
|
24
|
+
* view (memory/core-view.ts) wrapped in
|
|
25
|
+
* prompts/system/memory-core-view.md
|
|
22
26
|
* 4.5 Live state (capped) prompts/system/live-state.md
|
|
23
27
|
* wrapping ~/.talon/workspace/memory/state.md
|
|
24
28
|
* (heartbeat-owned, rewritten whole)
|
|
@@ -65,6 +69,9 @@ import { renderMemoryView } from "./memory-view.js";
|
|
|
65
69
|
import { renderWorkspaceListing } from "./workspace-listing.js";
|
|
66
70
|
import { renderSkillsPrompt } from "../../storage/skills.js";
|
|
67
71
|
import { renderStickerLibraryPrompt } from "../../storage/stickers.js";
|
|
72
|
+
import { recordHistogram } from "../../storage/metrics.js";
|
|
73
|
+
import { renderCoreView } from "../memory/core-view.js";
|
|
74
|
+
import { memoryStoreEnabled } from "../memory/flag.js";
|
|
68
75
|
import { getSoul } from "../soul/service.js";
|
|
69
76
|
|
|
70
77
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
@@ -109,6 +116,14 @@ export function joinSystemPromptParts(parts: SystemPromptParts): string {
|
|
|
109
116
|
*/
|
|
110
117
|
export const STATE_INJECT_MAX_CHARS = 2_000;
|
|
111
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Size of the injected memory block, recorded on every build from both
|
|
121
|
+
* tiers. The whole point of the flag being default-off is that these two
|
|
122
|
+
* populations can be compared before the store becomes the default path
|
|
123
|
+
* (rollout Decision 4).
|
|
124
|
+
*/
|
|
125
|
+
const MEMORY_CHARS_METRIC = "prompt.memory_chars";
|
|
126
|
+
|
|
112
127
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
113
128
|
|
|
114
129
|
function readOptionalFile(path: string): string {
|
|
@@ -122,6 +137,50 @@ function readOptionalFile(path: string): string {
|
|
|
122
137
|
|
|
123
138
|
let lastLoggedPromptKey = "";
|
|
124
139
|
|
|
140
|
+
/** A memory block ready for the static prompt, with the label it logs under. */
|
|
141
|
+
type MemorySection = { section: string; label: string };
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The store's core view, when `TALON_MEMORY_STORE` is on and the store
|
|
145
|
+
* has something to say. Budgeted (not truncated) and computed once per
|
|
146
|
+
* build — this is the session-frozen tier of plan §3.4, so it belongs in
|
|
147
|
+
* `staticText` and nowhere else.
|
|
148
|
+
*
|
|
149
|
+
* An empty store falls through to the file, which is what makes the flag
|
|
150
|
+
* safe to turn on before the import has ever run.
|
|
151
|
+
*/
|
|
152
|
+
function coreViewSection(): MemorySection | undefined {
|
|
153
|
+
if (!memoryStoreEnabled()) return undefined;
|
|
154
|
+
const view = renderCoreView();
|
|
155
|
+
if (view.rows === 0) return undefined;
|
|
156
|
+
recordHistogram(MEMORY_CHARS_METRIC, view.chars);
|
|
157
|
+
return {
|
|
158
|
+
section: loadSystemTemplate("memory-core-view", { content: view.text }),
|
|
159
|
+
label: "memory(store)",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The rendered `memory.md` file, ranked and capped. The path every
|
|
165
|
+
* deployment is on until the flag flips: over the cap the view ranks
|
|
166
|
+
* sections rather than head-slicing, so durable knowledge isn't evicted
|
|
167
|
+
* by whatever happens to sit at the top of the file (memory-view.ts).
|
|
168
|
+
*/
|
|
169
|
+
function memoryFileSection(): MemorySection | undefined {
|
|
170
|
+
const memory = readOptionalFile(pathFiles.memory);
|
|
171
|
+
if (!memory) return undefined;
|
|
172
|
+
const { text, truncated, omitted } = renderMemoryView(memory);
|
|
173
|
+
recordHistogram(MEMORY_CHARS_METRIC, text.length);
|
|
174
|
+
return {
|
|
175
|
+
section: loadSystemTemplate("persistent-memory", {
|
|
176
|
+
content: text,
|
|
177
|
+
truncated: truncated ? "yes" : undefined,
|
|
178
|
+
omitted: omitted || undefined,
|
|
179
|
+
}),
|
|
180
|
+
label: truncated ? "memory(ranked)" : "memory",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
125
184
|
// ── Assembly ────────────────────────────────────────────────────────────────
|
|
126
185
|
|
|
127
186
|
/** Inputs for `assembleSystemPrompt`. */
|
|
@@ -182,22 +241,16 @@ export function assembleSystemPrompt(
|
|
|
182
241
|
loaded.push(frontendFile.replace(".md", ""));
|
|
183
242
|
}
|
|
184
243
|
|
|
185
|
-
// 4. Persistent memory —
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
staticParts.push(
|
|
194
|
-
|
|
195
|
-
content: text,
|
|
196
|
-
truncated: truncated ? "yes" : undefined,
|
|
197
|
-
omitted: omitted || undefined,
|
|
198
|
-
}),
|
|
199
|
-
);
|
|
200
|
-
loaded.push(truncated ? "memory(ranked)" : "memory");
|
|
244
|
+
// 4. Persistent memory — the store's core view when the flag is on and
|
|
245
|
+
// the store has rows, else the ranked, size-capped `memory.md` file,
|
|
246
|
+
// so a memory file that has grown for months can't bloat every
|
|
247
|
+
// session from turn 0. Static either way: both tiers are frozen for
|
|
248
|
+
// the session's lifetime (plan §3.4/§3.6). Anything learned
|
|
249
|
+
// mid-session reaches the model through turn retrieval instead.
|
|
250
|
+
const memorySection = coreViewSection() ?? memoryFileSection();
|
|
251
|
+
if (memorySection) {
|
|
252
|
+
staticParts.push(memorySection.section);
|
|
253
|
+
loaded.push(memorySection.label);
|
|
201
254
|
}
|
|
202
255
|
|
|
203
256
|
// 4.5. Live state — the heartbeat's rewritten-whole status snapshot, kept
|
|
@@ -27,15 +27,16 @@ import asset13 from "../../../prompts/system/daily-memory.md" with { type: "file
|
|
|
27
27
|
import asset14 from "../../../prompts/system/goals.md" with { type: "file" };
|
|
28
28
|
import asset15 from "../../../prompts/system/heartbeat-agent.md" with { type: "file" };
|
|
29
29
|
import asset16 from "../../../prompts/system/live-state.md" with { type: "file" };
|
|
30
|
-
import asset17 from "../../../prompts/system/memory-
|
|
31
|
-
import asset18 from "../../../prompts/system/
|
|
32
|
-
import asset19 from "../../../prompts/system/
|
|
33
|
-
import asset20 from "../../../prompts/system/
|
|
34
|
-
import asset21 from "../../../prompts/system/
|
|
35
|
-
import asset22 from "../../../prompts/
|
|
36
|
-
import asset23 from "../../../prompts/
|
|
37
|
-
import asset24 from "../../../prompts/
|
|
38
|
-
import asset25 from "../../../prompts/
|
|
30
|
+
import asset17 from "../../../prompts/system/memory-core-view.md" with { type: "file" };
|
|
31
|
+
import asset18 from "../../../prompts/system/memory-recall.md" with { type: "file" };
|
|
32
|
+
import asset19 from "../../../prompts/system/persistent-memory.md" with { type: "file" };
|
|
33
|
+
import asset20 from "../../../prompts/system/skills.md" with { type: "file" };
|
|
34
|
+
import asset21 from "../../../prompts/system/triggers.md" with { type: "file" };
|
|
35
|
+
import asset22 from "../../../prompts/system/workspace.md" with { type: "file" };
|
|
36
|
+
import asset23 from "../../../prompts/teams.md" with { type: "file" };
|
|
37
|
+
import asset24 from "../../../prompts/telegram.md" with { type: "file" };
|
|
38
|
+
import asset25 from "../../../prompts/terminal.md" with { type: "file" };
|
|
39
|
+
import asset26 from "../../../prompts/whatsapp.md" with { type: "file" };
|
|
39
40
|
|
|
40
41
|
/** rel path (posix, under prompts/) → embedded file path (/$bunfs/… when compiled). */
|
|
41
42
|
const ASSETS: Record<string, string> = {
|
|
@@ -56,15 +57,16 @@ const ASSETS: Record<string, string> = {
|
|
|
56
57
|
"system/goals.md": asset14,
|
|
57
58
|
"system/heartbeat-agent.md": asset15,
|
|
58
59
|
"system/live-state.md": asset16,
|
|
59
|
-
"system/memory-
|
|
60
|
-
"system/
|
|
61
|
-
"system/
|
|
62
|
-
"system/
|
|
63
|
-
"system/
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
60
|
+
"system/memory-core-view.md": asset17,
|
|
61
|
+
"system/memory-recall.md": asset18,
|
|
62
|
+
"system/persistent-memory.md": asset19,
|
|
63
|
+
"system/skills.md": asset20,
|
|
64
|
+
"system/triggers.md": asset21,
|
|
65
|
+
"system/workspace.md": asset22,
|
|
66
|
+
"teams.md": asset23,
|
|
67
|
+
"telegram.md": asset24,
|
|
68
|
+
"terminal.md": asset25,
|
|
69
|
+
"whatsapp.md": asset26,
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
/** Read an embedded prompt by its rel path (e.g. "system/cron.md"). */
|
|
@@ -60,7 +60,7 @@ const MAX_OMITTED_NAMED = 8;
|
|
|
60
60
|
* dropped and most cheaply re-derived. `historical` is last for the mirror
|
|
61
61
|
* reason: it will never change again, so it is the least urgent to carry.
|
|
62
62
|
*/
|
|
63
|
-
const TIER_ORDER = [
|
|
63
|
+
export const TIER_ORDER = [
|
|
64
64
|
"directive",
|
|
65
65
|
"people",
|
|
66
66
|
"active",
|
|
@@ -69,7 +69,7 @@ const TIER_ORDER = [
|
|
|
69
69
|
"historical",
|
|
70
70
|
] as const;
|
|
71
71
|
|
|
72
|
-
type Tier = (typeof TIER_ORDER)[number];
|
|
72
|
+
export type Tier = (typeof TIER_ORDER)[number];
|
|
73
73
|
|
|
74
74
|
const tier = (name: Tier): number => TIER_ORDER.indexOf(name);
|
|
75
75
|
|
|
@@ -107,7 +107,7 @@ const MATCHERS: readonly { readonly tier: Tier; readonly match: RegExp }[] = [
|
|
|
107
107
|
const STATUS_TIER = tier("status");
|
|
108
108
|
const GENERAL_TIER = tier("general");
|
|
109
109
|
|
|
110
|
-
function classify(title: string): number {
|
|
110
|
+
export function classify(title: string): number {
|
|
111
111
|
for (const m of MATCHERS) {
|
|
112
112
|
if (m.match.test(title)) return tier(m.tier);
|
|
113
113
|
}
|
|
@@ -116,7 +116,7 @@ function classify(title: string): number {
|
|
|
116
116
|
|
|
117
117
|
// ── Parsing ─────────────────────────────────────────────────────────────────
|
|
118
118
|
|
|
119
|
-
type Section = {
|
|
119
|
+
export type Section = {
|
|
120
120
|
/** Heading with markup and trailing qualifiers stripped — the display name. */
|
|
121
121
|
readonly title: string;
|
|
122
122
|
/** Whole section including its heading and any `###` children. */
|
|
@@ -131,7 +131,7 @@ type Section = {
|
|
|
131
131
|
};
|
|
132
132
|
|
|
133
133
|
/** Strip `## ` markup and bold markers from a heading line. */
|
|
134
|
-
function headingTitle(heading: string): string {
|
|
134
|
+
export function headingTitle(heading: string): string {
|
|
135
135
|
return heading
|
|
136
136
|
.replace(/^#+\s*/, "")
|
|
137
137
|
.replace(/\*\*/g, "")
|
|
@@ -144,7 +144,7 @@ function headingTitle(heading: string): string {
|
|
|
144
144
|
* parenthetical is exactly what makes each snapshot look unique while the
|
|
145
145
|
* underlying topic repeats, so removing it is what lets a family collapse.
|
|
146
146
|
*/
|
|
147
|
-
function familyKey(title: string): string {
|
|
147
|
+
export function familyKey(title: string): string {
|
|
148
148
|
return title
|
|
149
149
|
.replace(/\s*\([^)]*\)\s*$/, "")
|
|
150
150
|
.replace(/\s*[—–-]\s*(?:as of|run #).*$/i, "")
|
|
@@ -172,7 +172,7 @@ function recencyKey(heading: string): string {
|
|
|
172
172
|
* the first `## `) plus one entry per `## ` section. The split is on `## `
|
|
173
173
|
* only, so an `h3` travels with the section it belongs to.
|
|
174
174
|
*/
|
|
175
|
-
function parseSections(content: string): {
|
|
175
|
+
export function parseSections(content: string): {
|
|
176
176
|
preamble: string;
|
|
177
177
|
sections: Section[];
|
|
178
178
|
} {
|
|
@@ -201,7 +201,7 @@ function parseSections(content: string): {
|
|
|
201
201
|
* the one that appeared first. Only the `status` tier collapses — two
|
|
202
202
|
* sections about people are two different people, not two snapshots of one.
|
|
203
203
|
*/
|
|
204
|
-
function collapseFamilies(sections: readonly Section[]): {
|
|
204
|
+
export function collapseFamilies(sections: readonly Section[]): {
|
|
205
205
|
kept: Section[];
|
|
206
206
|
dropped: Section[];
|
|
207
207
|
} {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "./extensions.js";
|
|
19
19
|
import { historyPage, searchHistory } from "./history.js";
|
|
20
20
|
import { readLogEntries } from "./logs.js";
|
|
21
|
+
import { listMemory, memoryWhy } from "./memory.js";
|
|
21
22
|
import {
|
|
22
23
|
describeAttachment,
|
|
23
24
|
MAX_UPLOAD_BYTES,
|
|
@@ -98,6 +99,10 @@ export function buildBridgeHandlers(
|
|
|
98
99
|
deleteChat: (id) => deleteChat(runtime, id),
|
|
99
100
|
history: (id, opts) => historyPage(runtime, id, opts),
|
|
100
101
|
search: (query, chatId) => searchHistory(runtime, query, chatId),
|
|
102
|
+
// Memory is daemon-wide state in SQLite, not per-runtime — these are
|
|
103
|
+
// straight delegations to the read-only half of the store.
|
|
104
|
+
listMemory,
|
|
105
|
+
memoryWhy,
|
|
101
106
|
send: (id, text, opts) => {
|
|
102
107
|
const entry = chats.get(id) ?? chats.ensure(id);
|
|
103
108
|
// Resolve the client's references into the records this daemon minted
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory reads for the client bridge — the read-only half of the typed
|
|
3
|
+
* memory store (`storage/memory.ts`), projected onto the wire shapes in
|
|
4
|
+
* protocol.ts.
|
|
5
|
+
*
|
|
6
|
+
* Read-only on purpose: a bridge client can ask what Talon remembers and
|
|
7
|
+
* why, and nothing more. Asserting, superseding and dropping stay with
|
|
8
|
+
* the write path (rollout PR 6), so a paired phone can never quietly
|
|
9
|
+
* rewrite the operator's memory.
|
|
10
|
+
*
|
|
11
|
+
* The one piece of policy here is the limit cap: whatever a client asks
|
|
12
|
+
* for, a single response carries at most `MAX_LIMIT` rows, so a stray
|
|
13
|
+
* `?limit=1000000` cannot turn a listing into a full-table dump.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
getMemory,
|
|
18
|
+
isMemoryKind,
|
|
19
|
+
listMemories,
|
|
20
|
+
memoryHistory,
|
|
21
|
+
searchMemories,
|
|
22
|
+
MEMORY_KINDS,
|
|
23
|
+
type MemoryHistoryRow,
|
|
24
|
+
type MemoryKind,
|
|
25
|
+
type MemoryRow,
|
|
26
|
+
} from "../../storage/memory.js";
|
|
27
|
+
import type {
|
|
28
|
+
MemoryHistoryWire,
|
|
29
|
+
MemoryRowWire,
|
|
30
|
+
MemoryWhyWire,
|
|
31
|
+
} from "./protocol.js";
|
|
32
|
+
|
|
33
|
+
/** Hard ceiling on one listing, whatever the client asks for. */
|
|
34
|
+
const MAX_LIMIT = 100;
|
|
35
|
+
|
|
36
|
+
/** What `GET /memory` accepts, already coerced from the query string. */
|
|
37
|
+
export type MemoryListQuery = {
|
|
38
|
+
/** Full-text query; when present the listing becomes a search. */
|
|
39
|
+
q?: string;
|
|
40
|
+
kind?: string;
|
|
41
|
+
limit?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** An ok listing, or the reason the request was rejected (a 400). */
|
|
45
|
+
export type MemoryListResult =
|
|
46
|
+
{ ok: true; rows: MemoryRowWire[] } | { ok: false; error: string };
|
|
47
|
+
|
|
48
|
+
/** Project a stored row onto the wire — internals stay daemon-side. */
|
|
49
|
+
function toWire(row: MemoryRow): MemoryRowWire {
|
|
50
|
+
return {
|
|
51
|
+
id: row.id,
|
|
52
|
+
kind: row.kind,
|
|
53
|
+
subject: row.subject,
|
|
54
|
+
...(row.key !== undefined ? { key: row.key } : {}),
|
|
55
|
+
text: row.text,
|
|
56
|
+
trust: row.trust,
|
|
57
|
+
confidence: row.confidence,
|
|
58
|
+
pinned: row.pinned,
|
|
59
|
+
hitCount: row.hitCount,
|
|
60
|
+
salience: row.salience,
|
|
61
|
+
createdAt: row.createdAt,
|
|
62
|
+
lastSeenAt: row.lastSeenAt,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Live rows: a bm25 search when `q` is given, otherwise the ranked
|
|
68
|
+
* listing (pinned, then salience, then recency). An unknown `kind` is
|
|
69
|
+
* a client error rather than an empty result — silently returning
|
|
70
|
+
* nothing for a typo is how a client ends up "showing" an empty memory.
|
|
71
|
+
*/
|
|
72
|
+
export function listMemory(query: MemoryListQuery = {}): MemoryListResult {
|
|
73
|
+
const raw = query.kind?.trim();
|
|
74
|
+
let kind: MemoryKind | undefined;
|
|
75
|
+
if (raw) {
|
|
76
|
+
if (!isMemoryKind(raw))
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: `Unknown kind "${raw}" (expected one of ${MEMORY_KINDS.join(", ")})`,
|
|
80
|
+
};
|
|
81
|
+
kind = raw;
|
|
82
|
+
}
|
|
83
|
+
const limit = Math.min(query.limit ?? MAX_LIMIT, MAX_LIMIT);
|
|
84
|
+
const q = query.q?.trim();
|
|
85
|
+
const rows = q
|
|
86
|
+
? searchMemories(q, { ...(kind ? { kind } : {}), limit })
|
|
87
|
+
: listMemories({ ...(kind ? { kind } : {}), limit });
|
|
88
|
+
return { ok: true, rows: rows.map(toWire) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One row plus its audit trail, or null when no such id. Reads by id
|
|
93
|
+
* rather than from the live listing, so a superseded or dropped row
|
|
94
|
+
* still explains itself — that is the whole point of "why".
|
|
95
|
+
*/
|
|
96
|
+
export function memoryWhy(id: number): MemoryWhyWire | null {
|
|
97
|
+
const row = getMemory(id);
|
|
98
|
+
if (!row) return null;
|
|
99
|
+
return {
|
|
100
|
+
row: toWire(row),
|
|
101
|
+
history: memoryHistory(row.id).map(toHistoryWire),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One audit entry on the wire — before/after text stays daemon-side. */
|
|
106
|
+
function toHistoryWire(entry: MemoryHistoryRow): MemoryHistoryWire {
|
|
107
|
+
return {
|
|
108
|
+
op: entry.op,
|
|
109
|
+
at: entry.at,
|
|
110
|
+
...(entry.reason !== undefined ? { reason: entry.reason } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -263,6 +263,51 @@ export type SkillItem = {
|
|
|
263
263
|
*/
|
|
264
264
|
export type ToggleResult = { ok: boolean; error?: string };
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* One row of the typed memory store, as `GET /memory` lists it.
|
|
268
|
+
*
|
|
269
|
+
* A projection, not the stored row: the internal `source`, `contentHash`
|
|
270
|
+
* and supersede/drop pointers stay daemon-side, and the numbers that
|
|
271
|
+
* decide where a row ranks (trust, confidence, hits, salience) come
|
|
272
|
+
* along so a client can show WHY something is remembered, not just
|
|
273
|
+
* what. `kind` and `trust` are the store's vocabularies as plain
|
|
274
|
+
* strings — a client renders what it gets rather than refusing an
|
|
275
|
+
* unknown one.
|
|
276
|
+
*/
|
|
277
|
+
export type MemoryRowWire = {
|
|
278
|
+
id: number;
|
|
279
|
+
kind: string;
|
|
280
|
+
subject: string;
|
|
281
|
+
/** Present only on keyed `state` rows. */
|
|
282
|
+
key?: string;
|
|
283
|
+
text: string;
|
|
284
|
+
trust: string;
|
|
285
|
+
/** 0..1. */
|
|
286
|
+
confidence: number;
|
|
287
|
+
pinned: boolean;
|
|
288
|
+
hitCount: number;
|
|
289
|
+
salience: number;
|
|
290
|
+
/** Epoch milliseconds. */
|
|
291
|
+
createdAt: number;
|
|
292
|
+
/** Epoch milliseconds. */
|
|
293
|
+
lastSeenAt: number;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/** One audit entry from a row's trail, oldest first in `MemoryWhyWire`. */
|
|
297
|
+
export type MemoryHistoryWire = {
|
|
298
|
+
/** assert | supersede | drop | merge | pin | unpin | replace_state. */
|
|
299
|
+
op: string;
|
|
300
|
+
/** Epoch milliseconds. */
|
|
301
|
+
at: number;
|
|
302
|
+
reason?: string;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** `GET /memory/why?id=` — one row plus everything that happened to it. */
|
|
306
|
+
export type MemoryWhyWire = {
|
|
307
|
+
row: MemoryRowWire;
|
|
308
|
+
history: MemoryHistoryWire[];
|
|
309
|
+
};
|
|
310
|
+
|
|
266
311
|
// Mesh device shapes are canonical in core (the mesh is daemon-wide state,
|
|
267
312
|
// readable from every frontend); re-exported here so bridge clients keep
|
|
268
313
|
// depending on the protocol module alone.
|