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.
- package/index.js +157 -80
- package/package.json +2 -2
- package/scripts/bin/bootstrap-worktree.sh +20 -0
- package/scripts/core/audit.js +190 -0
- package/scripts/core/handoff.js +780 -0
- package/scripts/core/handoff.test.js +1074 -0
- package/scripts/core/memory-model.js +183 -0
- package/scripts/core/memory-model.test.js +486 -0
- package/scripts/core/reactive-paths.js +44 -0
- package/scripts/core/reactive-paths.test.js +35 -0
- package/scripts/core/reactive-prompt.js +51 -0
- package/scripts/core/reactive-prompt.test.js +88 -0
- package/scripts/core/reactive-signal.js +40 -0
- package/scripts/core/reactive-signal.test.js +88 -0
- package/scripts/core/thread-chat-id.js +52 -0
- package/scripts/core/thread-chat-id.test.js +113 -0
- package/scripts/daemon-bridges.js +92 -38
- package/scripts/daemon-claude-engine.js +373 -444
- package/scripts/daemon-command-router.js +82 -8
- package/scripts/daemon-engine-runtime.js +7 -10
- package/scripts/daemon-reactive-lifecycle.js +100 -33
- package/scripts/daemon-session-commands.js +133 -43
- package/scripts/daemon-session-store.js +300 -82
- package/scripts/daemon-team-dispatch.js +16 -16
- package/scripts/daemon.js +21 -175
- package/scripts/deploy-manifest.js +90 -0
- package/scripts/docs/maintenance-manual.md +14 -11
- package/scripts/docs/pointer-map.md +13 -4
- package/scripts/feishu-adapter.js +31 -27
- package/scripts/hooks/intent-engine.js +6 -3
- package/scripts/hooks/intent-memory-recall.js +1 -0
- package/scripts/hooks/intent-perpetual.js +1 -1
- package/scripts/memory-extract.js +5 -97
- package/scripts/memory-gc.js +35 -90
- package/scripts/memory-migrate-v2.js +304 -0
- package/scripts/memory-nightly-reflect.js +40 -41
- package/scripts/memory.js +340 -859
- package/scripts/migrate-reactive-paths.js +122 -0
- package/scripts/signal-capture.js +4 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/scripts/memory-gc.js
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* memory-gc.js — Nightly
|
|
4
|
+
* memory-gc.js — Nightly Memory Garbage Collection
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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();
|