@audiofool/moondog 0.1.0 → 0.2.0-beta.2

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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -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 +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. 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");
@@ -501,16 +604,30 @@ export class LocalMemoryStore {
501
604
  #database;
502
605
  #clock;
503
606
  #closed = false;
607
+ #processId;
504
608
 
505
- constructor(database, { clock = () => new Date() } = {}) {
609
+ constructor(database, { clock = () => new Date(), processId = process.pid } = {}) {
506
610
  this.#database = database;
507
611
  this.#clock = clock;
612
+ this.#processId = processId;
508
613
  }
509
614
 
510
615
  #assertOpen() {
511
616
  if (this.#closed) throw new Error("Memory store is closed");
512
617
  }
513
618
 
619
+ // The reply another running Moondog process is writing in this conversation, if any.
620
+ #replyElsewhere(sessionId) {
621
+ const running = (pid) => {
622
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
623
+ };
624
+ const row = this.#database.prepare(`SELECT e.entry_id, e.user_text, e.assistant_text, w.pid
625
+ FROM conversation_entries e JOIN conversation_writers w ON w.entry_id = e.entry_id
626
+ WHERE e.session_id = ? AND e.outcome = 'running' AND w.pid != ?`).all(sessionId, this.#processId)
627
+ .find((candidate) => running(candidate.pid));
628
+ return row ? { entry_id: row.entry_id, user_text: row.user_text, assistant_text: row.assistant_text } : null;
629
+ }
630
+
514
631
  #insertEpisode({
515
632
  episodeId = randomUUID(),
516
633
  sessionId,
@@ -814,6 +931,27 @@ export class LocalMemoryStore {
814
931
  return existing ?? this.startSession({ routeKey: route });
815
932
  }
816
933
 
934
+ // The conversation opened last on the route, by this process or another. The terminal and
935
+ // the App both show this one.
936
+ activeSession(routeKey = "local:main") {
937
+ this.#assertOpen();
938
+ const route = cleanText(routeKey, 128, "Session route");
939
+ const row = this.#database.prepare("SELECT session_id, route_key, started_at FROM sessions WHERE route_key = ? AND status = 'active'")
940
+ .get(route);
941
+ return row ? { ...row } : null;
942
+ }
943
+
944
+ replyElsewhere(sessionId) {
945
+ this.#assertOpen();
946
+ return this.#replyElsewhere(sessionId);
947
+ }
948
+
949
+ latestConversationEntryId(sessionId) {
950
+ this.#assertOpen();
951
+ return this.#database.prepare("SELECT entry_id FROM conversation_entries WHERE session_id = ? ORDER BY position DESC LIMIT 1")
952
+ .get(sessionId)?.entry_id ?? null;
953
+ }
954
+
817
955
  listSessions({ routeKey = "local:main", limit = 200 } = {}) {
818
956
  this.#assertOpen();
819
957
  const route = cleanText(routeKey, 128, "Session route");
@@ -821,42 +959,191 @@ export class LocalMemoryStore {
821
959
  const bounded = Number.isFinite(requestedLimit)
822
960
  ? Math.max(1, Math.min(Math.trunc(requestedLimit), 200))
823
961
  : 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
- }));
962
+ return this.#database.prepare(`
963
+ SELECT s.session_id, s.started_at,
964
+ MAX(s.started_at, COALESCE((SELECT MAX(created_at) FROM conversation_entries WHERE session_id = s.session_id),
965
+ (SELECT MAX(created_at) FROM turns WHERE session_id = s.session_id), s.started_at)) AS updated_at,
966
+ CASE WHEN EXISTS (SELECT 1 FROM conversation_entries WHERE session_id = s.session_id)
967
+ THEN (SELECT SUM(1 + (assistant_text != '')) FROM conversation_entries WHERE session_id = s.session_id)
968
+ ELSE (SELECT COUNT(*) FROM turns WHERE session_id = s.session_id) END AS turn_count,
969
+ l.title AS label_title,
970
+ COALESCE((SELECT user_text FROM conversation_entries WHERE session_id = s.session_id ORDER BY position LIMIT 1),
971
+ (SELECT content FROM turns WHERE session_id = s.session_id AND role = 'user' ORDER BY position LIMIT 1),
972
+ b.draft, '') AS first_text,
973
+ l.title IS NOT NULL AS custom_title, l.pinned_at, l.folder_id,
974
+ b.parent_session_id,
975
+ CASE WHEN b.parent_session_id IS NOT NULL AND l.title IS NULL THEN
976
+ (SELECT title FROM conversation_labels WHERE session_id = b.root_session_id) END AS lineage_label,
977
+ CASE WHEN b.parent_session_id IS NOT NULL AND l.title IS NULL THEN COALESCE(
978
+ (SELECT user_text FROM conversation_entries WHERE session_id = b.root_session_id ORDER BY position LIMIT 1),
979
+ (SELECT content FROM turns WHERE session_id = b.root_session_id AND role = 'user' ORDER BY position LIMIT 1)) END AS lineage_first,
980
+ CASE WHEN b.parent_session_id IS NOT NULL AND l.title IS NULL THEN (SELECT COUNT(*) FROM conversation_branches r
981
+ JOIN sessions rs ON rs.session_id = r.session_id WHERE r.root_session_id = b.root_session_id
982
+ AND (rs.started_at < s.started_at OR (rs.started_at = s.started_at AND rs.rowid <= s.rowid))) END AS lineage_number,
983
+ 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
984
+ FROM sessions s LEFT JOIN conversation_branches b ON b.session_id = s.session_id
985
+ LEFT JOIN conversation_labels l ON l.session_id = s.session_id
986
+ WHERE s.route_key = ? AND (
987
+ EXISTS (SELECT 1 FROM conversation_entries WHERE session_id = s.session_id) OR
988
+ EXISTS (SELECT 1 FROM turns WHERE session_id = s.session_id) OR b.draft != '')
989
+ ORDER BY l.pinned_at IS NULL, updated_at DESC, s.started_at DESC, s.rowid DESC LIMIT ?
990
+ `).all(route, bounded).map(row => {
991
+ // An unnamed conversation shows its first message, cut short.
992
+ const title = row.label_title ?? shortConversationTitle(row.first_text);
993
+ // A branch starts with its parent's first message, so an unnamed one would share its parent's title.
994
+ // It shows its lineage's title and its place in that lineage instead, "Late night #2"; a name the
995
+ // listener gives it replaces both.
996
+ const lineage = row.lineage_label || shortConversationTitle(row.lineage_first) || title;
997
+ return {
998
+ session_id: row.session_id, started_at: row.started_at, updated_at: row.updated_at,
999
+ turn_count: row.turn_count, title: row.lineage_number ? `${lineage} #${row.lineage_number}` : title,
1000
+ custom_title: row.custom_title === 1, pinned_at: row.pinned_at ?? null, folder_id: row.folder_id ?? null,
1001
+ ...(row.parent_session_id ? { parent_session_id: row.parent_session_id, last_message: row.last_message } : {}),
1002
+ };
1003
+ });
1004
+ }
1005
+
1006
+ // The first message and reply of a conversation the model may name: one with no name, no parent and
1007
+ // exactly one finished reply. Null for any other, so each conversation is named at most once.
1008
+ untitledConversationStart(sessionId, { routeKey = "local:main" } = {}) {
1009
+ this.#assertOpen();
1010
+ const id = cleanSessionId(sessionId);
1011
+ const route = cleanText(routeKey, 128, "Session route");
1012
+ const row = this.#database.prepare(`SELECT
1013
+ (SELECT title FROM conversation_labels WHERE session_id = s.session_id) AS title,
1014
+ (SELECT parent_session_id FROM conversation_branches WHERE session_id = s.session_id) AS parent_session_id,
1015
+ (SELECT COUNT(*) FROM conversation_entries WHERE session_id = s.session_id AND outcome = 'completed' AND assistant_text != '') AS replies
1016
+ FROM sessions s WHERE s.session_id = ? AND s.route_key = ?`).get(id, route);
1017
+ if (!row || row.title !== null || row.parent_session_id || row.replies !== 1) return null;
1018
+ const entry = this.#database.prepare(`SELECT user_text, assistant_text FROM conversation_entries
1019
+ WHERE session_id = ? AND outcome = 'completed' AND assistant_text != '' LIMIT 1`).get(id);
1020
+ return { user: entry.user_text, assistant: entry.assistant_text };
1021
+ }
1022
+
1023
+ sessionExists(sessionId) {
1024
+ this.#assertOpen();
1025
+ return Boolean(this.#database.prepare("SELECT 1 FROM sessions WHERE session_id = ?").get(sessionId));
1026
+ }
1027
+
1028
+ // With ifUntitled, a conversation that already has a name keeps it and this returns null: the model's
1029
+ // name never replaces one the listener gave, even one given while the model was choosing.
1030
+ labelConversation(sessionId, patch, { routeKey = "local:main", ifUntitled = false } = {}) {
1031
+ this.#assertOpen();
1032
+ const id = cleanSessionId(sessionId);
1033
+ const route = cleanText(routeKey, 128, "Session route");
1034
+ const label = normalizeConversationLabel(patch);
1035
+ this.#database.exec("BEGIN IMMEDIATE");
1036
+ try {
1037
+ if (!this.#database.prepare("SELECT 1 FROM sessions WHERE session_id = ? AND route_key = ?").get(id, route)) {
1038
+ throw new Error("Saved conversation not found for this route.");
1039
+ }
1040
+ if (label.folderId && !this.#database.prepare("SELECT 1 FROM conversation_folders WHERE folder_id = ?").get(label.folderId)) {
1041
+ throw new Error("Conversation folder not found.");
1042
+ }
1043
+ const current = this.#database.prepare("SELECT title, pinned_at, folder_id FROM conversation_labels WHERE session_id = ?").get(id);
1044
+ if (ifUntitled && (current?.title ?? null) !== null) {
1045
+ this.#database.exec("COMMIT");
1046
+ return null;
1047
+ }
1048
+ const next = {
1049
+ title: label.title !== undefined ? label.title : current?.title ?? null,
1050
+ // Pinning again keeps the first pin time.
1051
+ pinned_at: label.pinned === undefined ? current?.pinned_at ?? null
1052
+ : label.pinned ? current?.pinned_at ?? isoTimestamp(this.#clock) : null,
1053
+ folder_id: label.folderId !== undefined ? label.folderId : current?.folder_id ?? null,
1054
+ };
1055
+ if (next.title === null && next.pinned_at === null && next.folder_id === null) {
1056
+ this.#database.prepare("DELETE FROM conversation_labels WHERE session_id = ?").run(id);
1057
+ } else {
1058
+ this.#database.prepare(`INSERT INTO conversation_labels(session_id, title, pinned_at, folder_id) VALUES (?, ?, ?, ?)
1059
+ ON CONFLICT(session_id) DO UPDATE SET title = excluded.title, pinned_at = excluded.pinned_at, folder_id = excluded.folder_id`)
1060
+ .run(id, next.title, next.pinned_at, next.folder_id);
1061
+ }
1062
+ this.#database.exec("COMMIT");
1063
+ return { session_id: id, ...next };
1064
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1065
+ }
1066
+
1067
+ listConversationFolders() {
1068
+ this.#assertOpen();
1069
+ return this.#database.prepare(`SELECT folder_id, name, position, created_at FROM conversation_folders
1070
+ ORDER BY position, created_at, folder_id`).all().map(row => ({ ...row }));
1071
+ }
1072
+
1073
+ // Without a folder ID this creates a folder; with one it renames or moves that folder.
1074
+ saveConversationFolder(folder) {
1075
+ this.#assertOpen();
1076
+ const { folderId, name, position } = normalizeConversationFolder(folder);
1077
+ this.#database.exec("BEGIN IMMEDIATE");
1078
+ try {
1079
+ let id = folderId;
1080
+ if (id) {
1081
+ const changed = this.#database.prepare("UPDATE conversation_folders SET name = ?, position = COALESCE(?, position) WHERE folder_id = ?")
1082
+ .run(name, position ?? null, id);
1083
+ if (changed.changes !== 1) throw new Error("Conversation folder not found.");
1084
+ } else {
1085
+ id = randomUUID();
1086
+ const last = this.#database.prepare("SELECT MAX(position) AS position FROM conversation_folders").get().position;
1087
+ this.#database.prepare("INSERT INTO conversation_folders(folder_id, name, position, created_at) VALUES (?, ?, ?, ?)")
1088
+ .run(id, name, position ?? (last === null ? 0 : last + 1), isoTimestamp(this.#clock));
1089
+ }
1090
+ const row = this.#database.prepare("SELECT folder_id, name, position, created_at FROM conversation_folders WHERE folder_id = ?").get(id);
1091
+ this.#database.exec("COMMIT");
1092
+ return { ...row };
1093
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1094
+ }
1095
+
1096
+ // Deleting a folder keeps its conversations; they return to the unfiled list.
1097
+ deleteConversationFolder(folderId) {
1098
+ this.#assertOpen();
1099
+ const id = cleanText(folderId, 128, "Folder ID");
1100
+ this.#database.exec("BEGIN IMMEDIATE");
1101
+ try {
1102
+ const unfiled = this.#database.prepare("UPDATE conversation_labels SET folder_id = NULL WHERE folder_id = ?").run(id).changes;
1103
+ this.#database.prepare("DELETE FROM conversation_labels WHERE title IS NULL AND pinned_at IS NULL AND folder_id IS NULL").run();
1104
+ const deleted = this.#database.prepare("DELETE FROM conversation_folders WHERE folder_id = ?").run(id).changes === 1;
1105
+ this.#database.exec("COMMIT");
1106
+ return { deleted, folder_id: id, unfiled };
1107
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1108
+ }
1109
+
1110
+ // Removes one conversation and its transcript but keeps durable memories, so a
1111
+ // preference stated in it still holds. Its branches stay, re-parented and re-rooted.
1112
+ deleteConversation(sessionId, { routeKey = "local:main" } = {}) {
1113
+ this.#assertOpen();
1114
+ const id = cleanSessionId(sessionId);
1115
+ const route = cleanText(routeKey, 128, "Session route");
1116
+ const run = (sql, ...values) => this.#database.prepare(sql).run(...values).changes;
1117
+ this.#database.exec("BEGIN IMMEDIATE");
1118
+ try {
1119
+ const session = this.#database.prepare("SELECT status FROM sessions WHERE session_id = ? AND route_key = ?").get(id, route);
1120
+ if (!session) throw new Error("Saved conversation not found for this route.");
1121
+ // The terminal and the App both show the active one, and a reply may still run in another one.
1122
+ if (session.status === "active" || this.#replyElsewhere(id)) throw new Error("This conversation is open in another Moondog window or terminal.");
1123
+ // Foreign keys are checked at once, so every reference to the session goes before its row.
1124
+ const parent = this.#database.prepare("SELECT parent_session_id FROM conversation_branches WHERE session_id = ?").get(id)?.parent_session_id ?? null;
1125
+ const reparented = run("UPDATE conversation_branches SET parent_session_id = ? WHERE parent_session_id = ?", parent, id);
1126
+ const heir = this.#database.prepare(`SELECT b.session_id FROM conversation_branches b JOIN sessions s ON s.session_id = b.session_id
1127
+ WHERE b.root_session_id = ? AND b.session_id != ? ORDER BY s.started_at, s.rowid LIMIT 1`).get(id, id)?.session_id;
1128
+ if (heir) run("UPDATE conversation_branches SET root_session_id = ? WHERE root_session_id = ? AND session_id != ?", heir, id, id);
1129
+ // Dialogue episodes hold the raw user text. With no session they would be recalled
1130
+ // in every conversation, so they go; explicit memories and retractions stay.
1131
+ run(`DELETE FROM claim_episode_refs WHERE episode_id IN
1132
+ (SELECT episode_id FROM episodes WHERE session_id = ? AND kind = 'dialogue')`, id);
1133
+ run("DELETE FROM episodes WHERE session_id = ? AND kind = 'dialogue'", id);
1134
+ run("UPDATE episodes SET session_id = NULL WHERE session_id = ?", id);
1135
+ run("UPDATE memory_claims SET source_session_id = NULL WHERE source_session_id = ?", id);
1136
+ for (const table of ["conversation_labels", "conversation_writers", "conversation_entries", "turns", "conversation_branches", "sessions"]) {
1137
+ run(`DELETE FROM ${table} WHERE session_id = ?`, id);
1138
+ }
1139
+ this.#database.exec("COMMIT");
1140
+ return { deleted: true, session_id: id, reparented };
1141
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
853
1142
  }
854
1143
 
855
1144
  resumeSession(sessionId, { routeKey = "local:main" } = {}) {
856
1145
  this.#assertOpen();
857
- if (typeof sessionId !== "string" || !sessionId || sessionId.length > 128) {
858
- throw new TypeError("Session ID is invalid");
859
- }
1146
+ cleanSessionId(sessionId);
860
1147
  const route = cleanText(routeKey, 128, "Session route");
861
1148
  this.#database.exec("BEGIN IMMEDIATE");
862
1149
  try {
@@ -1030,6 +1317,24 @@ export class LocalMemoryStore {
1030
1317
  }
1031
1318
  }
1032
1319
 
1320
+ commitMemoryMutations(memoryMutations) {
1321
+ this.#assertOpen();
1322
+ const timestamp = isoTimestamp(this.#clock);
1323
+ this.#database.exec("BEGIN IMMEDIATE");
1324
+ try {
1325
+ const results = memoryMutations.map((mutation) => {
1326
+ if (mutation?.type === "remember") return this.#applyPreparedRemember(mutation, { timestamp });
1327
+ if (mutation?.type === "forget") return this.#applyPreparedForget(mutation, { timestamp });
1328
+ throw new TypeError("Memory mutation is invalid");
1329
+ });
1330
+ this.#database.exec("COMMIT");
1331
+ return results;
1332
+ } catch (error) {
1333
+ this.#database.exec("ROLLBACK");
1334
+ throw error;
1335
+ }
1336
+ }
1337
+
1033
1338
  readSessionTurns(sessionId, { limit = 40 } = {}) {
1034
1339
  this.#assertOpen();
1035
1340
  const bounded = Math.max(1, Math.min(Number(limit) || 40, 100));
@@ -1050,6 +1355,118 @@ export class LocalMemoryStore {
1050
1355
  }));
1051
1356
  }
1052
1357
 
1358
+ #ensureConversation(sessionId) {
1359
+ const exists = this.#database.prepare("SELECT 1 FROM conversation_branches WHERE session_id = ?").get(sessionId);
1360
+ if (exists) return;
1361
+ this.#database.prepare(`INSERT INTO conversation_branches(session_id, root_session_id) VALUES (?, ?)`).run(sessionId, sessionId);
1362
+ // Import only an old session's rendered text, never tool messages or plans.
1363
+ const legacy = this.#database.prepare("SELECT * FROM turns WHERE session_id = ? ORDER BY position").all(sessionId);
1364
+ const insert = this.#database.prepare(`INSERT INTO conversation_entries
1365
+ (entry_id, session_id, position, user_text, assistant_text, outcome, transient, created_at)
1366
+ VALUES (?, ?, ?, ?, ?, 'completed', 0, ?)`);
1367
+ for (let index = 0, position = 1; index < legacy.length; index += 1) {
1368
+ const user = legacy[index];
1369
+ if (user.role !== 'user') continue;
1370
+ const assistant = legacy[index + 1]?.role === 'assistant' ? legacy[++index].content : '';
1371
+ insert.run(user.turn_id, sessionId, position++, user.content, assistant, user.created_at);
1372
+ }
1373
+ }
1374
+
1375
+ beginConversationEntry(sessionId, user) {
1376
+ this.#assertOpen();
1377
+ if (typeof user !== 'string' || !user.trim() || user.length > 8_000) throw new TypeError("Conversation input is invalid");
1378
+ const text = user;
1379
+ this.#database.exec("BEGIN IMMEDIATE");
1380
+ try {
1381
+ this.#ensureConversation(sessionId);
1382
+ if (this.#replyElsewhere(sessionId)) {
1383
+ throw Object.assign(new Error("Moondog is answering in this conversation in another window or terminal."), { code: "conversation_busy" });
1384
+ }
1385
+ const position = this.#database.prepare("SELECT COALESCE(MAX(position), 0) + 1 AS n FROM conversation_entries WHERE session_id = ?").get(sessionId).n;
1386
+ const entryId = randomUUID();
1387
+ this.#database.prepare(`INSERT INTO conversation_entries(entry_id, session_id, position, user_text, outcome, created_at)
1388
+ VALUES (?, ?, ?, ?, 'running', ?)`).run(entryId, sessionId, position, text, isoTimestamp(this.#clock));
1389
+ this.#database.prepare("INSERT INTO conversation_writers(entry_id, session_id, pid) VALUES (?, ?, ?)").run(entryId, sessionId, this.#processId);
1390
+ this.#database.prepare("UPDATE conversation_branches SET draft = '' WHERE session_id = ?").run(sessionId);
1391
+ this.#database.exec("COMMIT");
1392
+ return entryId;
1393
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1394
+ }
1395
+
1396
+ finishConversationEntry(sessionId, entryId, { text = '', status = 'failed', transient = false } = {}) {
1397
+ this.#assertOpen();
1398
+ if (!['completed', 'aborted', 'interrupted', 'failed'].includes(status) || typeof text !== 'string' || text.length > 40_000) {
1399
+ throw new TypeError("Conversation outcome is invalid");
1400
+ }
1401
+ this.#database.prepare("DELETE FROM conversation_writers WHERE entry_id = ?").run(entryId);
1402
+ const changed = this.#database.prepare(`UPDATE conversation_entries SET assistant_text = ?, outcome = ?, transient = ?
1403
+ WHERE session_id = ? AND entry_id = ? AND outcome = 'running'`).run(text, status, transient ? 1 : 0, sessionId, entryId);
1404
+ if (changed.changes !== 1) throw new Error("Conversation turn is no longer active");
1405
+ }
1406
+
1407
+ // The reply so far, so another Moondog window or terminal can show it while it grows.
1408
+ showConversationProgress(sessionId, entryId, text) {
1409
+ this.#assertOpen();
1410
+ if (typeof text !== 'string') throw new TypeError("Conversation progress is invalid");
1411
+ this.#database.prepare(`UPDATE conversation_entries SET assistant_text = ?
1412
+ WHERE session_id = ? AND entry_id = ? AND outcome = 'running'`).run(text.slice(0, 40_000), sessionId, entryId);
1413
+ }
1414
+
1415
+ readConversationEntries(sessionId, { limit = 200 } = {}) {
1416
+ this.#assertOpen();
1417
+ this.#database.exec("BEGIN IMMEDIATE");
1418
+ try { this.#ensureConversation(sessionId); this.#database.exec("COMMIT"); }
1419
+ catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1420
+ const bounded = Math.max(1, Math.min(Math.trunc(Number(limit)) || 200, 500));
1421
+ return this.#database.prepare(`SELECT entry_id, user_text, assistant_text, outcome, transient, created_at
1422
+ FROM conversation_entries WHERE session_id = ? ORDER BY position DESC LIMIT ?`).all(sessionId, bounded).reverse()
1423
+ .map(row => ({ ...row, transient: row.transient === 1 }));
1424
+ }
1425
+
1426
+ conversationPending(sessionId, pending) {
1427
+ this.#assertOpen();
1428
+ if (pending !== undefined && (typeof pending?.draft !== 'string' || pending.draft.length > 8_000 ||
1429
+ !Array.isArray(pending.queued) || pending.queued.length > 30 ||
1430
+ pending.queued.some(text => typeof text !== 'string' || text.length > 8_000))) throw new TypeError("Conversation draft is invalid");
1431
+ this.#database.exec("BEGIN IMMEDIATE");
1432
+ try {
1433
+ this.#ensureConversation(sessionId);
1434
+ if (pending !== undefined) this.#database.prepare("UPDATE conversation_branches SET draft = ?, queued_json = ? WHERE session_id = ?")
1435
+ .run(pending.draft, JSON.stringify(pending.queued), sessionId);
1436
+ const row = this.#database.prepare("SELECT draft, queued_json FROM conversation_branches WHERE session_id = ?").get(sessionId);
1437
+ this.#database.exec("COMMIT");
1438
+ return { draft: row.draft, queued: JSON.parse(row.queued_json) };
1439
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1440
+ }
1441
+
1442
+ forkConversation(sessionId, entryId, { routeKey = 'local:main' } = {}) {
1443
+ this.#assertOpen();
1444
+ const route = cleanText(routeKey, 128, "Session route");
1445
+ this.#database.exec("BEGIN IMMEDIATE");
1446
+ try {
1447
+ // Another window or terminal may have opened a conversation since this one; the branch still
1448
+ // becomes the one both show.
1449
+ const original = this.#database.prepare("SELECT * FROM sessions WHERE session_id = ? AND route_key = ?").get(sessionId, route);
1450
+ if (!original) throw new Error("The selected conversation is no longer available");
1451
+ this.#ensureConversation(sessionId);
1452
+ const selected = this.#database.prepare("SELECT * FROM conversation_entries WHERE session_id = ? AND entry_id = ?").get(sessionId, entryId);
1453
+ if (!selected) throw new Error("The selected turn is no longer available");
1454
+ const root = this.#database.prepare("SELECT root_session_id FROM conversation_branches WHERE session_id = ?").get(sessionId).root_session_id;
1455
+ const createdAt = isoTimestamp(this.#clock);
1456
+ const nextId = randomUUID();
1457
+ this.#database.prepare("UPDATE sessions SET status = 'closed', ended_at = ?, close_reason = 'rewind' WHERE route_key = ? AND status = 'active'").run(createdAt, route);
1458
+ this.#database.prepare("INSERT INTO sessions(session_id, route_key, started_at, status) VALUES (?, ?, ?, 'active')").run(nextId, route, createdAt);
1459
+ this.#database.prepare(`INSERT INTO conversation_branches(session_id, root_session_id, parent_session_id, source_entry_id, draft)
1460
+ VALUES (?, ?, ?, ?, ?)`).run(nextId, root, sessionId, entryId, selected.user_text);
1461
+ const prefix = this.#database.prepare("SELECT * FROM conversation_entries WHERE session_id = ? AND position < ? ORDER BY position").all(sessionId, selected.position);
1462
+ const insert = this.#database.prepare(`INSERT INTO conversation_entries
1463
+ (entry_id, session_id, position, user_text, assistant_text, outcome, transient, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
1464
+ for (const row of prefix) insert.run(randomUUID(), nextId, row.position, row.user_text, row.assistant_text, row.outcome, row.transient, row.created_at);
1465
+ this.#database.exec("COMMIT");
1466
+ return { session_id: nextId, route_key: route, started_at: createdAt, parent_session_id: sessionId, draft: selected.user_text };
1467
+ } catch (error) { this.#database.exec("ROLLBACK"); throw error; }
1468
+ }
1469
+
1053
1470
  prepareRemember({
1054
1471
  text,
1055
1472
  kind = "fact",
@@ -1058,7 +1475,7 @@ export class LocalMemoryStore {
1058
1475
  sourceSessionId,
1059
1476
  }) {
1060
1477
  this.#assertOpen();
1061
- const content = cleanText(text, 2_000, "Memory");
1478
+ const content = normalizeMemoryContent(text);
1062
1479
  const normalizedContent = normalized(content);
1063
1480
  const existing = this.#database
1064
1481
  .prepare(`
@@ -1535,6 +1952,7 @@ export class LocalMemoryStore {
1535
1952
  recentEpisodes({
1536
1953
  query,
1537
1954
  excludeSessionId,
1955
+ includeCurrentSession = false,
1538
1956
  limit = 12,
1539
1957
  now = this.#clock(),
1540
1958
  } = {}) {
@@ -1547,11 +1965,12 @@ export class LocalMemoryStore {
1547
1965
  FROM episodes
1548
1966
  WHERE status = 'active'
1549
1967
  AND (expires_at IS NULL OR expires_at > ?)
1550
- AND (session_id IS NULL OR session_id != COALESCE(?, ''))
1968
+ 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)
1969
+ != COALESCE((SELECT root_session_id FROM conversation_branches WHERE session_id = ?), ?, ''))
1551
1970
  ORDER BY occurred_at DESC, episode_id ASC
1552
1971
  LIMIT 256
1553
1972
  `)
1554
- .all(nowTimestamp, excludeSessionId ?? null);
1973
+ .all(nowTimestamp, excludeSessionId ?? null, includeCurrentSession ? 1 : 0, excludeSessionId ?? null, excludeSessionId ?? null);
1555
1974
  const terms = queryTerms(query);
1556
1975
  return rows
1557
1976
  .map((row) => ({
@@ -1582,12 +2001,13 @@ export class LocalMemoryStore {
1582
2001
  .prepare(`
1583
2002
  SELECT s.session_id, s.started_at, s.ended_at
1584
2003
  FROM sessions s
1585
- WHERE s.session_id != COALESCE(?, '')
2004
+ WHERE COALESCE((SELECT root_session_id FROM conversation_branches b WHERE b.session_id = s.session_id), s.session_id)
2005
+ != COALESCE((SELECT root_session_id FROM conversation_branches WHERE session_id = ?), ?, '')
1586
2006
  AND EXISTS (SELECT 1 FROM turns t WHERE t.session_id = s.session_id)
1587
2007
  ORDER BY COALESCE(s.ended_at, s.started_at) DESC
1588
2008
  LIMIT ?
1589
2009
  `)
1590
- .all(excludeSessionId ?? null, boundedSessions);
2010
+ .all(excludeSessionId ?? null, excludeSessionId ?? null, boundedSessions);
1591
2011
  const readTurns = this.#database.prepare(`
1592
2012
  SELECT role, content, created_at
1593
2013
  FROM turns
@@ -1663,6 +2083,13 @@ export class LocalMemoryStore {
1663
2083
  };
1664
2084
  }
1665
2085
 
2086
+ // SQLite's count of commits made by other connections, such as another Moondog window or terminal.
2087
+ // It changes when they write; this connection's own writes leave it alone.
2088
+ dataVersion() {
2089
+ this.#assertOpen();
2090
+ return this.#database.prepare("PRAGMA data_version").get().data_version;
2091
+ }
2092
+
1666
2093
  close() {
1667
2094
  if (this.#closed) return;
1668
2095
  this.#closed = true;
@@ -1673,6 +2100,7 @@ export class LocalMemoryStore {
1673
2100
  export async function openLocalMemoryStore({
1674
2101
  databasePath = defaultMoondogMemoryPath,
1675
2102
  clock,
2103
+ processId,
1676
2104
  } = {}) {
1677
2105
  const parent = path.dirname(path.resolve(databasePath));
1678
2106
  await mkdir(parent, { recursive: true, mode: 0o700 });
@@ -1687,7 +2115,7 @@ export async function openLocalMemoryStore({
1687
2115
  database.exec("PRAGMA trusted_schema = OFF");
1688
2116
  const currentVersion = database.prepare("PRAGMA user_version").get()
1689
2117
  .user_version;
1690
- if (![0, 1, MOONDOG_MEMORY_SCHEMA_VERSION].includes(currentVersion)) {
2118
+ if (![0, 1, 2, MOONDOG_MEMORY_SCHEMA_VERSION].includes(currentVersion)) {
1691
2119
  throw new Error("Memory database schema is incompatible");
1692
2120
  }
1693
2121
  database.exec(schema);
@@ -1697,7 +2125,7 @@ export async function openLocalMemoryStore({
1697
2125
  if (process.platform !== "win32") {
1698
2126
  await chmod(path.resolve(databasePath), 0o600);
1699
2127
  }
1700
- return new LocalMemoryStore(database, { clock });
2128
+ return new LocalMemoryStore(database, { clock, processId });
1701
2129
  } catch (error) {
1702
2130
  database.close();
1703
2131
  throw error;