talon-agent 4.3.3 → 4.4.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 (105) 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 +166 -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/models/active-model.ts +1 -1
  30. package/src/core/plugin/builtins.ts +2 -2
  31. package/src/core/plugin/manage.ts +1 -1
  32. package/src/frontend/discord/admin.ts +1 -1
  33. package/src/frontend/discord/callbacks/components/index.ts +1 -1
  34. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  35. package/src/frontend/discord/callbacks/components/types.ts +1 -1
  36. package/src/frontend/discord/callbacks/modals.ts +1 -1
  37. package/src/frontend/discord/commands/admin.ts +1 -1
  38. package/src/frontend/discord/commands/definitions.ts +1 -1
  39. package/src/frontend/discord/commands/info.ts +1 -1
  40. package/src/frontend/discord/commands/router.ts +1 -1
  41. package/src/frontend/discord/commands/session.ts +1 -1
  42. package/src/frontend/discord/commands/settings.ts +1 -1
  43. package/src/frontend/discord/handlers/delivery.ts +1 -1
  44. package/src/frontend/discord/handlers/messages.ts +1 -1
  45. package/src/frontend/discord/handlers/queue.ts +1 -1
  46. package/src/frontend/discord/handlers/state.ts +1 -1
  47. package/src/frontend/discord/index.ts +1 -1
  48. package/src/frontend/discord/middleware.ts +1 -1
  49. package/src/frontend/discord/runtime.ts +1 -1
  50. package/src/frontend/native/extensions.ts +4 -1
  51. package/src/frontend/native/index.ts +1 -1
  52. package/src/frontend/native/runtime.ts +1 -1
  53. package/src/frontend/native/settings.ts +1 -1
  54. package/src/frontend/shared/model-commands.ts +1 -1
  55. package/src/frontend/shared/plan-usage-report.ts +1 -1
  56. package/src/frontend/shared/reasoning-levels.ts +1 -1
  57. package/src/frontend/shared/session-status.ts +1 -1
  58. package/src/frontend/teams/index.ts +1 -1
  59. package/src/frontend/teams/runtime.ts +1 -1
  60. package/src/frontend/telegram/actions/chat-info.ts +1 -1
  61. package/src/frontend/telegram/actions/coerce.ts +15 -0
  62. package/src/frontend/telegram/actions/index.ts +1 -1
  63. package/src/frontend/telegram/actions/media.ts +1 -1
  64. package/src/frontend/telegram/actions/messaging.ts +3 -4
  65. package/src/frontend/telegram/actions/moderation/chat.ts +1 -1
  66. package/src/frontend/telegram/actions/moderation/index.ts +1 -1
  67. package/src/frontend/telegram/actions/moderation/topics.ts +1 -1
  68. package/src/frontend/telegram/actions/rich-messages.ts +51 -0
  69. package/src/frontend/telegram/actions/{shared.ts → send.ts} +9 -59
  70. package/src/frontend/telegram/admin/sessions.ts +1 -1
  71. package/src/frontend/telegram/admin.ts +1 -1
  72. package/src/frontend/telegram/callbacks/auth.ts +1 -1
  73. package/src/frontend/telegram/callbacks/effort.ts +1 -1
  74. package/src/frontend/telegram/callbacks/index.ts +3 -3
  75. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  76. package/src/frontend/telegram/callbacks/model/backend.ts +1 -1
  77. package/src/frontend/telegram/callbacks/model/control.ts +1 -1
  78. package/src/frontend/telegram/callbacks/model/select.ts +1 -1
  79. package/src/frontend/telegram/callbacks/model/types.ts +1 -1
  80. package/src/frontend/telegram/callbacks/model/views.ts +1 -1
  81. package/src/frontend/telegram/callbacks/model.ts +1 -1
  82. package/src/frontend/telegram/callbacks/pulse.ts +1 -1
  83. package/src/frontend/telegram/callbacks/{shared.ts → query.ts} +3 -2
  84. package/src/frontend/telegram/callbacks/settings.ts +1 -1
  85. package/src/frontend/telegram/callbacks/whatsapp.ts +1 -1
  86. package/src/frontend/telegram/commands/admin.ts +1 -1
  87. package/src/frontend/telegram/commands/index.ts +1 -1
  88. package/src/frontend/telegram/commands/state.ts +1 -1
  89. package/src/frontend/telegram/handlers/context.ts +1 -1
  90. package/src/frontend/telegram/handlers/delivery.ts +2 -2
  91. package/src/frontend/telegram/handlers/messages.ts +1 -1
  92. package/src/frontend/telegram/handlers/queue.ts +1 -1
  93. package/src/frontend/telegram/handlers/state.ts +1 -1
  94. package/src/frontend/telegram/index.ts +1 -1
  95. package/src/frontend/telegram/middleware.ts +1 -1
  96. package/src/frontend/telegram/model-menu.ts +1 -1
  97. package/src/frontend/terminal/command-registry.ts +1 -1
  98. package/src/frontend/terminal/index.ts +1 -1
  99. package/src/frontend/whatsapp/index.ts +1 -1
  100. package/src/frontend/whatsapp/runtime.ts +1 -1
  101. package/src/storage/memory.ts +505 -0
  102. package/src/storage/repositories/memory-repo.ts +308 -0
  103. package/src/storage/sql/memory.sql +95 -0
  104. package/src/storage/sql/schema.sql +81 -0
  105. package/src/storage/sql/statements.generated.ts +146 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Memory repository — executes the statements in sql/memory.sql against
