linksee-memory 0.12.0 → 0.13.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/README.md +1014 -1004
- package/dist/bin/setup.js +60 -47
- package/dist/db/migrate.js +28 -28
- package/dist/db/schema.sql +461 -461
- package/dist/lib/drift-anchors.js +3 -3
- package/dist/lib/drift-detection.js +20 -20
- package/dist/lib/drift-view.js +16 -16
- package/dist/lib/guard-wiring.d.ts +54 -0
- package/dist/lib/guard-wiring.js +85 -0
- package/dist/lib/guard.js +38 -31
- package/dist/lib/map-import.js +17 -17
- package/dist/lib/truth-engine.js +13 -13
- package/dist/mcp/prompts.js +58 -58
- package/dist/mcp/resources.js +7 -7
- package/dist/mcp/sampling.js +20 -20
- package/dist/mcp/server.js +130 -130
- package/package.json +96 -95
package/dist/bin/setup.js
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
// setup: One-command setup for Linksee Memory — the "Use Linksee" installer.
|
|
3
3
|
//
|
|
4
4
|
// Usage:
|
|
5
|
-
// npx linksee-memory setup
|
|
6
|
-
// npx linksee-memory setup --yes
|
|
5
|
+
// npx linksee-memory setup (interactive setup)
|
|
6
|
+
// npx linksee-memory setup --yes (accept all defaults, no prompts)
|
|
7
7
|
// npx linksee-memory setup --dry-run
|
|
8
|
+
// npx linksee-memory setup --no-guard (skip the re-injection guard)
|
|
9
|
+
// npx linksee-memory setup --project-guard (wire the guard to THIS repo only)
|
|
8
10
|
//
|
|
9
11
|
// Does four things:
|
|
10
12
|
// 1. Registers the MCP server with Claude Code
|
|
11
13
|
// 2. Installs the SKILL.md (agent trigger phrases)
|
|
12
14
|
// 3. Configures the Stop hook (auto-capture sessions) — user-global
|
|
13
|
-
// 4.
|
|
15
|
+
// 4. Wires the re-injection guard into ~/.claude/settings.json (every project)
|
|
14
16
|
//
|
|
15
17
|
// After setup, every Claude Code session:
|
|
16
18
|
// - Auto-captures decisions, learnings, caveats to local memory
|
|
@@ -21,15 +23,30 @@
|
|
|
21
23
|
// Why: Competing memory tools (claude-mem, etc.) are one-install-and-done.
|
|
22
24
|
// Our MCP approach gives more precision, but the setup was 3 manual steps.
|
|
23
25
|
// This command eliminates that friction entirely.
|
|
26
|
+
//
|
|
27
|
+
// Why the guard is user-global (2026-09-07): memory is global — one SQLite file holding the
|
|
28
|
+
// anchors for every repo — but the guard used to be wired per project, opt-in. So the anchors
|
|
29
|
+
// existed everywhere and were enforced nowhere. The author's own machine had 42 active anchors
|
|
30
|
+
// and no PreToolUse hook in any project; the one layer no competing tool has was switched off
|
|
31
|
+
// where it was written. A founder running twenty repos should not run setup twenty times.
|
|
32
|
+
//
|
|
33
|
+
// Safe by construction: the hook is fail-open, and it can only DENY when an anchor was
|
|
34
|
+
// explicitly hardened via resolve_drift(action:'harden'). Anything else re-injects text.
|
|
35
|
+
// Anchors with `affects` globs only fire on matching paths; unscoped ones fire on their own
|
|
36
|
+
// detect_terms / violation_signal, so cross-repo noise is bounded by what you declared.
|
|
24
37
|
import { spawnSync } from 'node:child_process';
|
|
25
38
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
26
39
|
import { join, dirname } from 'node:path';
|
|
27
40
|
import { homedir } from 'node:os';
|
|
28
41
|
import { fileURLToPath } from 'node:url';
|
|
29
42
|
import { createInterface } from 'node:readline';
|
|
43
|
+
import { guardFullyWired, wireGuard, syncWiredFor } from '../lib/guard-wiring.js';
|
|
30
44
|
const args = process.argv.slice(2);
|
|
31
45
|
const dryRun = args.includes('--dry-run');
|
|
32
46
|
const autoYes = args.includes('--yes') || args.includes('-y');
|
|
47
|
+
const noGuard = args.includes('--no-guard');
|
|
48
|
+
// Opt back into the old behaviour: wire the guard to this repo instead of every repo.
|
|
49
|
+
const projectGuard = args.includes('--project-guard');
|
|
33
50
|
const showHelp = args.includes('--help') || args.includes('-h');
|
|
34
51
|
if (showHelp) {
|
|
35
52
|
console.log(`linksee-memory-setup — One-command setup for Linksee Memory
|
|
@@ -62,11 +79,9 @@ const MCP_COMMAND = `claude mcp add -s user ${SERVER_NAME} -- npx -y linksee-mem
|
|
|
62
79
|
// Subcommand form (npx -y linksee-memory <sub>) so the hooks resolve for a cold user —
|
|
63
80
|
// npx can resolve the package name, but not sibling bin names like linksee-memory-sync.
|
|
64
81
|
const HOOK_COMMAND = 'npx -y linksee-memory sync';
|
|
65
|
-
// Re-injection guard
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// form (resolved at run time) survives npx-cache eviction; a baked dist path would not.
|
|
69
|
-
const GUARD_COMMAND = 'npx -y linksee-memory guard';
|
|
82
|
+
// Re-injection guard: command + merge rules live in lib/guard-wiring.ts (shared with the tests).
|
|
83
|
+
// It points at the globally-installed bin rather than a dist path, so it ships without a build
|
|
84
|
+
// step, and the shell form is resolved at run time so it survives npx-cache eviction.
|
|
70
85
|
const PROJECT_DIR = process.cwd();
|
|
71
86
|
const PROJECT_CLAUDE_DIR = join(PROJECT_DIR, '.claude');
|
|
72
87
|
const PROJECT_SETTINGS_PATH = join(PROJECT_CLAUDE_DIR, 'settings.json');
|
|
@@ -195,7 +210,9 @@ if (existsSync(SETTINGS_PATH)) {
|
|
|
195
210
|
}
|
|
196
211
|
// Check if hook already exists
|
|
197
212
|
const stopHooks = settings?.hooks?.Stop ?? [];
|
|
198
|
-
|
|
213
|
+
// Recognise every shape the sync hook has been wired in (npx subcommand, global bin, dist
|
|
214
|
+
// path, exec form) — a narrower probe appended a second copy, see lib/guard-wiring.ts.
|
|
215
|
+
const alreadyHooked = syncWiredFor(settings, 'Stop');
|
|
199
216
|
if (alreadyHooked) {
|
|
200
217
|
console.log(` ${SKIP} Stop hook already configured`);
|
|
201
218
|
}
|
|
@@ -217,17 +234,7 @@ else {
|
|
|
217
234
|
console.log(` ${CHECK} Stop hook added → ${SETTINGS_PATH}`);
|
|
218
235
|
}
|
|
219
236
|
console.log('');
|
|
220
|
-
|
|
221
|
-
const GUARD_HOOKS = {
|
|
222
|
-
// matchers + timeouts mirror the dogfood .claude/settings.json
|
|
223
|
-
SessionStart: { matcher: 'startup|resume|compact', hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 15 }] },
|
|
224
|
-
PreToolUse: { matcher: 'Edit|Write|Bash', hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 8 }] },
|
|
225
|
-
};
|
|
226
|
-
// Idempotency probe: is OUR guard already wired for this event? (Match by bin name so a manually-added
|
|
227
|
-
// or previously-installed entry isn't duplicated, and other people's hooks are never touched.)
|
|
228
|
-
function guardWiredFor(s, ev) {
|
|
229
|
-
return (s.hooks?.[ev] ?? []).some((entry) => entry?.hooks?.some((h) => typeof h?.command === 'string' && h.command.includes('linksee-memory-guard')));
|
|
230
|
-
}
|
|
237
|
+
// ── Step 4: Wire the re-injection guard (user-global by default) ─────────
|
|
231
238
|
function askYesNo(question, defaultYes = true) {
|
|
232
239
|
return new Promise((resolve) => {
|
|
233
240
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -241,54 +248,60 @@ function askYesNo(question, defaultYes = true) {
|
|
|
241
248
|
});
|
|
242
249
|
}
|
|
243
250
|
async function configureGuard() {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
251
|
+
// Default target is the user-global settings — the same scope the MCP server is registered
|
|
252
|
+
// at, and the same scope the memory itself lives at. --project-guard keeps the old per-repo
|
|
253
|
+
// behaviour for people who want the guard in one repo only.
|
|
254
|
+
const targetPath = projectGuard ? PROJECT_SETTINGS_PATH : SETTINGS_PATH;
|
|
255
|
+
const targetDir = projectGuard ? PROJECT_CLAUDE_DIR : CLAUDE_DIR;
|
|
256
|
+
const scopeLabel = projectGuard ? 'this project' : 'all projects';
|
|
257
|
+
console.log(`${BOLD}[4/4]${RESET} Configuring re-injection guard (${scopeLabel})...`);
|
|
258
|
+
if (noGuard) {
|
|
259
|
+
console.log(` ${SKIP} Skipped (--no-guard). Enable later: npx -y linksee-memory setup`);
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
let settings = {};
|
|
263
|
+
if (existsSync(targetPath)) {
|
|
247
264
|
try {
|
|
248
265
|
// Strip a leading BOM (U+FEFF) — Windows editors (Notepad) emit UTF-8+BOM, which JSON.parse rejects.
|
|
249
|
-
const raw = readFileSync(
|
|
250
|
-
|
|
266
|
+
const raw = readFileSync(targetPath, 'utf8');
|
|
267
|
+
settings = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
251
268
|
}
|
|
252
269
|
catch {
|
|
253
270
|
// Never clobber a file we can't parse — the user may have hand-authored it.
|
|
254
|
-
console.log(` ${FAIL} Could not parse ${
|
|
271
|
+
console.log(` ${FAIL} Could not parse ${targetPath} — left untouched`);
|
|
255
272
|
console.log(` ${DIM}Add the guard block by hand (see README → Re-injection Guard).${RESET}`);
|
|
256
273
|
return false;
|
|
257
274
|
}
|
|
258
275
|
}
|
|
259
|
-
if (
|
|
260
|
-
console.log(` ${SKIP} Guard already wired in ${
|
|
276
|
+
if (guardFullyWired(settings)) {
|
|
277
|
+
console.log(` ${SKIP} Guard already wired in ${targetPath}`);
|
|
261
278
|
return true;
|
|
262
279
|
}
|
|
263
280
|
if (dryRun) {
|
|
264
|
-
console.log(` ${DIM}[dry-run] Would merge SessionStart + PreToolUse guard hooks into ${
|
|
281
|
+
console.log(` ${DIM}[dry-run] Would merge SessionStart + PreToolUse guard hooks into ${targetPath}${RESET}`);
|
|
265
282
|
return false;
|
|
266
283
|
}
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return false;
|
|
272
|
-
}
|
|
284
|
+
// Ask when there is someone to ask. A non-interactive run used to skip silently, which is
|
|
285
|
+
// how the guard ended up installed nowhere — setup's job is to configure, so it configures
|
|
286
|
+
// and says so loudly instead.
|
|
287
|
+
if (!autoYes && process.stdin.isTTY) {
|
|
273
288
|
console.log(` ${DIM}Re-injects your accepted decisions before Edit/Write/Bash and on session start.`);
|
|
274
|
-
console.log(` Fail-open — only an action
|
|
275
|
-
const ok = await askYesNo(` Wire it into ${
|
|
289
|
+
console.log(` Fail-open — only an action contradicting a 'hard' anchor is ever blocked.${RESET}`);
|
|
290
|
+
const ok = await askYesNo(` Wire it into ${targetPath}?`);
|
|
276
291
|
if (!ok) {
|
|
277
292
|
console.log(` ${SKIP} Skipped. Enable later via the README → Re-injection Guard.`);
|
|
278
293
|
return false;
|
|
279
294
|
}
|
|
280
295
|
}
|
|
281
|
-
// Merge, don't replace
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
296
|
+
// Merge, don't replace (see lib/guard-wiring.ts for the rules it holds to).
|
|
297
|
+
wireGuard(settings);
|
|
298
|
+
mkdirSync(targetDir, { recursive: true });
|
|
299
|
+
writeFileSync(targetPath, JSON.stringify(settings, null, 2), 'utf8');
|
|
300
|
+
console.log(` ${CHECK} Guard wired → ${targetPath} ${DIM}(${scopeLabel})${RESET}`);
|
|
301
|
+
if (!projectGuard) {
|
|
302
|
+
console.log(` ${DIM}Active in every repo. Scope an anchor with \`affects\` globs to limit where it fires;`);
|
|
303
|
+
console.log(` --no-guard to skip, --project-guard for this repo only.${RESET}`);
|
|
288
304
|
}
|
|
289
|
-
mkdirSync(PROJECT_CLAUDE_DIR, { recursive: true });
|
|
290
|
-
writeFileSync(PROJECT_SETTINGS_PATH, JSON.stringify(project, null, 2), 'utf8');
|
|
291
|
-
console.log(` ${CHECK} Guard wired → ${PROJECT_SETTINGS_PATH}`);
|
|
292
305
|
return true;
|
|
293
306
|
}
|
|
294
307
|
const guardConfigured = await configureGuard();
|
package/dist/db/migrate.js
CHANGED
|
@@ -70,11 +70,11 @@ export function runMigrations(db) {
|
|
|
70
70
|
// v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support.
|
|
71
71
|
// Only runs when upgrading an existing DB from schema v1-3.
|
|
72
72
|
if (currentVersion > 0 && currentVersion < 4) {
|
|
73
|
-
db.exec(`
|
|
74
|
-
DROP TRIGGER IF EXISTS trg_memories_fts_ai;
|
|
75
|
-
DROP TRIGGER IF EXISTS trg_memories_fts_ad;
|
|
76
|
-
DROP TRIGGER IF EXISTS trg_memories_fts_au;
|
|
77
|
-
DROP TABLE IF EXISTS memories_fts;
|
|
73
|
+
db.exec(`
|
|
74
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_ai;
|
|
75
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_ad;
|
|
76
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_au;
|
|
77
|
+
DROP TABLE IF EXISTS memories_fts;
|
|
78
78
|
`);
|
|
79
79
|
}
|
|
80
80
|
// v4 → v5: add normalized_name column BEFORE schema.sql runs,
|
|
@@ -118,9 +118,9 @@ export function runMigrations(db) {
|
|
|
118
118
|
db.exec('ALTER TABLE memories ADD COLUMN thread_id TEXT');
|
|
119
119
|
}
|
|
120
120
|
// Backfill thread_id from content JSON session_id for existing memories
|
|
121
|
-
db.exec(`
|
|
122
|
-
UPDATE memories SET thread_id = json_extract(content, '$.session_id')
|
|
123
|
-
WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
|
|
121
|
+
db.exec(`
|
|
122
|
+
UPDATE memories SET thread_id = json_extract(content, '$.session_id')
|
|
123
|
+
WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
|
|
124
124
|
`);
|
|
125
125
|
}
|
|
126
126
|
// v8 → v9: ProjectCoreNode — extend drift_anchors into the Current Truth Map node.
|
|
@@ -221,12 +221,12 @@ function migrateV5EntityNormalization(db) {
|
|
|
221
221
|
}
|
|
222
222
|
})();
|
|
223
223
|
// 4. Auto-merge duplicate entities (same kind + normalized_name)
|
|
224
|
-
const dupes = db.prepare(`
|
|
225
|
-
SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
|
|
226
|
-
FROM entities
|
|
227
|
-
WHERE normalized_name IS NOT NULL
|
|
228
|
-
GROUP BY kind, normalized_name
|
|
229
|
-
HAVING COUNT(*) > 1
|
|
224
|
+
const dupes = db.prepare(`
|
|
225
|
+
SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
|
|
226
|
+
FROM entities
|
|
227
|
+
WHERE normalized_name IS NOT NULL
|
|
228
|
+
GROUP BY kind, normalized_name
|
|
229
|
+
HAVING COUNT(*) > 1
|
|
230
230
|
`).all();
|
|
231
231
|
if (dupes.length > 0) {
|
|
232
232
|
console.log(`[linksee-memory] v5 migration: merging ${dupes.length} duplicate entity clusters`);
|
|
@@ -247,12 +247,12 @@ function mergeEntityCluster(db, ids) {
|
|
|
247
247
|
if (ids.length < 2)
|
|
248
248
|
return;
|
|
249
249
|
// Score each entity: prefer most memories, then has canonical_key, then lowest id
|
|
250
|
-
const rows = db.prepare(`
|
|
251
|
-
SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
|
|
252
|
-
FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
|
|
253
|
-
WHERE e.id IN (${ids.map(() => '?').join(',')})
|
|
254
|
-
GROUP BY e.id
|
|
255
|
-
ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
|
|
250
|
+
const rows = db.prepare(`
|
|
251
|
+
SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
|
|
252
|
+
FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
|
|
253
|
+
WHERE e.id IN (${ids.map(() => '?').join(',')})
|
|
254
|
+
GROUP BY e.id
|
|
255
|
+
ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
|
|
256
256
|
`).all(...ids);
|
|
257
257
|
const keep = rows[0];
|
|
258
258
|
const mergeIds = rows.slice(1).map(r => r.id);
|
|
@@ -273,16 +273,16 @@ function mergeEntityCluster(db, ids) {
|
|
|
273
273
|
db.prepare('UPDATE events SET entity_id = ? WHERE entity_id = ?').run(keep.id, mid);
|
|
274
274
|
// Reassign edges (both directions)
|
|
275
275
|
// Handle UNIQUE constraint: delete duplicates first
|
|
276
|
-
db.prepare(`
|
|
277
|
-
DELETE FROM edges WHERE from_id = ? AND EXISTS (
|
|
278
|
-
SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
|
|
279
|
-
)
|
|
276
|
+
db.prepare(`
|
|
277
|
+
DELETE FROM edges WHERE from_id = ? AND EXISTS (
|
|
278
|
+
SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
|
|
279
|
+
)
|
|
280
280
|
`).run(mid, keep.id);
|
|
281
281
|
db.prepare('UPDATE edges SET from_id = ? WHERE from_id = ?').run(keep.id, mid);
|
|
282
|
-
db.prepare(`
|
|
283
|
-
DELETE FROM edges WHERE to_id = ? AND EXISTS (
|
|
284
|
-
SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
|
|
285
|
-
)
|
|
282
|
+
db.prepare(`
|
|
283
|
+
DELETE FROM edges WHERE to_id = ? AND EXISTS (
|
|
284
|
+
SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
|
|
285
|
+
)
|
|
286
286
|
`).run(mid, keep.id);
|
|
287
287
|
db.prepare('UPDATE edges SET to_id = ? WHERE to_id = ?').run(keep.id, mid);
|
|
288
288
|
// Reassign consolidations
|