metame-cli 1.5.19 → 1.5.21

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.
Files changed (40) hide show
  1. package/index.js +157 -80
  2. package/package.json +2 -2
  3. package/scripts/bin/bootstrap-worktree.sh +20 -0
  4. package/scripts/core/audit.js +190 -0
  5. package/scripts/core/handoff.js +780 -0
  6. package/scripts/core/handoff.test.js +1074 -0
  7. package/scripts/core/memory-model.js +183 -0
  8. package/scripts/core/memory-model.test.js +486 -0
  9. package/scripts/core/reactive-paths.js +44 -0
  10. package/scripts/core/reactive-paths.test.js +35 -0
  11. package/scripts/core/reactive-prompt.js +51 -0
  12. package/scripts/core/reactive-prompt.test.js +88 -0
  13. package/scripts/core/reactive-signal.js +40 -0
  14. package/scripts/core/reactive-signal.test.js +88 -0
  15. package/scripts/core/thread-chat-id.js +52 -0
  16. package/scripts/core/thread-chat-id.test.js +113 -0
  17. package/scripts/daemon-bridges.js +92 -38
  18. package/scripts/daemon-claude-engine.js +373 -444
  19. package/scripts/daemon-command-router.js +82 -8
  20. package/scripts/daemon-engine-runtime.js +7 -10
  21. package/scripts/daemon-reactive-lifecycle.js +100 -33
  22. package/scripts/daemon-session-commands.js +133 -43
  23. package/scripts/daemon-session-store.js +300 -82
  24. package/scripts/daemon-team-dispatch.js +16 -16
  25. package/scripts/daemon.js +21 -175
  26. package/scripts/deploy-manifest.js +90 -0
  27. package/scripts/docs/maintenance-manual.md +14 -11
  28. package/scripts/docs/pointer-map.md +13 -4
  29. package/scripts/feishu-adapter.js +31 -27
  30. package/scripts/hooks/intent-engine.js +6 -3
  31. package/scripts/hooks/intent-memory-recall.js +1 -0
  32. package/scripts/hooks/intent-perpetual.js +1 -1
  33. package/scripts/memory-extract.js +5 -97
  34. package/scripts/memory-gc.js +35 -90
  35. package/scripts/memory-migrate-v2.js +304 -0
  36. package/scripts/memory-nightly-reflect.js +40 -41
  37. package/scripts/memory.js +340 -859
  38. package/scripts/migrate-reactive-paths.js +122 -0
  39. package/scripts/signal-capture.js +4 -0
  40. package/scripts/sync-plugin.js +56 -0
@@ -108,68 +108,6 @@ function saveSessionTag(sessionId, sessionName, facts) {
108
108
  }
109
109
  }
110
110
 
