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