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