linksee-memory 0.7.2 → 0.10.0
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/LICENSE +21 -21
- package/README.md +839 -678
- package/dist/bin/declare-anchor.d.ts +2 -0
- package/dist/bin/declare-anchor.js +146 -0
- package/dist/bin/detect-drift.d.ts +2 -0
- package/dist/bin/detect-drift.js +91 -0
- package/dist/bin/guard-hook.d.ts +2 -0
- package/dist/bin/guard-hook.js +96 -0
- package/dist/bin/import-sessions.js +44 -11
- package/dist/bin/install-skill.js +14 -14
- package/dist/bin/setup.js +107 -18
- package/dist/bin/stats.js +20 -20
- package/dist/db/migrate.js +52 -28
- package/dist/db/schema.sql +371 -232
- package/dist/lib/consolidate.js +19 -19
- package/dist/lib/drift-anchors.d.ts +78 -0
- package/dist/lib/drift-anchors.js +224 -0
- package/dist/lib/drift-detection.d.ts +62 -0
- package/dist/lib/drift-detection.js +416 -0
- package/dist/lib/drift-view.d.ts +62 -0
- package/dist/lib/drift-view.js +120 -0
- package/dist/lib/edge-detection.js +8 -8
- package/dist/lib/guard.d.ts +81 -0
- package/dist/lib/guard.js +321 -0
- package/dist/lib/lexical-match.d.ts +6 -0
- package/dist/lib/lexical-match.js +87 -0
- package/dist/lib/momentum.js +7 -7
- package/dist/lib/session-extractor.d.ts +1 -0
- package/dist/lib/session-extractor.js +62 -11
- package/dist/lib/truth-engine.d.ts +84 -0
- package/dist/lib/truth-engine.js +417 -0
- package/dist/mcp/read-smart.js +8 -8
- package/dist/mcp/server.js +573 -3
- package/dist/skill/SKILL.md +734 -631
- package/package.json +10 -5
package/dist/bin/setup.js
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
// npx linksee-memory-setup --yes (accept all defaults, no prompts)
|
|
7
7
|
// npx linksee-memory-setup --dry-run
|
|
8
8
|
//
|
|
9
|
-
// Does
|
|
9
|
+
// Does four things:
|
|
10
10
|
// 1. Registers the MCP server with Claude Code
|
|
11
11
|
// 2. Installs the SKILL.md (agent trigger phrases)
|
|
12
|
-
// 3. Configures the Stop hook (auto-capture sessions)
|
|
12
|
+
// 3. Configures the Stop hook (auto-capture sessions) — user-global
|
|
13
|
+
// 4. Offers to wire the re-injection guard into THIS project's .claude/settings.json
|
|
13
14
|
//
|
|
14
15
|
// After setup, every Claude Code session:
|
|
15
16
|
// - Auto-captures decisions, learnings, caveats to local memory
|
|
16
17
|
// - Agent auto-recalls past context at task start (via SKILL.md triggers)
|
|
18
|
+
// - (if guard enabled) accepted decisions are re-injected before edits + on session start
|
|
17
19
|
// - "Use Linksee" in any prompt forces a recall
|
|
18
20
|
//
|
|
19
21
|
// Why: Competing memory tools (claude-mem, etc.) are one-install-and-done.
|
|
@@ -24,24 +26,26 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from
|
|
|
24
26
|
import { join, dirname } from 'node:path';
|
|
25
27
|
import { homedir } from 'node:os';
|
|
26
28
|
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { createInterface } from 'node:readline';
|
|
27
30
|
const args = process.argv.slice(2);
|
|
28
31
|
const dryRun = args.includes('--dry-run');
|
|
29
32
|
const autoYes = args.includes('--yes') || args.includes('-y');
|
|
30
33
|
const showHelp = args.includes('--help') || args.includes('-h');
|
|
31
34
|
if (showHelp) {
|
|
32
|
-
console.log(`linksee-memory-setup — One-command setup for Linksee Memory
|
|
33
|
-
|
|
34
|
-
Usage:
|
|
35
|
-
npx linksee-memory-setup Interactive setup
|
|
36
|
-
npx linksee-memory-setup --yes Accept all defaults, no prompts
|
|
37
|
-
npx linksee-memory-setup --dry-run Show what would happen
|
|
38
|
-
|
|
39
|
-
What it does:
|
|
40
|
-
1. Registers linksee-memory MCP server with Claude Code
|
|
41
|
-
2. Installs SKILL.md (teaches the agent when to recall/remember)
|
|
42
|
-
3. Configures Stop hook (auto-captures every session)
|
|
43
|
-
|
|
44
|
-
|
|
35
|
+
console.log(`linksee-memory-setup — One-command setup for Linksee Memory
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
npx linksee-memory-setup Interactive setup
|
|
39
|
+
npx linksee-memory-setup --yes Accept all defaults, no prompts
|
|
40
|
+
npx linksee-memory-setup --dry-run Show what would happen
|
|
41
|
+
|
|
42
|
+
What it does:
|
|
43
|
+
1. Registers linksee-memory MCP server with Claude Code
|
|
44
|
+
2. Installs SKILL.md (teaches the agent when to recall/remember)
|
|
45
|
+
3. Configures Stop hook (auto-captures every session)
|
|
46
|
+
4. Offers to wire the re-injection guard into THIS project's .claude/settings.json
|
|
47
|
+
|
|
48
|
+
After setup, just chat with Claude Code normally.
|
|
45
49
|
Add "Use Linksee" to any prompt to trigger memory recall.`);
|
|
46
50
|
process.exit(0);
|
|
47
51
|
}
|
|
@@ -56,6 +60,14 @@ const SKILL_SRC = join(dirname(__filename), '..', 'skill', 'SKILL.md');
|
|
|
56
60
|
const SERVER_NAME = 'linksee';
|
|
57
61
|
const MCP_COMMAND = `claude mcp add -s user ${SERVER_NAME} -- npx -y linksee-memory`;
|
|
58
62
|
const HOOK_COMMAND = 'npx -y linksee-memory-sync';
|
|
63
|
+
// Re-injection guard — wired into the PROJECT (not user-global) settings, because it enforces THIS
|
|
64
|
+
// project's accepted decisions. Mirrors the dogfood wiring's ${CLAUDE_PROJECT_DIR}/dist/bin path, but
|
|
65
|
+
// points at the globally-installed `linksee-memory-guard` bin so it ships without a build step. Shell
|
|
66
|
+
// form (resolved at run time) survives npx-cache eviction; a baked dist path would not.
|
|
67
|
+
const GUARD_COMMAND = 'npx -y linksee-memory-guard';
|
|
68
|
+
const PROJECT_DIR = process.cwd();
|
|
69
|
+
const PROJECT_CLAUDE_DIR = join(PROJECT_DIR, '.claude');
|
|
70
|
+
const PROJECT_SETTINGS_PATH = join(PROJECT_CLAUDE_DIR, 'settings.json');
|
|
59
71
|
const CHECK = '\x1b[32m✓\x1b[0m';
|
|
60
72
|
const SKIP = '\x1b[33m○\x1b[0m';
|
|
61
73
|
const FAIL = '\x1b[31m✗\x1b[0m';
|
|
@@ -67,7 +79,7 @@ console.log(`${BOLD}Linksee Memory Setup${RESET}`);
|
|
|
67
79
|
console.log(`${DIM}Local-first cross-LLM memory · precision recall${RESET}`);
|
|
68
80
|
console.log('');
|
|
69
81
|
// ── Step 1: Register MCP server ──────────────────────────
|
|
70
|
-
console.log(`${BOLD}[1/
|
|
82
|
+
console.log(`${BOLD}[1/4]${RESET} Registering MCP server...`);
|
|
71
83
|
let mcpAlreadyRegistered = false;
|
|
72
84
|
try {
|
|
73
85
|
// Check if already registered by looking at settings.json or .claude.json
|
|
@@ -132,7 +144,7 @@ else {
|
|
|
132
144
|
}
|
|
133
145
|
console.log('');
|
|
134
146
|
// ── Step 2: Install SKILL.md ─────────────────────────────
|
|
135
|
-
console.log(`${BOLD}[2/
|
|
147
|
+
console.log(`${BOLD}[2/4]${RESET} Installing agent skill...`);
|
|
136
148
|
if (!existsSync(SKILL_SRC)) {
|
|
137
149
|
console.log(` ${FAIL} Bundled SKILL.md not found (packaging bug)`);
|
|
138
150
|
console.log(` ${DIM}Expected at: ${SKILL_SRC}${RESET}`);
|
|
@@ -165,7 +177,7 @@ else {
|
|
|
165
177
|
}
|
|
166
178
|
console.log('');
|
|
167
179
|
// ── Step 3: Configure Stop hook ──────────────────────────
|
|
168
|
-
console.log(`${BOLD}[3/
|
|
180
|
+
console.log(`${BOLD}[3/4]${RESET} Configuring auto-capture hook...`);
|
|
169
181
|
let settings = {};
|
|
170
182
|
if (existsSync(SETTINGS_PATH)) {
|
|
171
183
|
try {
|
|
@@ -200,6 +212,80 @@ else {
|
|
|
200
212
|
console.log(` ${CHECK} Stop hook added → ${SETTINGS_PATH}`);
|
|
201
213
|
}
|
|
202
214
|
console.log('');
|
|
215
|
+
const GUARD_EVENTS = ['SessionStart', 'PreToolUse'];
|
|
216
|
+
const GUARD_HOOKS = {
|
|
217
|
+
// matchers + timeouts mirror the dogfood .claude/settings.json
|
|
218
|
+
SessionStart: { matcher: 'startup|resume|compact', hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 15 }] },
|
|
219
|
+
PreToolUse: { matcher: 'Edit|Write|Bash', hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 8 }] },
|
|
220
|
+
};
|
|
221
|
+
// Idempotency probe: is OUR guard already wired for this event? (Match by bin name so a manually-added
|
|
222
|
+
// or previously-installed entry isn't duplicated, and other people's hooks are never touched.)
|
|
223
|
+
function guardWiredFor(s, ev) {
|
|
224
|
+
return (s.hooks?.[ev] ?? []).some((entry) => entry?.hooks?.some((h) => typeof h?.command === 'string' && h.command.includes('linksee-memory-guard')));
|
|
225
|
+
}
|
|
226
|
+
function askYesNo(question) {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
229
|
+
rl.question(`${question} [Y/n] `, (ans) => {
|
|
230
|
+
rl.close();
|
|
231
|
+
const a = ans.trim().toLowerCase();
|
|
232
|
+
resolve(a === '' || a === 'y' || a === 'yes'); // default = yes
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
async function configureGuard() {
|
|
237
|
+
console.log(`${BOLD}[4/4]${RESET} Configuring re-injection guard (this project)...`);
|
|
238
|
+
let project = {};
|
|
239
|
+
if (existsSync(PROJECT_SETTINGS_PATH)) {
|
|
240
|
+
try {
|
|
241
|
+
// Strip a leading BOM (U+FEFF) — Windows editors (Notepad) emit UTF-8+BOM, which JSON.parse rejects.
|
|
242
|
+
const raw = readFileSync(PROJECT_SETTINGS_PATH, 'utf8');
|
|
243
|
+
project = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Never clobber a file we can't parse — the user may have hand-authored it.
|
|
247
|
+
console.log(` ${FAIL} Could not parse ${PROJECT_SETTINGS_PATH} — left untouched`);
|
|
248
|
+
console.log(` ${DIM}Add the guard block by hand (see README → Re-injection Guard).${RESET}`);
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (GUARD_EVENTS.every((ev) => guardWiredFor(project, ev))) {
|
|
253
|
+
console.log(` ${SKIP} Guard already wired in ${PROJECT_SETTINGS_PATH}`);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (dryRun) {
|
|
257
|
+
console.log(` ${DIM}[dry-run] Would merge SessionStart + PreToolUse guard hooks into ${PROJECT_SETTINGS_PATH}${RESET}`);
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
// "Offer" — opt-in, because the guard can deny tool calls on a 'hard' contradiction.
|
|
261
|
+
if (!autoYes) {
|
|
262
|
+
if (!process.stdin.isTTY) {
|
|
263
|
+
console.log(` ${SKIP} Skipped (non-interactive shell). Re-run with --yes, or paste the README block.`);
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
console.log(` ${DIM}Re-injects your accepted decisions before Edit/Write/Bash and on session start.`);
|
|
267
|
+
console.log(` Fail-open — only an action that contradicts a 'hard' anchor is ever blocked.${RESET}`);
|
|
268
|
+
const ok = await askYesNo(` Wire it into ${PROJECT_SETTINGS_PATH}?`);
|
|
269
|
+
if (!ok) {
|
|
270
|
+
console.log(` ${SKIP} Skipped. Enable later via the README → Re-injection Guard.`);
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Merge, don't replace: append only the events we don't already own; leave foreign hooks intact.
|
|
275
|
+
const hooks = project.hooks ?? (project.hooks = {});
|
|
276
|
+
for (const ev of GUARD_EVENTS) {
|
|
277
|
+
if (!Array.isArray(hooks[ev]))
|
|
278
|
+
hooks[ev] = [];
|
|
279
|
+
if (!guardWiredFor(project, ev))
|
|
280
|
+
hooks[ev].push(GUARD_HOOKS[ev]);
|
|
281
|
+
}
|
|
282
|
+
mkdirSync(PROJECT_CLAUDE_DIR, { recursive: true });
|
|
283
|
+
writeFileSync(PROJECT_SETTINGS_PATH, JSON.stringify(project, null, 2), 'utf8');
|
|
284
|
+
console.log(` ${CHECK} Guard wired → ${PROJECT_SETTINGS_PATH}`);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
const guardConfigured = await configureGuard();
|
|
288
|
+
console.log('');
|
|
203
289
|
// ── Summary ──────────────────────────────────────────────
|
|
204
290
|
console.log(`${BOLD}Setup complete!${RESET}`);
|
|
205
291
|
console.log('');
|
|
@@ -208,6 +294,9 @@ console.log(` ${DIM}• Every session is auto-captured (decisions, caveats, lea
|
|
|
208
294
|
console.log(` ${DIM}• Agent auto-recalls past context when starting a task${RESET}`);
|
|
209
295
|
console.log(` ${DIM}• Memory is local-first (nothing leaves your machine)${RESET}`);
|
|
210
296
|
console.log(` ${DIM}• Works across Claude Code, Cursor, ChatGPT (cross-LLM)${RESET}`);
|
|
297
|
+
if (guardConfigured) {
|
|
298
|
+
console.log(` ${DIM}• Re-injection guard re-surfaces this project's accepted decisions before edits${RESET}`);
|
|
299
|
+
}
|
|
211
300
|
console.log('');
|
|
212
301
|
console.log('Test by asking:');
|
|
213
302
|
console.log(` ${BOLD}"How did we solve this before?"${RESET}`);
|
package/dist/bin/stats.js
CHANGED
|
@@ -53,11 +53,11 @@ function humanAge(unix) {
|
|
|
53
53
|
function main() {
|
|
54
54
|
const args = parseArgs();
|
|
55
55
|
if (args.help) {
|
|
56
|
-
console.log(`linksee-memory-stats — summary of the local memory DB
|
|
57
|
-
|
|
58
|
-
--json Output machine-readable JSON
|
|
59
|
-
--per-entity N Show top N entities (default 5, 0 to skip)
|
|
60
|
-
-h, --help This message
|
|
56
|
+
console.log(`linksee-memory-stats — summary of the local memory DB
|
|
57
|
+
|
|
58
|
+
--json Output machine-readable JSON
|
|
59
|
+
--per-entity N Show top N entities (default 5, 0 to skip)
|
|
60
|
+
-h, --help This message
|
|
61
61
|
`);
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
@@ -88,23 +88,23 @@ function main() {
|
|
|
88
88
|
const oldest = db.prepare('SELECT MIN(created_at) as t FROM memories').get().t;
|
|
89
89
|
const newest = db.prepare('SELECT MAX(created_at) as t FROM memories').get().t;
|
|
90
90
|
const topEntities = args.perEntity > 0
|
|
91
|
-
? db.prepare(`
|
|
92
|
-
SELECT e.name, e.kind, e.momentum_score, COUNT(m.id) as memory_count,
|
|
93
|
-
MAX(m.last_accessed_at) as last_access
|
|
94
|
-
FROM entities e
|
|
95
|
-
LEFT JOIN memories m ON m.entity_id = e.id
|
|
96
|
-
GROUP BY e.id
|
|
97
|
-
ORDER BY memory_count DESC, e.momentum_score DESC
|
|
98
|
-
LIMIT ?
|
|
91
|
+
? db.prepare(`
|
|
92
|
+
SELECT e.name, e.kind, e.momentum_score, COUNT(m.id) as memory_count,
|
|
93
|
+
MAX(m.last_accessed_at) as last_access
|
|
94
|
+
FROM entities e
|
|
95
|
+
LEFT JOIN memories m ON m.entity_id = e.id
|
|
96
|
+
GROUP BY e.id
|
|
97
|
+
ORDER BY memory_count DESC, e.momentum_score DESC
|
|
98
|
+
LIMIT ?
|
|
99
99
|
`).all(args.perEntity)
|
|
100
100
|
: [];
|
|
101
|
-
const topFiles = db.prepare(`
|
|
102
|
-
SELECT file_path, COUNT(*) as edits, COUNT(DISTINCT session_id) as in_sessions
|
|
103
|
-
FROM session_file_edits
|
|
104
|
-
WHERE operation IN ('edit', 'write')
|
|
105
|
-
GROUP BY file_path
|
|
106
|
-
ORDER BY edits DESC
|
|
107
|
-
LIMIT 5
|
|
101
|
+
const topFiles = db.prepare(`
|
|
102
|
+
SELECT file_path, COUNT(*) as edits, COUNT(DISTINCT session_id) as in_sessions
|
|
103
|
+
FROM session_file_edits
|
|
104
|
+
WHERE operation IN ('edit', 'write')
|
|
105
|
+
GROUP BY file_path
|
|
106
|
+
ORDER BY edits DESC
|
|
107
|
+
LIMIT 5
|
|
108
108
|
`).all();
|
|
109
109
|
const result = {
|
|
110
110
|
db_path: dbPath,
|
package/dist/db/migrate.js
CHANGED
|
@@ -33,11 +33,11 @@ export function runMigrations(db) {
|
|
|
33
33
|
// v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support.
|
|
34
34
|
// Only runs when upgrading an existing DB from schema v1-3.
|
|
35
35
|
if (currentVersion > 0 && currentVersion < 4) {
|
|
36
|
-
db.exec(`
|
|
37
|
-
DROP TRIGGER IF EXISTS trg_memories_fts_ai;
|
|
38
|
-
DROP TRIGGER IF EXISTS trg_memories_fts_ad;
|
|
39
|
-
DROP TRIGGER IF EXISTS trg_memories_fts_au;
|
|
40
|
-
DROP TABLE IF EXISTS memories_fts;
|
|
36
|
+
db.exec(`
|
|
37
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_ai;
|
|
38
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_ad;
|
|
39
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_au;
|
|
40
|
+
DROP TABLE IF EXISTS memories_fts;
|
|
41
41
|
`);
|
|
42
42
|
}
|
|
43
43
|
// v4 → v5: add normalized_name column BEFORE schema.sql runs,
|
|
@@ -81,11 +81,35 @@ export function runMigrations(db) {
|
|
|
81
81
|
db.exec('ALTER TABLE memories ADD COLUMN thread_id TEXT');
|
|
82
82
|
}
|
|
83
83
|
// Backfill thread_id from content JSON session_id for existing memories
|
|
84
|
-
db.exec(`
|
|
85
|
-
UPDATE memories SET thread_id = json_extract(content, '$.session_id')
|
|
86
|
-
WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
|
|
84
|
+
db.exec(`
|
|
85
|
+
UPDATE memories SET thread_id = json_extract(content, '$.session_id')
|
|
86
|
+
WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
|
|
87
87
|
`);
|
|
88
88
|
}
|
|
89
|
+
// v8 → v9: ProjectCoreNode — extend drift_anchors into the Current Truth Map node.
|
|
90
|
+
// ADDITIVE columns only (ADD COLUMN with safe defaults) — NO CHECK rebuild, so the existing
|
|
91
|
+
// detector/dashboard (which read the original columns + status active/retired) are unaffected.
|
|
92
|
+
// `lifecycle` carries the rich state; `status` stays the coarse scan-gate. New tables
|
|
93
|
+
// (reality_events, memory_write_candidates) are created by db.exec(sql) below (CREATE IF NOT EXISTS).
|
|
94
|
+
if (currentVersion > 0 && currentVersion < 9) {
|
|
95
|
+
const have = new Set(db.prepare('PRAGMA table_info(drift_anchors)').all().map((c) => c.name));
|
|
96
|
+
const addCol = (name, ddl) => {
|
|
97
|
+
if (!have.has(name))
|
|
98
|
+
db.exec(`ALTER TABLE drift_anchors ADD COLUMN ${ddl}`);
|
|
99
|
+
};
|
|
100
|
+
addCol('node_type', 'node_type TEXT');
|
|
101
|
+
addCol('domain', 'domain TEXT');
|
|
102
|
+
addCol('decision_mode', 'decision_mode TEXT');
|
|
103
|
+
addCol('confidence', 'confidence REAL NOT NULL DEFAULT 0.8');
|
|
104
|
+
addCol('lifecycle', "lifecycle TEXT NOT NULL DEFAULT 'active'");
|
|
105
|
+
addCol('validity_scope', "validity_scope TEXT NOT NULL DEFAULT '{}'");
|
|
106
|
+
addCol('card_policy', "card_policy TEXT NOT NULL DEFAULT '{}'");
|
|
107
|
+
addCol('reality_manifestations', "reality_manifestations TEXT NOT NULL DEFAULT '[]'");
|
|
108
|
+
addCol('evidence_refs', "evidence_refs TEXT NOT NULL DEFAULT '[]'");
|
|
109
|
+
addCol('review_after', 'review_after INTEGER');
|
|
110
|
+
addCol('last_confirmed_at', 'last_confirmed_at INTEGER');
|
|
111
|
+
addCol('owner', 'owner TEXT');
|
|
112
|
+
}
|
|
89
113
|
db.exec(sql);
|
|
90
114
|
if (currentVersion > 0 && currentVersion < 4) {
|
|
91
115
|
db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
|
|
@@ -124,12 +148,12 @@ function migrateV5EntityNormalization(db) {
|
|
|
124
148
|
}
|
|
125
149
|
})();
|
|
126
150
|
// 4. Auto-merge duplicate entities (same kind + normalized_name)
|
|
127
|
-
const dupes = db.prepare(`
|
|
128
|
-
SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
|
|
129
|
-
FROM entities
|
|
130
|
-
WHERE normalized_name IS NOT NULL
|
|
131
|
-
GROUP BY kind, normalized_name
|
|
132
|
-
HAVING COUNT(*) > 1
|
|
151
|
+
const dupes = db.prepare(`
|
|
152
|
+
SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
|
|
153
|
+
FROM entities
|
|
154
|
+
WHERE normalized_name IS NOT NULL
|
|
155
|
+
GROUP BY kind, normalized_name
|
|
156
|
+
HAVING COUNT(*) > 1
|
|
133
157
|
`).all();
|
|
134
158
|
if (dupes.length > 0) {
|
|
135
159
|
console.log(`[linksee-memory] v5 migration: merging ${dupes.length} duplicate entity clusters`);
|
|
@@ -150,12 +174,12 @@ function mergeEntityCluster(db, ids) {
|
|
|
150
174
|
if (ids.length < 2)
|
|
151
175
|
return;
|
|
152
176
|
// Score each entity: prefer most memories, then has canonical_key, then lowest id
|
|
153
|
-
const rows = db.prepare(`
|
|
154
|
-
SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
|
|
155
|
-
FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
|
|
156
|
-
WHERE e.id IN (${ids.map(() => '?').join(',')})
|
|
157
|
-
GROUP BY e.id
|
|
158
|
-
ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
|
|
177
|
+
const rows = db.prepare(`
|
|
178
|
+
SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
|
|
179
|
+
FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
|
|
180
|
+
WHERE e.id IN (${ids.map(() => '?').join(',')})
|
|
181
|
+
GROUP BY e.id
|
|
182
|
+
ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
|
|
159
183
|
`).all(...ids);
|
|
160
184
|
const keep = rows[0];
|
|
161
185
|
const mergeIds = rows.slice(1).map(r => r.id);
|
|
@@ -176,16 +200,16 @@ function mergeEntityCluster(db, ids) {
|
|
|
176
200
|
db.prepare('UPDATE events SET entity_id = ? WHERE entity_id = ?').run(keep.id, mid);
|
|
177
201
|
// Reassign edges (both directions)
|
|
178
202
|
// Handle UNIQUE constraint: delete duplicates first
|
|
179
|
-
db.prepare(`
|
|
180
|
-
DELETE FROM edges WHERE from_id = ? AND EXISTS (
|
|
181
|
-
SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
|
|
182
|
-
)
|
|
203
|
+
db.prepare(`
|
|
204
|
+
DELETE FROM edges WHERE from_id = ? AND EXISTS (
|
|
205
|
+
SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
|
|
206
|
+
)
|
|
183
207
|
`).run(mid, keep.id);
|
|
184
208
|
db.prepare('UPDATE edges SET from_id = ? WHERE from_id = ?').run(keep.id, mid);
|
|
185
|
-
db.prepare(`
|
|
186
|
-
DELETE FROM edges WHERE to_id = ? AND EXISTS (
|
|
187
|
-
SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
|
|
188
|
-
)
|
|
209
|
+
db.prepare(`
|
|
210
|
+
DELETE FROM edges WHERE to_id = ? AND EXISTS (
|
|
211
|
+
SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
|
|
212
|
+
)
|
|
189
213
|
`).run(mid, keep.id);
|
|
190
214
|
db.prepare('UPDATE edges SET to_id = ? WHERE to_id = ?').run(keep.id, mid);
|
|
191
215
|
// Reassign consolidations
|