@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.
- package/LICENSE +202 -0
- package/README.md +153 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +123 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +321 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +8 -4
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +407 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2204 -605
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +160 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- 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 =
|
|
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
|
-
.
|
|
826
|
-
SELECT
|
|
827
|
-
s.session_id,
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
COUNT(
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
.
|
|
847
|
-
session_id
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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;
|