talon-agent 4.3.3 → 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.
Files changed (118) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +1 -1
  3. package/src/backend/claude-sdk/state.ts +1 -1
  4. package/src/backend/codex/init.ts +1 -1
  5. package/src/backend/codex/state.ts +1 -1
  6. package/src/backend/openai-agents/init.ts +1 -1
  7. package/src/backend/openai-agents/state.ts +1 -1
  8. package/src/backend/remote-server/factory.ts +1 -1
  9. package/src/backend/remote-server/mcp.ts +1 -1
  10. package/src/backend/remote-server/server-bindings.ts +1 -1
  11. package/src/backend/remote-server/state.ts +1 -1
  12. package/src/backend/shared/system-prompt.ts +1 -1
  13. package/src/bootstrap.ts +2 -2
  14. package/src/cli/chat.ts +1 -1
  15. package/src/cli/config.ts +2 -2
  16. package/src/cli/index.ts +8 -0
  17. package/src/cli/memory.ts +197 -0
  18. package/src/cli/setup.ts +1 -1
  19. package/src/core/agent-runtime/backend-registry.ts +1 -1
  20. package/src/core/agent-runtime/model-ref.ts +2 -2
  21. package/src/{util/config.ts → core/config/index.ts} +8 -8
  22. package/src/core/engine/backend-controller/legacy.ts +1 -1
  23. package/src/core/engine/backend-controller/pool.ts +1 -1
  24. package/src/core/engine/backend-controller/rebind.ts +1 -1
  25. package/src/core/engine/backend-controller/state.ts +1 -1
  26. package/src/core/engine/gateway-actions/plugins.ts +1 -1
  27. package/src/core/engine/model-audit.ts +1 -1
  28. package/src/core/frontend-runtime/capabilities.ts +1 -1
  29. package/src/core/memory/import.ts +380 -0
  30. package/src/core/memory/render.ts +243 -0
  31. package/src/core/models/active-model.ts +1 -1
  32. package/src/core/plugin/builtins.ts +2 -2
  33. package/src/core/plugin/manage.ts +1 -1
  34. package/src/core/prompt/memory-view.ts +8 -8
  35. package/src/frontend/discord/admin.ts +1 -1
  36. package/src/frontend/discord/callbacks/components/index.ts +1 -1
  37. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  38. package/src/frontend/discord/callbacks/components/types.ts +1 -1
  39. package/src/frontend/discord/callbacks/modals.ts +1 -1
  40. package/src/frontend/discord/commands/admin.ts +1 -1
  41. package/src/frontend/discord/commands/definitions.ts +1 -1
  42. package/src/frontend/discord/commands/info.ts +1 -1
  43. package/src/frontend/discord/commands/router.ts +1 -1
  44. package/src/frontend/discord/commands/session.ts +1 -1
  45. package/src/frontend/discord/commands/settings.ts +1 -1
  46. package/src/frontend/discord/handlers/delivery.ts +1 -1
  47. package/src/frontend/discord/handlers/messages.ts +1 -1
  48. package/src/frontend/discord/handlers/queue.ts +1 -1
  49. package/src/frontend/discord/handlers/state.ts +1 -1
  50. package/src/frontend/discord/index.ts +1 -1
  51. package/src/frontend/discord/middleware.ts +1 -1
  52. package/src/frontend/discord/runtime.ts +1 -1
  53. package/src/frontend/native/extensions.ts +4 -1
  54. package/src/frontend/native/handlers.ts +5 -0
  55. package/src/frontend/native/index.ts +1 -1
  56. package/src/frontend/native/memory.ts +112 -0
  57. package/src/frontend/native/protocol.ts +45 -0
  58. package/src/frontend/native/routes/host.ts +9 -0
  59. package/src/frontend/native/routes/index.ts +2 -0
  60. package/src/frontend/native/routes/memory.ts +34 -0
  61. package/src/frontend/native/routes/table.ts +7 -0
  62. package/src/frontend/native/runtime.ts +1 -1
  63. package/src/frontend/native/settings.ts +1 -1
  64. package/src/frontend/shared/model-commands.ts +1 -1
  65. package/src/frontend/shared/plan-usage-report.ts +1 -1
  66. package/src/frontend/shared/reasoning-levels.ts +1 -1
  67. package/src/frontend/shared/session-status.ts +1 -1
  68. package/src/frontend/teams/index.ts +1 -1
  69. package/src/frontend/teams/runtime.ts +1 -1
  70. package/src/frontend/telegram/actions/chat-info.ts +1 -1
  71. package/src/frontend/telegram/actions/coerce.ts +15 -0
  72. package/src/frontend/telegram/actions/index.ts +1 -1
  73. package/src/frontend/telegram/actions/media.ts +1 -1
  74. package/src/frontend/telegram/actions/messaging.ts +3 -4
  75. package/src/frontend/telegram/actions/moderation/chat.ts +1 -1
  76. package/src/frontend/telegram/actions/moderation/index.ts +1 -1
  77. package/src/frontend/telegram/actions/moderation/topics.ts +1 -1
  78. package/src/frontend/telegram/actions/rich-messages.ts +51 -0
  79. package/src/frontend/telegram/actions/{shared.ts → send.ts} +9 -59
  80. package/src/frontend/telegram/admin/sessions.ts +1 -1
  81. package/src/frontend/telegram/admin.ts +1 -1
  82. package/src/frontend/telegram/callbacks/auth.ts +1 -1
  83. package/src/frontend/telegram/callbacks/effort.ts +1 -1
  84. package/src/frontend/telegram/callbacks/index.ts +3 -3
  85. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  86. package/src/frontend/telegram/callbacks/model/backend.ts +1 -1
  87. package/src/frontend/telegram/callbacks/model/control.ts +1 -1
  88. package/src/frontend/telegram/callbacks/model/select.ts +1 -1
  89. package/src/frontend/telegram/callbacks/model/types.ts +1 -1
  90. package/src/frontend/telegram/callbacks/model/views.ts +1 -1
  91. package/src/frontend/telegram/callbacks/model.ts +1 -1
  92. package/src/frontend/telegram/callbacks/pulse.ts +1 -1
  93. package/src/frontend/telegram/callbacks/{shared.ts → query.ts} +3 -2
  94. package/src/frontend/telegram/callbacks/settings.ts +1 -1
  95. package/src/frontend/telegram/callbacks/whatsapp.ts +1 -1
  96. package/src/frontend/telegram/commands/admin.ts +1 -1
  97. package/src/frontend/telegram/commands/definitions.ts +4 -0
  98. package/src/frontend/telegram/commands/index.ts +4 -1
  99. package/src/frontend/telegram/commands/info.ts +3 -0
  100. package/src/frontend/telegram/commands/memory.ts +112 -0
  101. package/src/frontend/telegram/commands/state.ts +1 -1
  102. package/src/frontend/telegram/handlers/context.ts +1 -1
  103. package/src/frontend/telegram/handlers/delivery.ts +2 -2
  104. package/src/frontend/telegram/handlers/messages.ts +1 -1
  105. package/src/frontend/telegram/handlers/queue.ts +1 -1
  106. package/src/frontend/telegram/handlers/state.ts +1 -1
  107. package/src/frontend/telegram/index.ts +1 -1
  108. package/src/frontend/telegram/middleware.ts +1 -1
  109. package/src/frontend/telegram/model-menu.ts +1 -1
  110. package/src/frontend/terminal/command-registry.ts +1 -1
  111. package/src/frontend/terminal/index.ts +1 -1
  112. package/src/frontend/whatsapp/index.ts +1 -1
  113. package/src/frontend/whatsapp/runtime.ts +1 -1
  114. package/src/storage/memory.ts +513 -0
  115. package/src/storage/repositories/memory-repo.ts +308 -0
  116. package/src/storage/sql/memory.sql +95 -0
  117. package/src/storage/sql/schema.sql +81 -0
  118. package/src/storage/sql/statements.generated.ts +146 -0
