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/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 (interactive setup)
6
- // npx linksee-memory setup --yes (accept all defaults, no prompts)
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. Offers to wire the re-injection guard into THIS project's .claude/settings.json
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 wired into the PROJECT (not user-global) settings, because it enforces THIS
66
- // project's accepted decisions. Mirrors the dogfood wiring's ${CLAUDE_PROJECT_DIR}/dist/bin path, but
67
- // points at the globally-installed `linksee-memory-guard` bin so it ships without a build step. Shell
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
- const alreadyHooked = stopHooks.some((entry) => entry.hooks?.some((h) => h.command?.includes('linksee-memory-sync')));
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
- const GUARD_EVENTS = ['SessionStart', 'PreToolUse'];
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
- console.log(`${BOLD}[4/4]${RESET} Configuring re-injection guard (this project)...`);
245
- let project = {};
246
- if (existsSync(PROJECT_SETTINGS_PATH)) {
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(PROJECT_SETTINGS_PATH, 'utf8');
250
- project = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
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 ${PROJECT_SETTINGS_PATH} — left untouched`);
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 (GUARD_EVENTS.every((ev) => guardWiredFor(project, ev))) {
260
- console.log(` ${SKIP} Guard already wired in ${PROJECT_SETTINGS_PATH}`);
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 ${PROJECT_SETTINGS_PATH}${RESET}`);
281
+ console.log(` ${DIM}[dry-run] Would merge SessionStart + PreToolUse guard hooks into ${targetPath}${RESET}`);
265
282
  return false;
266
283
  }
267
- // "Offer" opt-in, because the guard can deny tool calls on a 'hard' contradiction.
268
- if (!autoYes) {
269
- if (!process.stdin.isTTY) {
270
- console.log(` ${SKIP} Skipped (non-interactive shell). Re-run with --yes, or paste the README block.`);
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 that contradicts a 'hard' anchor is ever blocked.${RESET}`);
275
- const ok = await askYesNo(` Wire it into ${PROJECT_SETTINGS_PATH}?`);
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: append only the events we don't already own; leave foreign hooks intact.
282
- const hooks = project.hooks ?? (project.hooks = {});
283
- for (const ev of GUARD_EVENTS) {
284
- if (!Array.isArray(hooks[ev]))
285
- hooks[ev] = [];
286
- if (!guardWiredFor(project, ev))
287
- hooks[ev].push(GUARD_HOOKS[ev]);
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();
@@ -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