111
- function normalizeConceptList(input) {
112
- if (!Array.isArray(input)) return [];
113
- const out = [];
114
- const seen = new Set();
115
- for (const raw of input) {
116
- const v = String(raw || '').trim();
117
- if (!v || v.length > 40) continue;
118
- if (seen.has(v)) continue;
119
- seen.add(v);
120
- out.push(v);
121
- if (out.length >= 3) break;
122
- }
123
- return out;
124
- }
125
-
126
- function normalizeDomain(input) {
127
- const v = String(input || '').trim();
128
- if (!v) return null;
129
- return v.length > 40 ? v.slice(0, 40) : v;
130
- }
131
-
132
- function factFingerprint(fact) {
133
- if (!fact || typeof fact !== 'object') return '';
134
- const entity = String(fact.entity || '').trim();
135
- const relation = String(fact.relation || '').trim();
136
- const value = String(fact.value || '').trim().slice(0, 100);
137
- if (!entity || !relation || !value) return '';
138
- return `${entity}||${relation}||${value}`;
139
- }
140
-
141
- function buildFactLabelRows(extractedFacts, savedFacts) {
142
- const source = Array.isArray(extractedFacts) ? extractedFacts : [];
143
- const saved = Array.isArray(savedFacts) ? savedFacts : [];
144
- if (source.length === 0 || saved.length === 0) return [];
145
-
146
- const byFp = new Map();
147
- for (const fact of source) {
148
- const fp = factFingerprint(fact);
149
- if (!fp) continue;
150
- if (!byFp.has(fp)) byFp.set(fp, fact);
151
- }
152
-
153
- const rows = [];
154
- const dedup = new Set();
155
- for (const sf of saved) {
156
- const fp = factFingerprint(sf);
157
- if (!fp) continue;
158
- const src = byFp.get(fp);
159
- if (!src) continue;
160
- const concepts = normalizeConceptList(src.concepts);
161
- if (concepts.length === 0) continue;
162
- const domain = normalizeDomain(src.domain);
163
- for (const label of concepts) {
164
- const rowKey = `${sf.id}::${label}`;
165
- if (dedup.has(rowKey)) continue;
166
- dedup.add(rowKey);
167
- rows.push({ fact_id: sf.id, label, domain });
168
- }
169
- }
170
- return rows;
171
- }
172
-
173
111
  const VAGUE_PATTERNS = [
174
112
  /^用户(问|提|说|提到)/, /^我们(讨论|分析|查看)/,
175
113
  /这个问题/, /上面(提到|说的|的)/, /可能是因为/,
@@ -216,13 +154,7 @@ async function extractFacts(skeleton, evidence, distillEnv) {
216
154
  return true;
217
155
  });
218
156
 
219
- const normalizedFacts = filteredFacts.map(f => ({
220
- ...f,
221
- concepts: normalizeConceptList(f.concepts),
222
- domain: normalizeDomain(f.domain),
223
- }));
224
-
225
- return { ok: true, facts: normalizedFacts, session_name };
157
+ return { ok: true, facts: filteredFacts, session_name };
226
158
  }
227
159
 
