@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.
- package/LICENSE +202 -0
- package/README.md +156 -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 +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -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 +166 -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 +328 -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 +9 -6
- 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 +410 -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 +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -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 +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -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 +2536 -692
- 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 +161 -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 +48 -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");
|
|
@@ -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
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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 })
|
|
1624
|
-
(
|
|
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;
|