@audiofool/moondog 0.1.0 → 0.2.0-beta.3

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 (125) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +156 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +253 -53
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +290 -55
  12. package/src/core/listener-avoids.mjs +101 -0
  13. package/src/core/listening-now.mjs +108 -0
  14. package/src/core/listening-profile-domain-services.mjs +189 -41
  15. package/src/core/listening-upkeep.mjs +31 -0
  16. package/src/core/moondog-application.mjs +2115 -107
  17. package/src/core/playback-preferences.mjs +97 -0
  18. package/src/core/spotify-listening-intent.mjs +124 -0
  19. package/src/core/synthetic-domain-services.mjs +24 -3
  20. package/src/core/track-title.mjs +107 -0
  21. package/src/demo/moondog-demo.mjs +4 -22
  22. package/src/i18n/index.mjs +285 -0
  23. package/src/i18n/messages/de.mjs +135 -0
  24. package/src/i18n/messages/en.mjs +136 -0
  25. package/src/i18n/messages/es.mjs +135 -0
  26. package/src/i18n/messages/fr.mjs +135 -0
  27. package/src/i18n/messages/it.mjs +135 -0
  28. package/src/i18n/messages/ja.mjs +135 -0
  29. package/src/i18n/messages/ko.mjs +135 -0
  30. package/src/i18n/messages/pt.mjs +135 -0
  31. package/src/i18n/messages/zh-Hant.mjs +135 -0
  32. package/src/i18n/messages/zh.mjs +135 -0
  33. package/src/i18n/preferences.mjs +34 -0
  34. package/src/i18n/screen/es.mjs +967 -0
  35. package/src/i18n/screen/ja.mjs +967 -0
  36. package/src/i18n/screen/pt.mjs +967 -0
  37. package/src/i18n/screen/zh.mjs +967 -0
  38. package/src/importers/apple-music-library/index.mjs +0 -16
  39. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  40. package/src/integrations/apple-music/catalog.mjs +166 -14
  41. package/src/integrations/apple-music/music-app.mjs +232 -0
  42. package/src/integrations/apple-music/storefront.mjs +28 -0
  43. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  44. package/src/integrations/lastfm/history.mjs +221 -0
  45. package/src/integrations/lastfm/settings.mjs +75 -0
  46. package/src/integrations/netease/ncm-cli-player.mjs +328 -0
  47. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  48. package/src/integrations/public-playlists.mjs +2 -1
  49. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  50. package/src/integrations/spotify/authentication.mjs +149 -15
  51. package/src/integrations/spotify/catalog-resolver.mjs +9 -6
  52. package/src/integrations/spotify/connection.mjs +2 -1
  53. package/src/integrations/spotify/credential-store.mjs +4 -0
  54. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  55. package/src/integrations/spotify/history-archive.mjs +1 -1
  56. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  57. package/src/integrations/spotify/recent-activity.mjs +2 -0
  58. package/src/integrations/spotify/service.mjs +873 -69
  59. package/src/integrations/spotify/web-api-client.mjs +410 -127
  60. package/src/integrations/web/brave-web.mjs +45 -0
  61. package/src/integrations/web/codex-web.mjs +15 -82
  62. package/src/integrations/web/deepseek-web.mjs +81 -0
  63. package/src/integrations/web/web-research.mjs +119 -0
  64. package/src/integrations/web/web-result.mjs +215 -0
  65. package/src/integrations/youtube-music/takeout.mjs +3 -1
  66. package/src/memory/local-memory-store.mjs +542 -57
  67. package/src/profile/history-import.mjs +42 -6
  68. package/src/profile/listener-corrections.mjs +22 -0
  69. package/src/profile/listener-profile-build.mjs +461 -0
  70. package/src/profile/listener-profile-store.mjs +130 -0
  71. package/src/profile/listener-profile-verification.mjs +68 -0
  72. package/src/profile/listener-profile-worker.mjs +179 -0
  73. package/src/profile/listening-history-store.mjs +640 -17
  74. package/src/profile/listening-profile-projection.mjs +454 -86
  75. package/src/profile/local-music-data.mjs +1 -1
  76. package/src/profile/moondog-suggestions.mjs +184 -0
  77. package/src/profile/music-providers.mjs +11 -7
  78. package/src/profile/profile-exploration.mjs +92 -0
  79. package/src/profile/recorded-measures.mjs +24 -0
  80. package/src/profile/spotify-archive-taste.mjs +0 -4
  81. package/src/runtime/pi/agent-runtime.mjs +2536 -692
  82. package/src/runtime/pi/authentication.mjs +34 -1
  83. package/src/runtime/pi/configured-runtime.mjs +22 -6
  84. package/src/runtime/pi/conversation-name.mjs +53 -0
  85. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  86. package/src/runtime/pi/model-catalog.mjs +28 -5
  87. package/src/runtime/pi/model-settings.mjs +202 -0
  88. package/src/runtime/pi/model-transport.mjs +10 -0
  89. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  90. package/src/runtime/pi/profile-build-runtime.mjs +161 -0
  91. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  92. package/src/runtime/pi/runtime-settings.mjs +10 -2
  93. package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
  94. package/src/runtime/pi/tool-trace.mjs +37 -0
  95. package/src/surfaces/cli/auth-command.mjs +12 -4
  96. package/src/surfaces/cli/brand-components.mjs +310 -132
  97. package/src/surfaces/cli/brand-theme.mjs +51 -28
  98. package/src/surfaces/cli/command-completions.mjs +58 -42
  99. package/src/surfaces/cli/format-output.mjs +199 -388
  100. package/src/surfaces/cli/history-import-view.mjs +292 -139
  101. package/src/surfaces/cli/import-guides.mjs +2 -0
  102. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  103. package/src/surfaces/cli/listening-menu.mjs +7 -4
  104. package/src/surfaces/cli/profile-command.mjs +30 -21
  105. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  106. package/src/surfaces/cli/spotify-command.mjs +54 -6
  107. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  108. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  109. package/src/surfaces/cli/terminal-art.mjs +140 -35
  110. package/src/surfaces/cli/tui.mjs +1066 -357
  111. package/src/surfaces/cli/update-command.mjs +32 -0
  112. package/src/surfaces/cli/web-command.mjs +53 -14
  113. package/src/surfaces/html/tasteprint.mjs +43 -70
  114. package/contracts/v1/capability-policy.schema.json +0 -572
  115. package/contracts/v1/profile-evidence.schema.json +0 -237
  116. package/contracts/v1/tool-invocation.schema.json +0 -318
  117. package/contracts/v1/tool-receipt.schema.json +0 -1066
  118. package/scripts/demo-studio.mjs +0 -58
  119. package/src/core/apple-data-migration.mjs +0 -50
  120. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  121. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  122. package/src/integrations/spotify/index.mjs +0 -80
  123. package/src/surfaces/web/showcase.mjs +0 -272
  124. package/src/surfaces/web/studio-page.mjs +0 -4148
  125. package/src/surfaces/web/studio.mjs +0 -2288
