Repository navigation
Expand file tree
/
Copy pathsqlite.ts
More file actions
3998 lines (3697 loc) · 157 KB
/
Copy pathsqlite.ts
File metadata and controls
3998 lines (3697 loc) · 157 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { DatabaseSync, StatementSync } from "node:sqlite";
import { randomUUID, createHash } from "node:crypto";
import type { ActiveSessionFilters, ActiveSessionRecord, ActiveSessionUsage, SessionInboxFilters, SessionInboxKind, SessionInboxRecord, PendingDelegationRecord, PendingDelegationEnqueueInput, PendingDelegationFilters, PendingDelegationStatus, DelegationPacket, MemoryJobRecord, MemoryJobStatus, MemoryJobEnqueueInput, MemoryJobListFilters, MemoryJobKindAggregate, ContradictionRecord, CursorPaginationOptions, EvidenceListFilters, ExtractionStatus, ImportResult, SensoryRecord, CognitiveRecord, CognitiveFtsResult, MemoryEvidence, MemoryExport, MemoryImport, MemoryListFilters, MemoryListItem, MemoryOperation, MemoryStatus, OperationLogFilters, VectorSearchResult, SkillActivationRecord, SkillHintsRecord, ContextualFocusRecord, CoreIdentityRecord, SchedulerState, GraphNode, GraphEdge, StalledExtractionBacklog, UserRecord, SourceDocument, SourceChunk, SourceChunkInput, BlackboardItem, BlackboardItemInput, BlackboardStatus, MemoryTreeNode, MemoryTreeNodeInput, MemoryTreeKind, VaultExportEntry, VaultExportInput } from "@kinqs/brainrouter-types";
import * as sqliteVec from "sqlite-vec";
import type { IMemoryStore } from "@kinqs/brainrouter-types";
import { extractIntraFileCallEdges } from "../code-retrieval.js";
import { expandImportRecord, readImportChunkChars } from "../pipeline/chunk-import.js";
import { mapWithConcurrency, readEmbedConcurrency } from "../concurrency.js";
// Ensure Node version has node:sqlite (v22+)
const DB_VERSION_ERROR = "Memory Engine requires Node.js v22+ with node:sqlite built-in.";
// A minimal BM25 search ranking helper (for simple text split)
function bm25RankToScore(rank: number): number {
if (!Number.isFinite(rank)) return 1 / (1 + 999);
if (rank < 0) {
const relevance = -rank;
return relevance / (1 + relevance);
}
return 1 / (1 + rank);
}
function buildFtsQuery(raw: string): string | null {
// Simple Unicode regex split for English + general tokens
const tokens = raw
.match(/[\p{L}\p{N}_]+/gu)
?.map((t) => t.trim())
.filter(Boolean) ?? [];
if (tokens.length === 0) return null;
const quoted = tokens.map((t) => `"${t.replaceAll('"', "")}"`);
return quoted.join(" OR ");
}
function parseJsonObject(raw: string | null | undefined): Record<string, unknown> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function parseJsonArray(raw: string | null | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
function cognitiveRowToRecord(row: any): CognitiveRecord {
return {
id: row.record_id,
userId: row.user_id,
sessionKey: row.session_key ?? "",
sessionId: row.session_id ?? "",
content: row.content,
type: row.type || "episodic",
priority: row.priority ?? 50,
sceneName: row.scene_name ?? "",
skillTag: row.skill_tag ?? "",
halfLifeDays: row.half_life_days ?? null,
supersededBy: row.superseded_by ?? null,
invalidAt: row.invalid_at ?? null,
timestampStr: row.timestamp_str ?? "",
timestampStart: row.timestamp_start ?? "",
timestampEnd: row.timestamp_end ?? "",
createdTime: row.created_time ?? "",
updatedTime: row.updated_time ?? "",
metadata: parseJsonObject(row.metadata_json),
confidence: typeof row.confidence === "number" ? row.confidence : 0.65,
status: row.status ?? (row.archived ? "archived" : "active"),
sourceKind: row.source_kind ?? "",
verificationStatus: row.verification_status ?? "",
repoPaths: parseJsonArray(row.repo_paths_json),
filePaths: parseJsonArray(row.file_paths_json),
commands: parseJsonArray(row.commands_json),
citationCount: row.citation_count ?? 0,
lastCitedAt: row.last_cited_at ?? null,
neverCitedCount: row.never_cited_count ?? 0,
archived: Boolean(row.archived),
workspaceTag: row.workspace_tag ?? null,
projectTag: row.project_tag ?? null,
};
}
function evidenceRowToRecord(row: any): MemoryEvidence {
return {
id: row.id,
userId: row.user_id,
recordId: row.record_id,
kind: row.kind,
ref: row.ref,
excerpt: row.excerpt ?? "",
observedAt: row.observed_at ?? "",
metadata: parseJsonObject(row.metadata_json),
};
}
function activeSessionRowToRecord(row: any, includeUsage: boolean): ActiveSessionRecord {
let usage: ActiveSessionUsage | null | undefined;
if (includeUsage && row.usage_json) {
try {
usage = JSON.parse(row.usage_json);
} catch {
usage = null;
}
} else if (!includeUsage) {
usage = undefined;
} else {
usage = null;
}
return {
sessionKey: row.session_key,
userId: row.user_id,
clientKind: row.client_kind ?? "http-unknown",
workspaceRoot: row.workspace_root ?? "",
startedAt: row.started_at,
lastHeartbeatAt: row.last_heartbeat_at,
metadata: parseJsonObject(row.metadata_json),
...(usage !== undefined ? { usage } : {}),
};
}
function inboxRowToRecord(row: {
id: string;
user_id: string;
from_session_key: string;
to_session_key: string;
kind: string;
payload_json: string;
created_at: string;
delivered_at: string | null;
}): SessionInboxRecord {
let payload: Record<string, unknown> = {};
try {
const parsed = JSON.parse(row.payload_json);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
payload = parsed;
}
} catch {
payload = {};
}
return {
id: row.id,
userId: row.user_id,
fromSessionKey: row.from_session_key,
toSessionKey: row.to_session_key,
kind: row.kind as SessionInboxKind,
payload,
createdAt: row.created_at,
deliveredAt: row.delivered_at,
};
}
function jobRowToRecord(row: {
id: string;
kind: string;
status: string;
priority: number;
attempts: number;
max_attempts: number;
run_after: string;
locked_at: string | null;
parent_job_id: string | null;
input_json: string;
output_json: string | null;
error: string | null;
created_at: string;
updated_at: string;
}): MemoryJobRecord {
const parse = (raw: string | null): unknown => {
if (raw == null) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
};
return {
id: row.id,
kind: row.kind,
status: row.status as MemoryJobStatus,
priority: row.priority,
attempts: row.attempts,
maxAttempts: row.max_attempts,
runAfter: row.run_after,
lockedAt: row.locked_at,
parentJobId: row.parent_job_id,
input: parse(row.input_json),
output: parse(row.output_json),
error: row.error,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
const JOB_COLUMNS =
"id, kind, status, priority, attempts, max_attempts, run_after, locked_at, parent_job_id, input_json, output_json, error, created_at, updated_at";
function operationRowToRecord(row: any): MemoryOperation {
return {
id: row.id,
userId: row.user_id,
recordId: row.record_id ?? null,
operation: row.operation,
actor: row.actor ?? "",
sessionKey: row.session_key ?? "",
reason: row.reason ?? "",
createdAt: row.created_at ?? "",
metadata: parseJsonObject(row.metadata_json),
};
}
export class SqliteMemoryStore implements IMemoryStore {
private db: DatabaseSync;
// Sensory statements
private stmtSensoryUpsertMeta!: StatementSync;
private stmtSensoryQueryAfter!: StatementSync;
// Cognitive statements
private stmtCognitiveUpsertMeta!: StatementSync;
private stmtCognitiveGetMeta!: StatementSync;
// FTS statements
private stmtCognitiveFtsInsert!: StatementSync;
private stmtCognitiveFtsSearch!: StatementSync;
// Vector statements
private stmtCognitiveVecInsert?: StatementSync;
private stmtCognitiveVecDelete?: StatementSync;
private vecLoaded = false;
private vecDimensions = 0;
constructor(dbPath: string) {
try {
this.db = new DatabaseSync(dbPath, { allowExtension: true });
} catch (e) {
throw new Error(`${DB_VERSION_ERROR}\n${e}`);
}
this.db.exec("PRAGMA busy_timeout = 5000");
// WAL is required by 0.4.0 federation — multiple MCP-aware CLIs share
// one db and need concurrent reads + a single writer. Without WAL,
// parallel writes serialize and a long extraction blocks every peer's
// recall. SQLite can silently refuse WAL on some filesystems
// (network shares, certain tmpfs configurations), so we set it AND
// read back the effective mode and warn if it didn't take. We don't
// throw — a single-client install on an exotic FS should still boot
// — but the federation hardening guarantees only hold when this
// resolves to `wal`.
this.db.exec("PRAGMA journal_mode = WAL");
const mode = this.getJournalMode();
if (mode.toLowerCase() !== "wal") {
console.error(
`[BrainRouter] WARNING: SQLite journal_mode resolved to '${mode}', not 'wal'. ` +
`Federation concurrency guarantees do not hold. ` +
`Check the underlying filesystem (network mounts and certain tmpfs configurations refuse WAL).`,
);
}
}
/**
* Returns the effective `journal_mode` PRAGMA. Federation depends on
* this being `wal`; the constructor warns when it isn't, but callers
* (and tests) may want to assert it directly.
*/
public getJournalMode(): string {
const row = this.db.prepare("PRAGMA journal_mode").get() as { journal_mode?: string } | undefined;
return row?.journal_mode ?? "unknown";
}
public init() {
this.initSchema();
}
public initVec(dimensions: number) {
if (dimensions <= 0) return;
try {
sqliteVec.load(this.db);
this.vecLoaded = true;
} catch (e) {
console.error("[BrainRouter] Failed to load sqlite-vec. Vector search disabled.", e);
return;
}
this.vecDimensions = dimensions;
this.db.exec(`
CREATE TABLE IF NOT EXISTS embedding_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
dimensions INTEGER NOT NULL,
created_at TEXT NOT NULL
)
`);
// Robust dimension check: extract actual dimension from the virtual table schema
const tableInfo = this.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'cognitive_vec'").get() as any;
let actualDimensions = -1;
if (tableInfo && tableInfo.sql) {
const match = tableInfo.sql.match(/float\[(\d+)\]/i);
if (match) actualDimensions = parseInt(match[1], 10);
}
if (actualDimensions !== -1 && actualDimensions !== dimensions) {
console.error(`[BrainRouter] Embedding dimensions changed (${actualDimensions} -> ${dimensions}). Recreating vector tables.`);
try {
this.db.exec("DROP TABLE IF EXISTS cognitive_vec");
} catch (e) {
console.warn("[BrainRouter] Error dropping cognitive_vec:", e);
}
this.db.prepare("UPDATE embedding_meta SET dimensions = ?, created_at = ? WHERE id = 1")
.run(dimensions, new Date().toISOString());
} else {
const metaRow = this.db.prepare("SELECT dimensions FROM embedding_meta WHERE id = 1").get() as any;
if (!metaRow) {
this.db.prepare("INSERT INTO embedding_meta (id, dimensions, created_at) VALUES (1, ?, ?)")
.run(dimensions, new Date().toISOString());
}
}
this.db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS cognitive_vec USING vec0(
record_id TEXT PRIMARY KEY,
embedding float[${dimensions}] distance_metric=cosine
)
`);
this.stmtCognitiveVecInsert = this.db.prepare("INSERT INTO cognitive_vec (record_id, embedding) VALUES (?, ?)");
this.stmtCognitiveVecDelete = this.db.prepare("DELETE FROM cognitive_vec WHERE record_id = ?");
}
public isVecAvailable(): boolean {
return this.vecLoaded && this.vecDimensions > 0;
}
public async reembedStaleRecords(embedder: (text: string) => Promise<Float32Array>): Promise<number> {
if (!this.vecLoaded) return 0;
const rows = this.db.prepare(`
SELECT r.record_id, r.content
FROM cognitive_records r
LEFT JOIN cognitive_vec v ON r.record_id = v.record_id
WHERE r.invalid_at IS NULL
AND r.archived = 0
AND v.record_id IS NULL
ORDER BY r.created_time ASC, r.record_id ASC
`).all() as Array<{ record_id: string; content: string }>;
// ASYNC-1 (0.4.14) — embed through a bounded worker pool instead of one
// record at a time. The shared LLM semaphore inside embed() still caps actual
// network concurrency (BRAINROUTER_LLM_MAX_CONCURRENT), so this saturates the
// cap rather than leaving slots idle. upsertCognitiveVec is a synchronous
// SQLite write — single-threaded, so the interleaved writes can't race.
const outcomes = await mapWithConcurrency(rows, readEmbedConcurrency(), async (row) => {
try {
const embedding = await embedder(row.content);
this.upsertCognitiveVec(row.record_id, embedding);
return true;
} catch (error) {
console.error(
`[BrainRouter] Failed to re-embed record ${row.record_id}:`,
error instanceof Error ? error.message : error
);
return false;
}
});
return outcomes.reduce((acc, ok) => acc + (ok ? 1 : 0), 0);
}
public getSqliteVersion(): string {
try {
const row = this.db.prepare("SELECT sqlite_version() AS version").get() as { version?: string } | undefined;
return row?.version ?? "unknown";
} catch {
return "unknown";
}
}
private initSchema() {
// ── Sensory Schema ──
this.db.exec(`
CREATE TABLE IF NOT EXISTS sensory_stream (
record_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
session_key TEXT NOT NULL,
session_id TEXT DEFAULT '',
role TEXT NOT NULL DEFAULT '',
message_text TEXT NOT NULL,
recorded_at TEXT DEFAULT '',
timestamp INTEGER DEFAULT 0,
skill_tag TEXT DEFAULT '',
extracted_at TEXT DEFAULT NULL
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_sensory_user_session ON sensory_stream(user_id, session_key)");
this.db.exec("CREATE INDEX IF NOT EXISTS idx_sensory_recorded ON sensory_stream(recorded_at)");
this.db.exec("CREATE INDEX IF NOT EXISTS idx_sensory_extracted ON sensory_stream(user_id, session_key, extracted_at)");
this.db.exec(`
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
api_key TEXT NOT NULL UNIQUE,
password_hash TEXT DEFAULT NULL,
display_name TEXT DEFAULT '',
email TEXT DEFAULT '',
is_admin INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
created_at TEXT NOT NULL
)
`);
// Federation Stage 2 (0.4.0) — active-session registry. Composite
// PK on `(session_key, user_id)` prevents a misbehaving client
// from accidentally stomping another user's session if it reuses
// the same key. `usage_json` is NULL when the client doesn't
// report telemetry (FED-S2-T8).
this.db.exec(`
CREATE TABLE IF NOT EXISTS active_sessions (
session_key TEXT NOT NULL,
user_id TEXT NOT NULL,
client_kind TEXT NOT NULL DEFAULT 'http-unknown',
workspace_root TEXT DEFAULT '',
started_at TEXT NOT NULL,
last_heartbeat_at TEXT NOT NULL,
metadata_json TEXT DEFAULT '{}',
usage_json TEXT DEFAULT NULL,
PRIMARY KEY (session_key, user_id)
)
`);
this.db.exec(
"CREATE INDEX IF NOT EXISTS idx_active_sessions_user_heartbeat ON active_sessions(user_id, last_heartbeat_at DESC)",
);
// Federation Stage 3 (0.4.0) — cross-CLI inbox. Per-recipient row;
// broadcasts are fanned out at send time so each recipient sees a
// distinct id and acks independently. Indexed by recipient + read
// order so `session_inbox_read` is O(log N + page).
this.db.exec(`
CREATE TABLE IF NOT EXISTS session_inbox (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
from_session_key TEXT NOT NULL,
to_session_key TEXT NOT NULL,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
delivered_at TEXT DEFAULT NULL
)
`);
this.db.exec(
"CREATE INDEX IF NOT EXISTS idx_session_inbox_recipient ON session_inbox(user_id, to_session_key, created_at)",
);
this.db.exec(
"CREATE INDEX IF NOT EXISTS idx_session_inbox_delivered ON session_inbox(delivered_at)",
);
// Federation Stage 5 (0.4.2) — FED-S5-T2 fallback queue for
// cross-vendor delegations that had no idle peer at send time.
this.db.exec(`
CREATE TABLE IF NOT EXISTS pending_delegations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
from_session_key TEXT NOT NULL,
to_agent_kind TEXT NOT NULL,
to_session_key TEXT DEFAULT NULL,
packet_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
claimed_at TEXT DEFAULT NULL
)
`);
this.db.exec(
"CREATE INDEX IF NOT EXISTS idx_pending_delegations_claimable ON pending_delegations(user_id, to_agent_kind, status, created_at)",
);
// BRAIN-P1 (0.4.1) — brain-agent job queue (BRAIN-DESIGN-T2).
// Global to the brain instance (single-tenant per API key — OQ-3);
// per-user routing lives in input_json, never a column. Every
// brain-side action becomes a row so it is observable + retryable.
this.db.exec(`
CREATE TABLE IF NOT EXISTS memory_jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
priority INTEGER NOT NULL DEFAULT 50,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
run_after TEXT NOT NULL,
locked_at TEXT,
parent_job_id TEXT,
input_json TEXT NOT NULL DEFAULT '{}',
output_json TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`);
this.db.exec(
`CREATE INDEX IF NOT EXISTS idx_memory_jobs_eligible
ON memory_jobs(status, priority DESC, run_after)
WHERE status = 'pending'`,
);
this.db.exec(
`CREATE INDEX IF NOT EXISTS idx_memory_jobs_running
ON memory_jobs(locked_at) WHERE locked_at IS NOT NULL`,
);
this.db.exec(
"CREATE INDEX IF NOT EXISTS idx_memory_jobs_kind ON memory_jobs(kind, updated_at)",
);
this.stmtSensoryUpsertMeta = this.db.prepare(`
INSERT INTO sensory_stream (
record_id, user_id, session_key, session_id, role, message_text, recorded_at, timestamp, skill_tag
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(record_id) DO UPDATE SET
message_text=excluded.message_text,
recorded_at=excluded.recorded_at,
timestamp=excluded.timestamp
`);
this.stmtSensoryQueryAfter = this.db.prepare(`
SELECT record_id as id, user_id as userId, session_key as sessionKey, session_id as sessionId,
role, message_text as messageText, recorded_at as recordedAt, timestamp, skill_tag as skillTag
FROM sensory_stream
WHERE user_id = ? AND session_key = ? AND recorded_at > ? AND extracted_at IS NULL
ORDER BY recorded_at DESC
LIMIT ?
`);
// ── Cognitive Schema ──
this.db.exec(`
CREATE TABLE IF NOT EXISTS cognitive_records (
record_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
session_key TEXT DEFAULT '',
session_id TEXT DEFAULT '',
content TEXT NOT NULL,
type TEXT DEFAULT '',
priority INTEGER DEFAULT 50,
scene_name TEXT DEFAULT '',
skill_tag TEXT DEFAULT '',
half_life_days INTEGER,
superseded_by TEXT,
invalid_at TEXT DEFAULT NULL,
timestamp_str TEXT DEFAULT '',
timestamp_start TEXT DEFAULT '',
timestamp_end TEXT DEFAULT '',
created_time TEXT DEFAULT '',
updated_time TEXT DEFAULT '',
metadata_json TEXT DEFAULT '{}',
citation_count INTEGER DEFAULT 0,
last_cited_at TEXT,
never_cited_count INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
confidence REAL DEFAULT 0.65,
status TEXT DEFAULT 'active',
source_kind TEXT DEFAULT '',
verification_status TEXT DEFAULT '',
repo_paths_json TEXT DEFAULT '[]',
file_paths_json TEXT DEFAULT '[]',
commands_json TEXT DEFAULT '[]',
workspace_tag TEXT,
project_tag TEXT
)
`);
// Federation Stage 1 (0.4.0) — add `workspace_tag` to existing
// databases that pre-date the column. SQLite's ADD COLUMN is
// idempotent only via try/catch; we swallow the "duplicate column"
// error so a brain that's been upgraded once doesn't crash on
// subsequent boots. Existing rows pick up NULL, which the recall
// filter treats as "tag unknown — surface in every workspace".
try {
this.db.exec("ALTER TABLE cognitive_records ADD COLUMN workspace_tag TEXT");
} catch (e) {
const msg = (e as Error).message || "";
if (!/duplicate column name/i.test(msg)) throw e;
}
this.db.exec("CREATE INDEX IF NOT EXISTS idx_cognitive_workspace_tag ON cognitive_records(user_id, workspace_tag)");
// AUG-A1 (0.4.1) — same idempotent migration for `project_tag`.
try {
this.db.exec("ALTER TABLE cognitive_records ADD COLUMN project_tag TEXT");
} catch (e) {
const msg = (e as Error).message || "";
if (!/duplicate column name/i.test(msg)) throw e;
}
this.db.exec("CREATE INDEX IF NOT EXISTS idx_cognitive_project_tag ON cognitive_records(user_id, project_tag)");
this.db.exec(`
CREATE TABLE IF NOT EXISTS memory_evidence (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
record_id TEXT NOT NULL,
kind TEXT NOT NULL,
ref TEXT NOT NULL,
excerpt TEXT DEFAULT '',
observed_at TEXT NOT NULL,
metadata_json TEXT DEFAULT '{}'
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_memory_evidence_record ON memory_evidence(user_id, record_id)");
this.db.exec(`
CREATE TABLE IF NOT EXISTS memory_operations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
record_id TEXT DEFAULT NULL,
operation TEXT NOT NULL,
actor TEXT DEFAULT '',
session_key TEXT DEFAULT '',
reason TEXT DEFAULT '',
created_at TEXT NOT NULL,
metadata_json TEXT DEFAULT '{}'
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_memory_operations_user_time ON memory_operations(user_id, created_at DESC, id ASC)");
this.db.exec(`
CREATE TABLE IF NOT EXISTS memory_file_index (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
record_id TEXT NOT NULL,
file_path TEXT NOT NULL,
symbol TEXT DEFAULT '',
created_time TEXT NOT NULL
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_memory_file_index_path ON memory_file_index(user_id, file_path, created_time DESC)");
this.db.exec("CREATE INDEX IF NOT EXISTS idx_memory_file_index_record ON memory_file_index(user_id, record_id)");
this.db.exec("CREATE INDEX IF NOT EXISTS idx_cognitive_user_type ON cognitive_records(user_id, type)");
this.db.exec("CREATE INDEX IF NOT EXISTS idx_cognitive_user_session ON cognitive_records(user_id, session_key)");
this.stmtCognitiveUpsertMeta = this.db.prepare(`
INSERT INTO cognitive_records (
record_id, user_id, session_key, session_id, content, type, priority, scene_name, skill_tag,
half_life_days, superseded_by, invalid_at, timestamp_str, timestamp_start, timestamp_end,
created_time, updated_time, metadata_json, confidence, status, source_kind, verification_status,
repo_paths_json, file_paths_json, commands_json, workspace_tag, project_tag
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(record_id) DO UPDATE SET
content=excluded.content,
type=excluded.type,
priority=excluded.priority,
scene_name=excluded.scene_name,
skill_tag=excluded.skill_tag,
half_life_days=excluded.half_life_days,
superseded_by=excluded.superseded_by,
invalid_at=excluded.invalid_at,
timestamp_str=excluded.timestamp_str,
timestamp_start=excluded.timestamp_start,
timestamp_end=excluded.timestamp_end,
updated_time=excluded.updated_time,
metadata_json=excluded.metadata_json,
confidence=excluded.confidence,
status=excluded.status,
source_kind=excluded.source_kind,
verification_status=excluded.verification_status,
repo_paths_json=excluded.repo_paths_json,
file_paths_json=excluded.file_paths_json,
commands_json=excluded.commands_json,
workspace_tag=COALESCE(excluded.workspace_tag, cognitive_records.workspace_tag),
project_tag=COALESCE(excluded.project_tag, cognitive_records.project_tag)
`);
this.stmtCognitiveGetMeta = this.db.prepare(`
SELECT * FROM cognitive_records WHERE record_id = ? AND user_id = ?
`);
// ── Cognitive FTS5 Schema ──
this.db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS cognitive_fts USING fts5(
content,
content_original UNINDEXED,
record_id UNINDEXED,
user_id UNINDEXED,
type UNINDEXED,
priority UNINDEXED,
scene_name UNINDEXED,
skill_tag UNINDEXED,
session_key UNINDEXED,
timestamp_str UNINDEXED,
created_time UNINDEXED
)
`);
this.stmtCognitiveFtsInsert = this.db.prepare(`
INSERT INTO cognitive_fts (
content, content_original, record_id, user_id, type, priority, scene_name,
skill_tag, session_key, timestamp_str, created_time
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.stmtCognitiveFtsSearch = this.db.prepare(`
SELECT
f.record_id, f.user_id, f.content_original as content, f.type, f.priority, f.scene_name,
f.skill_tag, f.session_key, f.timestamp_str, f.created_time,
f.rank, r.citation_count
FROM cognitive_fts f
JOIN cognitive_records r ON f.record_id = r.record_id
WHERE f.user_id = ? AND cognitive_fts MATCH ? AND r.invalid_at IS NULL AND r.archived = 0
ORDER BY rank
LIMIT ?
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS contradictions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
record_id_a TEXT NOT NULL,
record_id_b TEXT NOT NULL,
reason TEXT,
confidence REAL,
status TEXT DEFAULT 'pending', -- pending, resolved, dismissed
created_time TEXT DEFAULT ''
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_contradictions_user ON contradictions(user_id, status)");
// ── Skill Extraction Hints Schema ──
this.db.exec(`
CREATE TABLE IF NOT EXISTS skill_extraction_hints (
skill_name TEXT PRIMARY KEY,
hints TEXT NOT NULL,
source_file TEXT DEFAULT '',
registered_at TEXT DEFAULT ''
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS skill_activations (
user_id TEXT NOT NULL,
skill_name TEXT NOT NULL,
potential REAL DEFAULT 0.0,
last_decay_time TEXT NOT NULL,
PRIMARY KEY (user_id, skill_name)
)
`);
// ── Contextual Focus Scenes ──
this.db.exec(`
CREATE TABLE IF NOT EXISTS contextual_focus (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
scene_name TEXT NOT NULL,
summary_md TEXT NOT NULL,
heat_score REAL DEFAULT 100.0,
last_active_time TEXT DEFAULT '',
created_time TEXT DEFAULT '',
updated_time TEXT DEFAULT '',
UNIQUE(user_id, scene_name)
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_focus_user_heat ON contextual_focus(user_id, heat_score DESC)");
// ── Core Identity ──
this.db.exec(`
CREATE TABLE IF NOT EXISTS core_identity (
user_id TEXT PRIMARY KEY,
persona_md TEXT NOT NULL,
cognitive_count_at_generation INTEGER DEFAULT 0,
created_time TEXT DEFAULT '',
updated_time TEXT DEFAULT ''
)
`);
// ── Scheduler State ──
this.db.exec(`
CREATE TABLE IF NOT EXISTS scheduler_state (
user_id TEXT PRIMARY KEY,
cognitive_count_since_last_focus INTEGER DEFAULT 0,
cognitive_count_since_last_identity INTEGER DEFAULT 0,
total_cognitive_count INTEGER DEFAULT 0,
extraction_errors INTEGER DEFAULT 0,
last_error_message TEXT DEFAULT NULL,
last_error_at TEXT DEFAULT NULL
)
`);
// ── GraphRAG Nodes ──
this.db.exec(`
CREATE TABLE IF NOT EXISTS graph_nodes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
entity TEXT NOT NULL,
entity_type TEXT NOT NULL,
skill_tag TEXT DEFAULT '',
confidence REAL DEFAULT 1.0,
source_record_id TEXT,
created_time TEXT DEFAULT '',
UNIQUE(user_id, entity)
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_graph_nodes_user ON graph_nodes(user_id)");
// ── GraphRAG Edges ──
this.db.exec(`
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
from_node_id TEXT NOT NULL,
to_node_id TEXT NOT NULL,
relation TEXT NOT NULL,
skill_tag TEXT DEFAULT '',
confidence REAL DEFAULT 1.0,
source_record_id TEXT,
created_time TEXT DEFAULT '',
UNIQUE(user_id, from_node_id, to_node_id, relation)
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_graph_edges_from ON graph_edges(from_node_id)");
this.db.exec("CREATE INDEX IF NOT EXISTS idx_graph_edges_to ON graph_edges(to_node_id)");
// ── Dendritic Spines (Cognitive Connections) ──
this.db.exec(`
CREATE TABLE IF NOT EXISTS cognitive_connections (
user_id TEXT NOT NULL,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
weight REAL DEFAULT 0.5,
last_activated_at TEXT DEFAULT '',
PRIMARY KEY (user_id, source_id, target_id),
FOREIGN KEY (source_id) REFERENCES cognitive_records(record_id) ON DELETE CASCADE,
FOREIGN KEY (target_id) REFERENCES cognitive_records(record_id) ON DELETE CASCADE
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_cognitive_conn_user_src ON cognitive_connections(user_id, source_id)");
this.db.exec("CREATE INDEX IF NOT EXISTS idx_cognitive_conn_user_tgt ON cognitive_connections(user_id, target_id)");
// 0.4.3 Brain Phase 2/3 — source documents + token-aware chunks. Additive
// tables: every extracted cognitive record will cite chunk ids. user_id +
// workspace_tag are present now so team/RBAC can arrive without migration;
// `hash` makes re-ingest of the same source idempotent.
this.db.exec(`
CREATE TABLE IF NOT EXISTS source_documents (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
workspace_tag TEXT DEFAULT NULL,
kind TEXT NOT NULL,
uri TEXT DEFAULT NULL,
hash TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}'
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_source_docs_user_hash ON source_documents(user_id, hash)");
// 0.4.3 — the /sources view + transcript retention both query newest-first
// per user; without this index that's a filesort once transcripts pile up.
this.db.exec("CREATE INDEX IF NOT EXISTS idx_source_docs_user_created ON source_documents(user_id, created_at)");
this.db.exec(`
CREATE TABLE IF NOT EXISTS source_chunks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
user_id TEXT DEFAULT NULL,
workspace_tag TEXT DEFAULT NULL,
ordinal INTEGER NOT NULL,
content TEXT NOT NULL,
token_count INTEGER NOT NULL DEFAULT 0,
file_path TEXT DEFAULT NULL,
symbol TEXT DEFAULT NULL,
start_line INTEGER DEFAULT NULL,
end_line INTEGER DEFAULT NULL,
hash TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES source_documents(id) ON DELETE CASCADE
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_source_chunks_doc ON source_chunks(document_id, ordinal)");
// MEM-29 (0.4.4) — lexical index over source chunks so `find_related` can
// retrieve nearest code-chunk neighbours by symbol/identifier overlap. The
// record vector index only covers chunks that were distilled into a memory;
// code recall needs every chunk reachable, so we index chunk content +
// symbol directly here. Kept in sync by triggers (chunks are insert/replace-
// only, never updated in place), so addSourceChunks / replaceSourceChunks /
// document-delete cascades all stay consistent without per-method wiring.
this.db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS source_chunks_fts USING fts5(
content,
symbol,
chunk_id UNINDEXED,
user_id UNINDEXED,
document_id UNINDEXED,
file_path UNINDEXED
)
`);
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS source_chunks_fts_ai AFTER INSERT ON source_chunks BEGIN
INSERT INTO source_chunks_fts (content, symbol, chunk_id, user_id, document_id, file_path)
VALUES (new.content, COALESCE(new.symbol, ''), new.id, COALESCE(new.user_id, ''), new.document_id, COALESCE(new.file_path, ''));
END
`);
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS source_chunks_fts_ad AFTER DELETE ON source_chunks BEGIN
DELETE FROM source_chunks_fts WHERE chunk_id = old.id;
END
`);
// MEM-28 (0.4.4) — code symbol graph (Phase-1): intra-file call/reference
// edges (from_chunk references a symbol defined by to_chunk in the same
// document). Lets find_related expand a hit to its direct callers/callees.
this.db.exec(`
CREATE TABLE IF NOT EXISTS code_symbol_edges (
document_id TEXT NOT NULL,
from_chunk_id TEXT NOT NULL,
to_chunk_id TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'calls',
PRIMARY KEY (from_chunk_id, to_chunk_id, kind),
FOREIGN KEY (from_chunk_id) REFERENCES source_chunks(id) ON DELETE CASCADE,
FOREIGN KEY (to_chunk_id) REFERENCES source_chunks(id) ON DELETE CASCADE
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_code_edges_from ON code_symbol_edges(from_chunk_id)");
this.db.exec("CREATE INDEX IF NOT EXISTS idx_code_edges_to ON code_symbol_edges(to_chunk_id)");
// MEM-3 — batch-level provenance: which source chunks a cognitive record
// was distilled from. user_id carried for RBAC-readiness (MEM-14).
this.db.exec(`
CREATE TABLE IF NOT EXISTS cognitive_source_links (
user_id TEXT NOT NULL,
workspace_tag TEXT DEFAULT NULL,
record_id TEXT NOT NULL,
chunk_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (record_id, chunk_id),
FOREIGN KEY (chunk_id) REFERENCES source_chunks(id) ON DELETE CASCADE
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_cog_source_links_record ON cognitive_source_links(record_id)");
// MEM-4 — blackboard: extracted candidates staged here before committing to
// cognitive records. user_id for RBAC-readiness (MEM-14).
this.db.exec(`
CREATE TABLE IF NOT EXISTS memory_blackboard_items (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
workspace_tag TEXT DEFAULT NULL,
source_chunk_id TEXT DEFAULT NULL,
candidate_json TEXT NOT NULL,
score REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
conflict_ids_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
committed_record_id TEXT DEFAULT NULL
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_blackboard_user_status ON memory_blackboard_items(user_id, status)");
// MEM-5 — durable hierarchical summary tree (source/topic/global). user_id
// for RBAC-readiness (MEM-14). level 0 = leaf.
this.db.exec(`
CREATE TABLE IF NOT EXISTS memory_tree_nodes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
workspace_tag TEXT DEFAULT NULL,
kind TEXT NOT NULL,
parent_id TEXT DEFAULT NULL,
level INTEGER NOT NULL DEFAULT 0,
summary_md TEXT NOT NULL DEFAULT '',
source_chunk_ids_json TEXT NOT NULL DEFAULT '[]',
sealed_at TEXT DEFAULT NULL,
heat_score REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idx_tree_user_kind ON memory_tree_nodes(user_id, kind, parent_id)");
this.db.exec("CREATE INDEX IF NOT EXISTS idx_tree_parent ON memory_tree_nodes(parent_id)");
// MEM-7 — vault export ledger (path → content hash) for idempotent re-export.
this.db.exec(`
CREATE TABLE IF NOT EXISTS vault_exports (
user_id TEXT NOT NULL,
workspace_tag TEXT DEFAULT NULL,
path TEXT NOT NULL,
hash TEXT NOT NULL,
kind TEXT NOT NULL,
ref_id TEXT NOT NULL,
exported_at TEXT NOT NULL,
PRIMARY KEY (user_id, path)
)
`);
// MEM-14 — RBAC-ready schema. The CREATE statements above carry user_id +
// workspace_tag on fresh DBs; for DBs created earlier in 0.4.3 dev, add the
// columns idempotently so every new table is scope-ready without a future
// migration. Local-first: columns stay NULL until federation populates them.
this.ensureColumn("source_chunks", "user_id", "TEXT DEFAULT NULL");
this.ensureColumn("source_chunks", "workspace_tag", "TEXT DEFAULT NULL");
this.ensureColumn("cognitive_source_links", "workspace_tag", "TEXT DEFAULT NULL");
this.ensureColumn("memory_blackboard_items", "workspace_tag", "TEXT DEFAULT NULL");
this.ensureColumn("memory_tree_nodes", "workspace_tag", "TEXT DEFAULT NULL");