-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathindex.mjs
More file actions
4544 lines (4253 loc) · 201 KB
/
Copy pathindex.mjs
File metadata and controls
4544 lines (4253 loc) · 201 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
// ============================================================
// tokenmem v2.0 (SQLite + FTS5 + sqlite-vec)
// Token-efficient persistent memory for AI agents
// Inspired by: AIRI (moeru-ai/airi) memory architecture
//
// Core capabilities:
// - Structured memory storage (layers + categories + importance scoring)
// - FTS5 full-text search (built-in, zero dependencies)
// - Hybrid retrieval: FTS5 + sqlite-vec KNN + RRF fusion
// - Memory Transfer Learning (meta_knowledge / semi_abstract / concrete_trace)
// - Composite scoring (AIRI-style: importance + relevance + recency)
// - Context window expansion (recall surrounding messages)
// - Auto-expiry & memory promotion (working -> long_term)
// - Compression pipeline (LLM-based summarization)
// - Optional: vector similarity via sqlite-vec or JSON-stored embeddings
//
// Dependencies: better-sqlite3 (sync API, high performance)
// Data file: tokenmem.db (configurable via TOKENMEM_DB_PATH env var)
// ============================================================
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
import { createHash } from 'node:crypto'
import { applyMetaGate } from './meta-gate.mjs'
import { parseTemporalWindow } from './lib/temporal-parser.mjs'
import { expandRecallQuery } from './query-rewrite.mjs'
import { checkSupersedeShrink } from './high-signal-tokens.mjs'
import { HOST_LABEL_RE } from './auth.mjs'
import { normalizedFtsScore } from './recall-scoring.mjs'
import { encodeVector, decodeVector } from './vector-codec.mjs'
import {
MAX_RECALL_CANDIDATES,
MAX_RECALL_CONTEXT_CHARS,
MAX_RECALL_RESULTS,
createRecallTrace,
enforceContextBudget,
finishRecallTrace,
planRecallBudget,
stripHallucinatedMemoryIds,
traceRecallStep,
} from './recall-contract.mjs'
const require = createRequire(import.meta.url)
const __dirname = dirname(fileURLToPath(import.meta.url))
// Optional sender_id verify hook — downstream forks can drop a
// `../lib/team-registry-verify.mjs` sibling to gate stores by sender.
// Public checkouts have no such file; the fail-soft import below keeps
// startup working either way.
let verifyAndRecord = null
try {
const hookPath = resolve(__dirname, '..', 'lib', 'team-registry-verify.mjs')
if (existsSync(hookPath)) {
;({ verifyAndRecord } = await import(hookPath))
}
} catch (_) {
// Optional dependency — startup must not fail when absent
}
// Path resolution order (v2.1.2):
// 1. $TOKENMEM_DB_PATH if set (explicit override)
// 2. Existing engram.db beside this module (back-compat for users coming from
// the pre-rename era — silent migration would create a split-brain second
// DB; we'd rather keep using the populated one)
// 3. tokenmem.db (default for fresh installs)
const DB_PATH = process.env.TOKENMEM_DB_PATH
|| (existsSync(resolve(__dirname, 'engram.db'))
? resolve(__dirname, 'engram.db')
: resolve(__dirname, 'tokenmem.db'))
const SCHEMA_PATH = resolve(__dirname, 'schema.sql')
// wangfenjin/simple Chinese tokenizer extension (optional)
const SIMPLE_EXT_DIR = resolve(__dirname, 'lib/libsimple-windows-x64')
const SIMPLE_EXT_PATH = resolve(SIMPLE_EXT_DIR, 'simple') // .dll suffix handled by loadExtension
const SIMPLE_DICT_PATH = resolve(SIMPLE_EXT_DIR, 'dict')
// asg017/sqlite-vec vector search extension (optional)
const VEC_EXT_DIR = resolve(__dirname, 'lib/sqlite-vec-windows-x64')
const VEC_EXT_PATH = resolve(VEC_EXT_DIR, 'vec0')
const log = (msg) => process.stderr.write(`[${new Date().toISOString()}] [Memory] ${msg}\n`)
// ── DB Instance ─────────────────────────────────────────────
let _db = null
let _embeddingConfig = null
let _entityLlmConfig = null // v2.5: optional OpenAI-compatible chat LLM for async entity extraction
let _entityCache = null // v2.5.1: cached entity list for the recall path (parsed aliases)
let _entityCacheSig = '' // cheap change signature (count:maxId) — refreshes cross-process
let _simpleLoaded = false // whether the simple extension loaded successfully
let _vecLoaded = false // whether the sqlite-vec extension loaded successfully
const _hybridDegradeWarned = new Set() // warn once per reason, not once per recall
let _lastRecallTraceSweepAt = 0
let _writesSinceRecallTraceSweep = 0
/**
* Get or create DB instance (loads optional extensions)
*/
function getDb() {
if (_db) return _db
const Database = require('better-sqlite3')
_db = new Database(DB_PATH)
_db.pragma('journal_mode = WAL')
_db.pragma('foreign_keys = ON')
_db.pragma('busy_timeout = 5000') // wait 5s on concurrent writes instead of immediate error
// Load Chinese tokenizer extension (optional)
try {
if (existsSync(SIMPLE_EXT_PATH + '.dll') || existsSync(SIMPLE_EXT_PATH)) {
_db.loadExtension(SIMPLE_EXT_PATH)
_db.prepare('SELECT jieba_dict(?)').run(SIMPLE_DICT_PATH)
_simpleLoaded = true
log('Chinese tokenizer extension (libsimple + jieba) loaded')
}
} catch (e) {
log(`Chinese tokenizer load failed (falling back to character matching): ${e.message}`)
}
// Load sqlite-vec vector search extension (optional)
try {
if (existsSync(VEC_EXT_PATH + '.dll') || existsSync(VEC_EXT_PATH)) {
_db.loadExtension(VEC_EXT_PATH)
const ver = _db.prepare('SELECT vec_version() AS v').get()?.v || 'unknown'
_vecLoaded = true
log(`sqlite-vec extension loaded (${ver})`)
}
} catch (e) {
log(`sqlite-vec load failed (falling back to FTS5 only): ${e.message}`)
}
return _db
}
// ── Initialization ──────────────────────────────────────────
/**
* Initialize memory system: create tables, FTS indexes
*/
export function initMemory() {
const db = getDb()
const schema = readFileSync(SCHEMA_PATH, 'utf-8')
// PRAGMAs must be executed outside transactions
const pragmaLines = schema.match(/^PRAGMA\s+[^;]+;/gm) || []
for (const p of pragmaLines) {
try { db.exec(p) } catch {}
}
// Execute remaining DDL (better-sqlite3 supports multi-statement exec)
const ddl = schema.replace(/^PRAGMA\s+[^;]+;\s*$/gm, '')
try {
db.exec(ddl)
} catch (e) {
// First run creates normally; subsequent runs may report "already exists"
if (!e.message.includes('already exists')) {
log(`Schema exec: ${e.message.slice(0, 200)}`)
}
}
log(`Initialized — DB at ${DB_PATH}`)
// ── FTS migration: if simple extension loaded but FTS uses old tokenizer, rebuild ──
if (_simpleLoaded) {
try {
const ftsRow = db.prepare(`SELECT sql FROM sqlite_master WHERE name='memories_fts'`).get()
const currentSql = ftsRow?.sql || ''
const currentTokenizer = currentSql.match(/tokenize\s*=\s*'([^']+)'/)?.[1] || 'none'
if (!currentTokenizer.includes('simple')) {
log(`FTS migrating: ${currentTokenizer} -> simple (rebuilding index...)`)
db.exec(`
DROP TRIGGER IF EXISTS trg_mem_fts_insert;
DROP TRIGGER IF EXISTS trg_mem_fts_delete;
DROP TRIGGER IF EXISTS trg_mem_fts_update;
DROP TRIGGER IF EXISTS trg_conv_fts_insert;
DROP TRIGGER IF EXISTS trg_conv_fts_delete;
DROP TRIGGER IF EXISTS trg_conv_fts_update;
DROP TABLE IF EXISTS memories_fts;
DROP TABLE IF EXISTS conversations_fts;
CREATE VIRTUAL TABLE memories_fts USING fts5(
content, summary, tags,
content='memories', content_rowid='rowid',
tokenize='simple 0'
);
CREATE VIRTUAL TABLE conversations_fts USING fts5(
content, from_name,
content='conversations', content_rowid='rowid',
tokenize='simple 0'
);
CREATE TRIGGER trg_mem_fts_insert AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content, summary, tags)
VALUES (new.rowid, new.content, new.summary, new.tags);
END;
CREATE TRIGGER trg_mem_fts_delete AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, summary, tags)
VALUES ('delete', old.rowid, old.content, old.summary, old.tags);
END;
CREATE TRIGGER trg_mem_fts_update AFTER UPDATE OF content, summary, tags ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, summary, tags)
VALUES ('delete', old.rowid, old.content, old.summary, old.tags);
INSERT INTO memories_fts(rowid, content, summary, tags)
VALUES (new.rowid, new.content, new.summary, new.tags);
END;
CREATE TRIGGER trg_conv_fts_insert AFTER INSERT ON conversations BEGIN
INSERT INTO conversations_fts(rowid, content, from_name)
VALUES (new.rowid, new.content, new.from_name);
END;
CREATE TRIGGER trg_conv_fts_delete AFTER DELETE ON conversations BEGIN
INSERT INTO conversations_fts(conversations_fts, rowid, content, from_name)
VALUES ('delete', old.rowid, old.content, old.from_name);
END;
CREATE TRIGGER trg_conv_fts_update AFTER UPDATE OF content ON conversations BEGIN
INSERT INTO conversations_fts(conversations_fts, rowid, content, from_name)
VALUES ('delete', old.rowid, old.content, old.from_name);
INSERT INTO conversations_fts(rowid, content, from_name)
VALUES (new.rowid, new.content, new.from_name);
END;
`)
// Rebuild FTS indexes with existing data
db.exec(`INSERT INTO memories_fts(memories_fts) VALUES('rebuild')`)
db.exec(`INSERT INTO conversations_fts(conversations_fts) VALUES('rebuild')`)
const memCount = db.prepare(`SELECT COUNT(*) AS c FROM memories_fts`).get().c
log(`FTS migration complete, rebuilt ${memCount} memory indexes (tokenize=simple)`)
} else {
log(`FTS already using simple tokenizer, no migration needed`)
}
} catch (e) {
log(`FTS migration failed (non-critical, falling back to character matching): ${e.message}`)
}
}
// Detect embedding configuration
if (process.env.EMBEDDING_API_BASE_URL && process.env.EMBEDDING_API_KEY) {
_embeddingConfig = {
baseUrl: process.env.EMBEDDING_API_BASE_URL,
apiKey: process.env.EMBEDDING_API_KEY,
model: process.env.EMBEDDING_MODEL || 'text-embedding-3-small',
dimension: parseInt(process.env.EMBEDDING_DIMENSION || '1536', 10),
}
log(`Embedding API: ${_embeddingConfig.model} (${_embeddingConfig.dimension}d)`)
} else {
log('No embedding API — using FTS5 full-text search only')
}
// Detect entity-extraction LLM (v2.5, optional, OpenAI-compatible chat completions).
// Dormant if unset — the entity layer just stays empty and recall falls back to
// FTS5 + vector RRF (current behaviour). Extraction never runs on the recall hot path.
if (process.env.ENTITY_LLM_API_BASE_URL && process.env.ENTITY_LLM_API_KEY) {
_entityLlmConfig = {
baseUrl: process.env.ENTITY_LLM_API_BASE_URL,
apiKey: process.env.ENTITY_LLM_API_KEY,
model: process.env.ENTITY_LLM_MODEL || 'gpt-4o-mini',
}
log(`Entity LLM: ${_entityLlmConfig.model}`)
}
// ── Incremental schema migrations ───────────────────────────
// New columns: compressed_from, is_compressed (compression pipeline)
try {
db.exec(`ALTER TABLE memories ADD COLUMN compressed_from TEXT DEFAULT '[]'`)
log('Migration: added compressed_from column')
} catch {} // "duplicate column name" = already exists, ignore
try {
db.exec(`ALTER TABLE memories ADD COLUMN is_compressed INTEGER NOT NULL DEFAULT 0`)
log('Migration: added is_compressed column')
} catch {}
// Abstraction level (Memory Transfer Learning)
try {
db.exec(`ALTER TABLE memories ADD COLUMN memory_level TEXT NOT NULL DEFAULT 'semi_abstract'`)
log('Migration: added memory_level column')
} catch {} // already exists
try {
db.exec(`CREATE INDEX IF NOT EXISTS idx_mem_level ON memories(memory_level) WHERE deleted_at IS NULL`)
} catch {}
// Store-time dedup + event_time (migration 004). Inline so fresh installs get them
// without manually applying migrations/004 — storeMemory's dedup path needs content_hash.
try {
db.exec(`ALTER TABLE memories ADD COLUMN content_hash TEXT`)
log('Migration: added content_hash column')
} catch {} // already exists
try {
db.exec(`ALTER TABLE memories ADD COLUMN event_time INTEGER`)
log('Migration: added event_time column')
} catch {}
try {
db.exec(`CREATE INDEX IF NOT EXISTS idx_mem_content_hash ON memories(content_hash, created_at DESC) WHERE content_hash IS NOT NULL AND deleted_at IS NULL`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mem_event_time ON memories(event_time DESC) WHERE event_time IS NOT NULL AND deleted_at IS NULL`)
} catch {}
// migration 006: source_conversation_id — 把一条 L1 记忆链回它形成时的 L0 对话(conversations.rowid)。
// 借鉴 Tencent Agent Memory 的「full traceability drill-down」:召回一条记忆能回查原始对话佐证。
try {
db.exec(`ALTER TABLE memories ADD COLUMN source_conversation_id INTEGER`)
log('Migration: added source_conversation_id column')
} catch {} // already exists
try {
db.exec(`CREATE INDEX IF NOT EXISTS idx_mem_source_conv ON memories(source_conversation_id) WHERE source_conversation_id IS NOT NULL`)
} catch {}
// migration 007 (v2.5): entity layer — `entities` + `mentions` (memory<->entity), plus a
// memories.entities_extracted_at marker (NULL = not yet processed, mirrors content_vector IS NULL).
// A 3rd retrieval signal beside FTS5 + vector. Extraction is async / store-time, NEVER on the
// recall hot path; recall joins `mentions` and feeds an RRF 4th ranked list (NOT an additive
// boost — additive would re-drown relevance, see the v2.4 rrf*10 note). Same SQLite file, no
// graph DB. Soft-delete only (no tombstone), consistent with the rest of the store.
try {
db.exec(`ALTER TABLE memories ADD COLUMN entities_extracted_at INTEGER`)
log('Migration 007: added entities_extracted_at column')
} catch {} // already exists
try {
db.exec(`
CREATE TABLE IF NOT EXISTS entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
normalized TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'other',
aliases TEXT NOT NULL DEFAULT '[]',
mention_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
deleted_at INTEGER
)
`)
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_norm ON entities(normalized, type) WHERE deleted_at IS NULL`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name) WHERE deleted_at IS NULL`)
db.exec(`
CREATE TABLE IF NOT EXISTS mentions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memory_rowid INTEGER NOT NULL,
entity_id INTEGER NOT NULL REFERENCES entities(id),
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
)
`)
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mentions_pair ON mentions(memory_rowid, entity_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mentions_entity ON mentions(entity_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mentions_memory ON mentions(memory_rowid)`)
log('Migration 007: entity layer (entities + mentions) ready')
} catch (e) { log(`Migration 007 (entities) failed: ${e.message}`) }
// migration 003: decay_score + prior_versions (power-law decay + supersede paper trail).
// Inline so upgrading an OLD db gets them without manually applying migrations/003 —
// otherwise recall's `AND decay_score >= ?` (strict/cold-pool path) throws "no such column".
// (2026-06-10: caught during an engram v1.1→v2.2 upgrade — 003 was the only file not inlined.)
try {
db.exec(`ALTER TABLE memories ADD COLUMN decay_score REAL NOT NULL DEFAULT 1.0`)
log('Migration: added decay_score column')
} catch {} // already exists
try {
db.exec(`ALTER TABLE memories ADD COLUMN prior_versions TEXT NOT NULL DEFAULT '[]'`)
log('Migration: added prior_versions column')
} catch {}
try {
// Partial index for the cold pool. Rebuilt 2026-09-01: the old definition led
// on importance and was partial on `importance >= 8`. With that term gone from
// the pool query, SQLite can no longer use it and falls back to a scan. The
// new one leads on last_accessed, which is what the query range-filters.
//
// This whole migration block re-runs on every initMemory(), so the DROP is
// guarded on the old definition actually being present — otherwise every
// process start would rebuild the index, and that cost grows with the library.
const surfaceIdx = db.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_mem_surface_pool'`
).get()
if (surfaceIdx?.sql && /importance/i.test(surfaceIdx.sql)) {
db.exec(`DROP INDEX idx_mem_surface_pool`)
log('Migration: rebuilt idx_mem_surface_pool (was keyed on importance)')
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_mem_surface_pool ON memories(last_accessed, decay_score) WHERE deleted_at IS NULL AND superseded_by IS NULL`)
} catch {}
// migration 008 (v2.6): is_anchor / is_pinned scarcity-as-structure layer.
// Ombre-Brain inspired: importance 1-10 is a weak prior that gets inflated
// (real snapshot: 61% of memories >= 8). Add two boolean columns with hard
// quotas (anchor <= 40 / pinned <= 30) so callers must trade off. Anchor is
// the stronger tier (identity / rule level); pinned is a recall floor.
// Quota check lives in storeMemory (application layer).
//
// Catch narrowly: only swallow the "column already exists" case that ADD
// COLUMN throws on a second run. Anything else (disk full, permission,
// WAL lock) should surface loudly so the operator can act.
const isDupColumn = (e) => /duplicate column name/i.test(String(e?.message || ''))
try {
db.exec(`ALTER TABLE memories ADD COLUMN is_anchor INTEGER NOT NULL DEFAULT 0`)
log('Migration 008: added is_anchor column')
} catch (e) { if (!isDupColumn(e)) throw e }
try {
db.exec(`ALTER TABLE memories ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0`)
log('Migration 008: added is_pinned column')
} catch (e) { if (!isDupColumn(e)) throw e }
db.exec(`CREATE INDEX IF NOT EXISTS idx_mem_anchor ON memories(is_anchor) WHERE is_anchor = 1 AND deleted_at IS NULL`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mem_pinned ON memories(is_pinned) WHERE is_pinned = 1 AND deleted_at IS NULL`)
// migration 009 (v2.8): locations table — path alias KV layer, out of the
// memory store on purpose (exact-match, not RRF-ranked). See migrations/009.
db.exec(`
CREATE TABLE IF NOT EXISTS locations (
name TEXT PRIMARY KEY,
path TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'dir'
CHECK (kind IN ('dir', 'file', 'glob_root', 'executable', 'url', 'other')),
aliases TEXT NOT NULL DEFAULT '[]',
notes TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
)
`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_locations_kind ON locations(kind)`)
// Vector search virtual table (sqlite-vec)
// Dimension determined by _embeddingConfig; skip if not available
if (_vecLoaded && _embeddingConfig) {
try {
const dim = _embeddingConfig.dimension
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
memory_rowid INTEGER PRIMARY KEY,
embedding FLOAT[${dim}]
)
`)
log(`Vector table memories_vec ready (dim=${dim})`)
} catch (e) {
log(`memories_vec creation failed: ${e.message}`)
}
}
// New table: search_misses (search miss tracking)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS search_misses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'recall',
hit_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
)
`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_miss_query ON search_misses(query)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_miss_created ON search_misses(created_at DESC)`)
} catch {}
// migration 010: bounded recall trace. Keep this content-free: only hashes,
// counts, decisions, and the rowids actually exposed to the caller.
db.exec(`
CREATE TABLE IF NOT EXISTS recall_traces (
trace_id TEXT PRIMARY KEY,
source TEXT NOT NULL,
mode TEXT NOT NULL,
query_hash TEXT NOT NULL,
query_chars INTEGER NOT NULL,
requested_limit INTEGER NOT NULL,
effective_limit INTEGER NOT NULL,
candidate_limit INTEGER NOT NULL,
kept_ids TEXT NOT NULL DEFAULT '[]',
steps TEXT NOT NULL DEFAULT '[]',
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL
)
`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_recall_traces_started ON recall_traces(started_at DESC)`)
// NOTE ON NUMBERING: 011/012 below shipped as 009/010 in a downstream runtime
// copy before 009 (locations) and 010 (recall trace) existed here — the two
// sides grew apart and reused numbers. Renumbered on the way up. Safe to
// renumber because this schema has no version counter: every migration is
// guarded by its own existence check (isDupColumn for ALTER, IF NOT EXISTS
// for CREATE), so re-running against a DB that already has these objects
// is a no-op.
// migration 011: source_host — which agent RUNTIME wrote the row (cc /
// codex / ...). Distinct axis from source_platform (which SURFACE the
// conversation happened on: feishu / desktop). Stamped by the serving layer
// from channel auth (auth.mjs bearer token → host map); client-supplied
// claims never reach this column — provenance is evidence, evidence comes
// from the channel. NULL = pre-provenance rows / auth-off writes.
try {
db.exec(`ALTER TABLE memories ADD COLUMN source_host TEXT`)
log('Migration 011: added source_host column')
} catch (e) { if (!isDupColumn(e)) throw e }
try {
db.exec(`CREATE INDEX IF NOT EXISTS idx_mem_source_host ON memories(source_host) WHERE source_host IS NOT NULL AND deleted_at IS NULL`)
} catch {}
// migration 012: memories_quarantine — write isolation for
// not-yet-trusted hosts (Unified Agent dogfood). Quarantined writes live in
// a SEPARATE table so the main recall pool cannot see them BY CONSTRUCTION —
// no recall path needs to remember a WHERE filter (same philosophy as
// channel-derived provenance: structural guarantees over per-call-site
// discipline). Approval moves a row into `memories` preserving provenance
// and original created_at; requested supersedes execute only on approval.
try {
db.exec(`
CREATE TABLE IF NOT EXISTS memories_quarantine (
qid INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL CHECK (length(content) > 0),
summary TEXT,
memory_type TEXT NOT NULL DEFAULT 'long_term',
category TEXT NOT NULL DEFAULT 'general',
importance INTEGER NOT NULL DEFAULT 5,
memory_level TEXT NOT NULL DEFAULT 'semi_abstract',
tags TEXT DEFAULT '[]',
supersedes_requested TEXT DEFAULT '[]',
event_time INTEGER,
content_hash TEXT,
source TEXT DEFAULT 'conversation',
source_platform TEXT DEFAULT 'unknown',
source_host TEXT,
metadata TEXT DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
review_status TEXT NOT NULL DEFAULT 'pending'
CHECK (review_status IN ('pending', 'approved', 'rejected')),
reviewed_at INTEGER,
review_note TEXT,
merged_rowid TEXT
)
`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_quar_status ON memories_quarantine(review_status, created_at DESC)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_quar_host ON memories_quarantine(source_host, review_status)`)
log('Migration 012: memories_quarantine table ready')
} catch (e) { log(`Migration 012 (quarantine) failed: ${e.message}`) }
// Migration 013 (memories.content_vector JSON -> Float32 BLOB) is deliberately
// NOT run here. initMemory() executes inside hook children that spawnSync
// kills at ~2.8 s, and inside every CLI invocation; bulk conversion in those
// processes gets SIGTERM'd half-done and taxes every turn until it drains.
// It runs as a background loop in the long-lived MCP server (mcp-server.mjs,
// beside the self-heal sweep) and on demand via `--migrate-vectors`.
// Readers accept both formats, so nothing depends on it having run.
// Migration: memories.source CHECK constraint add 'compression'
// SQLite doesn't support ALTER CHECK -> check if current CHECK includes 'compression', rebuild if not
try {
const memSchema = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='memories'`).get()?.sql || ''
if (memSchema && !memSchema.includes("'compression'")) {
log('Migration: rebuilding memories table to add compression source')
db.exec(`
BEGIN TRANSACTION;
-- Disable FTS triggers during rebuild
DROP TRIGGER IF EXISTS trg_mem_fts_insert;
DROP TRIGGER IF EXISTS trg_mem_fts_delete;
DROP TRIGGER IF EXISTS trg_mem_fts_update;
ALTER TABLE memories RENAME TO memories_old;
CREATE TABLE memories (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
content TEXT NOT NULL CHECK (length(content) > 0),
summary TEXT,
memory_type TEXT NOT NULL DEFAULT 'working'
CHECK (memory_type IN ('working', 'short_term', 'long_term', 'permanent')),
category TEXT NOT NULL DEFAULT 'general'
CHECK (category IN ('general', 'people', 'project', 'decision', 'feedback',
'bug', 'relationship', 'skill', 'preference')),
importance INTEGER NOT NULL DEFAULT 5 CHECK (importance BETWEEN 1 AND 10),
emotional_impact INTEGER NOT NULL DEFAULT 0 CHECK (emotional_impact BETWEEN -10 AND 10),
source TEXT NOT NULL DEFAULT 'conversation'
CHECK (source IN ('conversation', 'observation', 'manual', 'extraction', 'compression')),
source_id TEXT,
source_platform TEXT DEFAULT 'unknown',
tags TEXT DEFAULT '[]',
compressed_from TEXT DEFAULT '[]',
is_compressed INTEGER NOT NULL DEFAULT 0,
memory_level TEXT NOT NULL DEFAULT 'semi_abstract'
CHECK (memory_level IN ('concrete_trace', 'semi_abstract', 'meta_knowledge')),
metadata TEXT DEFAULT '{}',
content_vector TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
last_accessed INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
access_count INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER,
deleted_at INTEGER
);
-- Copy data with explicit column names, fill NULLs with defaults
INSERT INTO memories (
id, content, summary, memory_type, category, importance, emotional_impact,
source, source_id, source_platform, tags, compressed_from, is_compressed,
memory_level, metadata, content_vector, created_at, updated_at, last_accessed,
access_count, expires_at, deleted_at
)
SELECT
id, content, summary, memory_type, category, importance, emotional_impact,
source, source_id, source_platform, tags,
COALESCE(compressed_from, '[]') AS compressed_from,
COALESCE(is_compressed, 0) AS is_compressed,
'semi_abstract' AS memory_level,
metadata, content_vector, created_at, updated_at, last_accessed, access_count,
expires_at, deleted_at
FROM memories_old;
DROP TABLE memories_old;
-- Rebuild indexes
CREATE INDEX IF NOT EXISTS idx_mem_type ON memories(memory_type) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_mem_category ON memories(category) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_mem_importance ON memories(importance DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_mem_created ON memories(created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_mem_accessed ON memories(last_accessed DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_mem_source ON memories(source_platform, source) WHERE deleted_at IS NULL;
-- Rebuild FTS triggers
CREATE TRIGGER trg_mem_fts_insert AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content, summary, tags)
VALUES (new.rowid, new.content, new.summary, new.tags);
END;
CREATE TRIGGER trg_mem_fts_delete AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, summary, tags)
VALUES ('delete', old.rowid, old.content, old.summary, old.tags);
END;
CREATE TRIGGER trg_mem_fts_update AFTER UPDATE OF content, summary, tags ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, summary, tags)
VALUES ('delete', old.rowid, old.content, old.summary, old.tags);
INSERT INTO memories_fts(rowid, content, summary, tags)
VALUES (new.rowid, new.content, new.summary, new.tags);
END;
COMMIT;
`)
// Rebuild FTS index (triggers were disabled during migration)
db.exec(`INSERT INTO memories_fts(memories_fts) VALUES('rebuild')`)
log('Migration: memories table rebuilt, FTS reindexed')
}
} catch (e) {
log(`Migration memories CHECK failed: ${e.message}`)
try { db.exec('ROLLBACK') } catch {}
}
// Clean up expired memories
expireMemories()
// Show stats
const stats = getMemoryStats()
log(`Stats: ${stats.memories.total_active} memories, ${stats.conversations} conversations, ${stats.activeGoals} active goals`)
}
// ── Embedding (Optional) ────────────────────────────────────
// How long a single embedding call may hold a caller before we give up on it.
//
// Chosen from the hybrid latency distribution over a 14-day recall log
// (1416 calls, 2026-09-01), which is bimodal with an empty middle:
//
// p50 501ms p70 919ms p80 2290ms | p85 10695ms p90 10734ms
//
// Nothing lives between ~3s and ~10s. A 2.5s bound therefore cuts the entire
// upstream-stall cluster (282 of the 286 calls it touches) while costing one
// legitimately slow embed. Tightening to 1s would start eating real ones (84
// more) and buys nothing — the stall cluster is already gone by then.
//
// Giving up is cheap here. FTS runs in the same Promise.all and is synchronous,
// so hybrid already holds its rows; losing the vector leg costs ranking quality
// on that one call, not the answer itself.
// Read per call, not at module load. Capturing env at import time makes the
// knob untunable by anything that configures itself after the import — which is
// every embedder of this library, and every test. Same trap as the DB path.
const embeddingTimeoutMs = () => {
const n = parseInt(process.env.EMBEDDING_TIMEOUT_MS || '', 10)
return Number.isFinite(n) && n > 0 ? n : 2500
}
/** Lets the hot path tell "upstream was slow" apart from "upstream was broken". */
const EMBED_TIMEOUT = Symbol('embedding-timeout')
/**
* Generate embedding vector (OpenAI-compatible API).
*
* Returns null on any failure — every caller guards on falsy and degrades to
* full-text search. Pass `{ signalTimeout: true }` to get EMBED_TIMEOUT back
* instead of null specifically when the deadline was hit, so a caller that
* cares can record *why* it degraded rather than reporting a normal result.
*/
// Query-embedding memo. Measured on a 10-day recall_log (2026-09-14): 23% of
// recall queries recur within 10 minutes — the same question asked again, a
// prompt re-sent, a second session asking what the first just asked. These are
// sequential repeats: a hit only exists once an earlier call has resolved.
// Two calls for the same text in flight at the same instant both go upstream
// (no coalescing; the window that matters is minutes, not milliseconds).
// Embeddings are deterministic for a given model, so the repeat is pure
// latency and upstream load. Opt-in (`memo: true`); the recall path uses it,
// the store paths do not (content rarely repeats and the map should stay
// small). Entries carry no model tag: _embeddingConfig is set once per
// process, so a model change means a restart, which empties the map.
const EMBED_MEMO_TTL_MS = 10 * 60_000
const EMBED_MEMO_MAX = 512
const _embedMemo = new Map() // text -> { vec, at }; Map keeps insertion order for FIFO eviction
const _embedStats = { calls: 0, memoHits: 0, timeouts: 0, clamped: 0, failures: 0 }
/** Counters since process start — surfaced by getMemoryStats() so a caller can see, not guess, whether the memo and deadline clamp are doing anything. */
export function getEmbeddingStats() { return { ..._embedStats, memoSize: _embedMemo.size } }
/**
* @param {string} text
* @param {object} [o]
* @param {boolean} [o.signalTimeout=false] return EMBED_TIMEOUT (not null) when the deadline hit
* @param {number|null} [o.timeoutMs=null] per-call ceiling; the effective timeout is
* min(EMBEDDING_TIMEOUT_MS, timeoutMs). A caller with a hard budget (a hook with
* 1.5 s before it gives up) passes its remaining time so the server degrades
* to FTS *inside* that budget instead of the caller aborting and re-doing the
* work cold while this call runs on as a zombie.
* @param {boolean} [o.memo=false] serve from / fill the 10-minute query memo
*/
export async function generateEmbedding(text, { signalTimeout = false, timeoutMs = null, memo = false } = {}) {
if (!_embeddingConfig) return null
const input = text.slice(0, 8000)
if (memo) {
const hit = _embedMemo.get(input)
if (hit && Date.now() - hit.at < EMBED_MEMO_TTL_MS) { _embedStats.memoHits++; return hit.vec }
if (hit) _embedMemo.delete(input)
}
const envTimeout = embeddingTimeoutMs()
const clamped = Number.isFinite(timeoutMs) && timeoutMs > 0 && timeoutMs < envTimeout
const effectiveTimeout = clamped ? Math.max(100, Math.floor(timeoutMs)) : envTimeout
_embedStats.calls++
if (clamped) _embedStats.clamped++
try {
const res = await fetch(`${_embeddingConfig.baseUrl}/embeddings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_embeddingConfig.apiKey}`,
},
body: JSON.stringify({
model: _embeddingConfig.model,
input,
dimensions: _embeddingConfig.dimension,
encoding_format: 'float',
}),
signal: AbortSignal.timeout(effectiveTimeout),
})
const data = await res.json()
const vec = data?.data?.[0]?.embedding || null
// Only a well-formed vector is worth remembering for ten minutes: an empty
// array or a wrong-dimension reply is a one-off upstream hiccup today and
// must not become sticky for that query text.
const wellFormed = Array.isArray(vec) && vec.length > 0
&& (!_embeddingConfig.dimension || vec.length === _embeddingConfig.dimension)
if (memo && wellFormed) {
if (_embedMemo.size >= EMBED_MEMO_MAX) _embedMemo.delete(_embedMemo.keys().next().value)
_embedMemo.set(input, { vec, at: Date.now() })
}
return vec
} catch (e) {
// A stalling upstream is the common case and should not read as a broken one.
const timedOut = e?.name === 'TimeoutError' || e?.name === 'AbortError'
if (timedOut) _embedStats.timeouts++; else _embedStats.failures++
log(timedOut
? `Embedding timed out after ${effectiveTimeout}ms${clamped ? ' (caller deadline)' : ''} — degrading to FTS for this call`
: `Embedding failed: ${e.message}`)
return timedOut && signalTimeout ? EMBED_TIMEOUT : null
}
}
// ── Entity extraction (v2.5, async / store-time — NEVER on the recall hot path) ──
// Optional: dormant unless ENTITY_LLM_* is configured. Extraction is an OpenAI-compatible
// chat call; the resulting entities feed an RRF 4th retrieval path (see recall), not an
// additive boost. Recall itself does zero LLM — query→entity matching is pure SQL.
async function callEntityLlm(prompt) {
if (!_entityLlmConfig) return null
try {
const res = await fetch(`${_entityLlmConfig.baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${_entityLlmConfig.apiKey}` },
body: JSON.stringify({
model: _entityLlmConfig.model,
messages: [{ role: 'user', content: prompt }],
temperature: 0,
max_tokens: 600,
}),
})
const data = await res.json()
return data?.choices?.[0]?.message?.content || null
} catch (e) {
log(`Entity LLM call failed: ${e.message}`)
return null
}
}
const ENTITY_PROMPT = `你是命名实体抽取器。从下面这条记忆里**只抽具体的命名实体**(专有名词),用作检索锚点。
只抽这五类(必须是特定的、可复用指代某个东西的专名):
- person: 具体人名
- project: 具体项目/产品/代号名(如 TANDEM / Fire-Seed / engram / mneme / KOS / GClaw)
- org: 具体组织/公司/团队名
- tech: 具体工具/库/服务/协议名(如 codex / DeepSeek / sqlite-vec / MCP / jieba)
- place: 具体地名
**绝不抽**:通用技术词(timeout / metric / cache / dedup / supersede / fail-soft / CLI 等)、动词、形容词、泛泛概念、角色词(owner / 负责人 / 用户)。
判据:这个词是不是一个**特定的、能反复指代同一个东西的专名**?不是就丢。宁缺毋滥,最多 6 个,中英文都抽。
每个给 name(规范名)、type(上面五类之一)、aliases(同义/简称,没有则 [])。
严格只输出 JSON 数组,无其他文字:[{"name":"","type":"","aliases":[]}]
记忆内容:
`
function parseEntityJson(text) {
if (!text) return []
const m = text.match(/\[[\s\S]*\]/) // tolerate ```json fences / surrounding prose
if (!m) return []
let arr
try { arr = JSON.parse(m[0]) } catch { return [] }
if (!Array.isArray(arr)) return []
// strict whitelist of types — drop (not remap) anything else, since the generic-concept
// junk the LLM occasionally emits ("metric", "timeout"...) makes useless broad-match anchors.
const TYPES = new Set(['person', 'project', 'org', 'tech', 'place'])
return arr
.filter(e => e && typeof e.name === 'string' && e.name.trim() && TYPES.has(e.type))
.map(e => ({
name: e.name.trim().slice(0, 120),
type: e.type,
aliases: Array.isArray(e.aliases)
? e.aliases.filter(a => typeof a === 'string' && a.trim()).map(a => a.trim().slice(0, 80)).slice(0, 6)
: [],
}))
.slice(0, 6)
}
async function extractEntitiesFromContent(content) {
return parseEntityJson(await callEntityLlm(ENTITY_PROMPT + String(content).slice(0, 4000)))
}
function normalizeEntityName(name) {
return String(name).toLowerCase().replace(/\s+/g, ' ').trim()
}
// Upsert by (normalized, type); merge aliases; bump mention_count. Returns entity id (or null).
function upsertEntity(db, { name, type, aliases }) {
const normalized = normalizeEntityName(name)
if (!normalized) return null
const now = Date.now()
const existing = db.prepare(`SELECT id, aliases FROM entities WHERE normalized = ? AND type = ? AND deleted_at IS NULL`).get(normalized, type)
if (existing) {
if (aliases?.length) {
const cur = safeJsonParse(existing.aliases, [])
const merged = Array.from(new Set([...cur, ...aliases]))
if (merged.length !== cur.length) {
db.prepare(`UPDATE entities SET aliases = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(merged), now, existing.id)
}
}
db.prepare(`UPDATE entities SET mention_count = mention_count + 1, updated_at = ? WHERE id = ?`).run(now, existing.id)
return existing.id
}
return db.prepare(`INSERT INTO entities (name, normalized, type, aliases, mention_count, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)`)
.run(name.slice(0, 120), normalized, type, JSON.stringify(aliases || []), now, now).lastInsertRowid
}
/**
* Async batch: extract entities for memories not yet processed (entities_extracted_at IS NULL).
* Off the store + recall hot paths. No-op if no entity LLM configured.
* @param {number} limit max memories per run (bounds LLM cost)
*/
export async function extractMissingEntities(limit = 100, concurrency = 8) {
if (!_entityLlmConfig) return { scanned: 0, processed: 0, entities: 0, mentions: 0, failed: 0, skipped: 'no_entity_llm' }
const db = getDb()
const rows = db.prepare(`
SELECT rowid, content FROM memories
WHERE entities_extracted_at IS NULL AND deleted_at IS NULL
ORDER BY rowid DESC LIMIT ?
`).all(limit)
if (rows.length === 0) return { scanned: 0, processed: 0, entities: 0, mentions: 0, failed: 0 }
let processed = 0, entCount = 0, mentCount = 0, failed = 0
const markStmt = db.prepare(`UPDATE memories SET entities_extracted_at = ? WHERE rowid = ?`)
const linkStmt = db.prepare(`INSERT OR IGNORE INTO mentions (memory_rowid, entity_id, created_at) VALUES (?, ?, ?)`)
// The slow part is the per-memory LLM call (network). Run them CONCURRENTLY in batches,
// then commit each memory's entities SEQUENTIALLY (better-sqlite3 is synchronous — DB writes
// can't and shouldn't overlap). Brings a full backfill from hours to ~minutes.
for (let i = 0; i < rows.length; i += concurrency) {
const batch = rows.slice(i, i + concurrency)
const extracted = await Promise.all(batch.map(row =>
extractEntitiesFromContent(row.content).then(ents => ({ row, ents })).catch(() => ({ row, ents: null }))
))
for (const { row, ents } of extracted) {
if (ents === null) { failed++; continue }
// dedup per memory (LLM may repeat) so mention_count isn't double-bumped
const seen = new Set()
const uniq = ents.filter(e => { const k = normalizeEntityName(e.name) + '|' + e.type; if (!k || seen.has(k)) return false; seen.add(k); return true })
const now = Date.now()
const tx = db.transaction(() => {
for (const e of uniq) {
const eid = upsertEntity(db, e)
if (eid != null) { const r = linkStmt.run(row.rowid, Number(eid), now); if (r.changes > 0) mentCount++ }
}
markStmt.run(now, row.rowid)
})
try { tx(); processed++; entCount += uniq.length } catch { failed++ }
}
}
log(`extractMissingEntities: processed=${processed}/${rows.length} entities=${entCount} mentions=${mentCount} failed=${failed}`)
return { scanned: rows.length, processed, entities: entCount, mentions: mentCount, failed }
}
/**
* Cosine similarity (application-layer computation)
*/
function cosineSimilarity(a, b) {
if (!a || !b || a.length !== b.length) return 0
let dotProduct = 0, normA = 0, normB = 0
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
const denom = Math.sqrt(normA) * Math.sqrt(normB)
return denom === 0 ? 0 : dotProduct / denom
}
// ── Conversation Recording ──────────────────────────────────
/**
* Record a conversation message
* @param {Object} msg
* @param {string} msg.platform
* @param {string} msg.chatId
* @param {string} [msg.messageId]
* @param {string} msg.fromId
* @param {string} msg.fromName
* @param {string} msg.role - user | assistant | system
* @param {string} msg.content
* @param {boolean} [msg.isReply]
* @param {string} [msg.replyToId]
* @param {Object} [msg.metadata]
* @returns {string|null} conversation id
*/
export function recordConversation(msg) {
// Optional sender_id verify against a team-registry hook.
// Fail-soft: log + metric, never blocks main flow. Hook is optional —
// public mneme checkout has no ../lib/ sibling so the import resolves to
// null and we silently skip the verify call.
if (verifyAndRecord) {
try {
verifyAndRecord(msg)
} catch (_) {
// Defensive: verify-hook errors must never affect recording
}
}
const db = getDb()
try {
const stmt = db.prepare(`
INSERT INTO conversations
(platform, chat_id, message_id, from_id, from_name, role, content, is_reply, reply_to_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const info = stmt.run(
msg.platform || 'unknown',
msg.chatId,
msg.messageId || null,
msg.fromId,
msg.fromName || '',
msg.role || 'user',
msg.content,
msg.isReply ? 1 : 0,
msg.replyToId || null,
JSON.stringify(msg.metadata || {}),
)
return info.lastInsertRowid ? String(info.lastInsertRowid) : null
} catch (e) {
// UNIQUE constraint = deduplication, silent
if (e.message.includes('UNIQUE')) return null
log(`recordConversation failed: ${e.message}`)
return null
}
}
/**
* Async version: record conversation + generate embedding vector
*/
export async function recordConversationAsync(msg) {
const id = recordConversation(msg)
if (!id) return null
// Background embedding generation
const embedding = await generateEmbedding(msg.content)
if (embedding) {
try {
getDb().prepare(`UPDATE conversations SET content_vector = ? WHERE rowid = ?`)
.run(encodeVector(embedding), id) // same Float32 BLOB codec as memories (v2.10)
} catch {}
}
return id
}
// ── Memory Storage ──────────────────────────────────────────
// migration 004 (v2.2): 5-min window dedup config
const DEDUP_WINDOW_MS = 5 * 60_000
// migration 004 (v2.2): event_time accepts ms number, ISO string, or Date object
function _parseEventTime(v) {
if (v == null) return null
if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime()
if (typeof v === 'string') {
const ms = Date.parse(v)
return Number.isNaN(ms) ? null : ms
}
return null
}
// v2.10: supersede shrink guard.
//
// supersede means "this replaces that wholesale", but callers drift into writing
// only the delta ("inventory is now 296") and let the rest fall off. Durable
// facts — service URLs, env vars, API routes, code locations — then leak out of
// `content` one version at a time. They survive in prior_versions[], but
// memories_fts only indexes content/summary/tags, so a dropped fact is
// unrecallable even though the audit trail still holds it. "Still in the DB"