@@ -3,7 +3,7 @@ import { chmod, mkdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
 
6
- export const MOONDOG_MEMORY_SCHEMA_VERSION = 2;
6
+ export const MOONDOG_MEMORY_SCHEMA_VERSION = 3;
7
7
  export function resolveMoondogMemoryPath(environment = process.env) {
8
8
  const configuredState = environment.MOONDOG_STATE_HOME?.trim();
9
9
  if (configuredState) {
@@ -92,6 +92,53 @@ const schema = `
92
92
  CREATE INDEX IF NOT EXISTS turns_session_position
93
93
  ON turns(session_id, position);
94
94
 
95
+ -- Display/model conversation history is separate from memory and reflection.
96
+ -- It contains no raw tool calls or reusable action authority.
97
+ CREATE TABLE IF NOT EXISTS conversation_entries (
98
+ entry_id TEXT PRIMARY KEY NOT NULL,
99
+ session_id TEXT NOT NULL REFERENCES sessions(session_id),
100
+ position INTEGER NOT NULL,
101
+ user_text TEXT NOT NULL,
102
+ assistant_text TEXT NOT NULL DEFAULT '',
103
+ outcome TEXT NOT NULL CHECK (outcome IN ('running', 'completed', 'aborted', 'interrupted', 'failed')),
104
+ transient INTEGER NOT NULL DEFAULT 0 CHECK (transient IN (0, 1)),
105
+ created_at TEXT NOT NULL,
106
+ UNIQUE (session_id, position)
107
+ ) STRICT;
108
+
109
+ CREATE TABLE IF NOT EXISTS conversation_branches (
110
+ session_id TEXT PRIMARY KEY NOT NULL REFERENCES sessions(session_id),
111
+ root_session_id TEXT NOT NULL REFERENCES sessions(session_id),
112
+ parent_session_id TEXT REFERENCES sessions(session_id),
113
+ source_entry_id TEXT,
114
+ draft TEXT NOT NULL DEFAULT '',
115
+ queued_json TEXT NOT NULL DEFAULT '[]'
116
+ ) STRICT;
117
+
118
+ -- Additive tables keep the schema version, so older cores still open this database.
119
+ CREATE TABLE IF NOT EXISTS conversation_folders (
120
+ folder_id TEXT PRIMARY KEY NOT NULL,
121
+ name TEXT NOT NULL,
122
+ position INTEGER NOT NULL,
123
+ created_at TEXT NOT NULL
124
+ ) STRICT;
125
+
126
+ CREATE TABLE IF NOT EXISTS conversation_labels (
127
+ session_id TEXT PRIMARY KEY NOT NULL REFERENCES sessions(session_id),
128
+ title TEXT,
129
+ pinned_at TEXT,
130
+ folder_id TEXT REFERENCES conversation_folders(folder_id)
131
+ ) STRICT;
132
+
133
+ -- The process writing each running reply, so the terminal and the App answer one message at a
134
+ -- time in the conversation they share, and a reply left by a process that died blocks nothing.
135
+ -- No foreign key: an older core deletes conversations without knowing this table.
136
+ CREATE TABLE IF NOT EXISTS conversation_writers (
137
+ entry_id TEXT PRIMARY KEY NOT NULL,
138
+ session_id TEXT NOT NULL,
139
+ pid INTEGER NOT NULL
140
+ ) STRICT;
141
+
95
142
  CREATE TABLE IF NOT EXISTS memory_claims (
96
143
  memory_id TEXT PRIMARY KEY NOT NULL,
97
144
  kind TEXT NOT NULL CHECK (kind IN ('fact', 'preference', 'constraint', 'goal')),
@@ -218,6 +265,62 @@ function cleanText(value, maximum, label) {
218
265
  return Array.from(cleaned).slice(0, maximum).join("");
219
266
  }
220
267
 
268
+ export function normalizeMemoryContent(value) {
269
+ return cleanText(value, 2_000, "Memory");
270
+ }
271
+
272
+ function cleanSessionId(value) {
273
+ if (typeof value !== "string" || !value || value.length > 128) {
274
+ throw new TypeError("Session ID is invalid");
275
+ }
276
+ return value;
277
+ }
278
+
279
+ // A partial patch: a missing key keeps its value, and a null or empty title clears it.
280
+ // The in-memory conversations reuse this, so both paths accept the same input.
281
+ export function normalizeConversationLabel(patch) {
282
+ if (!patch || typeof patch !== "object" || Array.isArray(patch) ||
283
+ Object.keys(patch).some((key) => !["title", "pinned", "folderId"].includes(key))) {
284
+ throw new TypeError("Conversation label is invalid");
285
+ }
286
+ const label = {};
287
+ if (patch.title !== undefined) {
288
+ label.title = patch.title === null || (typeof patch.title === "string" && !patch.title.trim())
289
+ ? null : cleanText(patch.title, 160, "Conversation title");
290
+ }
291
+ if (patch.pinned !== undefined) {
292
+ if (typeof patch.pinned !== "boolean") throw new TypeError("Conversation pin is invalid");
293
+ label.pinned = patch.pinned;
294
+ }
295
+ if (patch.folderId !== undefined) {
296
+ label.folderId = patch.folderId === null ? null : cleanText(patch.folderId, 128, "Folder ID");
297
+ }
298
+ return label;
299
+ }
300
+
301
+ // The first message stands in for a conversation's name until it has one. A long one is cut, at a space when
302
+ // that keeps most of it, so the list never shows a whole paragraph. Text without spaces is cut where it stands.
303
+ export function shortConversationTitle(text, limit = 60) {
304
+ const flat = String(text ?? "").replace(/\s+/gu, " ").trim();
305
+ const characters = Array.from(flat);
306
+ if (characters.length <= limit) return flat;
307
+ const cut = characters.slice(0, limit - 1).join("");
308
+ const space = cut.lastIndexOf(" ");
309
+ const head = space >= cut.length * 0.6 ? cut.slice(0, space) : cut;
310
+ return `${head.replace(/[\s.,;:!?\-…、。,;:!?]+$/u, "")}…`;
311
+ }
312
+
313
+ export function normalizeConversationFolder({ folderId, name, position } = {}) {
314
+ if (position !== undefined && (!Number.isSafeInteger(position) || position < 0)) {
315
+ throw new TypeError("Folder position is invalid");
316
+ }
317
+ return {
318
+ folderId: folderId === undefined || folderId === null ? null : cleanText(folderId, 128, "Folder ID"),
319
+ name: cleanText(name, 80, "Folder name"),
320
+ position,
321
+ };
322
+ }
323
+
221
324
  function cleanKind(value) {
222
325
  const kind = value ?? "fact";
223
326
  if (!memoryKinds.has(kind)) throw new TypeError("Memory kind is invalid");
@@ -469,8 +572,31 @@ function normalized(value) {
469
572
  return value.normalize("NFKC").toLocaleLowerCase("en-US");
470
573
  }
471
574
 
575
+ // Memories the listener saved themselves: with remember, /remember or the desktop app, or by
576
+ // asking Moondog in conversation, where the host checks the words against their message.
577
+ const listenerSavedOrigins = ["explicit_user", "agent_from_explicit_user_statement"];
578
+
579
+ // Text cut to `maximum` characters, ending in an ellipsis when it was cut.
580
+ function clipped(value, maximum) {
581
+ const characters = Array.from(value);
582
+ return characters.length > maximum ? `${characters.slice(0, maximum).join("").trimEnd()}…` : value;
583
+ }
584
+
585
+ // Common English words that would recall unrelated memories on their own.
586
+ const stopwords = new Set(`about after again all also am an and any are as at be been but by can could
587
+ did do does don for from get had has have he her here him his how if in into is it its just let like
588
+ me more most my no not now of on one or our out please really she so some than that the their them
589
+ then there these they this those to too up us very was we were what when where which who why will
590
+ with would you your`.split(/\s+/u));
591
+ const cjkScript = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
592
+ const cjkScripts = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu;
593
+ // A word without a plural "s", so "record" and "records" find each other.
594
+ const singular = (word) => (word.length > 3 && word.endsWith("s") && !word.endsWith("ss") ? word.slice(0, -1) : word);
595
+
596
+ // Search terms in a query: Han bigrams, and whole words elsewhere. No query gives null, which
597
+ // matches everything; a query of only common words gives no terms, which matches nothing.
472
598
  function queryTerms(value) {
473
- if (typeof value !== "string") return [];
599
+ if (typeof value !== "string" || !value.trim()) return null;
474
600
  const text = normalized(value);
475
601
  const segments = text.match(/\p{Script=Han}+|[\p{L}\p{N}]+/gu) ?? [];
476
602
  const terms = [];
@@ -481,18 +607,21 @@ function queryTerms(value) {
481
607
  for (let index = 0; index < characters.length - 1; index += 1) {
482
608
  terms.push(`${characters[index]}${characters[index + 1]}`);
483
609
  }
484
- } else if (Array.from(segment).length >= 2) {
485
- terms.push(segment);
610
+ } else if (Array.from(segment).length >= 2 && !stopwords.has(segment)) {
611
+ terms.push(cjkScript.test(segment) ? segment : singular(segment));
486
612
  }
487
613
  }
488
614
  return [...new Set(terms)].slice(0, 24);
489
615
  }
490
616
 
617
+ // CJK terms match inside the text, since those scripts do not space words; other terms match
618
+ // whole words only, plural or not, so "me" no longer recalls "named".
491
619
  function relevanceScore(memory, terms) {
492
- if (terms.length === 0) return 0;
620
+ if (!terms?.length) return 0;
493
621
  const content = normalized(memory.content);
622
+ const words = new Set((content.replace(cjkScripts, " ").match(/[\p{L}\p{N}]+/gu) ?? []).map(singular));
494
623
  return terms.reduce(
495
- (score, term) => score + (content.includes(term) ? term.length : 0),
624
+ (score, term) => score + ((cjkScript.test(term) ? content.includes(term) : words.has(term)) ? term.length : 0),
496
625
  0,
497
626
  );
498
627
  }
@@ -501,16 +630,30 @@ export class LocalMemoryStore {
501
630
  #database;
502
631
  #clock;
503
632
  #closed = false;
633
+ #processId;
504
634
 
505
- constructor(database, { clock = () => new Date() } = {}) {
635
+ constructor(database, { clock = () => new Date(), processId = process.pid } = {}) {
506
636
  this.#database = database;
507
637
  this.#clock = clock;
638
+ this.#processId = processId;
508
639
  }
509
640
 
510
641
  #assertOpen() {
511
642
  if (this.#closed) throw new Error("Memory store is closed");
512
643
  }
513
644
 
645
+ // The reply another running Moondog process is writing in this conversation, if any.
646
+ #replyElsewhere(sessionId) {
647
+ const running = (pid) => {
648
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
649
+ };
650
+ const row = this.#database.prepare(`SELECT e.entry_id, e.user_text, e.assistant_text, w.pid
651
+ FROM conversation_entries e JOIN conversation_writers w ON w.entry_id = e.entry_id
652
+ WHERE e.session_id = ? AND e.outcome = 'running' AND w.pid != ?`).all(sessionId, this.#processId)
653
+ .find((candidate) => running(candidate.pid));
654
+ return row ? { entry_id: row.entry_id, user_text: row.user_text, assistant_text: row.assistant_text } : null;
655
+ }
656
+
514
657
  #insertEpisode({
515
658
  episodeId = randomUUID(),
516
659
  sessionId,
@@ -607,6 +750,16 @@ export class LocalMemoryStore {
607
750
  timestamp,
608
751
  timestamp,
609
752
  );
753
+ // Saving again what the memory agent drew from talk makes it the listener's own.
754
+ if (inserted.changes === 0 && listenerSavedOrigins.includes(mutation.origin)) {
755
+ this.#database
756
+ .prepare(`
757
+ UPDATE memory_claims SET origin = ?
758
+ WHERE status = 'active' AND normalized_content = ?
759
+ AND origin NOT IN (${listenerSavedOrigins.map(() => "?").join(", ")})
760
+ `)
761
+ .run(mutation.origin, mutation.normalizedContent, ...listenerSavedOrigins);
762
+ }
610
763
  const row = this.#database
611
764
  .prepare(`
612
765
  SELECT *
@@ -814,6 +967,27 @@ export class LocalMemoryStore {
814
967
  return existing ?? this.startSession({ routeKey: route });
815
968
  }
816
969
 
970
+ // The conversation opened last on the route, by this process or another. The terminal and
971
+ // the App both show this one.
972
+ activeSession(routeKey = "local:main") {
973
+ this.#assertOpen();
974
+ const route = cleanText(routeKey, 128, "Session route");
975
+ const row = this.#database.prepare("SELECT session_id, route_key, started_at FROM sessions WHERE route_key = ? AND status = 'active'")
976
+ .get(route);
977
+ return row ? { ...row } : null;
978
+ }
979
+
980
+ replyElsewhere(sessionId) {
981
+ this.#assertOpen();
982
+ return this.#replyElsewhere(sessionId);
983
+ }
984
+
985
+ latestConversationEntryId(sessionId) {
986
+ this.#assertOpen();
987
+ return this.#database.prepare("SELECT entry_id FROM conversation_entries WHERE session_id = ? ORDER BY position DESC LIMIT 1")
988
+ .get(sessionId)?.entry_id ?? null;
989
+ }
990
+
817
991
  listSessions({ routeKey = "local:main", limit = 200 } = {}) {
818
992
  this.#assertOpen();
819
993
  const route = cleanText(routeKey, 128, "Session route");
@@ -821,42 +995,191 @@ export class LocalMemoryStore {
821
995
  const bounded = Number.isFinite(requestedLimit)
822
996
  ? Math.max(1, Math.min(Math.trunc(requestedLimit), 200))
823
997
  : 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
- }));
998
+ return this.#database.prepare(`
999
+ SELECT s.session_id, s.started_at,
1000
+ MAX(s.started_at, COALESCE((SELECT MAX(created_at) FROM conversation_entries WHERE session_id = s.session_id),
1001
+ (SELECT MAX(created_at) FROM turns WHERE session_id = s.session_id), s.started_at)) AS updated_at,
1002
+ CASE WHEN EXISTS (SELECT 1 FROM conversation_entries WHERE session_id = s.session_id)
1003
+ THEN (SELECT SUM(1 + (assistant_text != '')) FROM conversation_entries WHERE session_id = s.session_id)
1004
+ ELSE (SELECT COUNT(*) FROM turns WHERE session_id = s.session_id) END AS turn_count,
1005
+ l.title AS label_title,
1006
+ COALESCE((SELECT user_text FROM conversation_entries WHERE session_id = s.session_id ORDER BY position LIMIT 1),
1007
+ (SELECT content FROM turns WHERE session_id = s.session_id AND role = 'user' ORDER BY position LIMIT 1),
1008
+ b.draft, '') AS first_text,
1009
+ l.title IS NOT NULL AS custom_title, l.pinned_at, l.folder_id,
1010
+ b.parent_session_id,
1011
+ CASE WHEN b.parent_session_id IS NOT NULL AND l.title IS NULL THEN
1012
+ (SELECT title FROM conversation_labels WHERE session_id = b.root_session_id) END AS lineage_label,
1013
+ CASE WHEN b.parent_session_id IS NOT NULL AND l.title IS NULL THEN COALESCE(
1014
+ (SELECT user_text FROM conversation_entries WHERE session_id = b.root_session_id ORDER BY position LIMIT 1),
1015
+ (SELECT content FROM turns WHERE session_id = b.root_session_id AND role = 'user' ORDER BY position LIMIT 1)) END AS lineage_first,
1016
+ CASE WHEN b.parent_session_id IS NOT NULL AND l.title IS NULL THEN (SELECT COUNT(*) FROM conversation_branches r
1017
+ JOIN sessions rs ON rs.session_id = r.session_id WHERE r.root_session_id = b.root_session_id
1018
+ AND (rs.started_at < s.started_at OR (rs.started_at = s.started_at AND rs.rowid <= s.rowid))) END AS lineage_number,
1019
+ COALESCE(NULLIF(b.draft, ''), (SELECT user_text FROM conversation_entries WHERE session_id = s.session_id ORDER BY position DESC LIMIT 1), '') AS last_message
1020
+ FROM sessions s LEFT JOIN conversation_branches b ON b.session_id = s.session_id
1021
+ LEFT JOIN conversation_labels l ON l.session_id = s.session_id
1022
+ WHERE s.route_key = ? AND (
1023
+ EXISTS (SELECT 1 FROM conversation_entries WHERE session_id = s.session_id) OR
1024
+ EXISTS (SELECT 1 FROM turns WHERE session_id = s.session_id) OR b.draft != '')
1025
+ ORDER BY l.pinned_at IS NULL, updated_at DESC, s.started_at DESC, s.rowid DESC LIMIT ?
1026
+ `).all(route, bounded).map(row => {
1027
+ // An unnamed conversation shows its first message, cut short.
1028
+ const title = row.label_title ?? shortConversationTitle(row.first_text);
1029
+ // A branch starts with its parent's first message, so an unnamed one would share its parent's title.
1030
+ // It shows its lineage's title and its place in that lineage instead, "Late night #2"; a name the
1031
+ // listener gives it replaces both.
1032
+ const lineage = row.lineage_label || shortConversationTitle(row.lineage_first) || title;
1033
+ return {
1034
+ session_id: row.session_id, started_at: row.started_at, updated_at: row.updated_at,
1035
+ turn_count: row.turn_count, title: row.lineage_number ? `${lineage} #${row.lineage_number}` : title,
1036
+ custom_title: row.custom_title === 1, pinned_at: row.pinned_at ?? null, folder_id: row.folder_id ?? null,
1037
+ ...(row.parent_session_id ? { parent_session_id: row.parent_session_id, last_message: row.last_message } : {}),
1038
+ };
1039
+ });
1040
+ }
1041
+
1042
+ // The first message and reply of a conversation the model may name: one with no name, no parent and
1043
+ // exactly one finished reply. Null for any other, so each conversation is named at most once.
1044
+ untitledConversationStart(sessionId, { routeKey = "local:main" } = {}) {
1045
+ this.#assertOpen();
1046
+ const id = cleanSessionId(sessionId);
1047
+ const route = cleanText(routeKey, 128, "Session route");
1048
+ const row = this.#database.prepare(`SELECT
1049
+ (SELECT title FROM conversation_labels WHERE session_id = s.session_id) AS title,
1050
+ (SELECT parent_session_id FROM conversation_branches WHERE session_id = s.session_id) AS parent_session_id,
1051
+ (SELECT COUNT(*) FROM conversation_entries WHERE session_id = s.session_id AND outcome = 'completed' AND assistant_text != '') AS replies
1052
+ FROM sessions s WHERE s.session_id = ? AND s.route_key = ?`).get(id, route);
1053
+ if (!row || row.title !== null || row.parent_session_id || row.replies !== 1) return null;
1054
+ const entry = this.#database.prepare(`SELECT user_text, assistant_text FROM conversation_entries
1055
+ WHERE session_id = ? AND outcome = 'completed' AND assistant_text != '' LIMIT 1`).get(id);
1056
+ return { user: entry.user_text, assistant: entry.assistant_text };
1057
+ }
1058
+
1059
+ sessionExists(sessionId) {
1060
+ this.#assertOpen();
1061
+ return Boolean(this.#database.prepare("SELECT 1 FROM sessions WHERE session_id = ?").get(sessionId));
1062
+ }
1063
+
1064
+ // With ifUntitled, a conversation that already has a name keeps it and this returns null: the model's
1065
+ // name never replaces one the listener gave, even one given while the model was choosing.
1066
+ labelConversation(sessionId, patch, { routeKey = "local:main", ifUntitled = false } = {}) {
1067
+ this.#assertOpen();
1068
+ const id = cleanSessionId(sessionId);
1069
+ const route = cleanText(routeKey, 128, "Session route");
1070
+ const label = normalizeConversationLabel(patch);
1071
+ this.#database.exec("BEGIN IMMEDIATE");
1072
+ try {
1073
+ if (!this.#database.prepare("SELECT 1 FROM sessions WHERE session_id = ? AND route_key = ?").get(id, route)) {
1074
+ throw new Error("Saved conversation not found for this route.");
1075
+ }
1076
+ if (label.folderId && !this.#database.prepare("SELECT 1 FROM conversation_folders WHERE folder_id = ?").get(label.folderId)) {
1077
+ throw new Error("Conversation folder not found.");
1078
+ }
1079
+ const current = this.#database.prepare("SELECT title, pinned_at, folder_id FROM conversation_labels WHERE session_id = ?").get(id);
1080
+ if (ifUntitled && (current?.title ?? null) !== null) {
1081
+ this.#database.exec("COMMIT");
1082
+ return null;
1083
+ }
1084
+ const next = {
1085
+ title: label.title !== undefined ? label.title : current?.title ?? null,
1086
+ // Pinning again keeps the first pin time.
1087
+ pinned_at: label.pinned === undefined ? current?.pinned_at ?? null
1088
+ : label.pinned ? current?.pinned_at ?? isoTimestamp(this.#clock) : null,
1089
+ folder_id: label.folderId !== undefined ? label.folderId : current?.folder_id ?? null,
1090
+ };
1091
+ if (next.title === null && next.pinned_at === null && next.folder_id === null) {
1092
+ this.#database.prepare("DELETE FROM conversation_labels WHERE session_id = ?").run(id);
1093
+ } else {
1094
+ this.#database.prepare(`INSERT INTO conversation_labels(session_id, title, pinned_at, folder_id) VALUES (?, ?, ?, ?)
1095
+ ON CONFLICT(session_id) DO UPDATE SET title = excluded.title, pinned_at = excluded.pinned_at, folder_id = excluded.folder_id`)
1096
+ .run(id, next.title, next.pinned_at, next.folder_id);
1097
+ }
1098
+ this.#database.exec("COMMIT");
1099
+ return { session_id: id, ...next };
1100
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1101
+ }
1102
+
1103
+ listConversationFolders() {
1104
+ this.#assertOpen();
1105
+ return this.#database.prepare(`SELECT folder_id, name, position, created_at FROM conversation_folders
1106
+ ORDER BY position, created_at, folder_id`).all().map(row => ({ ...row }));
1107
+ }
1108
+
1109
+ // Without a folder ID this creates a folder; with one it renames or moves that folder.
1110
+ saveConversationFolder(folder) {
1111
+ this.#assertOpen();
1112
+ const { folderId, name, position } = normalizeConversationFolder(folder);
1113
+ this.#database.exec("BEGIN IMMEDIATE");
1114
+ try {
1115
+ let id = folderId;
1116
+ if (id) {
1117
+ const changed = this.#database.prepare("UPDATE conversation_folders SET name = ?, position = COALESCE(?, position) WHERE folder_id = ?")
1118
+ .run(name, position ?? null, id);
1119
+ if (changed.changes !== 1) throw new Error("Conversation folder not found.");
1120
+ } else {
1121
+ id = randomUUID();
1122
+ const last = this.#database.prepare("SELECT MAX(position) AS position FROM conversation_folders").get().position;
1123
+ this.#database.prepare("INSERT INTO conversation_folders(folder_id, name, position, created_at) VALUES (?, ?, ?, ?)")
1124
+ .run(id, name, position ?? (last === null ? 0 : last + 1), isoTimestamp(this.#clock));
1125
+ }
1126
+ const row = this.#database.prepare("SELECT folder_id, name, position, created_at FROM conversation_folders WHERE folder_id = ?").get(id);
1127
+ this.#database.exec("COMMIT");
1128
+ return { ...row };
1129
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1130
+ }
1131
+
1132
+ // Deleting a folder keeps its conversations; they return to the unfiled list.
1133
+ deleteConversationFolder(folderId) {
1134
+ this.#assertOpen();
1135
+ const id = cleanText(folderId, 128, "Folder ID");
1136
+ this.#database.exec("BEGIN IMMEDIATE");
1137
+ try {
1138
+ const unfiled = this.#database.prepare("UPDATE conversation_labels SET folder_id = NULL WHERE folder_id = ?").run(id).changes;
1139
+ this.#database.prepare("DELETE FROM conversation_labels WHERE title IS NULL AND pinned_at IS NULL AND folder_id IS NULL").run();
1140
+ const deleted = this.#database.prepare("DELETE FROM conversation_folders WHERE folder_id = ?").run(id).changes === 1;
1141
+ this.#database.exec("COMMIT");
1142
+ return { deleted, folder_id: id, unfiled };
1143
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1144
+ }
1145
+
1146
+ // Removes one conversation and its transcript but keeps durable memories, so a
1147
+ // preference stated in it still holds. Its branches stay, re-parented and re-rooted.
1148
+ deleteConversation(sessionId, { routeKey = "local:main" } = {}) {
1149
+ this.#assertOpen();
1150
+ const id = cleanSessionId(sessionId);
1151
+ const route = cleanText(routeKey, 128, "Session route");
1152
+ const run = (sql, ...values) => this.#database.prepare(sql).run(...values).changes;
1153
+ this.#database.exec("BEGIN IMMEDIATE");
1154
+ try {
1155
+ const session = this.#database.prepare("SELECT status FROM sessions WHERE session_id = ? AND route_key = ?").get(id, route);
1156
+ if (!session) throw new Error("Saved conversation not found for this route.");
1157
+ // The terminal and the App both show the active one, and a reply may still run in another one.
1158
+ if (session.status === "active" || this.#replyElsewhere(id)) throw new Error("This conversation is open in another Moondog window or terminal.");
1159
+ // Foreign keys are checked at once, so every reference to the session goes before its row.
1160
+ const parent = this.#database.prepare("SELECT parent_session_id FROM conversation_branches WHERE session_id = ?").get(id)?.parent_session_id ?? null;
1161
+ const reparented = run("UPDATE conversation_branches SET parent_session_id = ? WHERE parent_session_id = ?", parent, id);
1162
+ const heir = this.#database.prepare(`SELECT b.session_id FROM conversation_branches b JOIN sessions s ON s.session_id = b.session_id
1163
+ WHERE b.root_session_id = ? AND b.session_id != ? ORDER BY s.started_at, s.rowid LIMIT 1`).get(id, id)?.session_id;
1164
+ if (heir) run("UPDATE conversation_branches SET root_session_id = ? WHERE root_session_id = ? AND session_id != ?", heir, id, id);
1165
+ // Dialogue episodes hold the raw user text. With no session they would be recalled
1166
+ // in every conversation, so they go; explicit memories and retractions stay.
1167
+ run(`DELETE FROM claim_episode_refs WHERE episode_id IN
1168
+ (SELECT episode_id FROM episodes WHERE session_id = ? AND kind = 'dialogue')`, id);
1169
+ run("DELETE FROM episodes WHERE session_id = ? AND kind = 'dialogue'", id);
1170
+ run("UPDATE episodes SET session_id = NULL WHERE session_id = ?", id);
1171
+ run("UPDATE memory_claims SET source_session_id = NULL WHERE source_session_id = ?", id);
1172
+ for (const table of ["conversation_labels", "conversation_writers", "conversation_entries", "turns", "conversation_branches", "sessions"]) {
1173
+ run(`DELETE FROM ${table} WHERE session_id = ?`, id);
1174
+ }
1175
+ this.#database.exec("COMMIT");
1176
+ return { deleted: true, session_id: id, reparented };
1177
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
853
1178
  }
854
1179
 
855
1180
  resumeSession(sessionId, { routeKey = "local:main" } = {}) {
856
1181
  this.#assertOpen();
857
- if (typeof sessionId !== "string" || !sessionId || sessionId.length > 128) {
858
- throw new TypeError("Session ID is invalid");
859
- }
1182
+ cleanSessionId(sessionId);
860
1183
  const route = cleanText(routeKey, 128, "Session route");
861
1184
  this.#database.exec("BEGIN IMMEDIATE");
862
1185
  try {
@@ -1030,6 +1353,24 @@ export class LocalMemoryStore {
1030
1353
  }
1031
1354
  }
1032
1355
 
1356
+ commitMemoryMutations(memoryMutations) {
1357
+ this.#assertOpen();
1358
+ const timestamp = isoTimestamp(this.#clock);
1359
+ this.#database.exec("BEGIN IMMEDIATE");
1360
+ try {
1361
+ const results = memoryMutations.map((mutation) => {
1362
+ if (mutation?.type === "remember") return this.#applyPreparedRemember(mutation, { timestamp });
1363
+ if (mutation?.type === "forget") return this.#applyPreparedForget(mutation, { timestamp });
1364
+ throw new TypeError("Memory mutation is invalid");
1365
+ });
1366
+ this.#database.exec("COMMIT");
1367
+ return results;
1368
+ } catch (error) {
1369
+ this.#database.exec("ROLLBACK");
1370
+ throw error;
1371
+ }
1372
+ }
1373
+
1033
1374
  readSessionTurns(sessionId, { limit = 40 } = {}) {
1034
1375
  this.#assertOpen();
1035
1376
  const bounded = Math.max(1, Math.min(Number(limit) || 40, 100));
@@ -1050,6 +1391,118 @@ export class LocalMemoryStore {
1050
1391
  }));
1051
1392
  }
1052
1393
 
1394
+ #ensureConversation(sessionId) {
1395
+ const exists = this.#database.prepare("SELECT 1 FROM conversation_branches WHERE session_id = ?").get(sessionId);
1396
+ if (exists) return;
1397
+ this.#database.prepare(`INSERT INTO conversation_branches(session_id, root_session_id) VALUES (?, ?)`).run(sessionId, sessionId);
1398
+ // Import only an old session's rendered text, never tool messages or plans.
1399
+ const legacy = this.#database.prepare("SELECT * FROM turns WHERE session_id = ? ORDER BY position").all(sessionId);
1400
+ const insert = this.#database.prepare(`INSERT INTO conversation_entries
1401
+ (entry_id, session_id, position, user_text, assistant_text, outcome, transient, created_at)
1402
+ VALUES (?, ?, ?, ?, ?, 'completed', 0, ?)`);
1403
+ for (let index = 0, position = 1; index < legacy.length; index += 1) {
1404
+ const user = legacy[index];
1405
+ if (user.role !== 'user') continue;
1406
+ const assistant = legacy[index + 1]?.role === 'assistant' ? legacy[++index].content : '';
1407
+ insert.run(user.turn_id, sessionId, position++, user.content, assistant, user.created_at);
1408
+ }
1409
+ }
1410
+
1411
+ beginConversationEntry(sessionId, user) {
1412
+ this.#assertOpen();
1413
+ if (typeof user !== 'string' || !user.trim() || user.length > 8_000) throw new TypeError("Conversation input is invalid");
1414
+ const text = user;
1415
+ this.#database.exec("BEGIN IMMEDIATE");
1416
+ try {
1417
+ this.#ensureConversation(sessionId);
1418
+ if (this.#replyElsewhere(sessionId)) {
1419
+ throw Object.assign(new Error("Moondog is answering in this conversation in another window or terminal."), { code: "conversation_busy" });
1420
+ }
1421
+ const position = this.#database.prepare("SELECT COALESCE(MAX(position), 0) + 1 AS n FROM conversation_entries WHERE session_id = ?").get(sessionId).n;
1422
+ const entryId = randomUUID();
1423
+ this.#database.prepare(`INSERT INTO conversation_entries(entry_id, session_id, position, user_text, outcome, created_at)
1424
+ VALUES (?, ?, ?, ?, 'running', ?)`).run(entryId, sessionId, position, text, isoTimestamp(this.#clock));
1425
+ this.#database.prepare("INSERT INTO conversation_writers(entry_id, session_id, pid) VALUES (?, ?, ?)").run(entryId, sessionId, this.#processId);
1426
+ this.#database.prepare("UPDATE conversation_branches SET draft = '' WHERE session_id = ?").run(sessionId);
1427
+ this.#database.exec("COMMIT");
1428
+ return entryId;
1429
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1430
+ }
1431
+
1432
+ finishConversationEntry(sessionId, entryId, { text = '', status = 'failed', transient = false } = {}) {
1433
+ this.#assertOpen();
1434
+ if (!['completed', 'aborted', 'interrupted', 'failed'].includes(status) || typeof text !== 'string' || text.length > 40_000) {
1435
+ throw new TypeError("Conversation outcome is invalid");
1436
+ }
1437
+ this.#database.prepare("DELETE FROM conversation_writers WHERE entry_id = ?").run(entryId);
1438
+ const changed = this.#database.prepare(`UPDATE conversation_entries SET assistant_text = ?, outcome = ?, transient = ?
1439
+ WHERE session_id = ? AND entry_id = ? AND outcome = 'running'`).run(text, status, transient ? 1 : 0, sessionId, entryId);
1440
+ if (changed.changes !== 1) throw new Error("Conversation turn is no longer active");
1441
+ }
1442
+
1443
+ // The reply so far, so another Moondog window or terminal can show it while it grows.
1444
+ showConversationProgress(sessionId, entryId, text) {
1445
+ this.#assertOpen();
1446
+ if (typeof text !== 'string') throw new TypeError("Conversation progress is invalid");
1447
+ this.#database.prepare(`UPDATE conversation_entries SET assistant_text = ?
1448
+ WHERE session_id = ? AND entry_id = ? AND outcome = 'running'`).run(text.slice(0, 40_000), sessionId, entryId);
1449
+ }
1450
+
1451
+ readConversationEntries(sessionId, { limit = 200 } = {}) {
1452
+ this.#assertOpen();
1453
+ this.#database.exec("BEGIN IMMEDIATE");
1454
+ try { this.#ensureConversation(sessionId); this.#database.exec("COMMIT"); }
1455
+ catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1456
+ const bounded = Math.max(1, Math.min(Math.trunc(Number(limit)) || 200, 500));
1457
+ return this.#database.prepare(`SELECT entry_id, user_text, assistant_text, outcome, transient, created_at
1458
+ FROM conversation_entries WHERE session_id = ? ORDER BY position DESC LIMIT ?`).all(sessionId, bounded).reverse()
1459
+ .map(row => ({ ...row, transient: row.transient === 1 }));
1460
+ }
1461
+
1462
+ conversationPending(sessionId, pending) {
1463
+ this.#assertOpen();
1464
+ if (pending !== undefined && (typeof pending?.draft !== 'string' || pending.draft.length > 8_000 ||
1465
+ !Array.isArray(pending.queued) || pending.queued.length > 30 ||
1466
+ pending.queued.some(text => typeof text !== 'string' || text.length > 8_000))) throw new TypeError("Conversation draft is invalid");
1467
+ this.#database.exec("BEGIN IMMEDIATE");
1468
+ try {
1469
+ this.#ensureConversation(sessionId);
1470
+ if (pending !== undefined) this.#database.prepare("UPDATE conversation_branches SET draft = ?, queued_json = ? WHERE session_id = ?")
1471
+ .run(pending.draft, JSON.stringify(pending.queued), sessionId);
1472
+ const row = this.#database.prepare("SELECT draft, queued_json FROM conversation_branches WHERE session_id = ?").get(sessionId);
1473
+ this.#database.exec("COMMIT");
1474
+ return { draft: row.draft, queued: JSON.parse(row.queued_json) };
1475
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1476
+ }
1477
+
1478
+ forkConversation(sessionId, entryId, { routeKey = 'local:main' } = {}) {
1479
+ this.#assertOpen();
1480
+ const route = cleanText(routeKey, 128, "Session route");
1481
+ this.#database.exec("BEGIN IMMEDIATE");
1482
+ try {
1483
+ // Another window or terminal may have opened a conversation since this one; the branch still
1484
+ // becomes the one both show.
1485
+ const original = this.#database.prepare("SELECT * FROM sessions WHERE session_id = ? AND route_key = ?").get(sessionId, route);
1486
+ if (!original) throw new Error("The selected conversation is no longer available");
1487
+ this.#ensureConversation(sessionId);
1488
+ const selected = this.#database.prepare("SELECT * FROM conversation_entries WHERE session_id = ? AND entry_id = ?").get(sessionId, entryId);
1489
+ if (!selected) throw new Error("The selected turn is no longer available");
1490
+ const root = this.#database.prepare("SELECT root_session_id FROM conversation_branches WHERE session_id = ?").get(sessionId).root_session_id;
1491
+ const createdAt = isoTimestamp(this.#clock);
1492
+ const nextId = randomUUID();
1493
+ this.#database.prepare("UPDATE sessions SET status = 'closed', ended_at = ?, close_reason = 'rewind' WHERE route_key = ? AND status = 'active'").run(createdAt, route);
1494
+ this.#database.prepare("INSERT INTO sessions(session_id, route_key, started_at, status) VALUES (?, ?, ?, 'active')").run(nextId, route, createdAt);
1495
+ this.#database.prepare(`INSERT INTO conversation_branches(session_id, root_session_id, parent_session_id, source_entry_id, draft)
1496
+ VALUES (?, ?, ?, ?, ?)`).run(nextId, root, sessionId, entryId, selected.user_text);
1497
+ const prefix = this.#database.prepare("SELECT * FROM conversation_entries WHERE session_id = ? AND position < ? ORDER BY position").all(sessionId, selected.position);
1498
+ const insert = this.#database.prepare(`INSERT INTO conversation_entries
1499
+ (entry_id, session_id, position, user_text, assistant_text, outcome, transient, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
1500
+ for (const row of prefix) insert.run(randomUUID(), nextId, row.position, row.user_text, row.assistant_text, row.outcome, row.transient, row.created_at);
1501
+ this.#database.exec("COMMIT");
1502
+ return { session_id: nextId, route_key: route, started_at: createdAt, parent_session_id: sessionId, draft: selected.user_text };
1503
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1504
+ }
1505
+
1053
1506
  prepareRemember({
1054
1507
  text,
1055
1508
  kind = "fact",
@@ -1058,7 +1511,7 @@ export class LocalMemoryStore {
1058
1511
  sourceSessionId,
1059
1512
  }) {
1060
1513
  this.#assertOpen();
1061
- const content = cleanText(text, 2_000, "Memory");
1514
+ const content = normalizeMemoryContent(text);
1062
1515
  const normalizedContent = normalized(content);
1063
1516
  const existing = this.#database
1064
1517
  .prepare(`
@@ -1511,7 +1964,7 @@ export class LocalMemoryStore {
1511
1964
  const terms = queryTerms(query);
1512
1965
  const ranked = rows
1513
1966
  .map((row) => ({ row, score: relevanceScore(row, terms) }))
1514
- .filter(({ score }) => terms.length === 0 || score > 0)
1967
+ .filter(({ score }) => terms === null || score > 0)
1515
1968
  .sort(
1516
1969
  (left, right) =>
1517
1970
  right.score - left.score ||
@@ -1535,6 +1988,7 @@ export class LocalMemoryStore {
1535
1988
  recentEpisodes({
1536
1989
  query,
1537
1990
  excludeSessionId,
1991
+ includeCurrentSession = false,
1538
1992
  limit = 12,
1539
1993
  now = this.#clock(),
1540
1994
  } = {}) {
@@ -1547,11 +2001,12 @@ export class LocalMemoryStore {
1547
2001
  FROM episodes
1548
2002
  WHERE status = 'active'
1549
2003
  AND (expires_at IS NULL OR expires_at > ?)
1550
- AND (session_id IS NULL OR session_id != COALESCE(?, ''))
2004
+ AND (session_id IS NULL OR (session_id = ? AND ? = 1) OR COALESCE((SELECT root_session_id FROM conversation_branches b WHERE b.session_id = episodes.session_id), session_id)
2005
+ != COALESCE((SELECT root_session_id FROM conversation_branches WHERE session_id = ?), ?, ''))
1551
2006
  ORDER BY occurred_at DESC, episode_id ASC
1552
2007
  LIMIT 256
1553
2008
  `)
1554
- .all(nowTimestamp, excludeSessionId ?? null);
2009
+ .all(nowTimestamp, excludeSessionId ?? null, includeCurrentSession ? 1 : 0, excludeSessionId ?? null, excludeSessionId ?? null);
1555
2010
  const terms = queryTerms(query);
1556
2011
  return rows
1557
2012
  .map((row) => ({
@@ -1559,7 +2014,7 @@ export class LocalMemoryStore {
1559
2014
  score: relevanceScore({ content: row.summary }, terms),
1560
2015
  }))
1561
2016
  .filter(
1562
- ({ score }) => terms.length === 0 || score > 0,
2017
+ ({ score }) => terms === null || score > 0,
1563
2018
  )
1564
2019
  .sort(
1565
2020
  (left, right) =>
@@ -1582,12 +2037,13 @@ export class LocalMemoryStore {
1582
2037
  .prepare(`
1583
2038
  SELECT s.session_id, s.started_at, s.ended_at
1584
2039
  FROM sessions s
1585
- WHERE s.session_id != COALESCE(?, '')
2040
+ WHERE COALESCE((SELECT root_session_id FROM conversation_branches b WHERE b.session_id = s.session_id), s.session_id)
2041
+ != COALESCE((SELECT root_session_id FROM conversation_branches WHERE session_id = ?), ?, '')
1586
2042
  AND EXISTS (SELECT 1 FROM turns t WHERE t.session_id = s.session_id)
1587
2043
  ORDER BY COALESCE(s.ended_at, s.started_at) DESC
1588
2044
  LIMIT ?
1589
2045
  `)
1590
- .all(excludeSessionId ?? null, boundedSessions);
2046
+ .all(excludeSessionId ?? null, excludeSessionId ?? null, boundedSessions);
1591
2047
  const readTurns = this.#database.prepare(`
1592
2048
  SELECT role, content, created_at
1593
2049
  FROM turns
@@ -1610,29 +2066,50 @@ export class LocalMemoryStore {
1610
2066
  }));
1611
2067
  }
1612
2068
 
2069
+ // What the agent reads on every model request, so it is kept short: no ids it cannot use,
2070
+ // and earlier turns clipped.
1613
2071
  context({ query, currentSessionId } = {}) {
1614
2072
  this.#assertOpen();
1615
- const durableMemories = this.listMemories({ limit: 12 })
1616
- .filter((memory) => memory.kind !== "fact")
1617
- .slice(0, 6);
2073
+ // Facts the memory agent drew from conversations come back only when a request is about
2074
+ // them. A fact the listener saved themselves is something they want kept in mind, so it
2075
+ // stays in view, in slots of its own so saved notes never push out a preference or a rule.
2076
+ const latest = (filter, ...values) => this.#database.prepare(`SELECT * FROM memory_claims
2077
+ WHERE status = 'active' AND ${filter} ORDER BY updated_at DESC, memory_id ASC LIMIT ?`).all(...values);
2078
+ const durableMemories = [
2079
+ ...latest("kind != 'fact'", 6),
2080
+ ...latest(`kind = 'fact' AND origin IN (${listenerSavedOrigins.map(() => "?").join(", ")})`, ...listenerSavedOrigins, 4),
2081
+ ].map(publicMemory);
1618
2082
  const durableIds = new Set(
1619
2083
  durableMemories.map((memory) => memory.memory_id),
1620
2084
  );
2085
+ const memory = ({ memory_id, kind, horizon, text, created_at }) => ({ memory_id, kind, horizon, text, created_at });
1621
2086
  return {
1622
- durable_memories: durableMemories,
1623
- relevant_memories: this.listMemories({ limit: 8, query }).filter(
1624
- (memory) => !durableIds.has(memory.memory_id),
1625
- ),
2087
+ durable_memories: durableMemories.map(memory),
2088
+ relevant_memories: this.listMemories({ limit: 8, query })
2089
+ .filter((candidate) => !durableIds.has(candidate.memory_id))
2090
+ .map(memory),
1626
2091
  recent_episodes: this.recentEpisodes({
1627
2092
  query,
1628
2093
  excludeSessionId: currentSessionId,
1629
2094
  limit: 10,
1630
- }),
2095
+ }).map((episode) => ({
2096
+ kind: episode.kind,
2097
+ summary: clipped(episode.summary, 400),
2098
+ occurred_at: episode.occurred_at,
2099
+ ...(episode.expires_at ? { expires_at: episode.expires_at } : {}),
2100
+ })),
1631
2101
  recent_sessions: this.recentSessions({
1632
2102
  excludeSessionId: currentSessionId,
1633
2103
  limit: 2,
1634
2104
  turnsPerSession: 6,
1635
- }),
2105
+ }).map((session) => ({
2106
+ started_at: session.started_at,
2107
+ ended_at: session.ended_at,
2108
+ turns: session.turns.map((turn) => ({
2109
+ ...turn,
2110
+ text: clipped(turn.text, turn.role === "assistant" ? 600 : 400),
2111
+ })),
2112
+ })),
1636
2113
  };
1637
2114
  }
1638
2115
 
@@ -1663,6 +2140,13 @@ export class LocalMemoryStore {
1663
2140
  };
1664
2141
  }
1665
2142
 
2143
+ // SQLite's count of commits made by other connections, such as another Moondog window or terminal.
2144
+ // It changes when they write; this connection's own writes leave it alone.
2145
+ dataVersion() {
2146
+ this.#assertOpen();
2147
+ return this.#database.prepare("PRAGMA data_version").get().data_version;
2148
+ }
2149
+
1666
2150
  close() {
1667
2151
  if (this.#closed) return;
1668
2152
  this.#closed = true;
@@ -1673,6 +2157,7 @@ export class LocalMemoryStore {
1673
2157
  export async function openLocalMemoryStore({
1674
2158
  databasePath = defaultMoondogMemoryPath,
1675
2159
  clock,
2160
+ processId,
1676
2161
  } = {}) {
1677
2162
  const parent = path.dirname(path.resolve(databasePath));
1678
2163
  await mkdir(parent, { recursive: true, mode: 0o700 });
@@ -1687,7 +2172,7 @@ export async function openLocalMemoryStore({
1687
2172
  database.exec("PRAGMA trusted_schema = OFF");
1688
2173
  const currentVersion = database.prepare("PRAGMA user_version").get()
1689
2174
  .user_version;
1690
- if (![0, 1, MOONDOG_MEMORY_SCHEMA_VERSION].includes(currentVersion)) {
2175
+ if (![0, 1, 2, MOONDOG_MEMORY_SCHEMA_VERSION].includes(currentVersion)) {
1691
2176
  throw new Error("Memory database schema is incompatible");
1692
2177
  }
1693
2178
  database.exec(schema);
@@ -1697,7 +2182,7 @@ export async function openLocalMemoryStore({
1697
2182
  if (process.platform !== "win32") {
1698
2183
  await chmod(path.resolve(databasePath), 0o600);
1699
2184
  }
1700
- return new LocalMemoryStore(database, { clock });
2185
+ return new LocalMemoryStore(database, { clock, processId });
1701
2186
  } catch (error) {
1702
2187
  database.close();
1703
2188
  throw error;