3
+ * the `memory` / `memory_fts` / `memory_history` tables; no SQL text
4
+ * lives here. The public store (storage/memory.ts) holds the domain API,
5
+ * validation and the transactional lifecycle rules; this module owns
6
+ * statement execution, the row↔domain mapping and the content hash.
7
+ */
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { getDatabase } from "../db.js";
11
+ import { memorySql } from "../sql/statements.generated.js";
12
+
13
+ /** Kinds are lifecycles, not labels — see docs/memory-persona-plan.md §3.1. */
14
+ export type MemoryKind =
15
+ "directive" | "fact" | "state" | "episode" | "relationship" | "reflection";
16
+
17
+ /** Where a claim came from, and therefore how far it is allowed to rise. */
18
+ export type MemoryTrust = "operator" | "agent" | "user_claim" | "group_chat";
19
+
20
+ /** The mutation vocabulary recorded in memory_history. */
21
+ export type MemoryOp =
22
+ "assert" | "supersede" | "drop" | "merge" | "pin" | "unpin" | "replace_state";
23
+
24
+ /** Provenance of a claim: which frontend, chat, actor and turn asserted it. */
25
+ export type MemorySource = {
26
+ frontend?: string;
27
+ chat?: string;
28
+ actor?: string;
29
+ turn?: string;
30
+ };
31
+
32
+ /** One memory row as the domain sees it. */
33
+ export type MemoryRow = {
34
+ id: number;
35
+ kind: MemoryKind;
36
+ subject: string;
37
+ /** Present only for kind='state' — the key whose single live row this is. */
38
+ key?: string;
39
+ text: string;
40
+ source: MemorySource;
41
+ trust: MemoryTrust;
42
+ /** 0..1. */
43
+ confidence: number;
44
+ createdAt: number;
45
+ lastSeenAt: number;
46
+ hitCount: number;
47
+ salience: number;
48
+ pinned: boolean;
49
+ /** Id of the row that replaced this one; absent while live. */
50
+ supersededBy?: number;
51
+ /** When this row was dropped to the graveyard; absent while live. */
52
+ droppedAt?: number;
53
+ /** sha256 of kind|subject|key|text — the import idempotency key. */
54
+ contentHash: string;
55
+ };
56
+
57
+ /** One audit entry. */
58
+ export type MemoryHistoryRow = {
59
+ id: number;
60
+ memoryId: number;
61
+ op: MemoryOp;
62
+ beforeText?: string;
63
+ afterText?: string;
64
+ reason?: string;
65
+ at: number;
66
+ };
67
+
68
+ /** A new claim, as callers supply it (the store fills in the defaults). */
69
+ export type MemoryInput = {
70
+ kind: MemoryKind;
71
+ subject: string;
72
+ key?: string;
73
+ text: string;
74
+ source?: MemorySource;
75
+ trust: MemoryTrust;
76
+ confidence?: number;
77
+ salience?: number;
78
+ pinned?: boolean;
79
+ };
80
+
81
+ /** The fully-defaulted row the repository inserts. */
82
+ export type MemoryInsert = Omit<MemoryRow, "id" | "supersededBy" | "droppedAt">;
83
+
84
+ /** Filters for `list`; everything is optional and narrows the result. */
85
+ export type MemoryListFilter = {
86
+ kind?: MemoryKind;
87
+ subject?: string;
88
+ includeSuperseded?: boolean;
89
+ includeDropped?: boolean;
90
+ limit?: number;
91
+ };
92
+
93
+ type Row = {
94
+ id: number;
95
+ kind: string;
96
+ subject: string;
97
+ key: string | null;
98
+ text: string;
99
+ source_frontend: string | null;
100
+ source_chat: string | null;
101
+ source_actor: string | null;
102
+ source_turn: string | null;
103
+ trust: string;
104
+ confidence: number;
105
+ created_at: number;
106
+ last_seen_at: number;
107
+ hit_count: number;
108
+ salience: number;
109
+ pinned: number;
110
+ superseded_by: number | null;
111
+ dropped_at: number | null;
112
+ content_hash: string;
113
+ };
114
+
115
+ type HistoryRow = {
116
+ id: number;
117
+ memory_id: number;
118
+ op: string;
119
+ before_text: string | null;
120
+ after_text: string | null;
121
+ reason: string | null;
122
+ at: number;
123
+ };
124
+
125
+ function rowToMemory(row: Row): MemoryRow {
126
+ const source: MemorySource = {
127
+ ...(row.source_frontend !== null ? { frontend: row.source_frontend } : {}),
128
+ ...(row.source_chat !== null ? { chat: row.source_chat } : {}),
129
+ ...(row.source_actor !== null ? { actor: row.source_actor } : {}),
130
+ ...(row.source_turn !== null ? { turn: row.source_turn } : {}),
131
+ };
132
+ return {
133
+ id: Number(row.id),
134
+ kind: row.kind as MemoryKind,
135
+ subject: row.subject,
136
+ ...(row.key !== null ? { key: row.key } : {}),
137
+ text: row.text,
138
+ source,
139
+ trust: row.trust as MemoryTrust,
140
+ confidence: row.confidence,
141
+ createdAt: row.created_at,
142
+ lastSeenAt: row.last_seen_at,
143
+ hitCount: row.hit_count,
144
+ salience: row.salience,
145
+ pinned: row.pinned !== 0,
146
+ ...(row.superseded_by !== null
147
+ ? { supersededBy: Number(row.superseded_by) }
148
+ : {}),
149
+ ...(row.dropped_at !== null ? { droppedAt: row.dropped_at } : {}),
150
+ contentHash: row.content_hash,
151
+ };
152
+ }
153
+
154
+ function rowToHistory(row: HistoryRow): MemoryHistoryRow {
155
+ return {
156
+ id: Number(row.id),
157
+ memoryId: Number(row.memory_id),
158
+ op: row.op as MemoryOp,
159
+ ...(row.before_text !== null ? { beforeText: row.before_text } : {}),
160
+ ...(row.after_text !== null ? { afterText: row.after_text } : {}),
161
+ ...(row.reason !== null ? { reason: row.reason } : {}),
162
+ at: row.at,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * The idempotency key for PR 5's import: identical claims hash
168
+ * identically regardless of provenance or ranking, so re-importing the
169
+ * same `memory.md` produces no new rows.
170
+ */
171
+ export function contentHash(
172
+ kind: string,
173
+ subject: string,
174
+ key: string | undefined,
175
+ text: string,
176
+ ): string {
177
+ return createHash("sha256")
178
+ .update(`${kind}|${subject}|${key ?? ""}|${text}`)
179
+ .digest("hex");
180
+ }
181
+
182
+ /** Insert a fully-defaulted row; returns the new id. */
183
+ export function insert(row: MemoryInsert): number {
184
+ const inserted = getDatabase()
185
+ .prepare(memorySql.insert)
186
+ .get(
187
+ row.kind,
188
+ row.subject,
189
+ row.key ?? null,
190
+ row.text,
191
+ row.source.frontend ?? null,
192
+ row.source.chat ?? null,
193
+ row.source.actor ?? null,
194
+ row.source.turn ?? null,
195
+ row.trust,
196
+ row.confidence,
197
+ row.createdAt,
198
+ row.lastSeenAt,
199
+ row.hitCount,
200
+ row.salience,
201
+ row.pinned ? 1 : 0,
202
+ row.contentHash,
203
+ ) as { id: number };
204
+ return Number(inserted.id);
205
+ }
206
+
207
+ export function get(id: number): MemoryRow | undefined {
208
+ const row = getDatabase().prepare(memorySql.get).get(id) as Row | undefined;
209
+ return row ? rowToMemory(row) : undefined;
210
+ }
211
+
212
+ /** Filtered listing, pinned first then salience then recency. */
213
+ export function list(filter: MemoryListFilter = {}): MemoryRow[] {
214
+ const kind = filter.kind ?? null;
215
+ const subject = filter.subject ?? null;
216
+ const rows = getDatabase()
217
+ .prepare(memorySql.list)
218
+ .all(
219
+ kind,
220
+ kind,
221
+ subject,
222
+ subject,
223
+ filter.includeSuperseded ? 1 : 0,
224
+ filter.includeDropped ? 1 : 0,
225
+ filter.limit ?? 100,
226
+ ) as Row[];
227
+ return rows.map(rowToMemory);
228
+ }
229
+
230
+ /** The single live `state` row for a key, if there is one. */
231
+ export function liveStateByKey(key: string): MemoryRow | undefined {
232
+ const row = getDatabase().prepare(memorySql.liveStateByKey).get(key) as
233
+ Row | undefined;
234
+ return row ? rowToMemory(row) : undefined;
235
+ }
236
+
237
+ /** FTS5 search over live rows, best match first. `match` must be quoted. */
238
+ export function searchFts(
239
+ match: string,
240
+ kind: MemoryKind | undefined,
241
+ limit: number,
242
+ ): MemoryRow[] {
243
+ const k = kind ?? null;
244
+ const rows = getDatabase()
245
+ .prepare(memorySql.searchFts)
246
+ .all(match, k, k, limit) as Row[];
247
+ return rows.map(rowToMemory);
248
+ }
249
+
250
+ /** Near-duplicate candidates for a fresh assert. `match` must be quoted. */
251
+ export function similar(
252
+ match: string,
253
+ kind: MemoryKind,
254
+ subject: string,
255
+ excludeId: number,
256
+ limit: number,
257
+ ): MemoryRow[] {
258
+ const rows = getDatabase()
259
+ .prepare(memorySql.similar)
260
+ .all(match, kind, subject, excludeId, limit) as Row[];
261
+ return rows.map(rowToMemory);
262
+ }
263
+
264
+ export function setSupersededBy(id: number, newId: number): void {
265
+ getDatabase().prepare(memorySql.setSupersededBy).run(newId, id);
266
+ }
267
+
268
+ export function setDropped(id: number, at: number): void {
269
+ getDatabase().prepare(memorySql.setDropped).run(at, id);
270
+ }
271
+
272
+ export function setPinned(id: number, pinned: boolean): void {
273
+ getDatabase()
274
+ .prepare(memorySql.setPinned)
275
+ .run(pinned ? 1 : 0, id);
276
+ }
277
+
278
+ export function touch(id: number, at: number): void {
279
+ getDatabase().prepare(memorySql.touch).run(at, id);
280
+ }
281
+
282
+ /** Append one audit entry — every mutation writes exactly one. */
283
+ export function insertHistory(entry: {
284
+ memoryId: number;
285
+ op: MemoryOp;
286
+ beforeText?: string;
287
+ afterText?: string;
288
+ reason?: string;
289
+ at: number;
290
+ }): void {
291
+ getDatabase()
292
+ .prepare(memorySql.insertHistory)
293
+ .run(
294
+ entry.memoryId,
295
+ entry.op,
296
+ entry.beforeText ?? null,
297
+ entry.afterText ?? null,
298
+ entry.reason ?? null,
299
+ entry.at,
300
+ );
301
+ }
302
+
303
+ export function historyFor(id: number): MemoryHistoryRow[] {
304
+ const rows = getDatabase()
305
+ .prepare(memorySql.historyFor)
306
+ .all(id) as HistoryRow[];
307
+ return rows.map(rowToHistory);
308
+ }
@@ -0,0 +1,95 @@
1
+ -- Statements for the memory tables (see repositories/memory-repo.ts for
2
+ -- the parameter order and row↔domain mapping). "Live" everywhere means
3
+ -- superseded_by IS NULL AND dropped_at IS NULL: a row that has been
4
+ -- replaced or dropped stays in the table (stable ids, revertible
5
+ -- history) but is invisible to listings and search.
6
+ --
7
+ -- The optional filters bind the same value twice — `(? IS NULL OR
8
+ -- col = ?)` — so the statement text is constant and nothing is
9
+ -- interpolated into SQL at the call site.
10
+
11
+ -- name: insert
12
+ -- RETURNING id so the caller gets the new row id without a second
13
+ -- round trip through last_insert_rowid().
14
+ INSERT INTO memory
15
+ (kind, subject, key, text, source_frontend, source_chat, source_actor,
16
+ source_turn, trust, confidence, created_at, last_seen_at, hit_count,
17
+ salience, pinned, superseded_by, dropped_at, content_hash)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)
19
+ RETURNING id
20
+
21
+ -- name: get
22
+ SELECT id, kind, subject, key, text, source_frontend, source_chat,
23
+ source_actor, source_turn, trust, confidence, created_at,
24
+ last_seen_at, hit_count, salience, pinned, superseded_by,
25
+ dropped_at, content_hash
26
+ FROM memory WHERE id = ?
27
+
28
+ -- name: list
29
+ SELECT id, kind, subject, key, text, source_frontend, source_chat,
30
+ source_actor, source_turn, trust, confidence, created_at,
31
+ last_seen_at, hit_count, salience, pinned, superseded_by,
32
+ dropped_at, content_hash
33
+ FROM memory
34
+ WHERE (? IS NULL OR kind = ?)
35
+ AND (? IS NULL OR subject = ?)
36
+ AND (? = 1 OR superseded_by IS NULL)
37
+ AND (? = 1 OR dropped_at IS NULL)
38
+ ORDER BY pinned DESC, salience DESC, last_seen_at DESC, id DESC
39
+ LIMIT ?
40
+
41
+ -- name: liveStateByKey
42
+ SELECT id, kind, subject, key, text, source_frontend, source_chat,
43
+ source_actor, source_turn, trust, confidence, created_at,
44
+ last_seen_at, hit_count, salience, pinned, superseded_by,
45
+ dropped_at, content_hash
46
+ FROM memory
47
+ WHERE kind = 'state' AND key = ?
48
+ AND superseded_by IS NULL AND dropped_at IS NULL
49
+ ORDER BY id DESC LIMIT 1
50
+
51
+ -- name: searchFts
52
+ -- The match param must already be a valid FTS5 expression
53
+ -- (see memory.ts ftsQuery). Live rows only, best match first.
54
+ SELECT m.id, m.kind, m.subject, m.key, m.text, m.source_frontend,
55
+ m.source_chat, m.source_actor, m.source_turn, m.trust, m.confidence,
56
+ m.created_at, m.last_seen_at, m.hit_count, m.salience, m.pinned,
57
+ m.superseded_by, m.dropped_at, m.content_hash
58
+ FROM memory m JOIN memory_fts ON memory_fts.rowid = m.id
59
+ WHERE memory_fts MATCH ?
60
+ AND m.superseded_by IS NULL AND m.dropped_at IS NULL
61
+ AND (? IS NULL OR m.kind = ?)
62
+ ORDER BY bm25(memory_fts) LIMIT ?
63
+
64
+ -- name: similar
65
+ -- Near-duplicate candidates for a fresh assert: live rows of the same
66
+ -- kind + subject that match the new text, the new row itself excluded.
67
+ SELECT m.id, m.kind, m.subject, m.key, m.text, m.source_frontend,
68
+ m.source_chat, m.source_actor, m.source_turn, m.trust, m.confidence,
69
+ m.created_at, m.last_seen_at, m.hit_count, m.salience, m.pinned,
70
+ m.superseded_by, m.dropped_at, m.content_hash
71
+ FROM memory m JOIN memory_fts ON memory_fts.rowid = m.id
72
+ WHERE memory_fts MATCH ?
73
+ AND m.kind = ? AND m.subject = ? AND m.id <> ?
74
+ AND m.superseded_by IS NULL AND m.dropped_at IS NULL
75
+ ORDER BY bm25(memory_fts) LIMIT ?
76
+
77
+ -- name: setSupersededBy
78
+ UPDATE memory SET superseded_by = ? WHERE id = ?
79
+
80
+ -- name: setDropped
81
+ UPDATE memory SET dropped_at = ? WHERE id = ?
82
+
83
+ -- name: setPinned
84
+ UPDATE memory SET pinned = ? WHERE id = ?
85
+
86
+ -- name: touch
87
+ UPDATE memory SET hit_count = hit_count + 1, last_seen_at = ? WHERE id = ?
88
+
89
+ -- name: insertHistory
90
+ INSERT INTO memory_history (memory_id, op, before_text, after_text, reason, at)
91
+ VALUES (?, ?, ?, ?, ?, ?)
92
+
93
+ -- name: historyFor
94
+ SELECT id, memory_id, op, before_text, after_text, reason, at
95
+ FROM memory_history WHERE memory_id = ? ORDER BY id
@@ -52,6 +52,87 @@ CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE OF text, sender_name ON his
52
52
  VALUES (new.id, new.text, new.sender_name);
53
53
  END;
54
54
 
55
+ -- Typed memory: one row per claim, with an FTS5 index over subject +
56
+ -- text. Kinds are lifecycles, not labels (docs/memory-persona-plan.md
57
+ -- §3.1): `directive` is durable human intent, `fact` is durable and
58
+ -- supersedable, `state` is keyed (a write replaces the row for that
59
+ -- key), `episode` decays fast, `relationship` and `reflection` are the
60
+ -- persona layer. Rows are never physically deleted — `superseded_by`
61
+ -- points at the replacement and `dropped_at` is the graveyard, so ids
62
+ -- stay stable and every change is revertible.
63
+ CREATE TABLE IF NOT EXISTS memory (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ -- directive | fact | state | episode | relationship | reflection
66
+ kind TEXT NOT NULL,
67
+ -- Who or what the claim is about; the retrieval scope.
68
+ subject TEXT NOT NULL,
69
+ -- Required for kind='state' (e.g. 'heartbeat.health'), NULL otherwise.
70
+ -- One live row per key is enforced by the store's replaceStateKey,
71
+ -- which supersedes the previous row inside a transaction.
72
+ key TEXT,
73
+ text TEXT NOT NULL,
74
+ -- Provenance: which frontend/chat/actor/turn asserted this.
75
+ source_frontend TEXT,
76
+ source_chat TEXT,
77
+ source_actor TEXT,
78
+ source_turn TEXT,
79
+ -- operator | agent | user_claim | group_chat — the trust tier.
80
+ -- user_claim and group_chat can never be pinned (plan §5).
81
+ trust TEXT NOT NULL,
82
+ confidence REAL NOT NULL DEFAULT 1.0,
83
+ created_at INTEGER NOT NULL,
84
+ last_seen_at INTEGER NOT NULL,
85
+ hit_count INTEGER NOT NULL DEFAULT 0,
86
+ salience REAL NOT NULL DEFAULT 0,
87
+ pinned INTEGER NOT NULL DEFAULT 0,
88
+ -- memory.id of the row that replaced this one; NULL while live.
89
+ superseded_by INTEGER,
90
+ -- Soft delete: the graveyard, not oblivion.
91
+ dropped_at INTEGER,
92
+ -- sha256 of kind|subject|key|text — the idempotency key for import.
93
+ content_hash TEXT NOT NULL
94
+ );
95
+ CREATE INDEX IF NOT EXISTS idx_memory_kind_subject ON memory(kind, subject);
96
+ CREATE INDEX IF NOT EXISTS idx_memory_key ON memory(key) WHERE key IS NOT NULL;
97
+ CREATE INDEX IF NOT EXISTS idx_memory_salience ON memory(salience);
98
+
99
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
100
+ subject,
101
+ text,
102
+ content='memory',
103
+ content_rowid='id'
104
+ );
105
+ CREATE TRIGGER IF NOT EXISTS memory_ai AFTER INSERT ON memory BEGIN
106
+ INSERT INTO memory_fts(rowid, subject, text)
107
+ VALUES (new.id, new.subject, new.text);
108
+ END;
109
+ CREATE TRIGGER IF NOT EXISTS memory_ad AFTER DELETE ON memory BEGIN
110
+ INSERT INTO memory_fts(memory_fts, rowid, subject, text)
111
+ VALUES ('delete', old.id, old.subject, old.text);
112
+ END;
113
+ CREATE TRIGGER IF NOT EXISTS memory_au AFTER UPDATE OF subject, text ON memory BEGIN
114
+ INSERT INTO memory_fts(memory_fts, rowid, subject, text)
115
+ VALUES ('delete', old.id, old.subject, old.text);
116
+ INSERT INTO memory_fts(rowid, subject, text)
117
+ VALUES (new.id, new.subject, new.text);
118
+ END;
119
+
120
+ -- The audit log: one row per mutation, so every change is diffable and
121
+ -- revertible (`/memory diff`, `/memory undo`). Ops are the reconcile
122
+ -- vocabulary of plan §3.3 plus the store's own replace_state. A touch
123
+ -- (hit_count / last_seen_at) changes no content and is not audited.
124
+ CREATE TABLE IF NOT EXISTS memory_history (
125
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
126
+ memory_id INTEGER NOT NULL,
127
+ -- assert | supersede | drop | merge | pin | unpin | replace_state
128
+ op TEXT NOT NULL,
129
+ before_text TEXT,
130
+ after_text TEXT,
131
+ reason TEXT,
132
+ at INTEGER NOT NULL
133
+ );
134
+ CREATE INDEX IF NOT EXISTS idx_memory_history_memory ON memory_history(memory_id, id);
135
+
55
136
  -- Sessions: real columns rather than a JSON blob. The store's hot
