talon-agent 4.4.0 → 4.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
package/src/cli/index.ts CHANGED
@@ -157,7 +157,7 @@ export async function runCli(): Promise<void> {
157
157
  ` ${pc.cyan("skill")} Manage skills (install/enable/disable)`,
158
158
  );
159
159
  console.log(
160
- ` ${pc.cyan("memory")} Read/edit the memory store (list/search/remember)`,
160
+ ` ${pc.cyan("memory")} Read/edit the memory store (list/search/import/render)`,
161
161
  );
162
162
  console.log(` ${pc.cyan("config")} View/edit configuration`);
163
163
  console.log(` ${pc.cyan("logs")} Tail log file`);
package/src/cli/memory.ts CHANGED
@@ -8,6 +8,11 @@
8
8
  */
9
9
 
10
10
  import pc from "picocolors";
11
+ import { importAll } from "../core/memory/import.js";
12
+ import {
13
+ renderMemoryMarkdown,
14
+ writeRenderedMemory,
15
+ } from "../core/memory/render.js";
11
16
  import {
12
17
  assertMemory,
13
18
  dropMemory,
@@ -33,6 +38,8 @@ const USAGE = [
33
38
  ` ${pc.cyan("remember <kind> <subject> <text>")} Record an operator claim`,
34
39
  ` ${pc.cyan("forget <id> [reason]")} Drop a row to the graveyard`,
35
40
  ` ${pc.cyan("state <key> <text>")} Replace the row for a state key`,
41
+ ` ${pc.cyan("import")} Fold memory.md + daily notes into the store`,
42
+ ` ${pc.cyan("render [--write]")} Render the store as memory.md`,
36
43
  "",
37
44
  ` Kinds: ${MEMORY_KINDS.join(", ")}`,
38
45
  "",
@@ -134,6 +141,24 @@ function cmdState(args: readonly string[]): void {
134
141
  console.log(` ${pc.green("●")} ${key} is now #${id}\n`);
135
142
  }
136
143
 
144
+ function cmdImport(): void {
145
+ const { inserted, superseded, skipped } = importAll();
146
+ console.log(
147
+ ` ${pc.green("●")} Imported: ${inserted} new, ${superseded} updated, ${skipped} unchanged\n`,
148
+ );
149
+ }
150
+
151
+ function cmdRender(args: readonly string[]): void {
152
+ if (!args.includes("--write")) {
153
+ console.log(renderMemoryMarkdown());
154
+ return;
155
+ }
156
+ const { path, bytes, backup } = writeRenderedMemory();
157
+ console.log(` ${pc.green("●")} Wrote ${bytes} chars to ${path}`);
158
+ if (backup) console.log(` ${pc.dim(`Previous file archived to ${backup}`)}`);
159
+ console.log();
160
+ }
161
+
137
162
  /** Route a `talon memory <command>` invocation. */
138
163
  export function runMemoryCommand(args: readonly string[]): void {
139
164
  try {
@@ -157,6 +182,12 @@ export function runMemoryCommand(args: readonly string[]): void {
157
182
  case "state":
158
183
  cmdState(args.slice(1));
159
184
  break;
185
+ case "import":
186
+ cmdImport();
187
+ break;
188
+ case "render":
189
+ cmdRender(args.slice(1));
190
+ break;
160
191
  default:
161
192
  console.log(USAGE);
162
193
  }
@@ -0,0 +1,380 @@
1
+ /**
2
+ * `memory.md` and the daily notes → typed memory rows.
3
+ *
4
+ * The store (storage/memory.ts) becomes the source of truth in PR 8, but
5
+ * the markdown files hold years of the operator's own writing and stay
6
+ * hand-editable afterwards. This module is the bridge: it seeds the store
7
+ * from the files, and — because it is idempotent by content hash — it is
8
+ * also how a hand-edit to the rendered `memory.md` folds back in
9
+ * (plan §3.1, rollout PR 5).
10
+ *
11
+ * Three rules make re-running it safe:
12
+ *
13
+ * - **The parser is PR 1's.** Sections, families and tiers come from
14
+ * prompt/memory-view.ts, so what the prompt considers one status
15
+ * family is what the store considers one keyed state row. Nothing is
16
+ * classified twice, two different ways.
17
+ * - **Content hash decides.** A live import-owned row with the same
18
+ * kind + subject + key and the same hash is left alone; a different
19
+ * hash is a hand-edit, and supersedes the row so the old text stays
20
+ * in the audit trail. Only a subject never seen before inserts.
21
+ * - **The files are never written.** Import reads; render writes. A
22
+ * failed or partial import loses nothing.
23
+ *
24
+ * Kind follows the heading's tier, because the tier already encodes the
25
+ * lifecycle: directives are intent, status snapshots are keyed state,
26
+ * historical sections are episodes, people sections are relationships,
27
+ * everything else is a durable fact.
28
+ */
29
+
30
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
31
+ import { basename, join } from "node:path";
32
+
33
+ import {
34
+ MAX_SUBJECT_LENGTH,
35
+ MAX_TEXT_LENGTH,
36
+ assertMemory,
37
+ listMemories,
38
+ memoryContentHash,
39
+ replaceStateKey,
40
+ supersedeMemory,
41
+ type MemoryKind,
42
+ type MemoryRow,
43
+ type MemorySource,
44
+ } from "../../storage/memory.js";
45
+ import { dirs, files } from "../../util/paths.js";
46
+ import {
47
+ TIER_ORDER,
48
+ classify,
49
+ collapseFamilies,
50
+ familyKey,
51
+ parseSections,
52
+ type Section,
53
+ type Tier,
54
+ } from "../prompt/memory-view.js";
55
+
56
+ // ── Tunables ────────────────────────────────────────────────────────────────
57
+
58
+ /** `source.actor` on every row this module writes — the ownership marker. */
59
+ const IMPORT_ACTOR = "import";
60
+
61
+ /** Recorded on the supersede that a changed section causes. */
62
+ const RE_IMPORT_REASON = "re-import: file changed";
63
+
64
+ /** Daily notes only: `YYYY-MM-DD.md`, which excludes `diary-*.md`. */
65
+ const DAILY_NOTE = /^(\d{4}-\d{2}-\d{2})\.md$/;
66
+
67
+ /**
68
+ * A heading that is just a date is a day's episode — which is how the
69
+ * render emits an imported daily note, so reading one back produces the
70
+ * episode it came from rather than a fresh fact.
71
+ */
72
+ const DATE_HEADING = /^\d{4}-\d{2}-\d{2}$/;
73
+
74
+ /** How many same-subject rows are scanned for the import-owned one. */
75
+ const LOOKUP_LIMIT = 200;
76
+
77
+ /** Room left for a ` (n/N)` chunk or ` #n` duplicate suffix on a subject. */
78
+ const SUBJECT_SUFFIX_ROOM = 16;
79
+
80
+ /** Cap on a generated state key, inside the store's own 100-char limit. */
81
+ const MAX_SLUG_LENGTH = 90;
82
+
83
+ /**
84
+ * The lifecycle each heading tier maps to. `active` and `general` are
85
+ * durable knowledge with no special lifecycle, so both land as facts.
86
+ */
87
+ const KIND_BY_TIER: Readonly<Record<Tier, MemoryKind>> = {
88
+ directive: "directive",
89
+ people: "relationship",
90
+ active: "fact",
91
+ general: "fact",
92
+ status: "state",
93
+ historical: "episode",
94
+ };
95
+
96
+ // ── Types ───────────────────────────────────────────────────────────────────
97
+
98
+ /** What one import run did, per row. */
99
+ export type ImportCounts = {
100
+ inserted: number;
101
+ superseded: number;
102
+ skipped: number;
103
+ };
104
+
105
+ /** One row an import wants to land — the unit of the idempotency check. */
106
+ type Claim = {
107
+ kind: MemoryKind;
108
+ subject: string;
109
+ key?: string;
110
+ text: string;
111
+ source: MemorySource;
112
+ };
113
+
114
+ const emptyCounts = (): ImportCounts => ({
115
+ inserted: 0,
116
+ superseded: 0,
117
+ skipped: 0,
118
+ });
119
+
120
+ function addCounts(into: ImportCounts, from: ImportCounts): ImportCounts {
121
+ return {
122
+ inserted: into.inserted + from.inserted,
123
+ superseded: into.superseded + from.superseded,
124
+ skipped: into.skipped + from.skipped,
125
+ };
126
+ }
127
+
128
+ // ── Text shaping ────────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * A state key from a family key: `Inbox / CI Watch` → `inbox.ci-watch`.
132
+ * The slash is the family separator the operator already writes, so it
133
+ * becomes the key's dot; everything else collapses to a dash.
134
+ */
135
+ function slugKey(family: string): string {
136
+ const slug = family
137
+ .toLowerCase()
138
+ .replace(/\s*\/\s*/g, ".")
139
+ .replace(/\s+/g, "-")
140
+ .replace(/[^a-z0-9_.-]/g, "")
141
+ .replace(/-{2,}/g, "-")
142
+ .replace(/\.{2,}/g, ".")
143
+ .replace(/^[-._]+|[-._]+$/g, "");
144
+ return slug.slice(0, MAX_SLUG_LENGTH) || "section";
145
+ }
146
+
147
+ /**
148
+ * Demote `## ` headings inside a claim's body to `### `.
149
+ *
150
+ * A daily note is a whole document and routinely carries its own `## `
151
+ * headings. Stored verbatim, the render would emit them at the top level
152
+ * and the next import would read one row back as several sections — and
153
+ * an embedded `## Active Investigations` would collide with the real
154
+ * section of that name and supersede it. A claim's body therefore never
155
+ * holds a top-level heading. Running this twice changes nothing, which is
156
+ * what keeps the round trip a fixed point.
157
+ */
158
+ function demoteHeadings(body: string): string {
159
+ return body.replace(/^##(?=\s)/gm, "###");
160
+ }
161
+
162
+ /** Blank-line-separated paragraphs, trimmed and normalized. */
163
+ function paragraphsOf(text: string): string[] {
164
+ return demoteHeadings(text)
165
+ .split(/\n\s*\n/)
166
+ .map((para) => para.trim())
167
+ .filter(Boolean);
168
+ }
169
+
170
+ /** A single paragraph bigger than the cap — the one place text is lost. */
171
+ function hardCut(para: string): string {
172
+ return `${para.slice(0, MAX_TEXT_LENGTH - 1).trimEnd()}…`;
173
+ }
174
+
175
+ /**
176
+ * Pack paragraphs into chunks of at most `MAX_TEXT_LENGTH`. Splitting on
177
+ * paragraph boundaries keeps every chunk readable on its own — a claim
178
+ * cut mid-sentence is worse than no claim. Rejoining the chunks with a
179
+ * blank line reproduces the input exactly, which is what makes
180
+ * import → render → import a fixed point.
181
+ */
182
+ function chunkParagraphs(paras: readonly string[]): string[] {
183
+ const chunks: string[] = [];
184
+ let current = "";
185
+ for (const para of paras) {
186
+ const piece = para.length > MAX_TEXT_LENGTH ? hardCut(para) : para;
187
+ const joined = current ? `${current}\n\n${piece}` : piece;
188
+ if (joined.length <= MAX_TEXT_LENGTH) {
189
+ current = joined;
190
+ continue;
191
+ }
192
+ if (current) chunks.push(current);
193
+ current = piece;
194
+ }
195
+ if (current) chunks.push(current);
196
+ return chunks;
197
+ }
198
+
199
+ // ── Claims ──────────────────────────────────────────────────────────────────
200
+
201
+ /** Clamp a subject, leaving room for the suffixes appended below. */
202
+ function clampSubject(subject: string): string {
203
+ return subject.slice(0, MAX_SUBJECT_LENGTH - SUBJECT_SUFFIX_ROOM).trim();
204
+ }
205
+
206
+ /**
207
+ * Two sections can share a heading. Without a disambiguator the second
208
+ * would supersede the first on every run and vice versa, so occurrence
209
+ * `n > 1` gets a ` #n` marker — deliberately not a parenthetical, which
210
+ * `familyKey` would strip straight back off on the next import.
211
+ */
212
+ function disambiguate(
213
+ seen: Map<string, number>,
214
+ kind: MemoryKind,
215
+ subject: string,
216
+ ): string {
217
+ const slot = `${kind}|${subject}`;
218
+ const occurrence = (seen.get(slot) ?? 0) + 1;
219
+ seen.set(slot, occurrence);
220
+ return occurrence === 1 ? subject : `${subject} #${occurrence}`;
221
+ }
222
+
223
+ /** Spread one section's text across as many rows as the cap needs. */
224
+ function claimsFrom(
225
+ base: { kind: MemoryKind; subject: string; key?: string },
226
+ paras: readonly string[],
227
+ source: MemorySource,
228
+ ): Claim[] {
229
+ const chunks = chunkParagraphs(paras);
230
+ const many = chunks.length > 1;
231
+ return chunks.map((text, index) => {
232
+ const nth = index + 1;
233
+ const subject = many
234
+ ? `${base.subject} (${nth}/${chunks.length})`
235
+ : base.subject;
236
+ const key =
237
+ base.key !== undefined && many && nth > 1
238
+ ? `${base.key}.${nth}`
239
+ : base.key;
240
+ return {
241
+ kind: base.kind,
242
+ subject,
243
+ text,
244
+ source,
245
+ ...(key !== undefined ? { key } : {}),
246
+ };
247
+ });
248
+ }
249
+
250
+ /**
251
+ * One `## ` section → its rows. A `state` section's subject is its key:
252
+ * the render emits keyed rows under their key, so anchoring both on the
253
+ * slug is what keeps import → render → import a no-op.
254
+ */
255
+ function sectionClaims(
256
+ section: Section,
257
+ source: MemorySource,
258
+ seen: Map<string, number>,
259
+ ): Claim[] {
260
+ const dated = DATE_HEADING.test(section.title);
261
+ const tier = TIER_ORDER[classify(section.title)] ?? "general";
262
+ const kind = dated ? "episode" : KIND_BY_TIER[tier];
263
+ const family = familyKey(section.title) || section.title;
264
+ // People sections are per-person and a dated section is one day, so the
265
+ // heading itself is the subject; everything else is keyed on the family
266
+ // so a family's snapshots line up on one row.
267
+ const named = dated || tier === "people" ? section.title : family;
268
+ const key = kind === "state" ? slugKey(family) : undefined;
269
+ const subject = disambiguate(
270
+ seen,
271
+ kind,
272
+ clampSubject(key ?? named) || "untitled",
273
+ );
274
+ const newline = section.body.indexOf("\n");
275
+ const paras = paragraphsOf(
276
+ newline === -1 ? "" : section.body.slice(newline + 1),
277
+ );
278
+ if (paras.length === 0) return [];
279
+ const anchor = key === undefined ? { kind, subject } : { kind, subject, key };
280
+ return claimsFrom(anchor, paras, source);
281
+ }
282
+
283
+ // ── Landing ─────────────────────────────────────────────────────────────────
284
+
285
+ /** The live row this claim owns, if it has landed before. */
286
+ function liveImportRow(claim: Claim): MemoryRow | undefined {
287
+ return listMemories({
288
+ kind: claim.kind,
289
+ subject: claim.subject,
290
+ limit: LOOKUP_LIMIT,
291
+ }).find((row) => row.source.actor === IMPORT_ACTOR && row.key === claim.key);
292
+ }
293
+
294
+ /**
295
+ * Land one claim: skip an unchanged row, supersede a changed one, insert
296
+ * a new one. Keyed state goes in through `replaceStateKey`, so the newest
297
+ * snapshot of a family is the single live row for its key.
298
+ */
299
+ function landClaim(claim: Claim): keyof ImportCounts {
300
+ const hash = memoryContentHash(
301
+ claim.kind,
302
+ claim.subject,
303
+ claim.key,
304
+ claim.text,
305
+ );
306
+ const existing = liveImportRow(claim);
307
+ if (existing?.contentHash === hash) return "skipped";
308
+ if (existing) {
309
+ supersedeMemory(existing.id, claim.text, RE_IMPORT_REASON);
310
+ return "superseded";
311
+ }
312
+ if (claim.kind === "state" && claim.key !== undefined) {
313
+ replaceStateKey(claim.key, claim.text, claim.source, {
314
+ subject: claim.subject,
315
+ trust: "operator",
316
+ });
317
+ } else {
318
+ assertMemory({
319
+ kind: claim.kind,
320
+ subject: claim.subject,
321
+ text: claim.text,
322
+ source: claim.source,
323
+ trust: "operator",
324
+ });
325
+ }
326
+ return "inserted";
327
+ }
328
+
329
+ function landAll(claims: Iterable<Claim>): ImportCounts {
330
+ const counts = emptyCounts();
331
+ for (const claim of claims) counts[landClaim(claim)] += 1;
332
+ return counts;
333
+ }
334
+
335
+ // ── Public API ──────────────────────────────────────────────────────────────
336
+
337
+ /**
338
+ * Import `memory.md`: one row per `## ` section, families collapsed
339
+ * first so only a status family's newest snapshot becomes its live state
340
+ * row. A missing file is not an error — a fresh install has none.
341
+ */
342
+ export function importMemoryFile(path: string = files.memory): ImportCounts {
343
+ if (!existsSync(path)) return emptyCounts();
344
+ const content = readFileSync(path, "utf8");
345
+ const source: MemorySource = { actor: IMPORT_ACTOR, chat: basename(path) };
346
+ const { kept } = collapseFamilies(parseSections(content).sections);
347
+ const seen = new Map<string, number>();
348
+ return landAll(
349
+ kept.flatMap((section) => sectionClaims(section, source, seen)),
350
+ );
351
+ }
352
+
353
+ /**
354
+ * Import the daily notes: one `episode` per `YYYY-MM-DD.md`, subject the
355
+ * date. `diary-*.md` is the soul's first-person writing — a reflection,
356
+ * never a fact source (plan §3.1) — so the filename pattern excludes it.
357
+ */
358
+ export function importDailyNotes(dir: string = dirs.dailyMemory): ImportCounts {
359
+ if (!existsSync(dir)) return emptyCounts();
360
+ let counts = emptyCounts();
361
+ for (const name of readdirSync(dir).sort()) {
362
+ const date = DAILY_NOTE.exec(name)?.[1];
363
+ if (!date) continue;
364
+ const paras = paragraphsOf(readFileSync(join(dir, name), "utf8"));
365
+ if (paras.length === 0) continue;
366
+ const source: MemorySource = { actor: IMPORT_ACTOR, chat: name };
367
+ const claims = claimsFrom(
368
+ { kind: "episode", subject: date },
369
+ paras,
370
+ source,
371
+ );
372
+ counts = addCounts(counts, landAll(claims));
373
+ }
374
+ return counts;
375
+ }
376
+
377
+ /** Both sources, one set of counts — what `talon memory import` runs. */
378
+ export function importAll(): ImportCounts {
379
+ return addCounts(importMemoryFile(), importDailyNotes());
380
+ }
@@ -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
+ }
@@ -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.
@@ -12,12 +12,14 @@ import type {
12
12
  DeviceLocation,
13
13
  LogEntry,
14
14
  LogLevel,
15
+ MemoryWhyWire,
15
16
  ModelOption,
16
17
  PluginItem,
17
18
  SearchResult,
18
19
  SkillItem,
19
20
  ToggleResult,
20
21
  } from "../protocol.js";
22
+ import type { MemoryListQuery, MemoryListResult } from "../memory.js";
21
23
  import type { ConfigSnapshot } from "../settings.js";
22
24
 
23
25
  /** Optional attachment references carried alongside a sent message. */
@@ -48,6 +50,13 @@ export type BridgeServerHandlers = {
48
50
  ): ClientMessage[];
49
51
  /** Full-text search across chats (or one chat when `chatId` is given). */
50
52
  search(query: string, chatId?: string): SearchResult[];
53
+ /**
54
+ * Live memory rows — a full-text search when `q` is given, else the
55
+ * ranked listing. Read-only: the bridge exposes no memory writes.
56
+ */
57
+ listMemory(query: MemoryListQuery): MemoryListResult;
58
+ /** One memory row plus its audit trail, or null when no such id. */
59
+ memoryWhy(id: number): MemoryWhyWire | null;
51
60
  /** Fire-and-forget: streams its results back through `broadcast`. */
52
61
  send(id: string, text: string, opts?: SendOptions): void;
53
62
  /**
@@ -7,6 +7,7 @@ import type { RouteHost } from "./host.js";
7
7
  import type { BridgeRoutes } from "./table.js";
8
8
  import { preAuthRoutes } from "./pre-auth.js";
9
9
  import { chatRoutes } from "./chats.js";
10
+ import { memoryRoutes } from "./memory.js";
10
11
  import { modelRoutes } from "./models.js";
11
12
  import { daemonRoutes } from "./daemon.js";
12
13
  import { meshRoutes } from "./mesh.js";
@@ -15,6 +16,7 @@ export function buildRoutes(host: RouteHost): BridgeRoutes {
15
16
  return {
16
17
  ...preAuthRoutes(host),
17
18
  ...chatRoutes(host),
19
+ ...memoryRoutes(host),
18
20
  ...modelRoutes(host),
19
21
  ...daemonRoutes(host),
20
22
  ...meshRoutes(host),
@@ -0,0 +1,34 @@
1
+ import type { RouteHost } from "./host.js";
2
+ import type { BridgeRoutes } from "./table.js";
3
+ import { asPositiveInt } from "./params.js";
4
+
5
+ export function memoryRoutes(
6
+ host: RouteHost,
7
+ ): Pick<BridgeRoutes, "GET /memory" | "GET /memory/why"> {
8
+ const { json, handlers: h } = host;
9
+ return {
10
+ // ── Memory (read-only) ─────────────────────────────────────────────
11
+
12
+ // `q` turns the listing into a search; `kind` narrows either. A bad
13
+ // kind is a 400 naming the valid ones, not an empty list — a typo
14
+ // must not look like an empty memory.
15
+ "GET /memory": ({ res, url }) => {
16
+ const result = h.listMemory({
17
+ q: url.searchParams.get("q") ?? undefined,
18
+ kind: url.searchParams.get("kind") ?? undefined,
19
+ limit: asPositiveInt(url.searchParams.get("limit")),
20
+ });
21
+ return result.ok
22
+ ? json(res, 200, { rows: result.rows })
23
+ : json(res, 400, { ok: false, error: result.error });
24
+ },
25
+ "GET /memory/why": ({ res, url }) => {
26
+ const raw = url.searchParams.get("id") ?? "";
27
+ const id = asPositiveInt(raw);
28
+ const why = id === undefined ? null : h.memoryWhy(id);
29
+ return why
30
+ ? json(res, 200, why)
31
+ : json(res, 404, { ok: false, error: `No memory with id ${raw}` });
32
+ },
33
+ };
34
+ }
@@ -47,6 +47,13 @@ export const BRIDGE_ROUTE_AUTH = {
47
47
  "POST /queue": "bearer",
48
48
  "GET /history": "bearer",
49
49
  "GET /search": "bearer",
50
+
51
+ // Memory — read-only. The typed memory store is readable over the
52
+ // bridge but never writable from it: asserting and dropping stay with
53
+ // the daemon's own write path.
54
+ "GET /memory": "bearer",
55
+ "GET /memory/why": "bearer",
56
+
50
57
  "POST /send": "bearer",
51
58
  "POST /upload": "bearer",
52
59
  "GET /media": "bearer",
@@ -44,6 +44,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
44
44
  description: "Environment and native-module health",
45
45
  },
46
46
  { command: "dream", description: "Force memory consolidation" },
47
+ {
48
+ command: "memory",
49
+ description: "What Talon remembers — list, search, why <id>",
50
+ },
47
51
  { command: "plugins", description: "List loaded plugins" },
48
52
  { command: "help", description: "All commands and features" },
49
53
  ];
@@ -5,6 +5,7 @@
5
5
  * - `definitions` — the TELEGRAM_COMMANDS menu (single source of truth)
6
6
  * - `state` — shared admin-id holder + admin guard
7
7
  * - `info` — /start /help /ping /plugins
8
+ * - `memory` — /memory (read-only view of the typed memory store)
8
9
  * - `session` — /reset /status
9
10
  * - `settings` — /model /effort /pulse /settings
10
11
  * - `admin` — /admin /metrics /doctor /dream /soul /restart /update
@@ -19,6 +20,7 @@ import type { Bot } from "grammy";
19
20
  import type { TalonConfig } from "../../../core/config/index.js";
20
21
  import type { Backend } from "../../../core/agent-runtime/capabilities.js";
21
22
  import { registerInfoCommands } from "./info.js";
23
+ import { registerMemoryCommand } from "./memory.js";
22
24
  import { registerSessionCommands } from "./session.js";
23
25
  import { registerSettingsCommands } from "./settings.js";
24
26
  import { registerAdminCommands } from "./admin.js";
@@ -35,6 +37,7 @@ export function registerCommands(
35
37
  ): void {
36
38
  const deps = { config, gateway };
37
39
  registerInfoCommands(bot);
40
+ registerMemoryCommand(bot);
38
41
  registerSessionCommands(bot, deps);
39
42
  registerSettingsCommands(bot, deps);
40
43
  registerWhatsAppPairingCommand(bot);
@@ -50,6 +50,9 @@ export function registerInfoCommands(bot: Bot): void {
50
50
  " /metrics -- aggregate performance metrics (admin)",
51
51
  " /doctor -- environment and native-module health (admin)",
52
52
  " /dream -- force memory consolidation now",
53
+ // Entity-escaped: this whole message is sent with parse_mode HTML,
54
+ // so a literal <id> would be read as a tag and 400 the reply.
55
+ " /memory -- what Talon remembers; /memory why &lt;id&gt; for provenance",
53
56
  " /ping -- health check with latency",
54
57
  " /mesh -- ping and list companion mesh devices",
55
58
  " /reset -- clear session and start fresh",
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `/memory` — a read-only window on the typed memory store.
3
+ *
4
+ * Four shapes, all reads: the ranked listing, a full-text search, one
5
+ * row's provenance (`why <id>`) and a per-kind listing. Nothing here
6
+ * writes: asserting, superseding and dropping are the write path's job
7
+ * (rollout PR 6), so the operator can always ask what Talon remembers
8
+ * without the answer being able to change it.
9
+ *
10
+ * Gated exactly like `/status` — not at all. The store holds the
11
+ * operator's own memory, and a read of it is the least privileged thing
12
+ * a chat can do.
13
+ *
14
+ * Every line is model- or user-authored text reaching an HTML-parsed
15
+ * send, so it goes through `escapeHtml` before it is joined; the reply
16
+ * is chunked because a listing of 15 rows can outgrow Telegram's
17
+ * 4096-char cap on its own.
18
+ */
19
+
20
+ import type { Bot, Context } from "grammy";
21
+ import { escapeHtml } from "../formatting.js";
22
+ import { replyHtmlChunked } from "../admin/chunked-reply.js";
23
+ import {
24
+ formatMemory,
25
+ getMemory,
26
+ isMemoryKind,
27
+ listMemories,
28
+ memoryHistory,
29
+ searchMemories,
30
+ MEMORY_KINDS,
31
+ type MemoryRow,
32
+ } from "../../../storage/memory.js";
33
+
34
+ /** Rows per reply — a chat listing is a glance, not an export. */
35
+ const LIST_LIMIT = 15;
36
+
37
+ export function registerMemoryCommand(bot: Bot): void {
38
+ bot.command("memory", async (ctx: Context) => {
39
+ const arg = (ctx.match ?? "").toString().trim();
40
+ await replyHtmlChunked(ctx, renderMemory(arg));
41
+ });
42
+ }
43
+
44
+ /** Route the argument to one of the four reads. Returns ready HTML. */
45
+ function renderMemory(arg: string): string {
46
+ const why = /^why\b\s*(.*)$/is.exec(arg);
47
+ if (why) return renderWhy(why[1]!.trim());
48
+ const kind = /^kind\b\s*(.*)$/is.exec(arg);
49
+ if (kind) return renderKind(kind[1]!.trim());
50
+ if (!arg)
51
+ return renderRows(
52
+ listMemories({ limit: LIST_LIMIT }),
53
+ "Nothing remembered yet.",
54
+ );
55
+ return renderRows(
56
+ searchMemories(arg, { limit: LIST_LIMIT }),
57
+ `No memories matching "${arg}".`,
58
+ );
59
+ }
60
+
61
+ /** One escaped line per row, or the (escaped) empty-case sentence. */
62
+ function renderRows(rows: MemoryRow[], empty: string): string {
63
+ if (rows.length === 0) return escapeHtml(empty);
64
+ return rows.map((row) => escapeHtml(formatMemory(row))).join("\n");
65
+ }
66
+
67
+ function renderKind(kind: string): string {
68
+ if (!isMemoryKind(kind))
69
+ return escapeHtml(
70
+ `No such kind "${kind}". Valid kinds: ${MEMORY_KINDS.join(", ")}.`,
71
+ );
72
+ return renderRows(
73
+ listMemories({ kind, limit: LIST_LIMIT }),
74
+ `Nothing remembered under ${kind}.`,
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Provenance for one row: the row itself, the numbers that decide where
80
+ * it ranks, and its audit trail. Reads by id rather than by the live
81
+ * listing, so a superseded or dropped row still explains itself.
82
+ */
83
+ function renderWhy(raw: string): string {
84
+ const id = Number(raw);
85
+ if (!raw || !Number.isInteger(id))
86
+ return escapeHtml(`No memory with id ${raw || "(none given)"}.`);
87
+ const row = getMemory(id);
88
+ if (!row) return escapeHtml(`No memory with id ${id}.`);
89
+ const lines = [
90
+ escapeHtml(formatMemory(row)),
91
+ "",
92
+ escapeHtml(
93
+ `trust ${row.trust} · confidence ${row.confidence} · hits ${row.hitCount} · salience ${row.salience}`,
94
+ ),
95
+ escapeHtml(
96
+ `created ${isoTime(row.createdAt)} · last seen ${isoTime(row.lastSeenAt)}`,
97
+ ),
98
+ ];
99
+ const history = memoryHistory(row.id);
100
+ if (history.length > 0) {
101
+ lines.push("", "<b>History</b>");
102
+ for (const entry of history) {
103
+ const reason = entry.reason ? ` — ${entry.reason}` : "";
104
+ lines.push(escapeHtml(`${isoTime(entry.at)} ${entry.op}${reason}`));
105
+ }
106
+ }
107
+ return lines.join("\n");
108
+ }
109
+
110
+ function isoTime(ms: number): string {
111
+ return new Date(ms).toISOString();
112
+ }
@@ -440,6 +440,14 @@ export function touchMemory(id: number): void {
440
440
 
441
441
  // ── Reads ───────────────────────────────────────────────────────────────────
442
442
 
443
+ /**
444
+ * The idempotency key a re-import compares against: sha256 of
445
+ * `kind|subject|key|text`. Re-exported from the repository so
446
+ * core/memory/import.ts can decide skip-vs-supersede without reaching
447
+ * past the store (plan §3.1).
448
+ */
449
+ export const memoryContentHash = repo.contentHash;
450
+
443
451
  /** Any row by id — including superseded and dropped ones. */
444
452
  export function getMemory(id: number): MemoryRow | undefined {
445
453
  return repo.get(id);