228
160
  /**
@@ -313,25 +245,16 @@ async function run() {
313
245
  const fallbackScope = skeleton.session_id
314
246
  ? `sess_${String(skeleton.session_id).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24)}`
315
247
  : null;
316
- const { saved, skipped, superseded, savedFacts } = memory.saveFacts(
248
+ const { saved, skipped, superseded } = memory.saveFacts(
317
249
  skeleton.session_id,
318
250
  skeleton.project || 'unknown',
319
251
  facts,
320
252
  { scope: skeleton.project_id || fallbackScope }
321
253
  );
322
- let labelsSaved = 0;
323
- if (typeof memory.saveFactLabels === 'function' && Array.isArray(savedFacts) && savedFacts.length > 0) {
324
- const labelRows = buildFactLabelRows(facts, savedFacts);
325
- if (labelRows.length > 0) {
326
- const labelResult = memory.saveFactLabels(labelRows);
327
- labelsSaved = Number(labelResult && labelResult.saved) || 0;
328
- }
329
- }
330
254
  totalSaved += saved;
331
255
  totalSkipped += skipped;
332
256
  const superMsg = superseded > 0 ? `, ${superseded} superseded` : '';
333
- const labelMsg = labelsSaved > 0 ? `, ${labelsSaved} labels` : '';
334
- console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)}: ${saved} facts saved, ${skipped} skipped${superMsg}${labelMsg}`);
257
+ console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)}: ${saved} facts saved, ${skipped} skipped${superMsg}`);
335
258
  } else {
336
259
  console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)} (${session_name}): no facts extracted`);
337
260
  }
@@ -386,25 +309,16 @@ async function run() {
386
309
 
387
310
  if (facts.length > 0) {
388
311
  const fallbackScope = `codex_${String(cs.session_id).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24)}`;
389
- const { saved, skipped, superseded, savedFacts } = memory.saveFacts(
312
+ const { saved, skipped, superseded } = memory.saveFacts(
390
313
  cs.session_id,
391
314
  skeleton.project || 'unknown',
392
315
  facts,
393
316
  { scope: skeleton.project_id || fallbackScope, source_type: 'codex' }
394
317
  );
395
- let labelsSaved = 0;
396
- if (typeof memory.saveFactLabels === 'function' && Array.isArray(savedFacts) && savedFacts.length > 0) {
397
- const labelRows = buildFactLabelRows(facts, savedFacts);
398
- if (labelRows.length > 0) {
399
- const lr = memory.saveFactLabels(labelRows);
400
- labelsSaved = Number(lr && lr.saved) || 0;
401
- }
402
- }
403
318
  totalSaved += saved;
404
319
  totalSkipped += skipped;
405
320
  const superMsg = superseded > 0 ? `, ${superseded} superseded` : '';
406
- const labelMsg = labelsSaved > 0 ? `, ${labelsSaved} labels` : '';
407
- console.log(`[memory-extract] Codex ${cs.session_id.slice(0, 8)} (${session_name}): ${saved} facts saved${superMsg}${labelMsg}`);
321
+ console.log(`[memory-extract] Codex ${cs.session_id.slice(0, 8)} (${session_name}): ${saved} facts saved${superMsg}`);
408
322
 
409
323
  // Persist Codex session summary to memory.db sessions table
410
324
  try {
@@ -456,10 +370,4 @@ if (require.main === module) {
456
370
  module.exports = {
457
371
  run,
458
372
  extractFacts,
459
- _private: {
460
- normalizeConceptList,
461
- normalizeDomain,
462
- buildFactLabelRows,
463
- factFingerprint,
464
- },
465
373
  };
@@ -1,20 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * memory-gc.js — Nightly Fact Garbage Collection
4
+ * memory-gc.js — Nightly Memory Garbage Collection
5
5
  *
6
- * Archives stale, low-frequency facts from memory.db by marking them
7
- * with conflict_status = 'ARCHIVED' (soft delete, fully auditable).
8
- *
9
- * GC criteria (ALL must be true):
10
- * 1. last_searched_at older than 30 days (i.e. datetime < now-30d), OR NULL and created_at also older than 30 days
11
- * 2. search_count < 3
12
- * 3. superseded_by IS NULL (already-superseded facts excluded)
13
- * 4. conflict_status IS NULL OR conflict_status = 'OK' (skip CONFLICT/ARCHIVED)
14
- * 5. relation NOT IN protected set (workflow_rule, arch_convention, config_fact never archived)
15
- *
16
- * Protected relations are permanently excluded — they are high-value guardrails
17
- * that must survive regardless of search frequency.
6
+ * Promotes hot candidates and archives stale items in memory.db
7
+ * using the memory_items table and core/memory-model.js heuristics.
18
8
  *
19
9
  * Designed to run nightly at 02:00 via daemon.yaml scheduler.
20
10
  */
@@ -31,13 +21,6 @@ const DB_PATH = path.join(METAME_DIR, 'memory.db');
31
21
  const LOCK_FILE = path.join(METAME_DIR, 'memory-gc.lock');
32
22
  const GC_LOG_FILE = path.join(METAME_DIR, 'memory_gc_log.jsonl');
33
23
 
34
- // Relations that are permanently protected from archival
35
- const PROTECTED_RELATIONS = ['workflow_rule', 'arch_convention', 'config_fact'];
36
-
37
- // GC threshold: facts older than this many days are candidates
38
- const STALE_DAYS = 30;
39
- // GC threshold: facts with fewer searches than this are candidates
40
- const MIN_SEARCH_COUNT = 3;
41
24
  // Lock timeout: if a lock is older than this, it's stale and safe to break
42
25
  const LOCK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
43
26
 