56
137
  -- paths (recordUsage, incrementTurns, setSessionId) accumulate into
57
138
  -- individual fields per turn, and the usage counters are numeric
@@ -57,6 +57,87 @@ CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE OF text, sender_name ON his
57
57
  VALUES (new.id, new.text, new.sender_name);
58
58
  END;
59
59
 
60
+ -- Typed memory: one row per claim, with an FTS5 index over subject +
61
+ -- text. Kinds are lifecycles, not labels (docs/memory-persona-plan.md
62
+ -- §3.1): \`directive\` is durable human intent, \`fact\` is durable and
63
+ -- supersedable, \`state\` is keyed (a write replaces the row for that
64
+ -- key), \`episode\` decays fast, \`relationship\` and \`reflection\` are the
65
+ -- persona layer. Rows are never physically deleted — \`superseded_by\`
66
+ -- points at the replacement and \`dropped_at\` is the graveyard, so ids
67
+ -- stay stable and every change is revertible.
68
+ CREATE TABLE IF NOT EXISTS memory (
69
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ -- directive | fact | state | episode | relationship | reflection
71
+ kind TEXT NOT NULL,
72
+ -- Who or what the claim is about; the retrieval scope.
73
+ subject TEXT NOT NULL,
74
+ -- Required for kind='state' (e.g. 'heartbeat.health'), NULL otherwise.
75
+ -- One live row per key is enforced by the store's replaceStateKey,
76
+ -- which supersedes the previous row inside a transaction.
77
+ key TEXT,
78
+ text TEXT NOT NULL,
79
+ -- Provenance: which frontend/chat/actor/turn asserted this.
80
+ source_frontend TEXT,
81
+ source_chat TEXT,
82
+ source_actor TEXT,
83
+ source_turn TEXT,
84
+ -- operator | agent | user_claim | group_chat — the trust tier.
85
+ -- user_claim and group_chat can never be pinned (plan §5).
86
+ trust TEXT NOT NULL,
87
+ confidence REAL NOT NULL DEFAULT 1.0,
88
+ created_at INTEGER NOT NULL,
89
+ last_seen_at INTEGER NOT NULL,
90
+ hit_count INTEGER NOT NULL DEFAULT 0,
91
+ salience REAL NOT NULL DEFAULT 0,
92
+ pinned INTEGER NOT NULL DEFAULT 0,
93
+ -- memory.id of the row that replaced this one; NULL while live.
94
+ superseded_by INTEGER,
95
+ -- Soft delete: the graveyard, not oblivion.
96
+ dropped_at INTEGER,
97
+ -- sha256 of kind|subject|key|text — the idempotency key for import.
98
+ content_hash TEXT NOT NULL
99
+ );
100
+ CREATE INDEX IF NOT EXISTS idx_memory_kind_subject ON memory(kind, subject);
101
+ CREATE INDEX IF NOT EXISTS idx_memory_key ON memory(key) WHERE key IS NOT NULL;
102
+ CREATE INDEX IF NOT EXISTS idx_memory_salience ON memory(salience);
103
+
104
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
105
+ subject,
106
+ text,
107
+ content='memory',
108
+ content_rowid='id'
109
+ );
110
+ CREATE TRIGGER IF NOT EXISTS memory_ai AFTER INSERT ON memory BEGIN
111
+ INSERT INTO memory_fts(rowid, subject, text)
112
+ VALUES (new.id, new.subject, new.text);
113
+ END;
114
+ CREATE TRIGGER IF NOT EXISTS memory_ad AFTER DELETE ON memory BEGIN
115
+ INSERT INTO memory_fts(memory_fts, rowid, subject, text)
116
+ VALUES ('delete', old.id, old.subject, old.text);
117
+ END;
118
+ CREATE TRIGGER IF NOT EXISTS memory_au AFTER UPDATE OF subject, text ON memory BEGIN
119
+ INSERT INTO memory_fts(memory_fts, rowid, subject, text)
120
+ VALUES ('delete', old.id, old.subject, old.text);
121
+ INSERT INTO memory_fts(rowid, subject, text)
122
+ VALUES (new.id, new.subject, new.text);
123
+ END;
124
+
125
+ -- The audit log: one row per mutation, so every change is diffable and
126
+ -- revertible (\`/memory diff\`, \`/memory undo\`). Ops are the reconcile
127
+ -- vocabulary of plan §3.3 plus the store's own replace_state. A touch
128
+ -- (hit_count / last_seen_at) changes no content and is not audited.
129
+ CREATE TABLE IF NOT EXISTS memory_history (
130
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
131
+ memory_id INTEGER NOT NULL,
132
+ -- assert | supersede | drop | merge | pin | unpin | replace_state
133
+ op TEXT NOT NULL,
134
+ before_text TEXT,
135
+ after_text TEXT,
136
+ reason TEXT,
137
+ at INTEGER NOT NULL
138
+ );
139
+ CREATE INDEX IF NOT EXISTS idx_memory_history_memory ON memory_history(memory_id, id);
140
+
60
141
  -- Sessions: real columns rather than a JSON blob. The store's hot
61
142
  -- paths (recordUsage, incrementTurns, setSessionId) accumulate into
62
143
  -- individual fields per turn, and the usage counters are numeric
@@ -488,6 +569,71 @@ ORDER BY timestamp ASC, rowid ASC LIMIT 1`,
488
569
  countByFilePath: `SELECT COUNT(*) AS n FROM media_index WHERE file_path = ?`,
