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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "4.4.0",
3
+ "version": "4.6.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",
@@ -0,0 +1,7 @@
1
+ ## Persistent Memory
2
+
3
+ The following is your memory store's core view — the durable claims that matter most: pinned items, standing directives, who you are talking to, your highest-salience facts, and current status. It is selected and budgeted, not a whole file, so anything not here is not gone. The rest of the store is one query away (`talon memory list`, or `/memory` in chat), and the full rendered projection is at ~/.talon/workspace/memory/memory.md.
4
+
5
+ Durable facts, not live status. Reference it naturally; the Memory and Recall policy in this prompt governs how you add to it and keep it current.
6
+
7
+ {{content}}
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,158 @@
1
+ /**
2
+ * The **core view** — the store's contribution to the static system
3
+ * prompt (docs/memory-persona-plan.md §3.4).
4
+ *
5
+ * Retrieval has two tiers, and the boundary between them is a
6
+ * prompt-cache invariant:
7
+ *
8
+ * - **Core view** (this module) — pinned directives, the relationship
9
+ * layer and the top durable facts plus fresh `state`, computed
10
+ * **once per session build** and living in `staticText`. Budgeted,
11
+ * never truncated mid-section.
12
+ * - **Turn retrieval** (PR 8) — keyed to the incoming message and
13
+ * injected into the *user turn*, never into `prepareSystemPrompt()`.
14
+ *
15
+ * Because `prepareSystemPrompt` freezes the assembled prompt per
16
+ * `(chatId, sessionEpoch)`, a fact learned at turn 3 cannot appear in
17
+ * that session's core view. That is the design, not a bug: reaching for
18
+ * `notifyPromptInputsChanged()` to "fix" it would force a full-prompt
19
+ * cache write across every live session on every learned fact (plan
20
+ * §3.6). Nothing here may ever invalidate a snapshot.
21
+ *
22
+ * The store is consulted exactly once per build — one `listMemories`
23
+ * call, ranked and filtered in memory — so the cost of the core view is
24
+ * one query per session, not one per kind and not one per turn.
25
+ */
26
+
27
+ import { listMemories, type MemoryRow } from "../../storage/memory.js";
28
+ import { renderMemoryMarkdown } from "./render.js";
29
+
30
+ // ── Tunables ────────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Char budget for the whole core view (~2 k tokens). Deliberately well
34
+ * under the 12 k file-injection cap: this block is paid for by every
35
+ * session on every frontend, and the rest of the store is a `talon
36
+ * memory list` away.
37
+ */
38
+ export const CORE_VIEW_MAX_CHARS = 8_000;
39
+
40
+ /**
41
+ * A `state` row older than this is stale and left out — a status
42
+ * snapshot nobody has touched in a week is noise in a prompt, and the
43
+ * heartbeat rewrites the live ones far more often than that. Pinning
44
+ * overrides it, which is what pinning is for.
45
+ */
46
+ const STATE_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
47
+
48
+ /**
49
+ * Rows pulled from the store before ranking. Generous: the budget, not
50
+ * this number, is what decides how much reaches the prompt.
51
+ */
52
+ const CORE_VIEW_ROW_LIMIT = 500;
53
+
54
+ /**
55
+ * Kinds eligible for the core view, most durable first. `episode` and
56
+ * `reflection` are absent on purpose (plan §3.1): episodes decay fast
57
+ * and are a retrieval-only source, and the diary is the persona layer
58
+ * and never a fact source. Neither enters the core view — not even
59
+ * pinned, since a pin must not promote a kind past the tier boundary.
60
+ */
61
+ const CORE_KINDS: readonly MemoryRow["kind"][] = [
62
+ "directive",
63
+ "relationship",
64
+ "fact",
65
+ ];
66
+
67
+ // ── Selection ───────────────────────────────────────────────────────────────
68
+
69
+ /** Salience first, then recency; id breaks the tie so the order is stable. */
70
+ function byRank(a: MemoryRow, b: MemoryRow): number {
71
+ return b.salience - a.salience || b.lastSeenAt - a.lastSeenAt || a.id - b.id;
72
+ }
73
+
74
+ function eligible(row: MemoryRow, now: number): boolean {
75
+ if (row.kind === "state")
76
+ return row.pinned || now - row.lastSeenAt <= STATE_FRESH_MS;
77
+ return CORE_KINDS.includes(row.kind);
78
+ }
79
+
80
+ /**
81
+ * Take rows until the budget is spent. A coarse pre-cut on row text
82
+ * only: the render does the exact accounting and cuts on section
83
+ * boundaries, so this exists to keep a 500-row store from being ranked
84
+ * into a document that can hold a fraction of it.
85
+ */
86
+ function withinBudget(rows: readonly MemoryRow[], budget: number): MemoryRow[] {
87
+ const taken: MemoryRow[] = [];
88
+ let spent = 0;
89
+ for (const row of rows) {
90
+ if (spent > budget) break;
91
+ spent += row.text.length;
92
+ taken.push(row);
93
+ }
94
+ return taken;
95
+ }
96
+
97
+ /** Options shared by the selection and the render. */
98
+ export type CoreViewOptions = {
99
+ /** Char budget for the rendered view. Defaults to `CORE_VIEW_MAX_CHARS`. */
100
+ budget?: number;
101
+ /** Clock for the `state` freshness window (tests pin it). */
102
+ now?: number;
103
+ };
104
+
105
+ /**
106
+ * The ordered rows behind the core view: pinned first (any eligible
107
+ * kind), then directives, then the relationship layer, then facts by
108
+ * salience, then fresh `state`.
109
+ *
110
+ * The render re-sorts with the same ranking (`render.ts` `compareRows`),
111
+ * so this order is what reaches the prompt rather than merely what is
112
+ * handed over — but the selection is asserted here, where the tiers are
113
+ * decided, and not through the rendered bytes.
114
+ */
115
+ export function selectCoreRows(opts: CoreViewOptions = {}): MemoryRow[] {
116
+ const now = opts.now ?? Date.now();
117
+ const live = listMemories({ limit: CORE_VIEW_ROW_LIMIT }).filter((row) =>
118
+ eligible(row, now),
119
+ );
120
+ const tiers: MemoryRow[][] = [
121
+ live.filter((row) => row.pinned).sort(byRank),
122
+ ...CORE_KINDS.map((kind) =>
123
+ live.filter((row) => !row.pinned && row.kind === kind).sort(byRank),
124
+ ),
125
+ live.filter((row) => !row.pinned && row.kind === "state").sort(byRank),
126
+ ];
127
+ const seen = new Set<number>();
128
+ const ordered = tiers.flat().filter((row) => {
129
+ if (seen.has(row.id)) return false;
130
+ seen.add(row.id);
131
+ return true;
132
+ });
133
+ return withinBudget(ordered, opts.budget ?? CORE_VIEW_MAX_CHARS);
134
+ }
135
+
136
+ // ── Rendering ───────────────────────────────────────────────────────────────
137
+
138
+ /** A rendered core view, with what it cost to carry. */
139
+ export type CoreView = {
140
+ text: string;
141
+ /** Rows selected into the view (0 means the store had nothing to say). */
142
+ rows: number;
143
+ /** Rendered size — the number recorded as `prompt.memory_chars`. */
144
+ chars: number;
145
+ };
146
+
147
+ /**
148
+ * Render the core view as a `memory.md`-shaped document, reusing the
149
+ * projection renderer so the store reads identically in the prompt and
150
+ * in the file. Budgeted, not truncated: sections are dropped whole from
151
+ * the bottom of the ranking and the tail is named.
152
+ */
153
+ export function renderCoreView(opts: CoreViewOptions = {}): CoreView {
154
+ const budget = opts.budget ?? CORE_VIEW_MAX_CHARS;
155
+ const rows = selectCoreRows({ ...opts, budget });
156
+ const text = renderMemoryMarkdown({ rows, budget });
157
+ return { text, rows: rows.length, chars: text.length };
158
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The one flag that gates the typed memory store's read path
3
+ * (docs/memory-persona-rollout.md, "One flag gates the whole new path").
4
+ *
5
+ * Kept in its own module because `core/prompt/assemble.ts` asks the
6
+ * question but must not reach into `storage/` to answer it: the prompt
7
+ * layer sees the flag and the core view, nothing else.
8
+ *
9
+ * Read per call, never cached: tests flip it between builds, and the
10
+ * cost is an env lookup on a path that already reads files from disk.
11
+ */
12
+
13
+ /** True when `TALON_MEMORY_STORE=1`. Off by default until PR 9 is measured. */
14
+ export function memoryStoreEnabled(): boolean {
15
+ return process.env.TALON_MEMORY_STORE === "1";
16
+ }
@@ -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
+ }