@@ -127,84 +110,49 @@ function run() {
127
110
 
128
111
  const dbSizeBefore = getDbSizeBytes();
129
112
 
130
- // ── Ensure ARCHIVED status column accepts the new value ──
131
- // conflict_status was created with NOT NULL DEFAULT 'OK'; ARCHIVED is a new valid state.
132
- // No schema change needed — we just write the string value directly.
133
-
134
- const protectedPlaceholders = PROTECTED_RELATIONS.map(() => '?').join(', ');
135
-
136
- // ── DRY RUN: count candidates and protected exclusions ──
137
- console.log(`[MEMORY-GC] Scanning facts older than ${STALE_DAYS} days with search_count < ${MIN_SEARCH_COUNT}...`);
138
-
139
- const countCandidatesStmt = db.prepare(`
140
- SELECT COUNT(*) AS cnt
141
- FROM facts
142
- WHERE (
143
- (last_searched_at IS NOT NULL AND last_searched_at < datetime('now', '-${STALE_DAYS} days'))
144
- OR
145
- (last_searched_at IS NULL AND created_at < datetime('now', '-${STALE_DAYS} days'))
146
- )
147
- AND search_count < ${MIN_SEARCH_COUNT}
148
- AND superseded_by IS NULL
149
- AND (conflict_status IS NULL OR conflict_status = 'OK')
150
- AND relation NOT IN (${protectedPlaceholders})
151
- AND source_type != 'manual'
152
- `);
153
- const candidateCount = countCandidatesStmt.get(...PROTECTED_RELATIONS).cnt;
154
-
155
- // Count how many facts would be skipped due to the protected-relation guard
156
- const countProtectedStmt = db.prepare(`
157
- SELECT COUNT(*) AS cnt
158
- FROM facts
159
- WHERE (
160
- (last_searched_at IS NOT NULL AND last_searched_at < datetime('now', '-${STALE_DAYS} days'))
161
- OR
162
- (last_searched_at IS NULL AND created_at < datetime('now', '-${STALE_DAYS} days'))
163
- )
164
- AND search_count < ${MIN_SEARCH_COUNT}
165
- AND superseded_by IS NULL
166
- AND (conflict_status IS NULL OR conflict_status = 'OK')
167
- AND (relation IN (${protectedPlaceholders}) OR source_type = 'manual')
168
- `);
169
- const protectedCount = countProtectedStmt.get(...PROTECTED_RELATIONS).cnt;
170
-
171
- console.log(`[MEMORY-GC] Found ${candidateCount} candidates (excluded ${protectedCount} protected facts)`);
113
+ const memoryModel = require('./core/memory-model');
172
114
 
115
+ let promoted = 0;
173
116
  let archivedCount = 0;
174
117
 
175
118
  db.exec('BEGIN IMMEDIATE');
176
119
  try {
177
- if (candidateCount > 0) {
178
- // ── EXECUTE: archive the candidates ──
179
- const updateStmt = db.prepare(`
180
- UPDATE facts
181
- SET conflict_status = 'ARCHIVED',
182
- updated_at = datetime('now')
183
- WHERE (
184
- (last_searched_at IS NOT NULL AND last_searched_at < datetime('now', '-${STALE_DAYS} days'))
185
- OR
186
- (last_searched_at IS NULL AND created_at < datetime('now', '-${STALE_DAYS} days'))
187
- )
188
- AND search_count < ${MIN_SEARCH_COUNT}
189
- AND superseded_by IS NULL
190
- AND (conflict_status IS NULL OR conflict_status = 'OK')
191
- AND relation NOT IN (${protectedPlaceholders})
192
- AND source_type != 'manual'
193
- `);
194
-
195
- const result = updateStmt.run(...PROTECTED_RELATIONS);
196
- archivedCount = result.changes;
197
-
198
- console.log(`[MEMORY-GC] Archived ${archivedCount} facts → conflict_status = 'ARCHIVED'`);
199
- } else {
200
- console.log('[MEMORY-GC] No candidates to archive.');
120
+ // Phase 1: Promote hot candidates
121
+ const candidates = db.prepare(
122
+ `SELECT * FROM memory_items WHERE state = 'candidate'`
123
+ ).all();
124
+ for (const item of candidates) {
125
+ if (memoryModel.shouldPromote(item)) {
126
+ db.prepare(
127
+ `UPDATE memory_items SET state = 'active', updated_at = datetime('now') WHERE id = ?`
128
+ ).run(item.id);
129
+ promoted++;
130
+ }
201
131
  }
132
+ if (promoted > 0) console.log(`[MEMORY-GC] Promoted ${promoted} candidates`);
133
+
134
+ // Phase 2: Archive stale items
135
+ const allItems = db.prepare(
136
+ `SELECT * FROM memory_items WHERE state IN ('candidate', 'active')`
137
+ ).all();
138
+ for (const item of allItems) {
139
+ if (memoryModel.shouldArchive(item)) {
140
+ db.prepare(
141
+ `UPDATE memory_items SET state = 'archived', updated_at = datetime('now') WHERE id = ?`
142
+ ).run(item.id);
143
+ archivedCount++;
144
+ }
145
+ }
146
+ if (archivedCount > 0) console.log(`[MEMORY-GC] Archived ${archivedCount} stale items`);
147
+
202
148
  db.exec('COMMIT');
203
149
  } catch (e) {
204
150
  try { db.exec('ROLLBACK'); } catch {}
205
151
  throw e;
206
152
  }
207
153
 
154
+ console.log(`[MEMORY-GC] Promoted ${promoted}, archived ${archivedCount}`);
155
+
208
156
  // Run VACUUM to reclaim space (only if we archived something) — outside transaction
209
157
  if (archivedCount > 0) {
210
158
  try {
@@ -216,11 +164,8 @@ function run() {
216
164
 
217
165
  // ── Write audit log ──
218
166
  writeGcLog({
167
+ promoted,
219
168
  archived: archivedCount,
220
- skipped_protected: protectedCount,
221
- candidates_found: candidateCount,
222
- stale_days_threshold: STALE_DAYS,
223
- min_search_count_threshold: MIN_SEARCH_COUNT,
224
169
  db_size_before: dbSizeBefore,
225
170
  db_size_after: dbSizeAfter,
226
171
  });
@@ -0,0 +1,304 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const fs = require('fs');
6
+
7
+ const DB_PATH = path.join(os.homedir(), '.metame', 'memory.db');
8
+
9
+ function log(msg) { process.stderr.write(`[migrate-v2] ${msg}\n`); }
10
+
11
+ function die(msg) {
12
+ log(`FATAL: ${msg}`);
13
+ process.exit(1);
14
+ }
15
+
16
+ // ── Mapping helpers ──
17
+
18
+ const KIND_MAP_CONVENTION = new Set([
19
+ 'bug_lesson', 'arch_convention', 'workflow_rule', 'config_fact', 'config_change',
20
+ ]);
21
+ const KIND_MAP_INSIGHT = new Set([
22
+ 'tech_decision', 'project_milestone',
23
+ ]);
24
+
25
+ function mapKind(relation) {
26
+ if (KIND_MAP_CONVENTION.has(relation)) return 'convention';
27
+ if (KIND_MAP_INSIGHT.has(relation)) return 'insight';
28
+ return 'insight';
29
+ }
30
+
31
+ function mapState(conflictStatus) {
32
+ if (conflictStatus === 'OK') return 'active';
33
+ if (conflictStatus === 'ARCHIVED') return 'archived';
34
+ if (conflictStatus === 'CONFLICT') return 'candidate';
35
+ return 'active';
36
+ }
37
+
38
+ function mapConfidence(text) {
39
+ if (text === 'high') return 0.9;
40
+ if (text === 'medium') return 0.7;
41
+ if (text === 'low') return 0.4;
42
+ return 0.7;
43
+ }
44
+
45
+ // ── Main ──
46
+
47
+ function main() {
48
+ if (!fs.existsSync(DB_PATH)) die(`DB not found: ${DB_PATH}`);
49
+
50
+ // Backup
51
+ const ts = Date.now();
52
+ const backupPath = `${DB_PATH}.backup-v2-${ts}`;
53
+ fs.copyFileSync(DB_PATH, backupPath);
54
+ log(`Backup created: ${backupPath}`);
55
+
56
+ const { DatabaseSync } = require('node:sqlite');
57
+ const db = new DatabaseSync(DB_PATH);
58
+ db.exec('PRAGMA journal_mode = WAL');
59
+ db.exec('PRAGMA busy_timeout = 5000');
60
+ db.exec('PRAGMA foreign_keys = OFF');
61
+
62
+ // Safety check
63
+ try {
64
+ const row = db.prepare('SELECT COUNT(*) AS n FROM memory_items').get();
65
+ if (row.n > 0) die('Already migrated — memory_items has rows');
66
+ } catch {
67
+ // table doesn't exist yet, good
68
+ }
69
+
70
+ db.exec('BEGIN');
71
+
72
+ try {
73
+ // ── Step 1: Create new table ──
74
+ log('Step 1: Creating memory_items table...');
75
+
76
+ db.exec(`
77
+ CREATE TABLE IF NOT EXISTS memory_items (
78
+ id TEXT PRIMARY KEY,
79
+ kind TEXT NOT NULL,
80
+ state TEXT NOT NULL DEFAULT 'candidate',
81
+ title TEXT,
82
+ content TEXT NOT NULL,
83
+ summary TEXT,
84
+ confidence REAL DEFAULT 0.5,
85
+ project TEXT DEFAULT '*',
86
+ scope TEXT,
87
+ task_key TEXT,
88
+ session_id TEXT,
89
+ agent_key TEXT,
90
+ supersedes_id TEXT,
91
+ source_type TEXT,
92
+ source_id TEXT,
93
+ search_count INTEGER DEFAULT 0,
94
+ last_searched_at TEXT,
95
+ tags TEXT DEFAULT '[]',
96
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
97
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
98
+ )
99
+ `);
100
+
101
+ try {
102
+ db.exec(`
103
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_items_fts USING fts5(
104
+ title, content, tags,
105
+ content='memory_items',
106
+ content_rowid='rowid',
107
+ tokenize='trigram'
108
+ )
109
+ `);
110
+ } catch { /* already exists */ }
111
+
112
+ db.exec('CREATE INDEX IF NOT EXISTS idx_mi_kind_state ON memory_items(kind, state)');
113
+ db.exec('CREATE INDEX IF NOT EXISTS idx_mi_project ON memory_items(project)');
114
+ db.exec('CREATE INDEX IF NOT EXISTS idx_mi_scope ON memory_items(scope)');
115
+ db.exec('CREATE INDEX IF NOT EXISTS idx_mi_supersedes ON memory_items(supersedes_id)');
116
+
117
+ const ftsTriggers = [
118
+ `CREATE TRIGGER IF NOT EXISTS mi_ai AFTER INSERT ON memory_items BEGIN
119
+ INSERT INTO memory_items_fts(rowid, title, content, tags)
120
+ VALUES (new.rowid, new.title, new.content, new.tags);
121
+ END`,
122
+ `CREATE TRIGGER IF NOT EXISTS mi_ad AFTER DELETE ON memory_items BEGIN
123
+ INSERT INTO memory_items_fts(memory_items_fts, rowid, title, content, tags)
124
+ VALUES ('delete', old.rowid, old.title, old.content, old.tags);
125
+ END`,
126
+ `CREATE TRIGGER IF NOT EXISTS mi_au AFTER UPDATE ON memory_items BEGIN
127
+ INSERT INTO memory_items_fts(memory_items_fts, rowid, title, content, tags)
128
+ VALUES ('delete', old.rowid, old.title, old.content, old.tags);
129
+ INSERT INTO memory_items_fts(rowid, title, content, tags)
130
+ VALUES (new.rowid, new.title, new.content, new.tags);
131
+ END`,
132
+ ];
133
+ for (const t of ftsTriggers) {
134
+ try { db.exec(t); } catch { /* already exists */ }
135
+ }
136
+
137
+ // ── Step 2: Migrate facts ──
138
+ log('Step 2: Migrating facts...');
139
+
140
+ const insertMi = db.prepare(`
141
+ INSERT INTO memory_items
142
+ (id, kind, state, title, content, confidence, project, scope,
143
+ source_type, source_id, search_count, last_searched_at, tags,
144
+ created_at, updated_at)
145
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
146
+ `);
147
+
148
+ const facts = db.prepare('SELECT * FROM facts').all();
149
+ let factsMigrated = 0;
150
+
151
+ for (const f of facts) {
152
+ const newId = 'mi_' + f.id;
153
+ insertMi.run(
154
+ newId,
155
+ mapKind(f.relation),
156
+ mapState(f.conflict_status || 'OK'),
157
+ (f.entity || '') + ' \u00b7 ' + (f.relation || ''),
158
+ f.value,
159
+ mapConfidence(f.confidence),
160
+ f.project || '*',
161
+ f.scope || null,
162
+ f.source_type || null,
163
+ f.source_id || null,
164
+ f.search_count || 0,
165
+ f.last_searched_at || null,
166
+ f.tags || '[]',
167
+ f.created_at,
168
+ f.updated_at || f.created_at
169
+ );
170
+ factsMigrated++;
171
+ }
172
+
173
+ // Second pass: supersedes_id (reverse pointer)
174
+ const updateSupersedes = db.prepare(
175
+ 'UPDATE memory_items SET supersedes_id = ? WHERE id = ?'
176
+ );
177
+ for (const f of facts) {
178
+ if (f.superseded_by) {
179
+ const newNewId = 'mi_' + f.superseded_by;
180
+ const oldNewId = 'mi_' + f.id;
181
+ updateSupersedes.run(oldNewId, newNewId);
182
+ }
183
+ }
184
+
185
+ // ── Step 3: Migrate sessions ──
186
+ log('Step 3: Migrating sessions...');
187
+
188
+ const sessions = db.prepare('SELECT * FROM sessions').all();
189
+ let sessionsMigrated = 0;
190
+
191
+ const insertEpisode = db.prepare(`
192
+ INSERT INTO memory_items
193
+ (id, kind, state, title, content, confidence, project, scope,
194
+ session_id, source_type, source_id, tags, created_at, updated_at)
195
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
196
+ `);
197
+
198
+ for (const s of sessions) {
199
+ const newId = 'mi_ses_' + s.id;
200
+ const title = (s.summary || '').slice(0, 80);
201
+ const kw = (s.keywords || '').split(',').map(k => k.trim()).filter(Boolean);
202
+ const tags = JSON.stringify(kw);
203
+
204
+ insertEpisode.run(
205
+ newId,
206
+ 'episode',
207
+ 'active',
208
+ title,
209
+ s.summary || '',
210
+ 0.7,
211
+ s.project || '*',
212
+ s.scope || null,
213
+ s.id,
214
+ 'session',
215
+ s.id,
216
+ tags,
217
+ s.created_at,
218
+ s.created_at
219
+ );
220
+ sessionsMigrated++;
221
+ }
222
+
223
+ // ── Step 4: Merge fact_labels into tags ──
224
+ log('Step 4: Merging fact_labels into tags...');
225
+
226
+ let labelsTable = false;
227
+ try {
228
+ db.prepare("SELECT 1 FROM fact_labels LIMIT 1").get();
229
+ labelsTable = true;
230
+ } catch { /* table doesn't exist */ }
231
+
232
+ if (labelsTable) {
233
+ const labels = db.prepare('SELECT fact_id, label FROM fact_labels').all();
234
+ const labelMap = new Map();
235
+ for (const row of labels) {
236
+ if (!labelMap.has(row.fact_id)) labelMap.set(row.fact_id, []);
237
+ labelMap.get(row.fact_id).push(row.label);
238
+ }
239
+
240
+ const readTags = db.prepare('SELECT tags FROM memory_items WHERE id = ?');
241
+ const writeTags = db.prepare('UPDATE memory_items SET tags = ? WHERE id = ?');
242
+
243
+ for (const [factId, lbls] of labelMap) {
244
+ const miId = 'mi_' + factId;
245
+ const existing = readTags.get(miId);
246
+ if (!existing) continue;
247
+
248
+ let arr = [];
249
+ try { arr = JSON.parse(existing.tags || '[]'); } catch { arr = []; }
250
+ const merged = [...new Set([...arr, ...lbls])];
251
+ writeTags.run(JSON.stringify(merged), miId);
252
+ }
253
+ log(` Merged labels for ${labelMap.size} facts`);
254
+ }
255
+
256
+ // ── Step 5: Verify counts ──
257
+ log('Step 5: Verifying counts...');
258
+
259
+ const miFactCount = db.prepare(
260
+ "SELECT COUNT(*) AS n FROM memory_items WHERE kind IN ('insight','convention')"
261
+ ).get().n;
262
+ const miEpisodeCount = db.prepare(
263
+ "SELECT COUNT(*) AS n FROM memory_items WHERE kind = 'episode'"
264
+ ).get().n;
265
+
266
+ log(` Migrated ${facts.length} facts -> ${miFactCount} memory_items (insight/convention)`);
267
+ log(` Migrated ${sessions.length} sessions -> ${miEpisodeCount} memory_items (episode)`);
268
+
269
+ if (miFactCount !== factsMigrated) die(`Fact count mismatch: expected ${factsMigrated}, got ${miFactCount}`);
270
+ if (miEpisodeCount !== sessionsMigrated) die(`Session count mismatch: expected ${sessionsMigrated}, got ${miEpisodeCount}`);
271
+
272
+ // ── Step 6: Rename old tables ──
273
+ log('Step 6: Renaming old tables...');
274
+
275
+ db.exec('DROP TRIGGER IF EXISTS facts_ai');
276
+ db.exec('DROP TRIGGER IF EXISTS facts_ad');
277
+ db.exec('DROP TRIGGER IF EXISTS facts_au');
278
+ db.exec('DROP TRIGGER IF EXISTS sessions_ai');
279
+ db.exec('DROP TRIGGER IF EXISTS sessions_ad');
280
+ db.exec('DROP TRIGGER IF EXISTS sessions_au');
281
+
282
+ db.exec('DROP TABLE IF EXISTS facts_fts');
283
+ db.exec('DROP TABLE IF EXISTS sessions_fts');
284
+
285
+ db.exec('ALTER TABLE facts RENAME TO facts_v1');
286
+ db.exec('ALTER TABLE sessions RENAME TO sessions_v1');
287
+ if (labelsTable) db.exec('ALTER TABLE fact_labels RENAME TO fact_labels_v1');
288
+
289
+ // ── Step 7: Rebuild FTS5 ──
290
+ log('Step 7: Rebuilding FTS5 index...');
291
+ db.exec("INSERT INTO memory_items_fts(memory_items_fts) VALUES('rebuild')");
292
+
293
+ db.exec('COMMIT');
294
+ log('Migration complete.');
295
+ db.close();
296
+ process.exit(0);
297
+
298
+ } catch (err) {
299
+ try { db.exec('ROLLBACK'); } catch { /* ignore */ }
300
+ die(err.stack || err.message);
301
+ }
302
+ }
303
+
304
+ main();