@hoverlover/cc-discord 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/.claude/settings.template.json +94 -0
- package/.env.example +41 -0
- package/.env.relay.example +46 -0
- package/.env.worker.example +40 -0
- package/README.md +313 -0
- package/hooks/check-discord-messages.ts +204 -0
- package/hooks/cleanup-attachment.ts +47 -0
- package/hooks/safe-bash.ts +157 -0
- package/hooks/steer-send.ts +108 -0
- package/hooks/track-activity.ts +220 -0
- package/memory/README.md +60 -0
- package/memory/core/MemoryCoordinator.ts +703 -0
- package/memory/core/MemoryStore.ts +72 -0
- package/memory/core/session-key.ts +14 -0
- package/memory/core/types.ts +59 -0
- package/memory/index.ts +19 -0
- package/memory/providers/sqlite/SqliteMemoryStore.ts +838 -0
- package/memory/providers/sqlite/index.ts +1 -0
- package/package.json +45 -0
- package/prompts/autoreply-system.md +32 -0
- package/prompts/channel-system.md +22 -0
- package/prompts/orchestrator-system.md +56 -0
- package/scripts/channel-agent.sh +159 -0
- package/scripts/generate-settings.sh +17 -0
- package/scripts/load-env.sh +79 -0
- package/scripts/migrate-memory-to-channel-keys.ts +148 -0
- package/scripts/orchestrator.sh +325 -0
- package/scripts/parse-claude-stream.ts +349 -0
- package/scripts/start-orchestrator.sh +82 -0
- package/scripts/start-relay.sh +17 -0
- package/scripts/start.sh +175 -0
- package/server/attachment.ts +182 -0
- package/server/busy-notify.ts +69 -0
- package/server/config.ts +121 -0
- package/server/db.ts +249 -0
- package/server/index.ts +311 -0
- package/server/memory.ts +88 -0
- package/server/messages.ts +111 -0
- package/server/trace-thread.ts +340 -0
- package/server/typing.ts +101 -0
- package/tools/memory-inspect.ts +94 -0
- package/tools/memory-smoke.ts +173 -0
- package/tools/send-discord +2 -0
- package/tools/send-discord.ts +82 -0
- package/tools/wait-for-discord-messages +2 -0
- package/tools/wait-for-discord-messages.ts +369 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { MemoryStore } from "../../core/MemoryStore.ts";
|
|
6
|
+
import { clamp, MemoryCardTypes, MemoryScopes, nowIso, safeJsonParse, safeJsonStringify } from "../../core/types.ts";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_DB_PATH = join(process.cwd(), "data", "memory.db");
|
|
9
|
+
|
|
10
|
+
const SCHEMA_SQL = `
|
|
11
|
+
CREATE TABLE IF NOT EXISTS memory_sessions (
|
|
12
|
+
id TEXT PRIMARY KEY,
|
|
13
|
+
session_key TEXT NOT NULL UNIQUE,
|
|
14
|
+
agent_id TEXT,
|
|
15
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
16
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS memory_turns (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
session_key TEXT NOT NULL,
|
|
22
|
+
turn_index INTEGER NOT NULL,
|
|
23
|
+
role TEXT NOT NULL,
|
|
24
|
+
content TEXT NOT NULL,
|
|
25
|
+
metadata_json TEXT,
|
|
26
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
27
|
+
FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key),
|
|
28
|
+
UNIQUE(session_key, turn_index)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS memory_snapshots (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
session_key TEXT NOT NULL,
|
|
34
|
+
summary_text TEXT NOT NULL,
|
|
35
|
+
open_tasks_json TEXT NOT NULL DEFAULT '[]',
|
|
36
|
+
decisions_json TEXT NOT NULL DEFAULT '[]',
|
|
37
|
+
compacted_to_turn_id TEXT,
|
|
38
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
39
|
+
FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS memory_cards (
|
|
43
|
+
id TEXT PRIMARY KEY,
|
|
44
|
+
session_key TEXT NOT NULL,
|
|
45
|
+
scope TEXT NOT NULL DEFAULT 'session',
|
|
46
|
+
card_type TEXT NOT NULL,
|
|
47
|
+
title TEXT NOT NULL,
|
|
48
|
+
body TEXT NOT NULL,
|
|
49
|
+
confidence REAL DEFAULT 0.5,
|
|
50
|
+
pinned INTEGER DEFAULT 0,
|
|
51
|
+
source_turn_from TEXT,
|
|
52
|
+
source_turn_to TEXT,
|
|
53
|
+
expires_at TEXT,
|
|
54
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
55
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
56
|
+
FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS memory_compaction_state (
|
|
60
|
+
session_key TEXT PRIMARY KEY,
|
|
61
|
+
last_compacted_turn_id TEXT,
|
|
62
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
63
|
+
FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS memory_sync_jobs (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
store_id TEXT NOT NULL,
|
|
69
|
+
batch_id TEXT NOT NULL,
|
|
70
|
+
payload_json TEXT NOT NULL,
|
|
71
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
72
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
next_attempt_at TEXT,
|
|
74
|
+
last_error TEXT,
|
|
75
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
76
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS memory_runtime_state (
|
|
80
|
+
session_key TEXT PRIMARY KEY,
|
|
81
|
+
runtime_context_id TEXT NOT NULL,
|
|
82
|
+
runtime_epoch INTEGER NOT NULL DEFAULT 1,
|
|
83
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
84
|
+
FOREIGN KEY(session_key) REFERENCES memory_sessions(session_key)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
CREATE TABLE IF NOT EXISTS memory_batch_log (
|
|
88
|
+
batch_id TEXT PRIMARY KEY,
|
|
89
|
+
session_key TEXT NOT NULL,
|
|
90
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_memory_turns_session_turn_index
|
|
94
|
+
ON memory_turns(session_key, turn_index);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_memory_cards_session_scope_type
|
|
96
|
+
ON memory_cards(session_key, scope, card_type);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_memory_sync_jobs_status_next_attempt
|
|
98
|
+
ON memory_sync_jobs(status, next_attempt_at);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_memory_runtime_state_context
|
|
100
|
+
ON memory_runtime_state(runtime_context_id);
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
export class SqliteMemoryStore extends MemoryStore {
|
|
104
|
+
dbPath: string;
|
|
105
|
+
logger: Pick<Console, "log" | "warn" | "error">;
|
|
106
|
+
db: InstanceType<typeof Database> | null;
|
|
107
|
+
|
|
108
|
+
constructor(options: { dbPath?: string; logger?: Pick<Console, "log" | "warn" | "error"> } = {}) {
|
|
109
|
+
super("sqlite", {
|
|
110
|
+
atomicBatch: true,
|
|
111
|
+
fullTextSearch: false,
|
|
112
|
+
vectorSearch: false,
|
|
113
|
+
bidirectionalSync: false,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.dbPath = options.dbPath || DEFAULT_DB_PATH;
|
|
117
|
+
this.logger = options.logger || console;
|
|
118
|
+
this.db = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async init() {
|
|
122
|
+
if (this.db) return;
|
|
123
|
+
|
|
124
|
+
mkdirSync(dirname(this.dbPath), { recursive: true });
|
|
125
|
+
this.db = new Database(this.dbPath);
|
|
126
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
127
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
128
|
+
this.db.exec(SCHEMA_SQL);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async health() {
|
|
132
|
+
try {
|
|
133
|
+
await this.init();
|
|
134
|
+
const db = this.#requireDb();
|
|
135
|
+
const row = db.prepare("SELECT 1 as ok").get() as any;
|
|
136
|
+
return { ok: row?.ok === 1, details: this.dbPath };
|
|
137
|
+
} catch (err: any) {
|
|
138
|
+
return { ok: false, details: err?.message || String(err) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async writeBatch(inputBatch: any) {
|
|
143
|
+
await this.init();
|
|
144
|
+
|
|
145
|
+
const batch = normalizeBatch(inputBatch);
|
|
146
|
+
const db = this.#requireDb();
|
|
147
|
+
|
|
148
|
+
const result = {
|
|
149
|
+
ok: true,
|
|
150
|
+
idempotent: false,
|
|
151
|
+
batchId: batch.batchId,
|
|
152
|
+
sessionKey: batch.sessionKey,
|
|
153
|
+
counts: {
|
|
154
|
+
turns: 0,
|
|
155
|
+
snapshots: 0,
|
|
156
|
+
cardsUpserted: 0,
|
|
157
|
+
cardsDeleted: 0,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
let attempts = 0;
|
|
162
|
+
while (true) {
|
|
163
|
+
attempts++;
|
|
164
|
+
try {
|
|
165
|
+
db.exec("BEGIN IMMEDIATE");
|
|
166
|
+
|
|
167
|
+
const existingBatch = db
|
|
168
|
+
.prepare(`
|
|
169
|
+
SELECT batch_id
|
|
170
|
+
FROM memory_batch_log
|
|
171
|
+
WHERE batch_id = ?
|
|
172
|
+
`)
|
|
173
|
+
.get(batch.batchId);
|
|
174
|
+
|
|
175
|
+
if (existingBatch) {
|
|
176
|
+
db.exec("COMMIT");
|
|
177
|
+
result.idempotent = true;
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const now = nowIso();
|
|
182
|
+
ensureSessionRow(db, batch.sessionKey, batch.agentId, now);
|
|
183
|
+
|
|
184
|
+
db.prepare(`
|
|
185
|
+
INSERT INTO memory_batch_log (batch_id, session_key, created_at)
|
|
186
|
+
VALUES (?, ?, ?)
|
|
187
|
+
`).run(batch.batchId, batch.sessionKey, now);
|
|
188
|
+
|
|
189
|
+
if (batch.turns.length > 0) {
|
|
190
|
+
let nextTurnIndex = (
|
|
191
|
+
db
|
|
192
|
+
.prepare(`
|
|
193
|
+
SELECT COALESCE(MAX(turn_index), -1) + 1 AS next_index
|
|
194
|
+
FROM memory_turns
|
|
195
|
+
WHERE session_key = ?
|
|
196
|
+
`)
|
|
197
|
+
.get(batch.sessionKey) as any
|
|
198
|
+
).next_index;
|
|
199
|
+
|
|
200
|
+
for (const turn of batch.turns) {
|
|
201
|
+
const turnIndex = Number.isInteger(turn.turnIndex) ? turn.turnIndex : nextTurnIndex;
|
|
202
|
+
if (turnIndex >= nextTurnIndex) {
|
|
203
|
+
nextTurnIndex = turnIndex + 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
db.prepare(`
|
|
207
|
+
INSERT INTO memory_turns (
|
|
208
|
+
id,
|
|
209
|
+
session_key,
|
|
210
|
+
turn_index,
|
|
211
|
+
role,
|
|
212
|
+
content,
|
|
213
|
+
metadata_json,
|
|
214
|
+
created_at
|
|
215
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
216
|
+
`).run(
|
|
217
|
+
turn.id,
|
|
218
|
+
batch.sessionKey,
|
|
219
|
+
turnIndex,
|
|
220
|
+
turn.role,
|
|
221
|
+
turn.content,
|
|
222
|
+
safeJsonStringify(turn.metadata, "null"),
|
|
223
|
+
turn.createdAt,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
result.counts.turns++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (batch.snapshot) {
|
|
231
|
+
db.prepare(`
|
|
232
|
+
INSERT INTO memory_snapshots (
|
|
233
|
+
id,
|
|
234
|
+
session_key,
|
|
235
|
+
summary_text,
|
|
236
|
+
open_tasks_json,
|
|
237
|
+
decisions_json,
|
|
238
|
+
compacted_to_turn_id,
|
|
239
|
+
created_at
|
|
240
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
241
|
+
`).run(
|
|
242
|
+
batch.snapshot.id,
|
|
243
|
+
batch.sessionKey,
|
|
244
|
+
batch.snapshot.summaryText,
|
|
245
|
+
safeJsonStringify(batch.snapshot.openTasks, "[]"),
|
|
246
|
+
safeJsonStringify(batch.snapshot.decisions, "[]"),
|
|
247
|
+
batch.snapshot.compactedToTurnId,
|
|
248
|
+
batch.snapshot.createdAt,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
result.counts.snapshots++;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (batch.cardsUpsert.length > 0) {
|
|
255
|
+
for (const card of batch.cardsUpsert) {
|
|
256
|
+
db.prepare(`
|
|
257
|
+
INSERT INTO memory_cards (
|
|
258
|
+
id,
|
|
259
|
+
session_key,
|
|
260
|
+
scope,
|
|
261
|
+
card_type,
|
|
262
|
+
title,
|
|
263
|
+
body,
|
|
264
|
+
confidence,
|
|
265
|
+
pinned,
|
|
266
|
+
source_turn_from,
|
|
267
|
+
source_turn_to,
|
|
268
|
+
expires_at,
|
|
269
|
+
created_at,
|
|
270
|
+
updated_at
|
|
271
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
272
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
273
|
+
session_key = excluded.session_key,
|
|
274
|
+
scope = excluded.scope,
|
|
275
|
+
card_type = excluded.card_type,
|
|
276
|
+
title = excluded.title,
|
|
277
|
+
body = excluded.body,
|
|
278
|
+
confidence = excluded.confidence,
|
|
279
|
+
pinned = excluded.pinned,
|
|
280
|
+
source_turn_from = excluded.source_turn_from,
|
|
281
|
+
source_turn_to = excluded.source_turn_to,
|
|
282
|
+
expires_at = excluded.expires_at,
|
|
283
|
+
updated_at = excluded.updated_at
|
|
284
|
+
`).run(
|
|
285
|
+
card.id,
|
|
286
|
+
batch.sessionKey,
|
|
287
|
+
card.scope,
|
|
288
|
+
card.cardType,
|
|
289
|
+
card.title,
|
|
290
|
+
card.body,
|
|
291
|
+
card.confidence,
|
|
292
|
+
card.pinned ? 1 : 0,
|
|
293
|
+
card.sourceTurnFrom,
|
|
294
|
+
card.sourceTurnTo,
|
|
295
|
+
card.expiresAt,
|
|
296
|
+
card.createdAt,
|
|
297
|
+
card.updatedAt,
|
|
298
|
+
);
|
|
299
|
+
result.counts.cardsUpserted++;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (batch.cardsDelete.length > 0) {
|
|
304
|
+
const placeholders = batch.cardsDelete.map(() => "?").join(",");
|
|
305
|
+
const deleteResult = db
|
|
306
|
+
.prepare(`
|
|
307
|
+
DELETE FROM memory_cards
|
|
308
|
+
WHERE id IN (${placeholders})
|
|
309
|
+
`)
|
|
310
|
+
.run(...batch.cardsDelete);
|
|
311
|
+
result.counts.cardsDeleted += (deleteResult as any).changes || 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const compactedToTurnId =
|
|
315
|
+
batch.compactedToTurnId !== undefined ? batch.compactedToTurnId : batch.snapshot?.compactedToTurnId;
|
|
316
|
+
|
|
317
|
+
if (compactedToTurnId !== undefined) {
|
|
318
|
+
db.prepare(`
|
|
319
|
+
INSERT INTO memory_compaction_state (session_key, last_compacted_turn_id, updated_at)
|
|
320
|
+
VALUES (?, ?, ?)
|
|
321
|
+
ON CONFLICT(session_key) DO UPDATE SET
|
|
322
|
+
last_compacted_turn_id = excluded.last_compacted_turn_id,
|
|
323
|
+
updated_at = excluded.updated_at
|
|
324
|
+
`).run(batch.sessionKey, compactedToTurnId, nowIso());
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
db.exec("COMMIT");
|
|
328
|
+
return result;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
try {
|
|
331
|
+
db.exec("ROLLBACK");
|
|
332
|
+
} catch {
|
|
333
|
+
/* ignore */
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (isSqliteBusy(err) && attempts < 4) {
|
|
337
|
+
this.logger.warn?.(`[Memory] SQLite busy, retrying writeBatch (attempt ${attempts}/4)`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async readSessionSnapshot(sessionKey: string) {
|
|
347
|
+
await this.init();
|
|
348
|
+
const key = normalizeSessionKey(sessionKey);
|
|
349
|
+
|
|
350
|
+
const db = this.#requireDb();
|
|
351
|
+
const row = db
|
|
352
|
+
.prepare(`
|
|
353
|
+
SELECT id, session_key, summary_text, open_tasks_json, decisions_json, compacted_to_turn_id, created_at
|
|
354
|
+
FROM memory_snapshots
|
|
355
|
+
WHERE session_key = ?
|
|
356
|
+
ORDER BY created_at DESC, rowid DESC
|
|
357
|
+
LIMIT 1
|
|
358
|
+
`)
|
|
359
|
+
.get(key) as any;
|
|
360
|
+
|
|
361
|
+
if (!row) return null;
|
|
362
|
+
return mapSnapshotRow(row);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async listTurns(input: any) {
|
|
366
|
+
await this.init();
|
|
367
|
+
|
|
368
|
+
const { sessionKey, afterTurnId = null, limit = 50 } = input || {};
|
|
369
|
+
const key = normalizeSessionKey(sessionKey);
|
|
370
|
+
const safeLimit = clamp(Number(limit) || 50, 1, 500);
|
|
371
|
+
|
|
372
|
+
const db = this.#requireDb();
|
|
373
|
+
|
|
374
|
+
let afterIndex = -1;
|
|
375
|
+
if (afterTurnId) {
|
|
376
|
+
const row = db
|
|
377
|
+
.prepare(`
|
|
378
|
+
SELECT turn_index
|
|
379
|
+
FROM memory_turns
|
|
380
|
+
WHERE session_key = ? AND id = ?
|
|
381
|
+
LIMIT 1
|
|
382
|
+
`)
|
|
383
|
+
.get(key, String(afterTurnId)) as any;
|
|
384
|
+
|
|
385
|
+
if (row) afterIndex = row.turn_index;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const rows = db
|
|
389
|
+
.prepare(`
|
|
390
|
+
SELECT id, session_key, turn_index, role, content, metadata_json, created_at
|
|
391
|
+
FROM memory_turns
|
|
392
|
+
WHERE session_key = ?
|
|
393
|
+
AND turn_index > ?
|
|
394
|
+
ORDER BY turn_index ASC
|
|
395
|
+
LIMIT ?
|
|
396
|
+
`)
|
|
397
|
+
.all(key, afterIndex, safeLimit) as any[];
|
|
398
|
+
|
|
399
|
+
return rows.map(mapTurnRow);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async listRecentTurns(input: { sessionKey: string; limit?: number } = { sessionKey: "" }) {
|
|
403
|
+
await this.init();
|
|
404
|
+
|
|
405
|
+
const { sessionKey, limit = 20 } = input;
|
|
406
|
+
const key = normalizeSessionKey(sessionKey);
|
|
407
|
+
const safeLimit = clamp(Number(limit) || 20, 1, 2000);
|
|
408
|
+
|
|
409
|
+
const db = this.#requireDb();
|
|
410
|
+
const rows = db
|
|
411
|
+
.prepare(`
|
|
412
|
+
SELECT id, session_key, turn_index, role, content, metadata_json, created_at
|
|
413
|
+
FROM memory_turns
|
|
414
|
+
WHERE session_key = ?
|
|
415
|
+
ORDER BY turn_index DESC
|
|
416
|
+
LIMIT ?
|
|
417
|
+
`)
|
|
418
|
+
.all(key, safeLimit) as any[];
|
|
419
|
+
|
|
420
|
+
// Return ascending for chronological readability
|
|
421
|
+
rows.reverse();
|
|
422
|
+
return rows.map(mapTurnRow);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async queryCards(
|
|
426
|
+
input: { sessionKey?: string; scope?: string; cardType?: string; includeExpired?: boolean; limit?: number } = {},
|
|
427
|
+
) {
|
|
428
|
+
await this.init();
|
|
429
|
+
|
|
430
|
+
const { sessionKey, scope, cardType, includeExpired = false, limit = 50 } = input;
|
|
431
|
+
|
|
432
|
+
const safeLimit = clamp(Number(limit) || 50, 1, 500);
|
|
433
|
+
const db = this.#requireDb();
|
|
434
|
+
|
|
435
|
+
const conditions: string[] = [];
|
|
436
|
+
const params: any[] = [];
|
|
437
|
+
|
|
438
|
+
if (sessionKey) {
|
|
439
|
+
conditions.push("session_key = ?");
|
|
440
|
+
params.push(normalizeSessionKey(sessionKey));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (scope) {
|
|
444
|
+
conditions.push("scope = ?");
|
|
445
|
+
params.push(String(scope));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (cardType) {
|
|
449
|
+
conditions.push("card_type = ?");
|
|
450
|
+
params.push(String(cardType));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!includeExpired) {
|
|
454
|
+
conditions.push("(expires_at IS NULL OR expires_at > ?)");
|
|
455
|
+
params.push(nowIso());
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
459
|
+
|
|
460
|
+
const rows = db
|
|
461
|
+
.prepare(`
|
|
462
|
+
SELECT
|
|
463
|
+
id,
|
|
464
|
+
session_key,
|
|
465
|
+
scope,
|
|
466
|
+
card_type,
|
|
467
|
+
title,
|
|
468
|
+
body,
|
|
469
|
+
confidence,
|
|
470
|
+
pinned,
|
|
471
|
+
source_turn_from,
|
|
472
|
+
source_turn_to,
|
|
473
|
+
expires_at,
|
|
474
|
+
created_at,
|
|
475
|
+
updated_at
|
|
476
|
+
FROM memory_cards
|
|
477
|
+
${whereClause}
|
|
478
|
+
ORDER BY pinned DESC, updated_at DESC
|
|
479
|
+
LIMIT ?
|
|
480
|
+
`)
|
|
481
|
+
.all(...params, safeLimit) as any[];
|
|
482
|
+
|
|
483
|
+
return rows.map(mapCardRow);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async readCompactionState(sessionKey: string) {
|
|
487
|
+
await this.init();
|
|
488
|
+
const key = normalizeSessionKey(sessionKey);
|
|
489
|
+
|
|
490
|
+
const db = this.#requireDb();
|
|
491
|
+
const row = db
|
|
492
|
+
.prepare(`
|
|
493
|
+
SELECT session_key, last_compacted_turn_id, updated_at
|
|
494
|
+
FROM memory_compaction_state
|
|
495
|
+
WHERE session_key = ?
|
|
496
|
+
LIMIT 1
|
|
497
|
+
`)
|
|
498
|
+
.get(key) as any;
|
|
499
|
+
|
|
500
|
+
if (!row) return null;
|
|
501
|
+
return {
|
|
502
|
+
sessionKey: row.session_key,
|
|
503
|
+
lastCompactedTurnId: row.last_compacted_turn_id,
|
|
504
|
+
updatedAt: row.updated_at,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async getTurnById(input: { sessionKey?: string; turnId?: string } = {}) {
|
|
509
|
+
await this.init();
|
|
510
|
+
|
|
511
|
+
const { sessionKey, turnId } = input;
|
|
512
|
+
const key = normalizeSessionKey(sessionKey);
|
|
513
|
+
const id = nullableString(turnId);
|
|
514
|
+
if (!id) return null;
|
|
515
|
+
|
|
516
|
+
const db = this.#requireDb();
|
|
517
|
+
const row = db
|
|
518
|
+
.prepare(`
|
|
519
|
+
SELECT id, session_key, turn_index, role, content, metadata_json, created_at
|
|
520
|
+
FROM memory_turns
|
|
521
|
+
WHERE session_key = ?
|
|
522
|
+
AND id = ?
|
|
523
|
+
LIMIT 1
|
|
524
|
+
`)
|
|
525
|
+
.get(key, id) as any;
|
|
526
|
+
|
|
527
|
+
if (!row) return null;
|
|
528
|
+
return mapTurnRow(row);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async readRuntimeState(sessionKey: string) {
|
|
532
|
+
await this.init();
|
|
533
|
+
const key = normalizeSessionKey(sessionKey);
|
|
534
|
+
|
|
535
|
+
const db = this.#requireDb();
|
|
536
|
+
const row = db
|
|
537
|
+
.prepare(`
|
|
538
|
+
SELECT session_key, runtime_context_id, runtime_epoch, updated_at
|
|
539
|
+
FROM memory_runtime_state
|
|
540
|
+
WHERE session_key = ?
|
|
541
|
+
LIMIT 1
|
|
542
|
+
`)
|
|
543
|
+
.get(key) as any;
|
|
544
|
+
|
|
545
|
+
if (!row) return null;
|
|
546
|
+
return {
|
|
547
|
+
sessionKey: row.session_key,
|
|
548
|
+
runtimeContextId: row.runtime_context_id,
|
|
549
|
+
runtimeEpoch: row.runtime_epoch,
|
|
550
|
+
updatedAt: row.updated_at,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async upsertRuntimeState(input: { sessionKey?: string; runtimeContextId?: string; runtimeEpoch?: number } = {}) {
|
|
555
|
+
await this.init();
|
|
556
|
+
|
|
557
|
+
const sessionKey = normalizeSessionKey(input.sessionKey);
|
|
558
|
+
const runtimeContextId = nullableString(input.runtimeContextId) || makeRuntimeContextId("upsert");
|
|
559
|
+
const runtimeEpoch =
|
|
560
|
+
Number.isInteger(input.runtimeEpoch) && (input.runtimeEpoch as number) > 0 ? (input.runtimeEpoch as number) : 1;
|
|
561
|
+
|
|
562
|
+
const db = this.#requireDb();
|
|
563
|
+
const now = nowIso();
|
|
564
|
+
|
|
565
|
+
ensureSessionRow(db, sessionKey, null, now);
|
|
566
|
+
|
|
567
|
+
db.prepare(`
|
|
568
|
+
INSERT INTO memory_runtime_state (session_key, runtime_context_id, runtime_epoch, updated_at)
|
|
569
|
+
VALUES (?, ?, ?, ?)
|
|
570
|
+
ON CONFLICT(session_key) DO UPDATE SET
|
|
571
|
+
runtime_context_id = excluded.runtime_context_id,
|
|
572
|
+
runtime_epoch = excluded.runtime_epoch,
|
|
573
|
+
updated_at = excluded.updated_at
|
|
574
|
+
`).run(sessionKey, runtimeContextId, runtimeEpoch, now);
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
sessionKey,
|
|
578
|
+
runtimeContextId,
|
|
579
|
+
runtimeEpoch,
|
|
580
|
+
updatedAt: now,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async bumpRuntimeContext(input: { sessionKey?: string; runtimeContextId?: string } = {}) {
|
|
585
|
+
await this.init();
|
|
586
|
+
|
|
587
|
+
const sessionKey = normalizeSessionKey(input.sessionKey);
|
|
588
|
+
const requestedRuntimeContextId = nullableString(input.runtimeContextId);
|
|
589
|
+
const db = this.#requireDb();
|
|
590
|
+
|
|
591
|
+
let attempts = 0;
|
|
592
|
+
while (true) {
|
|
593
|
+
attempts++;
|
|
594
|
+
try {
|
|
595
|
+
db.exec("BEGIN IMMEDIATE");
|
|
596
|
+
|
|
597
|
+
const current = db
|
|
598
|
+
.prepare(`
|
|
599
|
+
SELECT runtime_context_id, runtime_epoch
|
|
600
|
+
FROM memory_runtime_state
|
|
601
|
+
WHERE session_key = ?
|
|
602
|
+
LIMIT 1
|
|
603
|
+
`)
|
|
604
|
+
.get(sessionKey) as any;
|
|
605
|
+
|
|
606
|
+
const nextEpoch = current ? Number(current.runtime_epoch || 0) + 1 : 1;
|
|
607
|
+
const nextContextId = requestedRuntimeContextId || makeRuntimeContextId(`epoch${nextEpoch}`);
|
|
608
|
+
const now = nowIso();
|
|
609
|
+
|
|
610
|
+
ensureSessionRow(db, sessionKey, null, now);
|
|
611
|
+
|
|
612
|
+
db.prepare(`
|
|
613
|
+
INSERT INTO memory_runtime_state (session_key, runtime_context_id, runtime_epoch, updated_at)
|
|
614
|
+
VALUES (?, ?, ?, ?)
|
|
615
|
+
ON CONFLICT(session_key) DO UPDATE SET
|
|
616
|
+
runtime_context_id = excluded.runtime_context_id,
|
|
617
|
+
runtime_epoch = excluded.runtime_epoch,
|
|
618
|
+
updated_at = excluded.updated_at
|
|
619
|
+
`).run(sessionKey, nextContextId, nextEpoch, now);
|
|
620
|
+
|
|
621
|
+
db.exec("COMMIT");
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
sessionKey,
|
|
625
|
+
runtimeContextId: nextContextId,
|
|
626
|
+
runtimeEpoch: nextEpoch,
|
|
627
|
+
updatedAt: now,
|
|
628
|
+
};
|
|
629
|
+
} catch (err) {
|
|
630
|
+
try {
|
|
631
|
+
db.exec("ROLLBACK");
|
|
632
|
+
} catch {
|
|
633
|
+
/* ignore */
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (isSqliteBusy(err) && attempts < 4) {
|
|
637
|
+
this.logger.warn?.(`[Memory] SQLite busy, retrying bumpRuntimeContext (attempt ${attempts}/4)`);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
throw err;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async close() {
|
|
647
|
+
if (!this.db) return;
|
|
648
|
+
try {
|
|
649
|
+
this.db.close();
|
|
650
|
+
} finally {
|
|
651
|
+
this.db = null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
#requireDb(): InstanceType<typeof Database> {
|
|
656
|
+
if (!this.db) {
|
|
657
|
+
throw new Error("SqliteMemoryStore is not initialized. Call init() first.");
|
|
658
|
+
}
|
|
659
|
+
return this.db;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function normalizeBatch(batch: any) {
|
|
664
|
+
if (!batch || typeof batch !== "object") {
|
|
665
|
+
throw new Error("writeBatch() requires a batch object");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const sessionKey = normalizeSessionKey(batch.sessionKey);
|
|
669
|
+
const batchId = String(batch.batchId || batch.id || "").trim();
|
|
670
|
+
if (!batchId) {
|
|
671
|
+
throw new Error("writeBatch() requires batch.batchId (or batch.id)");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const compactedToTurnId = Object.hasOwn(batch, "compactedToTurnId")
|
|
675
|
+
? nullableString(batch.compactedToTurnId)
|
|
676
|
+
: undefined;
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
batchId,
|
|
680
|
+
sessionKey,
|
|
681
|
+
agentId: nullableString(batch.agentId),
|
|
682
|
+
turns: normalizeTurns(batch.turns),
|
|
683
|
+
snapshot: batch.snapshot ? normalizeSnapshot(batch.snapshot) : null,
|
|
684
|
+
cardsUpsert: normalizeCards(batch.cardsUpsert),
|
|
685
|
+
cardsDelete: normalizeCardDeletes(batch.cardsDelete),
|
|
686
|
+
compactedToTurnId,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function normalizeSessionKey(sessionKey: any): string {
|
|
691
|
+
const key = String(sessionKey || "").trim();
|
|
692
|
+
if (!key) throw new Error("sessionKey is required");
|
|
693
|
+
return key;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function normalizeTurns(turns: any) {
|
|
697
|
+
if (!Array.isArray(turns)) return [];
|
|
698
|
+
|
|
699
|
+
return turns.map((turn: any) => {
|
|
700
|
+
const role = String(turn?.role || "user").trim() || "user";
|
|
701
|
+
const content = String(turn?.content || "");
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
id: nullableString(turn?.id) || `turn_${randomUUID()}`,
|
|
705
|
+
role,
|
|
706
|
+
content,
|
|
707
|
+
turnIndex: Number.isInteger(turn?.turnIndex) ? turn.turnIndex : null,
|
|
708
|
+
metadata: turn?.metadata ?? null,
|
|
709
|
+
createdAt: nullableString(turn?.createdAt) || nowIso(),
|
|
710
|
+
};
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function normalizeSnapshot(snapshot: any) {
|
|
715
|
+
return {
|
|
716
|
+
id: nullableString(snapshot?.id) || `snapshot_${randomUUID()}`,
|
|
717
|
+
summaryText: String(snapshot?.summaryText || snapshot?.summary || ""),
|
|
718
|
+
openTasks: Array.isArray(snapshot?.openTasks) ? snapshot.openTasks : [],
|
|
719
|
+
decisions: Array.isArray(snapshot?.decisions) ? snapshot.decisions : [],
|
|
720
|
+
compactedToTurnId: nullableString(snapshot?.compactedToTurnId),
|
|
721
|
+
createdAt: nullableString(snapshot?.createdAt) || nowIso(),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function normalizeCards(cards: any) {
|
|
726
|
+
if (!Array.isArray(cards)) return [];
|
|
727
|
+
|
|
728
|
+
return cards.map((card: any) => {
|
|
729
|
+
const scope = normalizeScope(card?.scope);
|
|
730
|
+
const cardType = normalizeCardType(card?.cardType || card?.type);
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
id: nullableString(card?.id) || `card_${randomUUID()}`,
|
|
734
|
+
scope,
|
|
735
|
+
cardType,
|
|
736
|
+
title: String(card?.title || ""),
|
|
737
|
+
body: String(card?.body || card?.content || ""),
|
|
738
|
+
confidence: clamp(Number(card?.confidence ?? 0.5) || 0.5, 0, 1),
|
|
739
|
+
pinned: Boolean(card?.pinned),
|
|
740
|
+
sourceTurnFrom: nullableString(card?.sourceTurnFrom),
|
|
741
|
+
sourceTurnTo: nullableString(card?.sourceTurnTo),
|
|
742
|
+
expiresAt: nullableString(card?.expiresAt),
|
|
743
|
+
createdAt: nullableString(card?.createdAt) || nowIso(),
|
|
744
|
+
updatedAt: nullableString(card?.updatedAt) || nowIso(),
|
|
745
|
+
};
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function normalizeCardDeletes(cardIds: any) {
|
|
750
|
+
if (!Array.isArray(cardIds)) return [];
|
|
751
|
+
return cardIds.map(nullableString).filter(Boolean) as string[];
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function normalizeScope(scope: any): string {
|
|
755
|
+
const value = String(scope || MemoryScopes.SESSION).toLowerCase();
|
|
756
|
+
if ((Object.values(MemoryScopes) as string[]).includes(value)) return value;
|
|
757
|
+
return MemoryScopes.SESSION;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function ensureSessionRow(
|
|
761
|
+
db: InstanceType<typeof Database>,
|
|
762
|
+
sessionKey: string,
|
|
763
|
+
agentId: string | null,
|
|
764
|
+
updatedAt: string = nowIso(),
|
|
765
|
+
) {
|
|
766
|
+
db.prepare(`
|
|
767
|
+
INSERT INTO memory_sessions (id, session_key, agent_id, created_at, updated_at)
|
|
768
|
+
VALUES (?, ?, ?, ?, ?)
|
|
769
|
+
ON CONFLICT(session_key) DO UPDATE SET
|
|
770
|
+
agent_id = COALESCE(excluded.agent_id, memory_sessions.agent_id),
|
|
771
|
+
updated_at = excluded.updated_at
|
|
772
|
+
`).run(`session_${randomUUID()}`, sessionKey, agentId, updatedAt, updatedAt);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function normalizeCardType(cardType: any): string {
|
|
776
|
+
const value = String(cardType || MemoryCardTypes.CONTEXT).toLowerCase();
|
|
777
|
+
if ((Object.values(MemoryCardTypes) as string[]).includes(value)) return value;
|
|
778
|
+
return MemoryCardTypes.CONTEXT;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function nullableString(value: any): string | null {
|
|
782
|
+
if (value === undefined || value === null) return null;
|
|
783
|
+
const out = String(value).trim();
|
|
784
|
+
return out.length > 0 ? out : null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function mapTurnRow(row: any) {
|
|
788
|
+
return {
|
|
789
|
+
id: row.id,
|
|
790
|
+
sessionKey: row.session_key,
|
|
791
|
+
turnIndex: row.turn_index,
|
|
792
|
+
role: row.role,
|
|
793
|
+
content: row.content,
|
|
794
|
+
metadata: safeJsonParse(row.metadata_json, null),
|
|
795
|
+
createdAt: row.created_at,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function mapSnapshotRow(row: any) {
|
|
800
|
+
return {
|
|
801
|
+
id: row.id,
|
|
802
|
+
sessionKey: row.session_key,
|
|
803
|
+
summaryText: row.summary_text,
|
|
804
|
+
openTasks: safeJsonParse(row.open_tasks_json, []),
|
|
805
|
+
decisions: safeJsonParse(row.decisions_json, []),
|
|
806
|
+
compactedToTurnId: row.compacted_to_turn_id,
|
|
807
|
+
createdAt: row.created_at,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function mapCardRow(row: any) {
|
|
812
|
+
return {
|
|
813
|
+
id: row.id,
|
|
814
|
+
sessionKey: row.session_key,
|
|
815
|
+
scope: row.scope,
|
|
816
|
+
cardType: row.card_type,
|
|
817
|
+
title: row.title,
|
|
818
|
+
body: row.body,
|
|
819
|
+
confidence: row.confidence,
|
|
820
|
+
pinned: row.pinned === 1,
|
|
821
|
+
sourceTurnFrom: row.source_turn_from,
|
|
822
|
+
sourceTurnTo: row.source_turn_to,
|
|
823
|
+
expiresAt: row.expires_at,
|
|
824
|
+
createdAt: row.created_at,
|
|
825
|
+
updatedAt: row.updated_at,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function makeRuntimeContextId(prefix: string = "runtime") {
|
|
830
|
+
const ts = Date.now().toString(36);
|
|
831
|
+
const rand = randomUUID().slice(0, 8);
|
|
832
|
+
return `${prefix}_${ts}_${rand}`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function isSqliteBusy(err: unknown): boolean {
|
|
836
|
+
const msg = String((err as any)?.message || "");
|
|
837
|
+
return msg.includes("SQLITE_BUSY") || (err as any)?.code === "SQLITE_BUSY";
|
|
838
|
+
}
|