489
570
  } as const;
490
571
 
572
+ export const memorySql = {
573
+ insert: `-- RETURNING id so the caller gets the new row id without a second
574
+ -- round trip through last_insert_rowid().
575
+ INSERT INTO memory
576
+ (kind, subject, key, text, source_frontend, source_chat, source_actor,
577
+ source_turn, trust, confidence, created_at, last_seen_at, hit_count,
578
+ salience, pinned, superseded_by, dropped_at, content_hash)
579
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)
580
+ RETURNING id`,
581
+ get: `SELECT id, kind, subject, key, text, source_frontend, source_chat,
582
+ source_actor, source_turn, trust, confidence, created_at,
583
+ last_seen_at, hit_count, salience, pinned, superseded_by,
584
+ dropped_at, content_hash
585
+ FROM memory WHERE id = ?`,
586
+ list: `SELECT id, kind, subject, key, text, source_frontend, source_chat,
587
+ source_actor, source_turn, trust, confidence, created_at,
588
+ last_seen_at, hit_count, salience, pinned, superseded_by,
589
+ dropped_at, content_hash
590
+ FROM memory
591
+ WHERE (? IS NULL OR kind = ?)
592
+ AND (? IS NULL OR subject = ?)
593
+ AND (? = 1 OR superseded_by IS NULL)
594
+ AND (? = 1 OR dropped_at IS NULL)
595
+ ORDER BY pinned DESC, salience DESC, last_seen_at DESC, id DESC
596
+ LIMIT ?`,
597
+ liveStateByKey: `SELECT id, kind, subject, key, text, source_frontend, source_chat,
598
+ source_actor, source_turn, trust, confidence, created_at,
599
+ last_seen_at, hit_count, salience, pinned, superseded_by,
600
+ dropped_at, content_hash
601
+ FROM memory
602
+ WHERE kind = 'state' AND key = ?
603
+ AND superseded_by IS NULL AND dropped_at IS NULL
604
+ ORDER BY id DESC LIMIT 1`,
605
+ searchFts: `-- The match param must already be a valid FTS5 expression
606
+ -- (see memory.ts ftsQuery). Live rows only, best match first.
607
+ SELECT m.id, m.kind, m.subject, m.key, m.text, m.source_frontend,
608
+ m.source_chat, m.source_actor, m.source_turn, m.trust, m.confidence,
609
+ m.created_at, m.last_seen_at, m.hit_count, m.salience, m.pinned,
610
+ m.superseded_by, m.dropped_at, m.content_hash
611
+ FROM memory m JOIN memory_fts ON memory_fts.rowid = m.id
612
+ WHERE memory_fts MATCH ?
613
+ AND m.superseded_by IS NULL AND m.dropped_at IS NULL
614
+ AND (? IS NULL OR m.kind = ?)
615
+ ORDER BY bm25(memory_fts) LIMIT ?`,
616
+ similar: `-- Near-duplicate candidates for a fresh assert: live rows of the same
617
+ -- kind + subject that match the new text, the new row itself excluded.
618
+ SELECT m.id, m.kind, m.subject, m.key, m.text, m.source_frontend,
619
+ m.source_chat, m.source_actor, m.source_turn, m.trust, m.confidence,
620
+ m.created_at, m.last_seen_at, m.hit_count, m.salience, m.pinned,
621
+ m.superseded_by, m.dropped_at, m.content_hash
622
+ FROM memory m JOIN memory_fts ON memory_fts.rowid = m.id
623
+ WHERE memory_fts MATCH ?
624
+ AND m.kind = ? AND m.subject = ? AND m.id <> ?
625
+ AND m.superseded_by IS NULL AND m.dropped_at IS NULL
626
+ ORDER BY bm25(memory_fts) LIMIT ?`,
627
+ setSupersededBy: `UPDATE memory SET superseded_by = ? WHERE id = ?`,
628
+ setDropped: `UPDATE memory SET dropped_at = ? WHERE id = ?`,
629
+ setPinned: `UPDATE memory SET pinned = ? WHERE id = ?`,
630
+ touch: `UPDATE memory SET hit_count = hit_count + 1, last_seen_at = ? WHERE id = ?`,
631
+ insertHistory: `INSERT INTO memory_history (memory_id, op, before_text, after_text, reason, at)
632
+ VALUES (?, ?, ?, ?, ?, ?)`,
633
+ historyFor: `SELECT id, memory_id, op, before_text, after_text, reason, at
634
+ FROM memory_history WHERE memory_id = ? ORDER BY id`,
635
+ } as const;
636
+
491
637
  export const scriptsSql = {
492
638
  upsert: `INSERT OR REPLACE INTO scripts
493
639
  (id, name, description, language, script_path, created_at,