claude-memory-layer 1.0.10 → 1.0.12
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/AGENTS.md +60 -0
- package/README.md +166 -2
- package/bootstrap-kb/decisions/decisions.md +244 -0
- package/bootstrap-kb/glossary/glossary.md +46 -0
- package/bootstrap-kb/modules/.claude-plugin.md +22 -0
- package/bootstrap-kb/modules/agents.md.md +15 -0
- package/bootstrap-kb/modules/claude.md.md +15 -0
- package/bootstrap-kb/modules/context.md.md +15 -0
- package/bootstrap-kb/modules/docs.md +18 -0
- package/bootstrap-kb/modules/handoff.md.md +15 -0
- package/bootstrap-kb/modules/package-lock.json.md +15 -0
- package/bootstrap-kb/modules/package.json.md +15 -0
- package/bootstrap-kb/modules/plan.md.md +15 -0
- package/bootstrap-kb/modules/readme.md.md +15 -0
- package/bootstrap-kb/modules/scripts.md +26 -0
- package/bootstrap-kb/modules/spec.md.md +15 -0
- package/bootstrap-kb/modules/specs.md +20 -0
- package/bootstrap-kb/modules/src.md +51 -0
- package/bootstrap-kb/modules/tests.md +42 -0
- package/bootstrap-kb/modules/tsconfig.json.md +15 -0
- package/bootstrap-kb/modules/vitest.config.ts.md +15 -0
- package/bootstrap-kb/overview/overview.md +40 -0
- package/bootstrap-kb/sources/manifest.json +950 -0
- package/bootstrap-kb/sources/manifest.md +227 -0
- package/bootstrap-kb/timeline/timeline.md +57 -0
- package/d.sh +3 -0
- package/deploy.sh +3 -0
- package/dist/cli/index.js +3577 -389
- package/dist/cli/index.js.map +4 -4
- package/dist/core/index.js +1383 -138
- package/dist/core/index.js.map +4 -4
- package/dist/hooks/post-tool-use.js +1917 -214
- package/dist/hooks/post-tool-use.js.map +4 -4
- package/dist/hooks/session-end.js +1813 -231
- package/dist/hooks/session-end.js.map +4 -4
- package/dist/hooks/session-start.js +1802 -205
- package/dist/hooks/session-start.js.map +4 -4
- package/dist/hooks/stop.js +1909 -248
- package/dist/hooks/stop.js.map +4 -4
- package/dist/hooks/user-prompt-submit.js +1861 -206
- package/dist/hooks/user-prompt-submit.js.map +4 -4
- package/dist/server/api/index.js +2341 -217
- package/dist/server/api/index.js.map +4 -4
- package/dist/server/index.js +2350 -226
- package/dist/server/index.js.map +4 -4
- package/dist/services/memory-service.js +1805 -206
- package/dist/services/memory-service.js.map +4 -4
- package/dist/ui/app.js +1447 -55
- package/dist/ui/index.html +318 -147
- package/dist/ui/style.css +892 -0
- package/docs/MCP_MEMORY_SERVICE_COMPARATIVE_REVIEW.md +271 -0
- package/docs/MEMU_ADOPTION.md +40 -0
- package/docs/OPERATIONS.md +18 -0
- package/memory/.claude-plugin/commands/2026-02-25.md +263 -0
- package/memory/_index.md +405 -0
- package/memory/default/uncategorized/2026-02-25.md +4839 -0
- package/memory/specs/20260207-dashboard-upgrade/2026-02-25.md +142 -0
- package/memory/specs/citations-system/2026-02-25.md +1121 -0
- package/memory/specs/endless-mode/2026-02-25.md +1392 -0
- package/memory/specs/entity-edge-model/2026-02-25.md +1263 -0
- package/memory/specs/evidence-aligner-v2/2026-02-25.md +1028 -0
- package/memory/specs/mcp-desktop-integration/2026-02-25.md +1334 -0
- package/memory/specs/post-tool-use-hook/2026-02-25.md +1164 -0
- package/memory/specs/private-tags/2026-02-25.md +1057 -0
- package/memory/specs/progressive-disclosure/2026-02-25.md +1436 -0
- package/memory/specs/task-entity-system/2026-02-25.md +924 -0
- package/memory/specs/vector-outbox-v2/2026-02-25.md +1510 -0
- package/memory/specs/web-viewer-ui/2026-02-25.md +1709 -0
- package/package.json +9 -2
- package/scripts/build.ts +6 -0
- package/scripts/fix-sync-gap.js +32 -0
- package/scripts/heartbeat-memory-orchestrator.sh +28 -0
- package/scripts/report-sync-gap.js +26 -0
- package/scripts/review-queue-auto-resolve.js +21 -0
- package/scripts/sync-gap-auto-heal.sh +17 -0
- package/specs/20260207-dashboard-upgrade/context.md +38 -0
- package/specs/20260207-dashboard-upgrade/spec.md +96 -0
- package/src/cli/index.ts +391 -60
- package/src/core/consolidated-store.ts +63 -1
- package/src/core/consolidation-worker.ts +115 -6
- package/src/core/event-store.ts +14 -0
- package/src/core/index.ts +1 -0
- package/src/core/ingest-interceptor.ts +80 -0
- package/src/core/markdown-mirror.ts +70 -0
- package/src/core/md-mirror.ts +92 -0
- package/src/core/mongo-sync-config.ts +165 -0
- package/src/core/mongo-sync-worker.ts +381 -0
- package/src/core/retriever.ts +540 -150
- package/src/core/sqlite-event-store.ts +794 -7
- package/src/core/sqlite-wrapper.ts +8 -0
- package/src/core/tag-taxonomy.ts +51 -0
- package/src/core/turn-state.ts +159 -0
- package/src/core/types.ts +51 -8
- package/src/core/vector-store.ts +21 -3
- package/src/hooks/post-tool-use.ts +68 -23
- package/src/hooks/session-end.ts +8 -3
- package/src/hooks/stop.ts +96 -25
- package/src/hooks/user-prompt-submit.ts +44 -5
- package/src/server/api/chat.ts +244 -0
- package/src/server/api/citations.ts +3 -3
- package/src/server/api/events.ts +30 -5
- package/src/server/api/health.ts +53 -0
- package/src/server/api/index.ts +9 -1
- package/src/server/api/projects.ts +74 -0
- package/src/server/api/search.ts +3 -3
- package/src/server/api/sessions.ts +3 -3
- package/src/server/api/stats.ts +89 -8
- package/src/server/api/turns.ts +143 -0
- package/src/server/api/utils.ts +46 -0
- package/src/services/bootstrap-organizer.ts +443 -0
- package/src/services/codex-session-history-importer.ts +474 -0
- package/src/services/memory-service.ts +508 -71
- package/src/services/session-history-importer.ts +215 -51
- package/src/ui/app.js +1447 -55
- package/src/ui/index.html +318 -147
- package/src/ui/style.css +892 -0
- package/tests/bootstrap-organizer.test.ts +111 -0
- package/tests/consolidation-worker.test.ts +75 -0
- package/tests/ingest-interceptor.test.ts +38 -0
- package/tests/markdown-mirror.test.ts +85 -0
- package/tests/md-mirror.test.ts +50 -0
- package/tests/retriever-fallback-chain.test.ts +223 -0
- package/tests/retriever-strategy-scope.test.ts +97 -0
- package/tests/retriever.memu-adoption.test.ts +122 -0
- package/tests/sqlite-event-store-replication.test.ts +92 -0
- package/.claude/settings.local.json +0 -27
- package/.claude-memory/test.sqlite +0 -0
- package/.history/package_20260201112328.json +0 -45
- package/.history/package_20260201113602.json +0 -45
- package/.history/package_20260201113713.json +0 -45
- package/.history/package_20260201114110.json +0 -45
- package/.history/package_20260201114632.json +0 -46
- package/.history/package_20260201133143.json +0 -45
- package/.history/package_20260201134319.json +0 -45
- package/.history/package_20260201134326.json +0 -45
- package/.history/package_20260201134334.json +0 -45
- package/.history/package_20260201134912.json +0 -45
- package/.history/package_20260201142928.json +0 -46
- package/.history/package_20260201192048.json +0 -47
- package/.history/package_20260202114053.json +0 -49
- package/.history/package_20260202121115.json +0 -49
- package/test_access.js +0 -49
|
@@ -24,15 +24,17 @@ import {
|
|
|
24
24
|
type SQLiteDatabase,
|
|
25
25
|
type SQLiteOptions
|
|
26
26
|
} from './sqlite-wrapper.js';
|
|
27
|
+
import { MarkdownMirror } from './markdown-mirror.js';
|
|
27
28
|
|
|
28
29
|
export interface SQLiteEventStoreOptions extends SQLiteOptions {
|
|
29
|
-
|
|
30
|
+
markdownMirrorRoot?: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export class SQLiteEventStore {
|
|
33
34
|
private db: SQLiteDatabase;
|
|
34
35
|
private initialized = false;
|
|
35
36
|
private readonly readOnly: boolean;
|
|
37
|
+
private readonly markdownMirror: MarkdownMirror | null;
|
|
36
38
|
|
|
37
39
|
constructor(private dbPath: string, options?: SQLiteEventStoreOptions) {
|
|
38
40
|
this.readOnly = options?.readonly ?? false;
|
|
@@ -40,6 +42,9 @@ export class SQLiteEventStore {
|
|
|
40
42
|
readonly: this.readOnly,
|
|
41
43
|
walMode: !this.readOnly
|
|
42
44
|
});
|
|
45
|
+
this.markdownMirror = this.readOnly || !options?.markdownMirrorRoot
|
|
46
|
+
? null
|
|
47
|
+
: new MarkdownMirror(options.markdownMirrorRoot);
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|
|
@@ -251,6 +256,17 @@ export class SQLiteEventStore {
|
|
|
251
256
|
created_at TEXT DEFAULT (datetime('now'))
|
|
252
257
|
);
|
|
253
258
|
|
|
259
|
+
-- Consolidated Rules table (long-term stable memory)
|
|
260
|
+
CREATE TABLE IF NOT EXISTS consolidated_rules (
|
|
261
|
+
rule_id TEXT PRIMARY KEY,
|
|
262
|
+
rule TEXT NOT NULL,
|
|
263
|
+
topics TEXT,
|
|
264
|
+
source_memory_ids TEXT,
|
|
265
|
+
source_events TEXT,
|
|
266
|
+
confidence REAL DEFAULT 0.5,
|
|
267
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
268
|
+
);
|
|
269
|
+
|
|
254
270
|
-- Endless Mode Config table
|
|
255
271
|
CREATE TABLE IF NOT EXISTS endless_config (
|
|
256
272
|
key TEXT PRIMARY KEY,
|
|
@@ -258,6 +274,41 @@ export class SQLiteEventStore {
|
|
|
258
274
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
259
275
|
);
|
|
260
276
|
|
|
277
|
+
-- Memory Helpfulness tracking
|
|
278
|
+
CREATE TABLE IF NOT EXISTS memory_helpfulness (
|
|
279
|
+
id TEXT PRIMARY KEY,
|
|
280
|
+
event_id TEXT NOT NULL,
|
|
281
|
+
session_id TEXT NOT NULL,
|
|
282
|
+
retrieval_score REAL DEFAULT 0,
|
|
283
|
+
query_preview TEXT,
|
|
284
|
+
session_continued INTEGER DEFAULT 0,
|
|
285
|
+
prompt_count_after INTEGER DEFAULT 0,
|
|
286
|
+
tool_success_count INTEGER DEFAULT 0,
|
|
287
|
+
tool_total_count INTEGER DEFAULT 0,
|
|
288
|
+
was_reasked INTEGER DEFAULT 0,
|
|
289
|
+
helpfulness_score REAL DEFAULT 0.5,
|
|
290
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
291
|
+
measured_at TEXT
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
-- Retrieval trace log (query -> candidates -> selected for context)
|
|
295
|
+
CREATE TABLE IF NOT EXISTS retrieval_traces (
|
|
296
|
+
trace_id TEXT PRIMARY KEY,
|
|
297
|
+
session_id TEXT,
|
|
298
|
+
project_hash TEXT,
|
|
299
|
+
query_text TEXT NOT NULL,
|
|
300
|
+
strategy TEXT,
|
|
301
|
+
candidate_event_ids TEXT,
|
|
302
|
+
selected_event_ids TEXT,
|
|
303
|
+
candidate_details_json TEXT,
|
|
304
|
+
selected_details_json TEXT,
|
|
305
|
+
candidate_count INTEGER DEFAULT 0,
|
|
306
|
+
selected_count INTEGER DEFAULT 0,
|
|
307
|
+
confidence TEXT,
|
|
308
|
+
fallback_trace TEXT,
|
|
309
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
310
|
+
);
|
|
311
|
+
|
|
261
312
|
-- Sync position tracking (for SQLite -> DuckDB sync)
|
|
262
313
|
CREATE TABLE IF NOT EXISTS sync_positions (
|
|
263
314
|
target_name TEXT PRIMARY KEY,
|
|
@@ -282,7 +333,14 @@ export class SQLiteEventStore {
|
|
|
282
333
|
CREATE INDEX IF NOT EXISTS idx_working_set_relevance ON working_set(relevance_score);
|
|
283
334
|
CREATE INDEX IF NOT EXISTS idx_consolidated_confidence ON consolidated_memories(confidence);
|
|
284
335
|
CREATE INDEX IF NOT EXISTS idx_continuity_created ON continuity_log(created_at);
|
|
336
|
+
CREATE INDEX IF NOT EXISTS idx_consolidated_rules_confidence ON consolidated_rules(confidence);
|
|
285
337
|
CREATE INDEX IF NOT EXISTS idx_embedding_outbox_status ON embedding_outbox(status);
|
|
338
|
+
CREATE INDEX IF NOT EXISTS idx_helpfulness_event ON memory_helpfulness(event_id);
|
|
339
|
+
CREATE INDEX IF NOT EXISTS idx_helpfulness_session ON memory_helpfulness(session_id);
|
|
340
|
+
CREATE INDEX IF NOT EXISTS idx_helpfulness_score ON memory_helpfulness(helpfulness_score DESC);
|
|
341
|
+
CREATE INDEX IF NOT EXISTS idx_retrieval_traces_created_at ON retrieval_traces(created_at DESC);
|
|
342
|
+
CREATE INDEX IF NOT EXISTS idx_retrieval_traces_project_hash ON retrieval_traces(project_hash);
|
|
343
|
+
CREATE INDEX IF NOT EXISTS idx_retrieval_traces_session_id ON retrieval_traces(session_id);
|
|
286
344
|
|
|
287
345
|
-- FTS5 Full-Text Search for fast keyword search
|
|
288
346
|
CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
|
|
@@ -307,7 +365,20 @@ export class SQLiteEventStore {
|
|
|
307
365
|
END;
|
|
308
366
|
`);
|
|
309
367
|
|
|
310
|
-
|
|
368
|
+
|
|
369
|
+
// Best-effort forward migration for retrieval trace detail column
|
|
370
|
+
try {
|
|
371
|
+
sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN selected_details_json TEXT;`);
|
|
372
|
+
} catch {
|
|
373
|
+
// column may already exist
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
sqliteExec(this.db, `ALTER TABLE retrieval_traces ADD COLUMN candidate_details_json TEXT;`);
|
|
377
|
+
} catch {
|
|
378
|
+
// column may already exist
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Migrate existing events table to add new columns if they don't exist
|
|
311
382
|
// Check if columns exist before trying to add them
|
|
312
383
|
const tableInfo = sqliteAll(this.db, "PRAGMA table_info(events)", []);
|
|
313
384
|
const columnNames = tableInfo.map((col: any) => col.name);
|
|
@@ -332,6 +403,17 @@ export class SQLiteEventStore {
|
|
|
332
403
|
}
|
|
333
404
|
}
|
|
334
405
|
|
|
406
|
+
// Add turn_id column for grouping events within a conversation turn
|
|
407
|
+
if (!columnNames.includes('turn_id')) {
|
|
408
|
+
try {
|
|
409
|
+
sqliteExec(this.db, `
|
|
410
|
+
ALTER TABLE events ADD COLUMN turn_id TEXT;
|
|
411
|
+
`);
|
|
412
|
+
} catch (err: any) {
|
|
413
|
+
console.error('Error adding turn_id column:', err);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
335
417
|
// Create indexes for new columns if they don't exist
|
|
336
418
|
try {
|
|
337
419
|
sqliteExec(this.db, `
|
|
@@ -349,6 +431,14 @@ export class SQLiteEventStore {
|
|
|
349
431
|
// Index may already exist, ignore
|
|
350
432
|
}
|
|
351
433
|
|
|
434
|
+
try {
|
|
435
|
+
sqliteExec(this.db, `
|
|
436
|
+
CREATE INDEX IF NOT EXISTS idx_events_turn_id ON events(turn_id);
|
|
437
|
+
`);
|
|
438
|
+
} catch (err: any) {
|
|
439
|
+
// Index may already exist, ignore
|
|
440
|
+
}
|
|
441
|
+
|
|
352
442
|
this.initialized = true;
|
|
353
443
|
}
|
|
354
444
|
|
|
@@ -380,10 +470,14 @@ export class SQLiteEventStore {
|
|
|
380
470
|
const timestamp = toSQLiteTimestamp(input.timestamp);
|
|
381
471
|
|
|
382
472
|
try {
|
|
473
|
+
// Extract turnId from metadata if present
|
|
474
|
+
const metadata = input.metadata || {};
|
|
475
|
+
const turnId = (metadata.turnId as string) || null;
|
|
476
|
+
|
|
383
477
|
// Use transaction for atomicity
|
|
384
478
|
const insertEvent = this.db.prepare(`
|
|
385
|
-
INSERT INTO events (id, event_type, session_id, timestamp, content, canonical_key, dedupe_key, metadata)
|
|
386
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
479
|
+
INSERT INTO events (id, event_type, session_id, timestamp, content, canonical_key, dedupe_key, metadata, turn_id)
|
|
480
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
387
481
|
`);
|
|
388
482
|
|
|
389
483
|
const insertDedup = this.db.prepare(`
|
|
@@ -403,7 +497,8 @@ export class SQLiteEventStore {
|
|
|
403
497
|
input.content,
|
|
404
498
|
canonicalKey,
|
|
405
499
|
dedupeKey,
|
|
406
|
-
JSON.stringify(
|
|
500
|
+
JSON.stringify(metadata),
|
|
501
|
+
turnId
|
|
407
502
|
);
|
|
408
503
|
insertDedup.run(dedupeKey, id);
|
|
409
504
|
insertLevel.run(id);
|
|
@@ -411,6 +506,22 @@ export class SQLiteEventStore {
|
|
|
411
506
|
|
|
412
507
|
transaction();
|
|
413
508
|
|
|
509
|
+
if (this.markdownMirror) {
|
|
510
|
+
const event: MemoryEvent = {
|
|
511
|
+
id,
|
|
512
|
+
eventType: input.eventType,
|
|
513
|
+
sessionId: input.sessionId,
|
|
514
|
+
timestamp: input.timestamp,
|
|
515
|
+
content: input.content,
|
|
516
|
+
canonicalKey,
|
|
517
|
+
dedupeKey,
|
|
518
|
+
metadata
|
|
519
|
+
};
|
|
520
|
+
this.markdownMirror.append(event).catch((err) => {
|
|
521
|
+
console.warn('[SQLiteEventStore] markdown mirror append failed:', err);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
414
525
|
return { success: true, eventId: id, isDuplicate: false };
|
|
415
526
|
} catch (error) {
|
|
416
527
|
return {
|
|
@@ -481,6 +592,114 @@ export class SQLiteEventStore {
|
|
|
481
592
|
return rows.map(this.rowToEvent);
|
|
482
593
|
}
|
|
483
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Get events since a SQLite rowid (for robust incremental replication).
|
|
597
|
+
* Rowid is monotonic for append-only tables, independent of client timestamps.
|
|
598
|
+
*/
|
|
599
|
+
async getEventsSinceRowid(
|
|
600
|
+
lastRowid: number,
|
|
601
|
+
limit: number = 1000
|
|
602
|
+
): Promise<Array<{ rowid: number; event: MemoryEvent }>> {
|
|
603
|
+
await this.initialize();
|
|
604
|
+
|
|
605
|
+
const rows = sqliteAll<Record<string, unknown>>(
|
|
606
|
+
this.db,
|
|
607
|
+
`SELECT rowid as _rowid, * FROM events WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`,
|
|
608
|
+
[lastRowid, limit]
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
return rows.map(row => ({
|
|
612
|
+
rowid: row._rowid as number,
|
|
613
|
+
event: this.rowToEvent(row)
|
|
614
|
+
}));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Import events with fixed IDs (used for cross-machine replication).
|
|
619
|
+
* Idempotent: skips if event id or dedupeKey already exists.
|
|
620
|
+
*
|
|
621
|
+
* NOTE: This bypasses the append() id generation to preserve stable IDs.
|
|
622
|
+
*/
|
|
623
|
+
async importEvents(events: MemoryEvent[]): Promise<{ inserted: number; skipped: number }> {
|
|
624
|
+
if (events.length === 0) return { inserted: 0, skipped: 0 };
|
|
625
|
+
if (this.readOnly) return { inserted: 0, skipped: events.length };
|
|
626
|
+
|
|
627
|
+
await this.initialize();
|
|
628
|
+
|
|
629
|
+
const getById = this.db.prepare(`SELECT id FROM events WHERE id = ?`);
|
|
630
|
+
const getByDedupe = this.db.prepare(`SELECT event_id FROM event_dedup WHERE dedupe_key = ?`);
|
|
631
|
+
|
|
632
|
+
const insertEvent = this.db.prepare(`
|
|
633
|
+
INSERT INTO events (id, event_type, session_id, timestamp, content, canonical_key, dedupe_key, metadata, turn_id)
|
|
634
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
635
|
+
`);
|
|
636
|
+
|
|
637
|
+
const insertDedup = this.db.prepare(`
|
|
638
|
+
INSERT INTO event_dedup (dedupe_key, event_id) VALUES (?, ?)
|
|
639
|
+
`);
|
|
640
|
+
|
|
641
|
+
const insertLevel = this.db.prepare(`
|
|
642
|
+
INSERT INTO memory_levels (event_id, level) VALUES (?, 'L0')
|
|
643
|
+
`);
|
|
644
|
+
|
|
645
|
+
let inserted = 0;
|
|
646
|
+
let skipped = 0;
|
|
647
|
+
const insertedEvents: MemoryEvent[] = [];
|
|
648
|
+
|
|
649
|
+
const tx = this.db.transaction((batch: MemoryEvent[]) => {
|
|
650
|
+
for (const ev of batch) {
|
|
651
|
+
// Skip if already present by id
|
|
652
|
+
const existingById = getById.get(ev.id) as { id: string } | undefined;
|
|
653
|
+
if (existingById) {
|
|
654
|
+
skipped++;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const canonicalKey = ev.canonicalKey || makeCanonicalKey(ev.content);
|
|
659
|
+
const dedupeKey = ev.dedupeKey || makeDedupeKey(ev.content, ev.sessionId);
|
|
660
|
+
|
|
661
|
+
// Skip if already present by dedupe key
|
|
662
|
+
const existingByDedupe = getByDedupe.get(dedupeKey) as { event_id: string } | undefined;
|
|
663
|
+
if (existingByDedupe) {
|
|
664
|
+
skipped++;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const metadata = ev.metadata || {};
|
|
669
|
+
const turnId = (metadata as any).turnId as string | undefined;
|
|
670
|
+
|
|
671
|
+
insertEvent.run(
|
|
672
|
+
ev.id,
|
|
673
|
+
ev.eventType,
|
|
674
|
+
ev.sessionId,
|
|
675
|
+
toSQLiteTimestamp(ev.timestamp),
|
|
676
|
+
ev.content,
|
|
677
|
+
canonicalKey,
|
|
678
|
+
dedupeKey,
|
|
679
|
+
JSON.stringify(metadata),
|
|
680
|
+
turnId ?? null
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
insertDedup.run(dedupeKey, ev.id);
|
|
684
|
+
insertLevel.run(ev.id);
|
|
685
|
+
inserted++;
|
|
686
|
+
insertedEvents.push(ev);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
tx(events);
|
|
691
|
+
|
|
692
|
+
if (this.markdownMirror && insertedEvents.length > 0) {
|
|
693
|
+
for (const ev of insertedEvents) {
|
|
694
|
+
this.markdownMirror.append(ev).catch((err) => {
|
|
695
|
+
console.warn('[SQLiteEventStore] markdown mirror append failed:', err);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return { inserted, skipped };
|
|
701
|
+
}
|
|
702
|
+
|
|
484
703
|
/**
|
|
485
704
|
* Create or update session
|
|
486
705
|
*/
|
|
@@ -664,6 +883,43 @@ export class SQLiteEventStore {
|
|
|
664
883
|
);
|
|
665
884
|
}
|
|
666
885
|
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Get embedding/vector outbox health statistics
|
|
889
|
+
*/
|
|
890
|
+
async getOutboxStats(): Promise<{
|
|
891
|
+
embedding: { pending: number; processing: number; failed: number; total: number };
|
|
892
|
+
vector: { pending: number; processing: number; failed: number; total: number };
|
|
893
|
+
}> {
|
|
894
|
+
await this.initialize();
|
|
895
|
+
|
|
896
|
+
const embeddingRows = sqliteAll<{ status: string; count: number }>(
|
|
897
|
+
this.db,
|
|
898
|
+
`SELECT status, COUNT(*) as count FROM embedding_outbox GROUP BY status`
|
|
899
|
+
);
|
|
900
|
+
const vectorRows = sqliteAll<{ status: string; count: number }>(
|
|
901
|
+
this.db,
|
|
902
|
+
`SELECT status, COUNT(*) as count FROM vector_outbox GROUP BY status`
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
const fromRows = (rows: Array<{ status: string; count: number }>) => {
|
|
906
|
+
const out = { pending: 0, processing: 0, failed: 0, total: 0 };
|
|
907
|
+
for (const row of rows) {
|
|
908
|
+
const key = row.status as 'pending' | 'processing' | 'failed' | 'done';
|
|
909
|
+
if (key === 'pending' || key === 'processing' || key === 'failed') {
|
|
910
|
+
out[key] += row.count;
|
|
911
|
+
}
|
|
912
|
+
out.total += row.count;
|
|
913
|
+
}
|
|
914
|
+
return out;
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
embedding: fromRows(embeddingRows),
|
|
919
|
+
vector: fromRows(vectorRows)
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
667
923
|
/**
|
|
668
924
|
* Update memory level
|
|
669
925
|
*/
|
|
@@ -812,12 +1068,13 @@ export class SQLiteEventStore {
|
|
|
812
1068
|
}
|
|
813
1069
|
|
|
814
1070
|
/**
|
|
815
|
-
* Get most accessed memories
|
|
1071
|
+
* Get most accessed memories (falls back to recent events if none accessed)
|
|
816
1072
|
*/
|
|
817
1073
|
async getMostAccessed(limit: number = 10): Promise<MemoryEvent[]> {
|
|
818
1074
|
await this.initialize();
|
|
819
1075
|
|
|
820
|
-
|
|
1076
|
+
// First try events with access_count > 0
|
|
1077
|
+
let rows = sqliteAll<Record<string, unknown>>(
|
|
821
1078
|
this.db,
|
|
822
1079
|
`SELECT * FROM events
|
|
823
1080
|
WHERE access_count > 0
|
|
@@ -826,9 +1083,203 @@ export class SQLiteEventStore {
|
|
|
826
1083
|
[limit]
|
|
827
1084
|
);
|
|
828
1085
|
|
|
1086
|
+
// Fallback: if no accessed events, show recent events
|
|
1087
|
+
if (rows.length === 0) {
|
|
1088
|
+
rows = sqliteAll<Record<string, unknown>>(
|
|
1089
|
+
this.db,
|
|
1090
|
+
`SELECT * FROM events
|
|
1091
|
+
ORDER BY timestamp DESC
|
|
1092
|
+
LIMIT ?`,
|
|
1093
|
+
[limit]
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
829
1097
|
return rows.map(row => this.rowToEvent(row));
|
|
830
1098
|
}
|
|
831
1099
|
|
|
1100
|
+
/**
|
|
1101
|
+
* Record a memory retrieval for helpfulness tracking
|
|
1102
|
+
*/
|
|
1103
|
+
async recordRetrieval(eventId: string, sessionId: string, score: number, query: string): Promise<void> {
|
|
1104
|
+
if (this.readOnly) return;
|
|
1105
|
+
await this.initialize();
|
|
1106
|
+
|
|
1107
|
+
const id = randomUUID();
|
|
1108
|
+
sqliteRun(
|
|
1109
|
+
this.db,
|
|
1110
|
+
`INSERT INTO memory_helpfulness (id, event_id, session_id, retrieval_score, query_preview, created_at)
|
|
1111
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
|
1112
|
+
[id, eventId, sessionId, score, query.slice(0, 100)]
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Evaluate helpfulness for all retrievals in a session
|
|
1118
|
+
* Called at session end - uses behavioral signals to compute score
|
|
1119
|
+
*/
|
|
1120
|
+
async evaluateSessionHelpfulness(sessionId: string): Promise<void> {
|
|
1121
|
+
if (this.readOnly) return;
|
|
1122
|
+
await this.initialize();
|
|
1123
|
+
|
|
1124
|
+
// Get all retrieval records for this session
|
|
1125
|
+
const retrievals = sqliteAll<Record<string, unknown>>(
|
|
1126
|
+
this.db,
|
|
1127
|
+
`SELECT * FROM memory_helpfulness WHERE session_id = ? AND measured_at IS NULL`,
|
|
1128
|
+
[sessionId]
|
|
1129
|
+
);
|
|
1130
|
+
|
|
1131
|
+
if (retrievals.length === 0) return;
|
|
1132
|
+
|
|
1133
|
+
// Get session events to analyze behavior after retrieval
|
|
1134
|
+
const sessionEvents = sqliteAll<Record<string, unknown>>(
|
|
1135
|
+
this.db,
|
|
1136
|
+
`SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC`,
|
|
1137
|
+
[sessionId]
|
|
1138
|
+
);
|
|
1139
|
+
|
|
1140
|
+
const promptEvents = sessionEvents.filter((e: any) => e.event_type === 'user_prompt');
|
|
1141
|
+
const toolEvents = sessionEvents.filter((e: any) => e.event_type === 'tool_observation');
|
|
1142
|
+
|
|
1143
|
+
// Count successful vs failed tools
|
|
1144
|
+
let toolSuccessCount = 0;
|
|
1145
|
+
let toolTotalCount = toolEvents.length;
|
|
1146
|
+
for (const t of toolEvents) {
|
|
1147
|
+
try {
|
|
1148
|
+
const content = JSON.parse(t.content as string);
|
|
1149
|
+
if (content.success !== false) toolSuccessCount++;
|
|
1150
|
+
} catch {
|
|
1151
|
+
toolSuccessCount++; // Assume success if can't parse
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
const toolSuccessRatio = toolTotalCount > 0 ? toolSuccessCount / toolTotalCount : 0.5;
|
|
1155
|
+
|
|
1156
|
+
for (const retrieval of retrievals) {
|
|
1157
|
+
const retrievalTime = retrieval.created_at as string;
|
|
1158
|
+
|
|
1159
|
+
// 1. Session continued after retrieval?
|
|
1160
|
+
const eventsAfter = sessionEvents.filter((e: any) => e.timestamp > retrievalTime);
|
|
1161
|
+
const sessionContinued = eventsAfter.length > 0 ? 1 : 0;
|
|
1162
|
+
|
|
1163
|
+
// 2. How many prompts came after?
|
|
1164
|
+
const promptsAfter = promptEvents.filter((e: any) => e.timestamp > retrievalTime);
|
|
1165
|
+
const promptCountAfter = promptsAfter.length;
|
|
1166
|
+
|
|
1167
|
+
// 3. Was a similar query asked again? (simple word overlap check)
|
|
1168
|
+
const queryWords = new Set((retrieval.query_preview as string || '').toLowerCase().split(/\s+/).filter(w => w.length > 2));
|
|
1169
|
+
let wasReasked = 0;
|
|
1170
|
+
for (const p of promptsAfter) {
|
|
1171
|
+
const pWords = new Set((p.content as string).toLowerCase().split(/\s+/).filter((w: string) => w.length > 2));
|
|
1172
|
+
let overlap = 0;
|
|
1173
|
+
for (const w of queryWords) {
|
|
1174
|
+
if (pWords.has(w)) overlap++;
|
|
1175
|
+
}
|
|
1176
|
+
if (queryWords.size > 0 && overlap / queryWords.size > 0.5) {
|
|
1177
|
+
wasReasked = 1;
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Calculate helpfulness score
|
|
1183
|
+
const retrievalScore = retrieval.retrieval_score as number || 0;
|
|
1184
|
+
const helpfulnessScore = (
|
|
1185
|
+
0.30 * Math.min(retrievalScore, 1.0) +
|
|
1186
|
+
0.25 * (sessionContinued ? 1.0 : 0.0) +
|
|
1187
|
+
0.25 * toolSuccessRatio +
|
|
1188
|
+
0.20 * (wasReasked ? 0.0 : 1.0)
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
sqliteRun(
|
|
1192
|
+
this.db,
|
|
1193
|
+
`UPDATE memory_helpfulness
|
|
1194
|
+
SET session_continued = ?, prompt_count_after = ?,
|
|
1195
|
+
tool_success_count = ?, tool_total_count = ?,
|
|
1196
|
+
was_reasked = ?, helpfulness_score = ?,
|
|
1197
|
+
measured_at = datetime('now')
|
|
1198
|
+
WHERE id = ?`,
|
|
1199
|
+
[sessionContinued, promptCountAfter, toolSuccessCount, toolTotalCount,
|
|
1200
|
+
wasReasked, helpfulnessScore, retrieval.id]
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Get most helpful memories ranked by helpfulness score
|
|
1207
|
+
*/
|
|
1208
|
+
async getHelpfulMemories(limit: number = 10): Promise<Array<{
|
|
1209
|
+
eventId: string;
|
|
1210
|
+
summary: string;
|
|
1211
|
+
helpfulnessScore: number;
|
|
1212
|
+
accessCount: number;
|
|
1213
|
+
evaluationCount: number;
|
|
1214
|
+
}>> {
|
|
1215
|
+
await this.initialize();
|
|
1216
|
+
|
|
1217
|
+
const rows = sqliteAll<Record<string, unknown>>(
|
|
1218
|
+
this.db,
|
|
1219
|
+
`SELECT
|
|
1220
|
+
mh.event_id,
|
|
1221
|
+
AVG(mh.helpfulness_score) as avg_score,
|
|
1222
|
+
COUNT(*) as eval_count,
|
|
1223
|
+
e.content,
|
|
1224
|
+
e.access_count
|
|
1225
|
+
FROM memory_helpfulness mh
|
|
1226
|
+
JOIN events e ON e.id = mh.event_id
|
|
1227
|
+
WHERE mh.measured_at IS NOT NULL
|
|
1228
|
+
GROUP BY mh.event_id
|
|
1229
|
+
ORDER BY avg_score DESC
|
|
1230
|
+
LIMIT ?`,
|
|
1231
|
+
[limit]
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
return rows.map(r => ({
|
|
1235
|
+
eventId: r.event_id as string,
|
|
1236
|
+
summary: (r.content as string).substring(0, 200) + ((r.content as string).length > 200 ? '...' : ''),
|
|
1237
|
+
helpfulnessScore: Math.round((r.avg_score as number) * 100) / 100,
|
|
1238
|
+
accessCount: (r.access_count as number) || 0,
|
|
1239
|
+
evaluationCount: r.eval_count as number
|
|
1240
|
+
}));
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Get helpfulness statistics for dashboard
|
|
1245
|
+
*/
|
|
1246
|
+
async getHelpfulnessStats(): Promise<{
|
|
1247
|
+
avgScore: number;
|
|
1248
|
+
totalEvaluated: number;
|
|
1249
|
+
totalRetrievals: number;
|
|
1250
|
+
helpful: number;
|
|
1251
|
+
neutral: number;
|
|
1252
|
+
unhelpful: number;
|
|
1253
|
+
}> {
|
|
1254
|
+
await this.initialize();
|
|
1255
|
+
|
|
1256
|
+
const stats = sqliteGet<Record<string, unknown>>(
|
|
1257
|
+
this.db,
|
|
1258
|
+
`SELECT
|
|
1259
|
+
AVG(helpfulness_score) as avg_score,
|
|
1260
|
+
COUNT(*) as total_evaluated,
|
|
1261
|
+
SUM(CASE WHEN helpfulness_score >= 0.7 THEN 1 ELSE 0 END) as helpful,
|
|
1262
|
+
SUM(CASE WHEN helpfulness_score >= 0.4 AND helpfulness_score < 0.7 THEN 1 ELSE 0 END) as neutral,
|
|
1263
|
+
SUM(CASE WHEN helpfulness_score < 0.4 THEN 1 ELSE 0 END) as unhelpful
|
|
1264
|
+
FROM memory_helpfulness
|
|
1265
|
+
WHERE measured_at IS NOT NULL`
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
const totalRow = sqliteGet<Record<string, unknown>>(
|
|
1269
|
+
this.db,
|
|
1270
|
+
`SELECT COUNT(*) as total FROM memory_helpfulness`
|
|
1271
|
+
);
|
|
1272
|
+
|
|
1273
|
+
return {
|
|
1274
|
+
avgScore: Math.round(((stats?.avg_score as number) || 0) * 100) / 100,
|
|
1275
|
+
totalEvaluated: (stats?.total_evaluated as number) || 0,
|
|
1276
|
+
totalRetrievals: (totalRow?.total as number) || 0,
|
|
1277
|
+
helpful: (stats?.helpful as number) || 0,
|
|
1278
|
+
neutral: (stats?.neutral as number) || 0,
|
|
1279
|
+
unhelpful: (stats?.unhelpful as number) || 0
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
832
1283
|
/**
|
|
833
1284
|
* Fast keyword search using FTS5
|
|
834
1285
|
* Returns events matching the search query, ranked by relevance
|
|
@@ -912,6 +1363,143 @@ export class SQLiteEventStore {
|
|
|
912
1363
|
return this.db;
|
|
913
1364
|
}
|
|
914
1365
|
|
|
1366
|
+
|
|
1367
|
+
async recordRetrievalTrace(input: {
|
|
1368
|
+
sessionId?: string;
|
|
1369
|
+
projectHash?: string;
|
|
1370
|
+
queryText: string;
|
|
1371
|
+
strategy?: string;
|
|
1372
|
+
candidateEventIds: string[];
|
|
1373
|
+
selectedEventIds: string[];
|
|
1374
|
+
candidateDetails?: Array<{
|
|
1375
|
+
eventId: string;
|
|
1376
|
+
score: number;
|
|
1377
|
+
semanticScore?: number;
|
|
1378
|
+
lexicalScore?: number;
|
|
1379
|
+
recencyScore?: number;
|
|
1380
|
+
}>;
|
|
1381
|
+
selectedDetails?: Array<{
|
|
1382
|
+
eventId: string;
|
|
1383
|
+
score: number;
|
|
1384
|
+
semanticScore?: number;
|
|
1385
|
+
lexicalScore?: number;
|
|
1386
|
+
recencyScore?: number;
|
|
1387
|
+
}>;
|
|
1388
|
+
confidence?: string;
|
|
1389
|
+
fallbackTrace?: string[];
|
|
1390
|
+
}): Promise<void> {
|
|
1391
|
+
await this.initialize();
|
|
1392
|
+
|
|
1393
|
+
const traceId = randomUUID();
|
|
1394
|
+
sqliteRun(
|
|
1395
|
+
this.db,
|
|
1396
|
+
`INSERT INTO retrieval_traces (
|
|
1397
|
+
trace_id, session_id, project_hash, query_text, strategy,
|
|
1398
|
+
candidate_event_ids, selected_event_ids, candidate_details_json, selected_details_json,
|
|
1399
|
+
candidate_count, selected_count, confidence, fallback_trace
|
|
1400
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1401
|
+
[
|
|
1402
|
+
traceId,
|
|
1403
|
+
input.sessionId || null,
|
|
1404
|
+
input.projectHash || null,
|
|
1405
|
+
input.queryText,
|
|
1406
|
+
input.strategy || null,
|
|
1407
|
+
JSON.stringify(input.candidateEventIds || []),
|
|
1408
|
+
JSON.stringify(input.selectedEventIds || []),
|
|
1409
|
+
JSON.stringify(input.candidateDetails || []),
|
|
1410
|
+
JSON.stringify(input.selectedDetails || []),
|
|
1411
|
+
(input.candidateEventIds || []).length,
|
|
1412
|
+
(input.selectedEventIds || []).length,
|
|
1413
|
+
input.confidence || null,
|
|
1414
|
+
JSON.stringify(input.fallbackTrace || [])
|
|
1415
|
+
]
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
async getRecentRetrievalTraces(limit: number = 50): Promise<Array<{
|
|
1420
|
+
traceId: string;
|
|
1421
|
+
sessionId?: string;
|
|
1422
|
+
projectHash?: string;
|
|
1423
|
+
queryText: string;
|
|
1424
|
+
strategy?: string;
|
|
1425
|
+
candidateEventIds: string[];
|
|
1426
|
+
selectedEventIds: string[];
|
|
1427
|
+
candidateDetails: Array<{
|
|
1428
|
+
eventId: string;
|
|
1429
|
+
score: number;
|
|
1430
|
+
semanticScore?: number;
|
|
1431
|
+
lexicalScore?: number;
|
|
1432
|
+
recencyScore?: number;
|
|
1433
|
+
}>;
|
|
1434
|
+
selectedDetails: Array<{
|
|
1435
|
+
eventId: string;
|
|
1436
|
+
score: number;
|
|
1437
|
+
semanticScore?: number;
|
|
1438
|
+
lexicalScore?: number;
|
|
1439
|
+
recencyScore?: number;
|
|
1440
|
+
}>;
|
|
1441
|
+
candidateCount: number;
|
|
1442
|
+
selectedCount: number;
|
|
1443
|
+
confidence?: string;
|
|
1444
|
+
fallbackTrace: string[];
|
|
1445
|
+
createdAt: Date;
|
|
1446
|
+
}>> {
|
|
1447
|
+
await this.initialize();
|
|
1448
|
+
|
|
1449
|
+
const rows = sqliteAll<Record<string, unknown>>(
|
|
1450
|
+
this.db,
|
|
1451
|
+
`SELECT * FROM retrieval_traces ORDER BY created_at DESC LIMIT ?`,
|
|
1452
|
+
[limit]
|
|
1453
|
+
);
|
|
1454
|
+
|
|
1455
|
+
return rows.map((row) => ({
|
|
1456
|
+
traceId: row.trace_id as string,
|
|
1457
|
+
sessionId: (row.session_id as string) || undefined,
|
|
1458
|
+
projectHash: (row.project_hash as string) || undefined,
|
|
1459
|
+
queryText: row.query_text as string,
|
|
1460
|
+
strategy: (row.strategy as string) || undefined,
|
|
1461
|
+
candidateEventIds: row.candidate_event_ids ? JSON.parse(row.candidate_event_ids as string) : [],
|
|
1462
|
+
selectedEventIds: row.selected_event_ids ? JSON.parse(row.selected_event_ids as string) : [],
|
|
1463
|
+
candidateDetails: row.candidate_details_json ? JSON.parse(row.candidate_details_json as string) : [],
|
|
1464
|
+
selectedDetails: row.selected_details_json ? JSON.parse(row.selected_details_json as string) : [],
|
|
1465
|
+
candidateCount: Number(row.candidate_count || 0),
|
|
1466
|
+
selectedCount: Number(row.selected_count || 0),
|
|
1467
|
+
confidence: (row.confidence as string) || undefined,
|
|
1468
|
+
fallbackTrace: row.fallback_trace ? JSON.parse(row.fallback_trace as string) : [],
|
|
1469
|
+
createdAt: toDateFromSQLite(row.created_at),
|
|
1470
|
+
}));
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
async getRetrievalTraceStats(): Promise<{
|
|
1474
|
+
totalQueries: number;
|
|
1475
|
+
avgCandidateCount: number;
|
|
1476
|
+
avgSelectedCount: number;
|
|
1477
|
+
selectionRate: number;
|
|
1478
|
+
}> {
|
|
1479
|
+
await this.initialize();
|
|
1480
|
+
|
|
1481
|
+
const row = sqliteGet<Record<string, unknown>>(
|
|
1482
|
+
this.db,
|
|
1483
|
+
`SELECT
|
|
1484
|
+
COUNT(*) as total_queries,
|
|
1485
|
+
AVG(candidate_count) as avg_candidate_count,
|
|
1486
|
+
AVG(selected_count) as avg_selected_count,
|
|
1487
|
+
CASE
|
|
1488
|
+
WHEN SUM(candidate_count) > 0 THEN (SUM(selected_count) * 1.0 / SUM(candidate_count))
|
|
1489
|
+
ELSE 0
|
|
1490
|
+
END as selection_rate
|
|
1491
|
+
FROM retrieval_traces`,
|
|
1492
|
+
[]
|
|
1493
|
+
);
|
|
1494
|
+
|
|
1495
|
+
return {
|
|
1496
|
+
totalQueries: Number(row?.total_queries || 0),
|
|
1497
|
+
avgCandidateCount: Number(row?.avg_candidate_count || 0),
|
|
1498
|
+
avgSelectedCount: Number(row?.avg_selected_count || 0),
|
|
1499
|
+
selectionRate: Number(row?.selection_rate || 0),
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
915
1503
|
/**
|
|
916
1504
|
* Close database connection
|
|
917
1505
|
*/
|
|
@@ -919,6 +1507,201 @@ export class SQLiteEventStore {
|
|
|
919
1507
|
sqliteClose(this.db);
|
|
920
1508
|
}
|
|
921
1509
|
|
|
1510
|
+
/**
|
|
1511
|
+
* Get events grouped by turn_id for a session
|
|
1512
|
+
* Returns turns ordered by first event timestamp (newest first)
|
|
1513
|
+
*/
|
|
1514
|
+
async getSessionTurns(sessionId: string, options?: { limit?: number; offset?: number }): Promise<Array<{
|
|
1515
|
+
turnId: string;
|
|
1516
|
+
events: MemoryEvent[];
|
|
1517
|
+
startedAt: Date;
|
|
1518
|
+
promptPreview: string;
|
|
1519
|
+
eventCount: number;
|
|
1520
|
+
toolCount: number;
|
|
1521
|
+
hasResponse: boolean;
|
|
1522
|
+
}>> {
|
|
1523
|
+
await this.initialize();
|
|
1524
|
+
|
|
1525
|
+
const limit = options?.limit || 20;
|
|
1526
|
+
const offset = options?.offset || 0;
|
|
1527
|
+
|
|
1528
|
+
// Get distinct turn_ids for this session, ordered by first event timestamp
|
|
1529
|
+
const turnRows = sqliteAll<{ turn_id: string; min_ts: string }>(
|
|
1530
|
+
this.db,
|
|
1531
|
+
`SELECT turn_id, MIN(timestamp) as min_ts
|
|
1532
|
+
FROM events
|
|
1533
|
+
WHERE session_id = ? AND turn_id IS NOT NULL
|
|
1534
|
+
GROUP BY turn_id
|
|
1535
|
+
ORDER BY min_ts DESC
|
|
1536
|
+
LIMIT ? OFFSET ?`,
|
|
1537
|
+
[sessionId, limit, offset]
|
|
1538
|
+
);
|
|
1539
|
+
|
|
1540
|
+
const turns: Array<{
|
|
1541
|
+
turnId: string;
|
|
1542
|
+
events: MemoryEvent[];
|
|
1543
|
+
startedAt: Date;
|
|
1544
|
+
promptPreview: string;
|
|
1545
|
+
eventCount: number;
|
|
1546
|
+
toolCount: number;
|
|
1547
|
+
hasResponse: boolean;
|
|
1548
|
+
}> = [];
|
|
1549
|
+
|
|
1550
|
+
for (const turnRow of turnRows) {
|
|
1551
|
+
const events = await this.getEventsByTurn(turnRow.turn_id);
|
|
1552
|
+
|
|
1553
|
+
const promptEvent = events.find(e => e.eventType === 'user_prompt');
|
|
1554
|
+
const toolEvents = events.filter(e => e.eventType === 'tool_observation');
|
|
1555
|
+
const hasResponse = events.some(e => e.eventType === 'agent_response');
|
|
1556
|
+
|
|
1557
|
+
turns.push({
|
|
1558
|
+
turnId: turnRow.turn_id,
|
|
1559
|
+
events,
|
|
1560
|
+
startedAt: toDateFromSQLite(turnRow.min_ts),
|
|
1561
|
+
promptPreview: promptEvent
|
|
1562
|
+
? promptEvent.content.slice(0, 200) + (promptEvent.content.length > 200 ? '...' : '')
|
|
1563
|
+
: '(no prompt)',
|
|
1564
|
+
eventCount: events.length,
|
|
1565
|
+
toolCount: toolEvents.length,
|
|
1566
|
+
hasResponse
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
return turns;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* Get all events for a specific turn_id
|
|
1575
|
+
*/
|
|
1576
|
+
async getEventsByTurn(turnId: string): Promise<MemoryEvent[]> {
|
|
1577
|
+
await this.initialize();
|
|
1578
|
+
|
|
1579
|
+
const rows = sqliteAll<Record<string, unknown>>(
|
|
1580
|
+
this.db,
|
|
1581
|
+
`SELECT * FROM events WHERE turn_id = ? ORDER BY timestamp ASC`,
|
|
1582
|
+
[turnId]
|
|
1583
|
+
);
|
|
1584
|
+
|
|
1585
|
+
return rows.map(this.rowToEvent);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* Count total turns for a session
|
|
1590
|
+
*/
|
|
1591
|
+
async countSessionTurns(sessionId: string): Promise<number> {
|
|
1592
|
+
await this.initialize();
|
|
1593
|
+
|
|
1594
|
+
const row = sqliteGet<{ count: number }>(
|
|
1595
|
+
this.db,
|
|
1596
|
+
`SELECT COUNT(DISTINCT turn_id) as count
|
|
1597
|
+
FROM events
|
|
1598
|
+
WHERE session_id = ? AND turn_id IS NOT NULL`,
|
|
1599
|
+
[sessionId]
|
|
1600
|
+
);
|
|
1601
|
+
|
|
1602
|
+
return row?.count || 0;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Migrate existing events: backfill turn_id for events that have turnId in metadata
|
|
1607
|
+
* but no turn_id column value (for events stored before this migration)
|
|
1608
|
+
*/
|
|
1609
|
+
async backfillTurnIds(): Promise<number> {
|
|
1610
|
+
await this.initialize();
|
|
1611
|
+
|
|
1612
|
+
// Find events with turnId in metadata JSON but no turn_id column value
|
|
1613
|
+
const rows = sqliteAll<{ id: string; metadata: string }>(
|
|
1614
|
+
this.db,
|
|
1615
|
+
`SELECT id, metadata FROM events
|
|
1616
|
+
WHERE turn_id IS NULL AND metadata IS NOT NULL AND metadata LIKE '%turnId%'`
|
|
1617
|
+
);
|
|
1618
|
+
|
|
1619
|
+
let updated = 0;
|
|
1620
|
+
for (const row of rows) {
|
|
1621
|
+
try {
|
|
1622
|
+
const metadata = JSON.parse(row.metadata);
|
|
1623
|
+
if (metadata.turnId) {
|
|
1624
|
+
sqliteRun(
|
|
1625
|
+
this.db,
|
|
1626
|
+
`UPDATE events SET turn_id = ? WHERE id = ?`,
|
|
1627
|
+
[metadata.turnId, row.id]
|
|
1628
|
+
);
|
|
1629
|
+
updated++;
|
|
1630
|
+
}
|
|
1631
|
+
} catch {
|
|
1632
|
+
// Skip rows with invalid JSON
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
return updated;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* Delete all events for a session (for force reimport)
|
|
1641
|
+
*/
|
|
1642
|
+
async deleteSessionEvents(sessionId: string): Promise<number> {
|
|
1643
|
+
await this.initialize();
|
|
1644
|
+
|
|
1645
|
+
// Get event IDs first for cascading deletes
|
|
1646
|
+
const events = sqliteAll<{ id: string }>(
|
|
1647
|
+
this.db,
|
|
1648
|
+
`SELECT id FROM events WHERE session_id = ?`,
|
|
1649
|
+
[sessionId]
|
|
1650
|
+
);
|
|
1651
|
+
|
|
1652
|
+
if (events.length === 0) return 0;
|
|
1653
|
+
|
|
1654
|
+
const eventIds = events.map(e => e.id);
|
|
1655
|
+
const placeholders = eventIds.map(() => '?').join(',');
|
|
1656
|
+
|
|
1657
|
+
// Drop FTS triggers to prevent SQLITE_CORRUPT_VTAB during bulk delete
|
|
1658
|
+
const ftsTriggersDropped: string[] = [];
|
|
1659
|
+
for (const triggerName of ['events_fts_delete', 'events_fts_update', 'events_fts_insert']) {
|
|
1660
|
+
try {
|
|
1661
|
+
sqliteRun(this.db, `DROP TRIGGER IF EXISTS ${triggerName}`);
|
|
1662
|
+
ftsTriggersDropped.push(triggerName);
|
|
1663
|
+
} catch {
|
|
1664
|
+
// Trigger may not exist
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// Delete from related tables first (some may not exist depending on DB version)
|
|
1669
|
+
for (const table of ['event_dedup', 'memory_levels', 'embedding_queue', 'embedding_outbox', 'vector_outbox']) {
|
|
1670
|
+
try {
|
|
1671
|
+
sqliteRun(this.db, `DELETE FROM ${table} WHERE event_id IN (${placeholders})`, eventIds);
|
|
1672
|
+
} catch {
|
|
1673
|
+
// Table may not exist
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// Delete events
|
|
1678
|
+
const result = sqliteRun(this.db, `DELETE FROM events WHERE session_id = ?`, [sessionId]);
|
|
1679
|
+
|
|
1680
|
+
// Rebuild FTS index if we dropped triggers
|
|
1681
|
+
if (ftsTriggersDropped.length > 0) {
|
|
1682
|
+
try {
|
|
1683
|
+
// Rebuild FTS from remaining events
|
|
1684
|
+
sqliteRun(this.db, `INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
|
|
1685
|
+
|
|
1686
|
+
// Recreate triggers
|
|
1687
|
+
sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
|
|
1688
|
+
INSERT INTO events_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
|
1689
|
+
END`);
|
|
1690
|
+
sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
|
|
1691
|
+
INSERT INTO events_fts(events_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content);
|
|
1692
|
+
END`);
|
|
1693
|
+
sqliteRun(this.db, `CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
|
|
1694
|
+
INSERT INTO events_fts(events_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content);
|
|
1695
|
+
INSERT INTO events_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
|
1696
|
+
END`);
|
|
1697
|
+
} catch {
|
|
1698
|
+
// FTS rebuild failed - non-critical, will be rebuilt on next initialize
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
return result.changes || 0;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
922
1705
|
/**
|
|
923
1706
|
* Convert database row to MemoryEvent
|
|
924
1707
|
*/
|
|
@@ -941,6 +1724,10 @@ export class SQLiteEventStore {
|
|
|
941
1724
|
if (row.last_accessed_at !== undefined) {
|
|
942
1725
|
event.last_accessed_at = row.last_accessed_at;
|
|
943
1726
|
}
|
|
1727
|
+
// Include turn_id if present
|
|
1728
|
+
if (row.turn_id !== undefined && row.turn_id !== null) {
|
|
1729
|
+
event.turn_id = row.turn_id;
|
|
1730
|
+
}
|
|
944
1731
|
|
|
945
1732
|
return event;
|
|
946
1733
|
}
|