@audiofool/moondog 0.1.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 (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,1705 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ export const MOONDOG_MEMORY_SCHEMA_VERSION = 2;
7
+ export function resolveMoondogMemoryPath(environment = process.env) {
8
+ const configuredState = environment.MOONDOG_STATE_HOME?.trim();
9
+ if (configuredState) {
10
+ if (!path.isAbsolute(configuredState)) {
11
+ throw new TypeError("MOONDOG_STATE_HOME must be an absolute path");
12
+ }
13
+ return path.join(path.resolve(configuredState), "memory.sqlite");
14
+ }
15
+
16
+ const configuredConfig = environment.MOONDOG_CONFIG_HOME?.trim();
17
+ if (configuredConfig) {
18
+ if (!path.isAbsolute(configuredConfig)) {
19
+ throw new TypeError("MOONDOG_CONFIG_HOME must be an absolute path");
20
+ }
21
+ return path.join(path.resolve(configuredConfig), "memory.sqlite");
22
+ }
23
+
24
+ const xdgState = environment.XDG_STATE_HOME?.trim();
25
+ if (xdgState && path.isAbsolute(xdgState)) {
26
+ return path.join(path.resolve(xdgState), "moondog", "memory.sqlite");
27
+ }
28
+
29
+ return path.join(homedir(), ".local", "state", "moondog", "memory.sqlite");
30
+ }
31
+
32
+ export const defaultMoondogMemoryPath = resolveMoondogMemoryPath();
33
+
34
+ const memoryKinds = new Set(["fact", "preference", "constraint", "goal"]);
35
+ const memoryHorizons = new Set(["recent", "persistent"]);
36
+ const episodeKinds = new Set([
37
+ "dialogue",
38
+ "session_summary",
39
+ "listening_activity",
40
+ "situational_context",
41
+ "explicit_assertion",
42
+ "memory_retraction",
43
+ ]);
44
+ const episodeSourceKinds = new Set([
45
+ "turn",
46
+ "listening_event",
47
+ "taste_event",
48
+ "session",
49
+ "manual",
50
+ ]);
51
+ const promotionStates = new Set(["none", "candidate", "promoted", "rejected"]);
52
+ const reflectionTriggers = new Set([
53
+ "manual",
54
+ "schedule",
55
+ "session_end",
56
+ "import",
57
+ ]);
58
+ const reflectionActions = new Set([
59
+ "candidate_memory",
60
+ "candidate_music_profile",
61
+ "ignore",
62
+ ]);
63
+
64
+ const schema = `
65
+ PRAGMA foreign_keys = ON;
66
+ PRAGMA trusted_schema = OFF;
67
+
68
+ CREATE TABLE IF NOT EXISTS sessions (
69
+ session_id TEXT PRIMARY KEY NOT NULL,
70
+ route_key TEXT NOT NULL,
71
+ started_at TEXT NOT NULL,
72
+ ended_at TEXT,
73
+ close_reason TEXT,
74
+ status TEXT NOT NULL CHECK (status IN ('active', 'closed'))
75
+ ) STRICT;
76
+
77
+ CREATE UNIQUE INDEX IF NOT EXISTS sessions_one_active_route
78
+ ON sessions(route_key)
79
+ WHERE status = 'active';
80
+
81
+ CREATE TABLE IF NOT EXISTS turns (
82
+ turn_id TEXT PRIMARY KEY NOT NULL,
83
+ session_id TEXT NOT NULL,
84
+ position INTEGER NOT NULL CHECK (position >= 1),
85
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
86
+ content TEXT NOT NULL,
87
+ created_at TEXT NOT NULL,
88
+ UNIQUE (session_id, position),
89
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id)
90
+ ) STRICT;
91
+
92
+ CREATE INDEX IF NOT EXISTS turns_session_position
93
+ ON turns(session_id, position);
94
+
95
+ CREATE TABLE IF NOT EXISTS memory_claims (
96
+ memory_id TEXT PRIMARY KEY NOT NULL,
97
+ kind TEXT NOT NULL CHECK (kind IN ('fact', 'preference', 'constraint', 'goal')),
98
+ horizon TEXT NOT NULL CHECK (horizon IN ('recent', 'persistent')),
99
+ content TEXT NOT NULL,
100
+ normalized_content TEXT NOT NULL,
101
+ status TEXT NOT NULL CHECK (status IN ('active', 'forgotten', 'superseded')),
102
+ origin TEXT NOT NULL,
103
+ source_session_id TEXT,
104
+ created_at TEXT NOT NULL,
105
+ updated_at TEXT NOT NULL,
106
+ supersedes_memory_id TEXT,
107
+ recall_count INTEGER NOT NULL DEFAULT 0 CHECK (recall_count >= 0),
108
+ last_recalled_at TEXT,
109
+ FOREIGN KEY (source_session_id) REFERENCES sessions(session_id),
110
+ FOREIGN KEY (supersedes_memory_id) REFERENCES memory_claims(memory_id)
111
+ ) STRICT;
112
+
113
+ CREATE INDEX IF NOT EXISTS memory_claims_active_recent
114
+ ON memory_claims(status, updated_at DESC);
115
+
116
+ CREATE UNIQUE INDEX IF NOT EXISTS memory_claims_one_active_text
117
+ ON memory_claims(normalized_content)
118
+ WHERE status = 'active';
119
+
120
+ CREATE TABLE IF NOT EXISTS episodes (
121
+ episode_id TEXT PRIMARY KEY NOT NULL,
122
+ session_id TEXT,
123
+ kind TEXT NOT NULL CHECK (kind IN (
124
+ 'dialogue',
125
+ 'session_summary',
126
+ 'listening_activity',
127
+ 'situational_context',
128
+ 'explicit_assertion',
129
+ 'memory_retraction'
130
+ )),
131
+ source_kind TEXT NOT NULL CHECK (source_kind IN (
132
+ 'turn',
133
+ 'listening_event',
134
+ 'taste_event',
135
+ 'session',
136
+ 'manual'
137
+ )),
138
+ source_id TEXT,
139
+ summary TEXT NOT NULL,
140
+ details_json TEXT,
141
+ occurred_at TEXT NOT NULL,
142
+ expires_at TEXT,
143
+ importance INTEGER NOT NULL DEFAULT 1 CHECK (importance BETWEEN 0 AND 3),
144
+ promotion_state TEXT NOT NULL CHECK (
145
+ promotion_state IN ('none', 'candidate', 'promoted', 'rejected')
146
+ ),
147
+ status TEXT NOT NULL CHECK (status IN ('active', 'retracted')),
148
+ created_at TEXT NOT NULL,
149
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id)
150
+ ) STRICT;
151
+
152
+ CREATE INDEX IF NOT EXISTS episodes_recent_active
153
+ ON episodes(status, occurred_at DESC);
154
+
155
+ CREATE TABLE IF NOT EXISTS claim_episode_refs (
156
+ memory_id TEXT NOT NULL,
157
+ episode_id TEXT NOT NULL,
158
+ relation TEXT NOT NULL CHECK (relation IN ('supports', 'contradicts')),
159
+ created_at TEXT NOT NULL,
160
+ PRIMARY KEY (memory_id, episode_id, relation),
161
+ FOREIGN KEY (memory_id) REFERENCES memory_claims(memory_id),
162
+ FOREIGN KEY (episode_id) REFERENCES episodes(episode_id)
163
+ ) STRICT;
164
+
165
+ CREATE TABLE IF NOT EXISTS reflection_runs (
166
+ run_id TEXT PRIMARY KEY NOT NULL,
167
+ trigger_kind TEXT NOT NULL CHECK (
168
+ trigger_kind IN ('manual', 'schedule', 'session_end', 'import')
169
+ ),
170
+ worker_version TEXT NOT NULL,
171
+ provider TEXT NOT NULL,
172
+ model TEXT NOT NULL,
173
+ status TEXT NOT NULL CHECK (
174
+ status IN ('started', 'completed', 'dry_run', 'failed')
175
+ ),
176
+ episode_ids_json TEXT NOT NULL,
177
+ proposals_json TEXT,
178
+ counts_json TEXT,
179
+ error_code TEXT,
180
+ lease_token TEXT NOT NULL,
181
+ lease_expires_at TEXT NOT NULL,
182
+ started_at TEXT NOT NULL,
183
+ completed_at TEXT
184
+ ) STRICT;
185
+
186
+ CREATE UNIQUE INDEX IF NOT EXISTS reflection_runs_one_active
187
+ ON reflection_runs(status)
188
+ WHERE status = 'started';
189
+
190
+ CREATE INDEX IF NOT EXISTS reflection_runs_recent
191
+ ON reflection_runs(started_at DESC);
192
+ `;
193
+
194
+ let databaseConstructorPromise;
195
+
196
+ async function databaseConstructor() {
197
+ databaseConstructorPromise ??= import("node:sqlite").then(
198
+ ({ DatabaseSync }) => DatabaseSync,
199
+ );
200
+ return databaseConstructorPromise;
201
+ }
202
+
203
+ function isoTimestamp(clock) {
204
+ const value = clock();
205
+ const date = value instanceof Date ? value : new Date(value);
206
+ if (Number.isNaN(date.getTime())) throw new TypeError("Memory clock is invalid");
207
+ return date.toISOString();
208
+ }
209
+
210
+ function cleanText(value, maximum, label) {
211
+ if (typeof value !== "string") throw new TypeError(`${label} must be text`);
212
+ const cleaned = value
213
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
214
+ .replace(/[\u202a-\u202e\u2066-\u2069]/gu, "")
215
+ .replace(/\s+/gu, " ")
216
+ .trim();
217
+ if (!cleaned) throw new TypeError(`${label} must not be empty`);
218
+ return Array.from(cleaned).slice(0, maximum).join("");
219
+ }
220
+
221
+ function cleanKind(value) {
222
+ const kind = value ?? "fact";
223
+ if (!memoryKinds.has(kind)) throw new TypeError("Memory kind is invalid");
224
+ return kind;
225
+ }
226
+
227
+ function cleanHorizon(value) {
228
+ const horizon = value ?? "persistent";
229
+ if (!memoryHorizons.has(horizon)) {
230
+ throw new TypeError("Memory horizon is invalid");
231
+ }
232
+ return horizon;
233
+ }
234
+
235
+ function cleanEpisodeKind(value) {
236
+ if (!episodeKinds.has(value)) throw new TypeError("Episode kind is invalid");
237
+ return value;
238
+ }
239
+
240
+ function cleanEpisodeSourceKind(value) {
241
+ if (!episodeSourceKinds.has(value)) {
242
+ throw new TypeError("Episode source kind is invalid");
243
+ }
244
+ return value;
245
+ }
246
+
247
+ function cleanOptionalText(value, maximum, label) {
248
+ if (value === undefined || value === null || value === "") return null;
249
+ return cleanText(value, maximum, label);
250
+ }
251
+
252
+ function cleanTimestamp(value, label) {
253
+ if (value === undefined || value === null || value === "") return null;
254
+ const date = value instanceof Date ? value : new Date(value);
255
+ if (Number.isNaN(date.getTime())) throw new TypeError(`${label} is invalid`);
256
+ return date.toISOString();
257
+ }
258
+
259
+ function cleanImportance(value = 1) {
260
+ const importance = Number(value);
261
+ if (!Number.isInteger(importance) || importance < 0 || importance > 3) {
262
+ throw new TypeError("Episode importance is invalid");
263
+ }
264
+ return importance;
265
+ }
266
+
267
+ function cleanPromotionState(value = "none") {
268
+ if (!promotionStates.has(value)) {
269
+ throw new TypeError("Episode promotion state is invalid");
270
+ }
271
+ return value;
272
+ }
273
+
274
+ function cleanReflectionTrigger(value = "manual") {
275
+ if (!reflectionTriggers.has(value)) {
276
+ throw new TypeError("Memory reflection trigger is invalid");
277
+ }
278
+ return value;
279
+ }
280
+
281
+ function cleanConfidence(value) {
282
+ const confidence = Number(value);
283
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
284
+ throw new TypeError("Memory reflection confidence is invalid");
285
+ }
286
+ return confidence;
287
+ }
288
+
289
+ function exactKeys(value, expected, label) {
290
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
291
+ throw new TypeError(`${label} must be an object`);
292
+ }
293
+ const actual = Object.keys(value).sort().join(",");
294
+ if (actual !== [...expected].sort().join(",")) {
295
+ throw new TypeError(`${label} has an unsupported shape`);
296
+ }
297
+ }
298
+
299
+ function cleanEpisodeIds(value) {
300
+ if (!Array.isArray(value) || value.length < 1 || value.length > 24) {
301
+ throw new TypeError("Memory reflection episode IDs are invalid");
302
+ }
303
+ const ids = value.map((episodeId) =>
304
+ cleanText(episodeId, 128, "Memory reflection episode ID"),
305
+ );
306
+ if (new Set(ids).size !== ids.length) {
307
+ throw new TypeError("Memory reflection episode IDs must be unique");
308
+ }
309
+ return ids;
310
+ }
311
+
312
+ function normalizeReflectionProposal(value) {
313
+ if (!reflectionActions.has(value?.action)) {
314
+ throw new TypeError("Memory reflection action is invalid");
315
+ }
316
+ if (value.action === "candidate_memory") {
317
+ exactKeys(
318
+ value,
319
+ [
320
+ "action",
321
+ "assertion_mode",
322
+ "confidence",
323
+ "episode_ids",
324
+ "kind",
325
+ "rationale",
326
+ "statement",
327
+ ],
328
+ "Memory proposal",
329
+ );
330
+ if (!new Set(["explicit", "inferred"]).has(value.assertion_mode)) {
331
+ throw new TypeError("Memory proposal assertion mode is invalid");
332
+ }
333
+ return {
334
+ action: value.action,
335
+ assertion_mode: value.assertion_mode,
336
+ confidence: cleanConfidence(value.confidence),
337
+ episode_ids: cleanEpisodeIds(value.episode_ids),
338
+ kind: cleanKind(value.kind),
339
+ rationale: cleanText(value.rationale, 500, "Memory proposal rationale"),
340
+ statement: cleanText(value.statement, 1_000, "Memory proposal statement"),
341
+ };
342
+ }
343
+ if (value.action === "candidate_music_profile") {
344
+ exactKeys(
345
+ value,
346
+ [
347
+ "action",
348
+ "confidence",
349
+ "dimension",
350
+ "direction",
351
+ "episode_ids",
352
+ "rationale",
353
+ "value",
354
+ ],
355
+ "Music profile proposal",
356
+ );
357
+ if (!new Set(["positive", "negative", "contextual"]).has(value.direction)) {
358
+ throw new TypeError("Music profile proposal direction is invalid");
359
+ }
360
+ return {
361
+ action: value.action,
362
+ confidence: cleanConfidence(value.confidence),
363
+ dimension: cleanText(value.dimension, 128, "Music profile dimension"),
364
+ direction: value.direction,
365
+ episode_ids: cleanEpisodeIds(value.episode_ids),
366
+ rationale: cleanText(
367
+ value.rationale,
368
+ 500,
369
+ "Music profile proposal rationale",
370
+ ),
371
+ value: cleanText(value.value, 1_000, "Music profile value"),
372
+ };
373
+ }
374
+ exactKeys(value, ["action", "episode_ids", "reason"], "Ignore proposal");
375
+ return {
376
+ action: value.action,
377
+ episode_ids: cleanEpisodeIds(value.episode_ids),
378
+ reason: cleanText(value.reason, 500, "Ignore proposal reason"),
379
+ };
380
+ }
381
+
382
+ function normalizeReflectionProposals(value, expectedEpisodeIds) {
383
+ if (!Array.isArray(value) || value.length < 1 || value.length > 24) {
384
+ throw new TypeError("Memory reflection proposals are invalid");
385
+ }
386
+ const proposals = value.map(normalizeReflectionProposal);
387
+ const covered = proposals.flatMap((proposal) => proposal.episode_ids);
388
+ if (new Set(covered).size !== covered.length) {
389
+ throw new TypeError("Each episode must appear in one reflection proposal");
390
+ }
391
+ const expected = [...expectedEpisodeIds].sort();
392
+ const actual = [...covered].sort();
393
+ if (
394
+ actual.length !== expected.length ||
395
+ actual.some((episodeId, index) => episodeId !== expected[index])
396
+ ) {
397
+ throw new TypeError("Reflection proposals must cover the exact episode batch");
398
+ }
399
+ return proposals;
400
+ }
401
+
402
+ function cleanDetailsJson(value) {
403
+ if (value === undefined || value === null) return null;
404
+ if (typeof value !== "object" || Array.isArray(value)) {
405
+ throw new TypeError("Episode details must be an object");
406
+ }
407
+ const serialized = JSON.stringify(value);
408
+ if (Buffer.byteLength(serialized, "utf8") > 8_000) {
409
+ throw new TypeError("Episode details are too large");
410
+ }
411
+ return serialized;
412
+ }
413
+
414
+ function publicMemory(row) {
415
+ return {
416
+ memory_id: row.memory_id,
417
+ kind: row.kind,
418
+ horizon: row.horizon,
419
+ text: row.content,
420
+ created_at: row.created_at,
421
+ updated_at: row.updated_at,
422
+ };
423
+ }
424
+
425
+ function publicEpisode(row) {
426
+ return {
427
+ episode_id: row.episode_id,
428
+ kind: row.kind,
429
+ summary: row.summary,
430
+ occurred_at: row.occurred_at,
431
+ expires_at: row.expires_at,
432
+ importance: row.importance,
433
+ promotion_state: row.promotion_state,
434
+ };
435
+ }
436
+
437
+ function reflectionEpisode(row) {
438
+ return {
439
+ episode_id: row.episode_id,
440
+ session_id: row.session_id,
441
+ kind: row.kind,
442
+ source_kind: row.source_kind,
443
+ summary: row.summary,
444
+ occurred_at: row.occurred_at,
445
+ expires_at: row.expires_at,
446
+ importance: row.importance,
447
+ };
448
+ }
449
+
450
+ function publicReflectionRun(row) {
451
+ if (!row) return null;
452
+ const counts = row.counts_json ? JSON.parse(row.counts_json) : null;
453
+ return {
454
+ run_id: row.run_id,
455
+ trigger: row.trigger_kind,
456
+ worker_version: row.worker_version,
457
+ provider: row.provider,
458
+ model: row.model,
459
+ status: row.status,
460
+ episode_count: JSON.parse(row.episode_ids_json).length,
461
+ ...(counts ? { counts } : {}),
462
+ ...(row.error_code ? { error_code: row.error_code } : {}),
463
+ started_at: row.started_at,
464
+ completed_at: row.completed_at,
465
+ };
466
+ }
467
+
468
+ function normalized(value) {
469
+ return value.normalize("NFKC").toLocaleLowerCase("en-US");
470
+ }
471
+
472
+ function queryTerms(value) {
473
+ if (typeof value !== "string") return [];
474
+ const text = normalized(value);
475
+ const segments = text.match(/\p{Script=Han}+|[\p{L}\p{N}]+/gu) ?? [];
476
+ const terms = [];
477
+ for (const segment of segments) {
478
+ if (/^\p{Script=Han}+$/u.test(segment)) {
479
+ const characters = Array.from(segment);
480
+ if (characters.length === 1) terms.push(characters[0]);
481
+ for (let index = 0; index < characters.length - 1; index += 1) {
482
+ terms.push(`${characters[index]}${characters[index + 1]}`);
483
+ }
484
+ } else if (Array.from(segment).length >= 2) {
485
+ terms.push(segment);
486
+ }
487
+ }
488
+ return [...new Set(terms)].slice(0, 24);
489
+ }
490
+
491
+ function relevanceScore(memory, terms) {
492
+ if (terms.length === 0) return 0;
493
+ const content = normalized(memory.content);
494
+ return terms.reduce(
495
+ (score, term) => score + (content.includes(term) ? term.length : 0),
496
+ 0,
497
+ );
498
+ }
499
+
500
+ export class LocalMemoryStore {
501
+ #database;
502
+ #clock;
503
+ #closed = false;
504
+
505
+ constructor(database, { clock = () => new Date() } = {}) {
506
+ this.#database = database;
507
+ this.#clock = clock;
508
+ }
509
+
510
+ #assertOpen() {
511
+ if (this.#closed) throw new Error("Memory store is closed");
512
+ }
513
+
514
+ #insertEpisode({
515
+ episodeId = randomUUID(),
516
+ sessionId,
517
+ kind,
518
+ sourceKind,
519
+ sourceId,
520
+ summary,
521
+ details,
522
+ occurredAt,
523
+ expiresAt,
524
+ importance = 1,
525
+ promotionState = "none",
526
+ timestamp = isoTimestamp(this.#clock),
527
+ }) {
528
+ const occurred =
529
+ cleanTimestamp(occurredAt, "Episode occurrence") ?? timestamp;
530
+ const expires = cleanTimestamp(expiresAt, "Episode expiration");
531
+ const cleanedEpisodeId = cleanText(episodeId, 128, "Episode ID");
532
+ if (expires && expires <= occurred) {
533
+ throw new TypeError("Episode expiration must follow its occurrence");
534
+ }
535
+ this.#database
536
+ .prepare(`
537
+ INSERT INTO episodes(
538
+ episode_id,
539
+ session_id,
540
+ kind,
541
+ source_kind,
542
+ source_id,
543
+ summary,
544
+ details_json,
545
+ occurred_at,
546
+ expires_at,
547
+ importance,
548
+ promotion_state,
549
+ status,
550
+ created_at
551
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)
552
+ `)
553
+ .run(
554
+ cleanedEpisodeId,
555
+ sessionId ?? null,
556
+ cleanEpisodeKind(kind),
557
+ cleanEpisodeSourceKind(sourceKind),
558
+ cleanOptionalText(sourceId, 128, "Episode source ID"),
559
+ cleanText(summary, 2_000, "Episode summary"),
560
+ cleanDetailsJson(details),
561
+ occurred,
562
+ expires,
563
+ cleanImportance(importance),
564
+ cleanPromotionState(promotionState),
565
+ timestamp,
566
+ );
567
+ return this.#database
568
+ .prepare("SELECT * FROM episodes WHERE episode_id = ?")
569
+ .get(cleanedEpisodeId);
570
+ }
571
+
572
+ #applyPreparedRemember(mutation, { sourceId, timestamp }) {
573
+ const episode = this.#insertEpisode({
574
+ episodeId: mutation.episodeId,
575
+ sessionId: mutation.sourceSessionId,
576
+ kind: "explicit_assertion",
577
+ sourceKind: sourceId ? "turn" : "manual",
578
+ sourceId,
579
+ summary: mutation.content,
580
+ importance: 3,
581
+ promotionState: "promoted",
582
+ timestamp,
583
+ });
584
+ const inserted = this.#database
585
+ .prepare(`
586
+ INSERT OR IGNORE INTO memory_claims(
587
+ memory_id,
588
+ kind,
589
+ horizon,
590
+ content,
591
+ normalized_content,
592
+ status,
593
+ origin,
594
+ source_session_id,
595
+ created_at,
596
+ updated_at
597
+ ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
598
+ `)
599
+ .run(
600
+ mutation.memoryId,
601
+ mutation.kind,
602
+ mutation.horizon,
603
+ mutation.content,
604
+ mutation.normalizedContent,
605
+ mutation.origin,
606
+ mutation.sourceSessionId,
607
+ timestamp,
608
+ timestamp,
609
+ );
610
+ const row = this.#database
611
+ .prepare(`
612
+ SELECT *
613
+ FROM memory_claims
614
+ WHERE status = 'active' AND normalized_content = ?
615
+ `)
616
+ .get(mutation.normalizedContent);
617
+ this.#database
618
+ .prepare(`
619
+ INSERT OR IGNORE INTO claim_episode_refs(
620
+ memory_id, episode_id, relation, created_at
621
+ ) VALUES (?, ?, 'supports', ?)
622
+ `)
623
+ .run(row.memory_id, episode.episode_id, timestamp);
624
+ return {
625
+ ...publicMemory(row),
626
+ created: inserted.changes === 1,
627
+ evidence_episode_ids: [episode.episode_id],
628
+ };
629
+ }
630
+
631
+ #applyPreparedForget(mutation, { sourceId, timestamp }) {
632
+ const row = this.#database
633
+ .prepare(`
634
+ SELECT * FROM memory_claims
635
+ WHERE memory_id = ? AND status = 'active'
636
+ `)
637
+ .get(mutation.memoryId);
638
+ if (!row) return { forgotten: false, memory_id: mutation.memoryId };
639
+ const episode = this.#insertEpisode({
640
+ episodeId: mutation.episodeId,
641
+ sessionId: mutation.sourceSessionId,
642
+ kind: "memory_retraction",
643
+ sourceKind: sourceId ? "turn" : "manual",
644
+ sourceId,
645
+ summary: "The user retracted a stored memory.",
646
+ importance: 3,
647
+ promotionState: "promoted",
648
+ timestamp,
649
+ });
650
+ const result = this.#database
651
+ .prepare(`
652
+ UPDATE memory_claims
653
+ SET status = 'forgotten', updated_at = ?
654
+ WHERE memory_id = ? AND status = 'active'
655
+ `)
656
+ .run(timestamp, mutation.memoryId);
657
+ this.#database
658
+ .prepare(`
659
+ INSERT OR IGNORE INTO claim_episode_refs(
660
+ memory_id, episode_id, relation, created_at
661
+ ) VALUES (?, ?, 'contradicts', ?)
662
+ `)
663
+ .run(mutation.memoryId, episode.episode_id, timestamp);
664
+ return {
665
+ forgotten: result.changes === 1,
666
+ memory_id: mutation.memoryId,
667
+ evidence_episode_ids: [episode.episode_id],
668
+ };
669
+ }
670
+
671
+ #applyReflectionMemoryProposal(proposal, timestamp) {
672
+ const normalizedContent = normalized(proposal.statement);
673
+ const timeBoundedEvidence = this.#database
674
+ .prepare(`
675
+ SELECT episode_id
676
+ FROM episodes
677
+ WHERE episode_id IN (${proposal.episode_ids.map(() => "?").join(", ")})
678
+ AND expires_at IS NOT NULL
679
+ LIMIT 1
680
+ `)
681
+ .get(...proposal.episode_ids);
682
+ if (timeBoundedEvidence) {
683
+ return {
684
+ memory_id: null,
685
+ created: false,
686
+ blocked_by_conflict: false,
687
+ blocked_by_expiry: true,
688
+ blocked_by_retraction: false,
689
+ };
690
+ }
691
+ const existing = this.#database
692
+ .prepare(`
693
+ SELECT *
694
+ FROM memory_claims
695
+ WHERE status = 'active' AND normalized_content = ?
696
+ `)
697
+ .get(normalizedContent);
698
+ if (
699
+ existing &&
700
+ (existing.kind !== proposal.kind || existing.horizon !== "persistent")
701
+ ) {
702
+ return {
703
+ memory_id: existing.memory_id,
704
+ created: false,
705
+ blocked_by_conflict: true,
706
+ blocked_by_expiry: false,
707
+ blocked_by_retraction: false,
708
+ };
709
+ }
710
+ let row = existing;
711
+ let created = false;
712
+ if (!row) {
713
+ const priorRetraction = this.#database
714
+ .prepare(`
715
+ SELECT memory_id
716
+ FROM memory_claims
717
+ WHERE normalized_content = ?
718
+ AND status IN ('forgotten', 'superseded')
719
+ ORDER BY updated_at DESC, memory_id ASC
720
+ LIMIT 1
721
+ `)
722
+ .get(normalizedContent);
723
+ if (priorRetraction) {
724
+ return {
725
+ memory_id: priorRetraction.memory_id,
726
+ created: false,
727
+ blocked_by_conflict: false,
728
+ blocked_by_expiry: false,
729
+ blocked_by_retraction: true,
730
+ };
731
+ }
732
+ const sourceSessions = this.#database
733
+ .prepare(`
734
+ SELECT DISTINCT session_id
735
+ FROM episodes
736
+ WHERE episode_id IN (${proposal.episode_ids.map(() => "?").join(", ")})
737
+ AND session_id IS NOT NULL
738
+ `)
739
+ .all(...proposal.episode_ids);
740
+ const sourceSessionId =
741
+ sourceSessions.length === 1 ? sourceSessions[0].session_id : null;
742
+ const memoryId = randomUUID();
743
+ this.#database
744
+ .prepare(`
745
+ INSERT INTO memory_claims(
746
+ memory_id,
747
+ kind,
748
+ horizon,
749
+ content,
750
+ normalized_content,
751
+ status,
752
+ origin,
753
+ source_session_id,
754
+ created_at,
755
+ updated_at
756
+ ) VALUES (?, ?, 'persistent', ?, ?, 'active', ?, ?, ?, ?)
757
+ `)
758
+ .run(
759
+ memoryId,
760
+ proposal.kind,
761
+ proposal.statement,
762
+ normalizedContent,
763
+ "memory_agent_explicit",
764
+ sourceSessionId,
765
+ timestamp,
766
+ timestamp,
767
+ );
768
+ row = this.#database
769
+ .prepare("SELECT * FROM memory_claims WHERE memory_id = ?")
770
+ .get(memoryId);
771
+ created = true;
772
+ }
773
+ const link = this.#database.prepare(`
774
+ INSERT OR IGNORE INTO claim_episode_refs(
775
+ memory_id, episode_id, relation, created_at
776
+ ) VALUES (?, ?, 'supports', ?)
777
+ `);
778
+ for (const episodeId of proposal.episode_ids) {
779
+ link.run(row.memory_id, episodeId, timestamp);
780
+ }
781
+ return {
782
+ memory_id: row.memory_id,
783
+ created,
784
+ blocked_by_conflict: false,
785
+ blocked_by_expiry: false,
786
+ blocked_by_retraction: false,
787
+ };
788
+ }
789
+
790
+ startSession({ routeKey = "local:main" } = {}) {
791
+ this.#assertOpen();
792
+ const sessionId = randomUUID();
793
+ const startedAt = isoTimestamp(this.#clock);
794
+ const route = cleanText(routeKey, 128, "Session route");
795
+ this.#database
796
+ .prepare(`
797
+ INSERT INTO sessions(session_id, route_key, started_at, status)
798
+ VALUES (?, ?, ?, 'active')
799
+ `)
800
+ .run(sessionId, route, startedAt);
801
+ return { session_id: sessionId, route_key: route, started_at: startedAt };
802
+ }
803
+
804
+ resumeOrStartSession(routeKey = "local:main") {
805
+ this.#assertOpen();
806
+ const route = cleanText(routeKey, 128, "Session route");
807
+ const existing = this.#database
808
+ .prepare(`
809
+ SELECT session_id, route_key, started_at
810
+ FROM sessions
811
+ WHERE route_key = ? AND status = 'active'
812
+ `)
813
+ .get(route);
814
+ return existing ?? this.startSession({ routeKey: route });
815
+ }
816
+
817
+ listSessions({ routeKey = "local:main", limit = 200 } = {}) {
818
+ this.#assertOpen();
819
+ const route = cleanText(routeKey, 128, "Session route");
820
+ const requestedLimit = Number(limit);
821
+ const bounded = Number.isFinite(requestedLimit)
822
+ ? Math.max(1, Math.min(Math.trunc(requestedLimit), 200))
823
+ : 200;
824
+ return this.#database
825
+ .prepare(`
826
+ SELECT
827
+ s.session_id,
828
+ s.started_at,
829
+ MAX(t.created_at) AS updated_at,
830
+ COUNT(t.turn_id) AS turn_count,
831
+ COALESCE((
832
+ SELECT first_turn.content
833
+ FROM turns first_turn
834
+ WHERE first_turn.session_id = s.session_id AND first_turn.role = 'user'
835
+ ORDER BY first_turn.position ASC
836
+ LIMIT 1
837
+ ), '') AS title
838
+ FROM sessions s
839
+ JOIN turns t ON t.session_id = s.session_id
840
+ WHERE s.route_key = ?
841
+ GROUP BY s.session_id
842
+ ORDER BY updated_at DESC, s.started_at DESC, s.rowid DESC
843
+ LIMIT ?
844
+ `)
845
+ .all(route, bounded)
846
+ .map((session) => ({
847
+ session_id: session.session_id,
848
+ started_at: session.started_at,
849
+ updated_at: session.updated_at,
850
+ turn_count: session.turn_count,
851
+ title: session.title,
852
+ }));
853
+ }
854
+
855
+ resumeSession(sessionId, { routeKey = "local:main" } = {}) {
856
+ this.#assertOpen();
857
+ if (typeof sessionId !== "string" || !sessionId || sessionId.length > 128) {
858
+ throw new TypeError("Session ID is invalid");
859
+ }
860
+ const route = cleanText(routeKey, 128, "Session route");
861
+ this.#database.exec("BEGIN IMMEDIATE");
862
+ try {
863
+ const session = this.#database
864
+ .prepare(`
865
+ SELECT session_id, route_key, started_at, status
866
+ FROM sessions
867
+ WHERE session_id = ? AND route_key = ?
868
+ `)
869
+ .get(sessionId, route);
870
+ if (!session) throw new Error("Saved conversation not found for this route.");
871
+ if (session.status !== "active") {
872
+ const timestamp = isoTimestamp(this.#clock);
873
+ this.#database
874
+ .prepare(`
875
+ UPDATE sessions
876
+ SET ended_at = ?, close_reason = 'resume_session', status = 'closed'
877
+ WHERE route_key = ? AND status = 'active'
878
+ `)
879
+ .run(timestamp, route);
880
+ this.#database
881
+ .prepare(`
882
+ UPDATE sessions
883
+ SET ended_at = NULL, close_reason = NULL, status = 'active'
884
+ WHERE session_id = ? AND route_key = ?
885
+ `)
886
+ .run(sessionId, route);
887
+ }
888
+ this.#database.exec("COMMIT");
889
+ return {
890
+ session_id: session.session_id,
891
+ route_key: session.route_key,
892
+ started_at: session.started_at,
893
+ };
894
+ } catch (error) {
895
+ this.#database.exec("ROLLBACK");
896
+ throw error;
897
+ }
898
+ }
899
+
900
+ rotateSession(routeKey = "local:main", reason = "new_session") {
901
+ this.#assertOpen();
902
+ const route = cleanText(routeKey, 128, "Session route");
903
+ const closeReason = cleanText(reason, 64, "Session close reason");
904
+ const timestamp = isoTimestamp(this.#clock);
905
+ const sessionId = randomUUID();
906
+ this.#database.exec("BEGIN IMMEDIATE");
907
+ try {
908
+ this.#database
909
+ .prepare(`
910
+ UPDATE sessions
911
+ SET ended_at = ?, close_reason = ?, status = 'closed'
912
+ WHERE route_key = ? AND status = 'active'
913
+ `)
914
+ .run(timestamp, closeReason, route);
915
+ this.#database
916
+ .prepare(`
917
+ INSERT INTO sessions(session_id, route_key, started_at, status)
918
+ VALUES (?, ?, ?, 'active')
919
+ `)
920
+ .run(sessionId, route, timestamp);
921
+ this.#database.exec("COMMIT");
922
+ return {
923
+ session_id: sessionId,
924
+ route_key: route,
925
+ started_at: timestamp,
926
+ };
927
+ } catch (error) {
928
+ this.#database.exec("ROLLBACK");
929
+ throw error;
930
+ }
931
+ }
932
+
933
+ closeSession(sessionId, reason = "closed") {
934
+ this.#assertOpen();
935
+ const endedAt = isoTimestamp(this.#clock);
936
+ const result = this.#database
937
+ .prepare(`
938
+ UPDATE sessions
939
+ SET ended_at = ?, close_reason = ?, status = 'closed'
940
+ WHERE session_id = ? AND status = 'active'
941
+ `)
942
+ .run(endedAt, cleanText(reason, 64, "Session close reason"), sessionId);
943
+ return { closed: result.changes === 1, ended_at: endedAt };
944
+ }
945
+
946
+ appendCompletedTurn(sessionId, { user, assistant }) {
947
+ return this.commitCompletedPrompt(sessionId, { user, assistant });
948
+ }
949
+
950
+ commitCompletedPrompt(
951
+ sessionId,
952
+ { user, assistant, memoryMutations = [] },
953
+ ) {
954
+ this.#assertOpen();
955
+ const userText = cleanText(user, 8_000, "User turn");
956
+ const assistantText = cleanText(assistant, 20_000, "Assistant turn");
957
+ const createdAt = isoTimestamp(this.#clock);
958
+ const insert = this.#database.prepare(`
959
+ INSERT INTO turns(turn_id, session_id, position, role, content, created_at)
960
+ VALUES (?, ?, ?, ?, ?, ?)
961
+ `);
962
+
963
+ this.#database.exec("BEGIN IMMEDIATE");
964
+ try {
965
+ const current = this.#database
966
+ .prepare(`
967
+ SELECT COALESCE(MAX(position), 0) AS position
968
+ FROM turns
969
+ WHERE session_id = ?
970
+ `)
971
+ .get(sessionId);
972
+ const userTurnId = randomUUID();
973
+ insert.run(
974
+ userTurnId,
975
+ sessionId,
976
+ current.position + 1,
977
+ "user",
978
+ userText,
979
+ createdAt,
980
+ );
981
+ insert.run(
982
+ randomUUID(),
983
+ sessionId,
984
+ current.position + 2,
985
+ "assistant",
986
+ assistantText,
987
+ createdAt,
988
+ );
989
+ this.#insertEpisode({
990
+ sessionId,
991
+ kind: "dialogue",
992
+ sourceKind: "turn",
993
+ sourceId: userTurnId,
994
+ summary: userText,
995
+ occurredAt: createdAt,
996
+ importance: 1,
997
+ promotionState: "none",
998
+ timestamp: createdAt,
999
+ });
1000
+ const memoryResults = [];
1001
+ for (const mutation of memoryMutations) {
1002
+ if (mutation?.type === "remember") {
1003
+ memoryResults.push(
1004
+ this.#applyPreparedRemember(mutation, {
1005
+ sourceId: userTurnId,
1006
+ timestamp: createdAt,
1007
+ }),
1008
+ );
1009
+ } else if (mutation?.type === "forget") {
1010
+ memoryResults.push(
1011
+ this.#applyPreparedForget(mutation, {
1012
+ sourceId: userTurnId,
1013
+ timestamp: createdAt,
1014
+ }),
1015
+ );
1016
+ } else {
1017
+ throw new TypeError("Memory mutation is invalid");
1018
+ }
1019
+ }
1020
+ this.#database.exec("COMMIT");
1021
+ return {
1022
+ recorded: true,
1023
+ turns: 2,
1024
+ episodes: 1 + memoryResults.length,
1025
+ memory_results: memoryResults,
1026
+ };
1027
+ } catch (error) {
1028
+ this.#database.exec("ROLLBACK");
1029
+ throw error;
1030
+ }
1031
+ }
1032
+
1033
+ readSessionTurns(sessionId, { limit = 40 } = {}) {
1034
+ this.#assertOpen();
1035
+ const bounded = Math.max(1, Math.min(Number(limit) || 40, 100));
1036
+ return this.#database
1037
+ .prepare(`
1038
+ SELECT role, content, created_at
1039
+ FROM turns
1040
+ WHERE session_id = ?
1041
+ ORDER BY position DESC
1042
+ LIMIT ?
1043
+ `)
1044
+ .all(sessionId, bounded)
1045
+ .reverse()
1046
+ .map((turn) => ({
1047
+ role: turn.role,
1048
+ text: turn.content,
1049
+ created_at: turn.created_at,
1050
+ }));
1051
+ }
1052
+
1053
+ prepareRemember({
1054
+ text,
1055
+ kind = "fact",
1056
+ horizon = "persistent",
1057
+ origin = "explicit_user",
1058
+ sourceSessionId,
1059
+ }) {
1060
+ this.#assertOpen();
1061
+ const content = cleanText(text, 2_000, "Memory");
1062
+ const normalizedContent = normalized(content);
1063
+ const existing = this.#database
1064
+ .prepare(`
1065
+ SELECT *
1066
+ FROM memory_claims
1067
+ WHERE status = 'active' AND normalized_content = ?
1068
+ `)
1069
+ .get(normalizedContent);
1070
+ const timestamp = isoTimestamp(this.#clock);
1071
+ const memoryId = existing?.memory_id ?? randomUUID();
1072
+ const cleanedKind = cleanKind(kind);
1073
+ const cleanedHorizon = cleanHorizon(horizon);
1074
+ const mutation = Object.freeze({
1075
+ type: "remember",
1076
+ memoryId,
1077
+ episodeId: randomUUID(),
1078
+ kind: cleanedKind,
1079
+ horizon: cleanedHorizon,
1080
+ content,
1081
+ normalizedContent,
1082
+ origin: cleanText(origin, 64, "Memory origin"),
1083
+ sourceSessionId: sourceSessionId ?? null,
1084
+ });
1085
+ const result = existing
1086
+ ? { ...publicMemory(existing), created: false }
1087
+ : {
1088
+ memory_id: memoryId,
1089
+ kind: cleanedKind,
1090
+ horizon: cleanedHorizon,
1091
+ text: content,
1092
+ created_at: timestamp,
1093
+ updated_at: timestamp,
1094
+ created: true,
1095
+ };
1096
+ return { mutation, result };
1097
+ }
1098
+
1099
+ prepareForget(memoryId, { sourceSessionId } = {}) {
1100
+ this.#assertOpen();
1101
+ const cleanedId = cleanText(memoryId, 128, "Memory ID");
1102
+ const existing = this.#database
1103
+ .prepare(`
1104
+ SELECT memory_id FROM memory_claims
1105
+ WHERE memory_id = ? AND status = 'active'
1106
+ `)
1107
+ .get(cleanedId);
1108
+ return {
1109
+ mutation: existing
1110
+ ? Object.freeze({
1111
+ type: "forget",
1112
+ memoryId: cleanedId,
1113
+ episodeId: randomUUID(),
1114
+ sourceSessionId: sourceSessionId ?? null,
1115
+ })
1116
+ : null,
1117
+ result: { forgotten: existing !== undefined, memory_id: cleanedId },
1118
+ };
1119
+ }
1120
+
1121
+ remember(input) {
1122
+ this.#assertOpen();
1123
+ const prepared = this.prepareRemember(input);
1124
+ const timestamp = isoTimestamp(this.#clock);
1125
+ this.#database.exec("BEGIN IMMEDIATE");
1126
+ try {
1127
+ const result = this.#applyPreparedRemember(prepared.mutation, {
1128
+ timestamp,
1129
+ });
1130
+ this.#database.exec("COMMIT");
1131
+ return result;
1132
+ } catch (error) {
1133
+ this.#database.exec("ROLLBACK");
1134
+ throw error;
1135
+ }
1136
+ }
1137
+
1138
+ forget(memoryId, options = {}) {
1139
+ this.#assertOpen();
1140
+ const prepared = this.prepareForget(memoryId, options);
1141
+ if (!prepared.mutation) return prepared.result;
1142
+ const timestamp = isoTimestamp(this.#clock);
1143
+ this.#database.exec("BEGIN IMMEDIATE");
1144
+ try {
1145
+ const result = this.#applyPreparedForget(prepared.mutation, {
1146
+ timestamp,
1147
+ });
1148
+ this.#database.exec("COMMIT");
1149
+ return result;
1150
+ } catch (error) {
1151
+ this.#database.exec("ROLLBACK");
1152
+ throw error;
1153
+ }
1154
+ }
1155
+
1156
+ recordEpisode(input) {
1157
+ this.#assertOpen();
1158
+ const timestamp = isoTimestamp(this.#clock);
1159
+ this.#database.exec("BEGIN IMMEDIATE");
1160
+ try {
1161
+ const row = this.#insertEpisode({ ...input, timestamp });
1162
+ this.#database.exec("COMMIT");
1163
+ return publicEpisode(row);
1164
+ } catch (error) {
1165
+ this.#database.exec("ROLLBACK");
1166
+ throw error;
1167
+ }
1168
+ }
1169
+
1170
+ pendingReflectionEpisodes({ limit = 24, now = this.#clock() } = {}) {
1171
+ this.#assertOpen();
1172
+ const bounded = Math.max(1, Math.min(Number(limit) || 24, 24));
1173
+ const nowTimestamp = cleanTimestamp(now, "Memory reflection time");
1174
+ return this.#database
1175
+ .prepare(`
1176
+ SELECT *
1177
+ FROM episodes
1178
+ WHERE status = 'active'
1179
+ AND promotion_state = 'none'
1180
+ AND (expires_at IS NULL OR expires_at > ?)
1181
+ ORDER BY occurred_at ASC, episode_id ASC
1182
+ LIMIT ?
1183
+ `)
1184
+ .all(nowTimestamp, bounded)
1185
+ .map(reflectionEpisode);
1186
+ }
1187
+
1188
+ beginReflection({
1189
+ trigger = "manual",
1190
+ workerVersion = "memory-agent/1",
1191
+ provider,
1192
+ model,
1193
+ limit = 24,
1194
+ leaseMs = 10 * 60 * 1_000,
1195
+ }) {
1196
+ this.#assertOpen();
1197
+ const triggerKind = cleanReflectionTrigger(trigger);
1198
+ const cleanedWorkerVersion = cleanText(
1199
+ workerVersion,
1200
+ 64,
1201
+ "Memory worker version",
1202
+ );
1203
+ const cleanedProvider = cleanText(provider, 128, "Memory worker provider");
1204
+ const cleanedModel = cleanText(model, 256, "Memory worker model");
1205
+ const boundedLimit = Math.max(1, Math.min(Number(limit) || 24, 24));
1206
+ const boundedLeaseMs = Math.max(
1207
+ 30_000,
1208
+ Math.min(Number(leaseMs) || 10 * 60 * 1_000, 60 * 60 * 1_000),
1209
+ );
1210
+ const startedAt = isoTimestamp(this.#clock);
1211
+ const leaseExpiresAt = new Date(
1212
+ Date.parse(startedAt) + boundedLeaseMs,
1213
+ ).toISOString();
1214
+
1215
+ this.#database.exec("BEGIN IMMEDIATE");
1216
+ try {
1217
+ const active = this.#database
1218
+ .prepare(`
1219
+ SELECT *
1220
+ FROM reflection_runs
1221
+ WHERE status = 'started'
1222
+ LIMIT 1
1223
+ `)
1224
+ .get();
1225
+ if (active && active.lease_expires_at > startedAt) {
1226
+ this.#database.exec("COMMIT");
1227
+ return { state: "busy", active_run: publicReflectionRun(active) };
1228
+ }
1229
+ if (active) {
1230
+ this.#database
1231
+ .prepare(`
1232
+ UPDATE reflection_runs
1233
+ SET status = 'failed', error_code = 'lease_expired', completed_at = ?
1234
+ WHERE run_id = ? AND status = 'started'
1235
+ `)
1236
+ .run(startedAt, active.run_id);
1237
+ }
1238
+ const episodes = this.#database
1239
+ .prepare(`
1240
+ SELECT *
1241
+ FROM episodes
1242
+ WHERE status = 'active'
1243
+ AND promotion_state = 'none'
1244
+ AND (expires_at IS NULL OR expires_at > ?)
1245
+ ORDER BY occurred_at ASC, episode_id ASC
1246
+ LIMIT ?
1247
+ `)
1248
+ .all(startedAt, boundedLimit);
1249
+ if (episodes.length === 0) {
1250
+ this.#database.exec("COMMIT");
1251
+ return { state: "no_work", episodes: [] };
1252
+ }
1253
+ const runId = randomUUID();
1254
+ const leaseToken = randomUUID();
1255
+ this.#database
1256
+ .prepare(`
1257
+ INSERT INTO reflection_runs(
1258
+ run_id,
1259
+ trigger_kind,
1260
+ worker_version,
1261
+ provider,
1262
+ model,
1263
+ status,
1264
+ episode_ids_json,
1265
+ lease_token,
1266
+ lease_expires_at,
1267
+ started_at
1268
+ ) VALUES (?, ?, ?, ?, ?, 'started', ?, ?, ?, ?)
1269
+ `)
1270
+ .run(
1271
+ runId,
1272
+ triggerKind,
1273
+ cleanedWorkerVersion,
1274
+ cleanedProvider,
1275
+ cleanedModel,
1276
+ JSON.stringify(episodes.map((episode) => episode.episode_id)),
1277
+ leaseToken,
1278
+ leaseExpiresAt,
1279
+ startedAt,
1280
+ );
1281
+ this.#database.exec("COMMIT");
1282
+ return {
1283
+ state: "started",
1284
+ run_id: runId,
1285
+ lease_token: leaseToken,
1286
+ episodes: episodes.map(reflectionEpisode),
1287
+ active_memories: this.listMemories({ limit: 12 }),
1288
+ };
1289
+ } catch (error) {
1290
+ this.#database.exec("ROLLBACK");
1291
+ throw error;
1292
+ }
1293
+ }
1294
+
1295
+ completeReflection({
1296
+ runId,
1297
+ leaseToken,
1298
+ proposals,
1299
+ dryRun = false,
1300
+ }) {
1301
+ this.#assertOpen();
1302
+ const cleanedRunId = cleanText(runId, 128, "Memory reflection run ID");
1303
+ const cleanedLeaseToken = cleanText(
1304
+ leaseToken,
1305
+ 128,
1306
+ "Memory reflection lease token",
1307
+ );
1308
+ const snapshot = this.#database
1309
+ .prepare(`
1310
+ SELECT *
1311
+ FROM reflection_runs
1312
+ WHERE run_id = ? AND status = 'started' AND lease_token = ?
1313
+ `)
1314
+ .get(cleanedRunId, cleanedLeaseToken);
1315
+ if (!snapshot) throw new Error("Memory reflection run is not active");
1316
+ const episodeIds = JSON.parse(snapshot.episode_ids_json);
1317
+ const normalizedProposals = normalizeReflectionProposals(
1318
+ proposals,
1319
+ episodeIds,
1320
+ );
1321
+ const completedAt = isoTimestamp(this.#clock);
1322
+ const counts = {
1323
+ proposals: normalizedProposals.length,
1324
+ promoted_memories: 0,
1325
+ created_memories: 0,
1326
+ candidate_episodes: 0,
1327
+ ignored_episodes: 0,
1328
+ };
1329
+
1330
+ this.#database.exec("BEGIN IMMEDIATE");
1331
+ try {
1332
+ const active = this.#database
1333
+ .prepare(`
1334
+ SELECT run_id
1335
+ FROM reflection_runs
1336
+ WHERE run_id = ?
1337
+ AND status = 'started'
1338
+ AND lease_token = ?
1339
+ AND lease_expires_at > ?
1340
+ `)
1341
+ .get(cleanedRunId, cleanedLeaseToken, completedAt);
1342
+ if (!active) throw new Error("Memory reflection lease was lost");
1343
+ const eligibleEpisodes = this.#database
1344
+ .prepare(`
1345
+ SELECT COUNT(*) AS count
1346
+ FROM episodes
1347
+ WHERE episode_id IN (${episodeIds.map(() => "?").join(", ")})
1348
+ AND status = 'active'
1349
+ AND promotion_state = 'none'
1350
+ AND (expires_at IS NULL OR expires_at > ?)
1351
+ `)
1352
+ .get(...episodeIds, completedAt).count;
1353
+ if (eligibleEpisodes !== episodeIds.length) {
1354
+ throw new Error("Memory reflection episode checkpoint expired or changed");
1355
+ }
1356
+ if (dryRun) this.#database.exec("SAVEPOINT reflection_preview");
1357
+ const updateEpisode = this.#database.prepare(`
1358
+ UPDATE episodes
1359
+ SET promotion_state = ?
1360
+ WHERE episode_id = ? AND status = 'active' AND promotion_state = 'none'
1361
+ `);
1362
+ for (const proposal of normalizedProposals) {
1363
+ let nextState = "candidate";
1364
+ if (proposal.action === "ignore") {
1365
+ nextState = "rejected";
1366
+ counts.ignored_episodes += proposal.episode_ids.length;
1367
+ } else if (
1368
+ proposal.action === "candidate_memory" &&
1369
+ proposal.assertion_mode === "explicit" &&
1370
+ proposal.confidence >= 0.9
1371
+ ) {
1372
+ const promoted = this.#applyReflectionMemoryProposal(
1373
+ proposal,
1374
+ completedAt,
1375
+ );
1376
+ if (
1377
+ promoted.blocked_by_conflict ||
1378
+ promoted.blocked_by_expiry ||
1379
+ promoted.blocked_by_retraction
1380
+ ) {
1381
+ nextState = "candidate";
1382
+ counts.candidate_episodes += proposal.episode_ids.length;
1383
+ } else {
1384
+ nextState = "promoted";
1385
+ counts.promoted_memories += 1;
1386
+ if (promoted.created) counts.created_memories += 1;
1387
+ }
1388
+ } else {
1389
+ counts.candidate_episodes += proposal.episode_ids.length;
1390
+ }
1391
+ for (const episodeId of proposal.episode_ids) {
1392
+ const result = updateEpisode.run(nextState, episodeId);
1393
+ if (result.changes !== 1) {
1394
+ throw new Error("Memory reflection episode checkpoint changed");
1395
+ }
1396
+ }
1397
+ }
1398
+ if (dryRun) {
1399
+ this.#database.exec("ROLLBACK TO reflection_preview");
1400
+ this.#database.exec("RELEASE reflection_preview");
1401
+ }
1402
+ this.#database
1403
+ .prepare(`
1404
+ UPDATE reflection_runs
1405
+ SET status = ?, proposals_json = ?, counts_json = ?, completed_at = ?
1406
+ WHERE run_id = ? AND status = 'started' AND lease_token = ?
1407
+ `)
1408
+ .run(
1409
+ dryRun ? "dry_run" : "completed",
1410
+ JSON.stringify(normalizedProposals),
1411
+ JSON.stringify(counts),
1412
+ completedAt,
1413
+ cleanedRunId,
1414
+ cleanedLeaseToken,
1415
+ );
1416
+ this.#database.exec("COMMIT");
1417
+ return {
1418
+ state: dryRun ? "dry_run" : "completed",
1419
+ run_id: cleanedRunId,
1420
+ counts,
1421
+ proposals: normalizedProposals,
1422
+ };
1423
+ } catch (error) {
1424
+ this.#database.exec("ROLLBACK");
1425
+ throw error;
1426
+ }
1427
+ }
1428
+
1429
+ failReflection(runId, leaseToken, errorCode = "reflection_failed") {
1430
+ this.#assertOpen();
1431
+ const cleanedRunId = cleanText(runId, 128, "Memory reflection run ID");
1432
+ const cleanedLeaseToken = cleanText(
1433
+ leaseToken,
1434
+ 128,
1435
+ "Memory reflection lease token",
1436
+ );
1437
+ const cleanedErrorCode = /^[a-z0-9_]{1,64}$/u.test(errorCode)
1438
+ ? errorCode
1439
+ : "reflection_failed";
1440
+ const completedAt = isoTimestamp(this.#clock);
1441
+ const result = this.#database
1442
+ .prepare(`
1443
+ UPDATE reflection_runs
1444
+ SET status = 'failed', error_code = ?, completed_at = ?
1445
+ WHERE run_id = ? AND status = 'started' AND lease_token = ?
1446
+ `)
1447
+ .run(
1448
+ cleanedErrorCode,
1449
+ completedAt,
1450
+ cleanedRunId,
1451
+ cleanedLeaseToken,
1452
+ );
1453
+ return { failed: result.changes === 1, run_id: cleanedRunId };
1454
+ }
1455
+
1456
+ reflectionStatus({ now = this.#clock() } = {}) {
1457
+ this.#assertOpen();
1458
+ const nowTimestamp = cleanTimestamp(now, "Memory reflection status time");
1459
+ const pending = this.#database
1460
+ .prepare(`
1461
+ SELECT COUNT(*) AS count
1462
+ FROM episodes
1463
+ WHERE status = 'active'
1464
+ AND promotion_state = 'none'
1465
+ AND (expires_at IS NULL OR expires_at > ?)
1466
+ `)
1467
+ .get(nowTimestamp).count;
1468
+ const candidates = this.#database
1469
+ .prepare(`
1470
+ SELECT COUNT(*) AS count
1471
+ FROM episodes
1472
+ WHERE status = 'active' AND promotion_state = 'candidate'
1473
+ `)
1474
+ .get().count;
1475
+ const latest = this.#database
1476
+ .prepare(`
1477
+ SELECT *
1478
+ FROM reflection_runs
1479
+ ORDER BY started_at DESC, run_id DESC
1480
+ LIMIT 1
1481
+ `)
1482
+ .get();
1483
+ const state =
1484
+ latest?.status === "started"
1485
+ ? latest.lease_expires_at > nowTimestamp
1486
+ ? "running"
1487
+ : "stale"
1488
+ : latest?.status === "failed"
1489
+ ? "degraded"
1490
+ : "ready";
1491
+ return {
1492
+ state,
1493
+ pending_episodes: pending,
1494
+ candidate_episodes: candidates,
1495
+ last_run: publicReflectionRun(latest),
1496
+ };
1497
+ }
1498
+
1499
+ listMemories({ limit = 12, query } = {}) {
1500
+ this.#assertOpen();
1501
+ const bounded = Math.max(1, Math.min(Number(limit) || 12, 50));
1502
+ const rows = this.#database
1503
+ .prepare(`
1504
+ SELECT *
1505
+ FROM memory_claims
1506
+ WHERE status = 'active'
1507
+ ORDER BY updated_at DESC, memory_id ASC
1508
+ LIMIT 256
1509
+ `)
1510
+ .all();
1511
+ const terms = queryTerms(query);
1512
+ const ranked = rows
1513
+ .map((row) => ({ row, score: relevanceScore(row, terms) }))
1514
+ .filter(({ score }) => terms.length === 0 || score > 0)
1515
+ .sort(
1516
+ (left, right) =>
1517
+ right.score - left.score ||
1518
+ right.row.updated_at.localeCompare(left.row.updated_at),
1519
+ );
1520
+ const evidence = this.#database.prepare(`
1521
+ SELECT episode_id
1522
+ FROM claim_episode_refs
1523
+ WHERE memory_id = ?
1524
+ ORDER BY created_at DESC
1525
+ LIMIT 8
1526
+ `);
1527
+ return ranked.slice(0, bounded).map(({ row }) => ({
1528
+ ...publicMemory(row),
1529
+ evidence_episode_ids: evidence
1530
+ .all(row.memory_id)
1531
+ .map((reference) => reference.episode_id),
1532
+ }));
1533
+ }
1534
+
1535
+ recentEpisodes({
1536
+ query,
1537
+ excludeSessionId,
1538
+ limit = 12,
1539
+ now = this.#clock(),
1540
+ } = {}) {
1541
+ this.#assertOpen();
1542
+ const bounded = Math.max(1, Math.min(Number(limit) || 12, 50));
1543
+ const nowTimestamp = cleanTimestamp(now, "Episode recall time");
1544
+ const rows = this.#database
1545
+ .prepare(`
1546
+ SELECT *
1547
+ FROM episodes
1548
+ WHERE status = 'active'
1549
+ AND (expires_at IS NULL OR expires_at > ?)
1550
+ AND (session_id IS NULL OR session_id != COALESCE(?, ''))
1551
+ ORDER BY occurred_at DESC, episode_id ASC
1552
+ LIMIT 256
1553
+ `)
1554
+ .all(nowTimestamp, excludeSessionId ?? null);
1555
+ const terms = queryTerms(query);
1556
+ return rows
1557
+ .map((row) => ({
1558
+ row,
1559
+ score: relevanceScore({ content: row.summary }, terms),
1560
+ }))
1561
+ .filter(
1562
+ ({ score }) => terms.length === 0 || score > 0,
1563
+ )
1564
+ .sort(
1565
+ (left, right) =>
1566
+ right.score - left.score ||
1567
+ right.row.importance - left.row.importance ||
1568
+ right.row.occurred_at.localeCompare(left.row.occurred_at),
1569
+ )
1570
+ .slice(0, bounded)
1571
+ .map(({ row }) => publicEpisode(row));
1572
+ }
1573
+
1574
+ recentSessions({ excludeSessionId, limit = 2, turnsPerSession = 4 } = {}) {
1575
+ this.#assertOpen();
1576
+ const boundedSessions = Math.max(1, Math.min(Number(limit) || 2, 5));
1577
+ const boundedTurns = Math.max(
1578
+ 1,
1579
+ Math.min(Number(turnsPerSession) || 4, 10),
1580
+ );
1581
+ const sessions = this.#database
1582
+ .prepare(`
1583
+ SELECT s.session_id, s.started_at, s.ended_at
1584
+ FROM sessions s
1585
+ WHERE s.session_id != COALESCE(?, '')
1586
+ AND EXISTS (SELECT 1 FROM turns t WHERE t.session_id = s.session_id)
1587
+ ORDER BY COALESCE(s.ended_at, s.started_at) DESC
1588
+ LIMIT ?
1589
+ `)
1590
+ .all(excludeSessionId ?? null, boundedSessions);
1591
+ const readTurns = this.#database.prepare(`
1592
+ SELECT role, content, created_at
1593
+ FROM turns
1594
+ WHERE session_id = ?
1595
+ ORDER BY position DESC
1596
+ LIMIT ?
1597
+ `);
1598
+ return sessions.map((session) => ({
1599
+ session_id: session.session_id,
1600
+ started_at: session.started_at,
1601
+ ended_at: session.ended_at,
1602
+ turns: readTurns
1603
+ .all(session.session_id, boundedTurns)
1604
+ .reverse()
1605
+ .map((turn) => ({
1606
+ role: turn.role,
1607
+ text: turn.content,
1608
+ created_at: turn.created_at,
1609
+ })),
1610
+ }));
1611
+ }
1612
+
1613
+ context({ query, currentSessionId } = {}) {
1614
+ this.#assertOpen();
1615
+ const durableMemories = this.listMemories({ limit: 12 })
1616
+ .filter((memory) => memory.kind !== "fact")
1617
+ .slice(0, 6);
1618
+ const durableIds = new Set(
1619
+ durableMemories.map((memory) => memory.memory_id),
1620
+ );
1621
+ return {
1622
+ durable_memories: durableMemories,
1623
+ relevant_memories: this.listMemories({ limit: 8, query }).filter(
1624
+ (memory) => !durableIds.has(memory.memory_id),
1625
+ ),
1626
+ recent_episodes: this.recentEpisodes({
1627
+ query,
1628
+ excludeSessionId: currentSessionId,
1629
+ limit: 10,
1630
+ }),
1631
+ recent_sessions: this.recentSessions({
1632
+ excludeSessionId: currentSessionId,
1633
+ limit: 2,
1634
+ turnsPerSession: 6,
1635
+ }),
1636
+ };
1637
+ }
1638
+
1639
+ status() {
1640
+ this.#assertOpen();
1641
+ const sessions = this.#database
1642
+ .prepare("SELECT COUNT(*) AS count FROM sessions")
1643
+ .get().count;
1644
+ const turns = this.#database
1645
+ .prepare("SELECT COUNT(*) AS count FROM turns")
1646
+ .get().count;
1647
+ const memories = this.#database
1648
+ .prepare(
1649
+ "SELECT COUNT(*) AS count FROM memory_claims WHERE status = 'active'",
1650
+ )
1651
+ .get().count;
1652
+ const episodes = this.#database
1653
+ .prepare(
1654
+ "SELECT COUNT(*) AS count FROM episodes WHERE status = 'active'",
1655
+ )
1656
+ .get().count;
1657
+ return {
1658
+ state: "ready",
1659
+ sessions,
1660
+ turns,
1661
+ episodes,
1662
+ active_memories: memories,
1663
+ };
1664
+ }
1665
+
1666
+ close() {
1667
+ if (this.#closed) return;
1668
+ this.#closed = true;
1669
+ this.#database.close();
1670
+ }
1671
+ }
1672
+
1673
+ export async function openLocalMemoryStore({
1674
+ databasePath = defaultMoondogMemoryPath,
1675
+ clock,
1676
+ } = {}) {
1677
+ const parent = path.dirname(path.resolve(databasePath));
1678
+ await mkdir(parent, { recursive: true, mode: 0o700 });
1679
+ if (process.platform !== "win32") await chmod(parent, 0o700);
1680
+
1681
+ const DatabaseSync = await databaseConstructor();
1682
+ const database = new DatabaseSync(path.resolve(databasePath));
1683
+ try {
1684
+ database.exec("PRAGMA busy_timeout = 5000");
1685
+ database.exec("PRAGMA journal_mode = WAL");
1686
+ database.exec("PRAGMA foreign_keys = ON");
1687
+ database.exec("PRAGMA trusted_schema = OFF");
1688
+ const currentVersion = database.prepare("PRAGMA user_version").get()
1689
+ .user_version;
1690
+ if (![0, 1, MOONDOG_MEMORY_SCHEMA_VERSION].includes(currentVersion)) {
1691
+ throw new Error("Memory database schema is incompatible");
1692
+ }
1693
+ database.exec(schema);
1694
+ if (currentVersion < MOONDOG_MEMORY_SCHEMA_VERSION) {
1695
+ database.exec(`PRAGMA user_version = ${MOONDOG_MEMORY_SCHEMA_VERSION}`);
1696
+ }
1697
+ if (process.platform !== "win32") {
1698
+ await chmod(path.resolve(databasePath), 0o600);
1699
+ }
1700
+ return new LocalMemoryStore(database, { clock });
1701
+ } catch (error) {
1702
+ database.close();
1703
+ throw error;
1704
+ }
1705
+ }