@@ -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
+ }
@@ -73,7 +73,7 @@ import {
73
73
  type ModelSource,
74
74
  } from "../agent-runtime/model-ref.js";
75
75
  import type { UnifiedModelInfo } from "../types.js";
76
- import type { TalonConfig } from "../../util/config.js";
76
+ import type { TalonConfig } from "../config/index.js";
77
77
  import { logWarn } from "../../util/log.js";
78
78
 
79
79
  /**
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { log, logError, logWarn } from "../../util/log.js";
7
- import type { TalonConfig } from "../../util/config.js";
7
+ import type { TalonConfig } from "../config/index.js";
8
8
  import { registry, reloadState } from "./registry.js";
9
9
  import type { ProvisionOutcome } from "./provision.js";
10
10
  import { NATIVE_RUNTIMES, type NativePluginId } from "./native-runtimes.js";
@@ -226,7 +226,7 @@ export async function reloadPlugins(
226
226
  ): Promise<{ names: string[]; config: TalonConfig }> {
227
227
  // Validate config BEFORE tearing down existing plugins. If the config is
228
228
  // malformed the error propagates and current plugins stay intact.
229
- const { loadConfig, getFrontends } = await import("../../util/config.js");
229
+ const { loadConfig, getFrontends } = await import("../config/index.js");
230
230
  const config = loadConfig();
231
231
 
232
232
  // Derive frontends from config if not explicitly provided
@@ -8,7 +8,7 @@
8
8
  * talon.json so the surface owns its own write path.
9
9
  */
10
10
 
11
- import type { TalonConfig } from "../../util/config.js";
11
+ import type { TalonConfig } from "../config/index.js";
12
12
  import { isPathPlugin, type PluginEntry } from "./types.js";
13
13
  import {
14
14
  BUILTIN_PLUGINS,