linksee-memory 0.12.1 → 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 CHANGED
@@ -207,7 +207,14 @@ It is **fail-open by construction**: any parse / DB / logic error surfaces nothi
207
207
 
208
208
  ### Enable it
209
209
 
210
- `npx -y linksee-memory setup` offers to wire this into your **project's** `.claude/settings.json` (Step 4). To do it by hand, drop this block into `.claude/settings.json` at your project root it points at the globally-installed `linksee-memory-guard` bin, so no build step is needed:
210
+ `npx -y linksee-memory setup` wires this into `~/.claude/settings.json` (Step 4), so it is on in **every** repo the same scope your memory already lives at. One SQLite file holds the anchors for all your projects; enforcing them per-repo meant declaring a decision once and having it enforced nowhere.
211
+
212
+ - `--project-guard` — this repo only, the old behaviour
213
+ - `--no-guard` — skip it
214
+
215
+ Anchors with `affects` globs fire only on matching paths; an unscoped anchor fires on its own `detect_terms` / `violation_signal`. Nothing is ever **blocked** unless you explicitly hardened it (`resolve_drift(action:'harden')`) — everything else re-injects the decision as context.
216
+
217
+ To wire it by hand instead, drop this block into `.claude/settings.json` (project root, or `~/.claude/settings.json` for every repo) — it points at the globally-installed `linksee-memory-guard` bin, so no build step is needed:
211
218
 
212
219
  ```json
213
220
  {
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();
@@ -0,0 +1,54 @@
1
+ export type HookCommand = {
2
+ type: string;
3
+ command?: string;
4
+ timeout?: number;
5
+ args?: string[];
6
+ };
7
+ export type HookEntry = {
8
+ matcher?: string;
9
+ hooks?: HookCommand[];
10
+ };
11
+ export type ClaudeSettings = {
12
+ hooks?: Record<string, HookEntry[]>;
13
+ [k: string]: unknown;
14
+ };
15
+ export declare const GUARD_EVENTS: readonly ["SessionStart", "PreToolUse"];
16
+ export type GuardEvent = (typeof GUARD_EVENTS)[number];
17
+ /** The bin every wiring form resolves to; used as the idempotency key. */
18
+ export declare const GUARD_BIN = "linksee-memory-guard";
19
+ export declare const GUARD_COMMAND = "npx -y linksee-memory guard";
20
+ export declare const GUARD_HOOKS: Record<GuardEvent, HookEntry>;
21
+ /**
22
+ * Is one of OUR hooks of `kind` already wired for this event?
23
+ *
24
+ * Shared by the guard and the session-sync hook because they hit the same trap: each has been
25
+ * wired as an npx subcommand, as a global bin, as a dist path, and in exec form with the path
26
+ * in `args`. A probe that knows only one shape appends a duplicate — which is exactly what
27
+ * happened to the Stop hook on 2026-09-07 (`sync-session.js` did not match `linksee-memory-sync`,
28
+ * so setup added a second one and sessions were captured twice).
29
+ */
30
+ export declare function linkseeHookWired(settings: ClaudeSettings, event: string, kind: 'guard' | 'sync'): boolean;
31
+ /** Is the session-sync (Stop) hook already wired? */
32
+ export declare function syncWiredFor(settings: ClaudeSettings, event?: string): boolean;
33
+ /**
34
+ * Is OUR guard already wired for this event?
35
+ *
36
+ * Has to recognise every shape the guard has ever been wired in, or setup duplicates it:
37
+ * npx -y linksee-memory guard (what setup writes)
38
+ * linksee-memory-guard (the global bin)
39
+ * node /path/to/linksee-memory/dist/bin/guard-hook.js (the old README block)
40
+ * { command: 'node', args: ['.../dist/bin/guard-hook.js'] } (exec form — the path is in args)
41
+ *
42
+ * The last two put the identifying part in different places, so match against command and args
43
+ * joined together. `guard-hook` alone is accepted because the exec form carries no package name.
44
+ */
45
+ export declare function guardWiredFor(settings: ClaudeSettings, event: string): boolean;
46
+ export declare function guardFullyWired(settings: ClaudeSettings): boolean;
47
+ /**
48
+ * Add the guard to any event it does not already own. Mutates and returns `settings`, plus the
49
+ * events that were actually added (empty when it was already wired).
50
+ */
51
+ export declare function wireGuard(settings: ClaudeSettings): {
52
+ settings: ClaudeSettings;
53
+ added: GuardEvent[];
54
+ };
@@ -0,0 +1,85 @@
1
+ // guard-wiring — merge the re-injection guard's hooks into a Claude Code settings object.
2
+ //
3
+ // Extracted from bin/setup.ts so the merge is testable without running the installer (which
4
+ // also registers an MCP server and copies a skill). The rules that matter here:
5
+ //
6
+ // • merge, never replace — other people's hooks in the same event must survive
7
+ // • idempotent — running setup twice must not produce two guard entries
8
+ // • recognise a hand-pasted guard from the README as already-wired (match on the bin name,
9
+ // not on the exact command string, which differs between `npx` and a dist path)
10
+ export const GUARD_EVENTS = ['SessionStart', 'PreToolUse'];
11
+ /** The bin every wiring form resolves to; used as the idempotency key. */
12
+ export const GUARD_BIN = 'linksee-memory-guard';
13
+ export const GUARD_COMMAND = 'npx -y linksee-memory guard';
14
+ export const GUARD_HOOKS = {
15
+ SessionStart: {
16
+ matcher: 'startup|resume|compact',
17
+ hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 15 }],
18
+ },
19
+ PreToolUse: {
20
+ matcher: 'Edit|Write|Bash',
21
+ hooks: [{ type: 'command', command: GUARD_COMMAND, timeout: 8 }],
22
+ },
23
+ };
24
+ /** Everything a hook entry could carry an identifier in: `command`, or `args` for the exec form. */
25
+ function hookHaystack(h) {
26
+ return [h?.command, ...(h?.args ?? [])].filter((x) => typeof x === 'string').join(' ');
27
+ }
28
+ /**
29
+ * Is one of OUR hooks of `kind` already wired for this event?
30
+ *
31
+ * Shared by the guard and the session-sync hook because they hit the same trap: each has been
32
+ * wired as an npx subcommand, as a global bin, as a dist path, and in exec form with the path
33
+ * in `args`. A probe that knows only one shape appends a duplicate — which is exactly what
34
+ * happened to the Stop hook on 2026-09-07 (`sync-session.js` did not match `linksee-memory-sync`,
35
+ * so setup added a second one and sessions were captured twice).
36
+ */
37
+ export function linkseeHookWired(settings, event, kind) {
38
+ const bare = kind === 'guard' ? 'guard-hook' : 'sync-session';
39
+ return (settings.hooks?.[event] ?? []).some((entry) => entry?.hooks?.some((h) => {
40
+ const hay = hookHaystack(h);
41
+ if (!hay)
42
+ return false;
43
+ return hay.includes(bare) || (hay.includes('linksee-memory') && hay.includes(kind));
44
+ }));
45
+ }
46
+ /** Is the session-sync (Stop) hook already wired? */
47
+ export function syncWiredFor(settings, event = 'Stop') {
48
+ return linkseeHookWired(settings, event, 'sync');
49
+ }
50
+ /**
51
+ * Is OUR guard already wired for this event?
52
+ *
53
+ * Has to recognise every shape the guard has ever been wired in, or setup duplicates it:
54
+ * npx -y linksee-memory guard (what setup writes)
55
+ * linksee-memory-guard (the global bin)
56
+ * node /path/to/linksee-memory/dist/bin/guard-hook.js (the old README block)
57
+ * { command: 'node', args: ['.../dist/bin/guard-hook.js'] } (exec form — the path is in args)
58
+ *
59
+ * The last two put the identifying part in different places, so match against command and args
60
+ * joined together. `guard-hook` alone is accepted because the exec form carries no package name.
61
+ */
62
+ export function guardWiredFor(settings, event) {
63
+ return linkseeHookWired(settings, event, 'guard');
64
+ }
65
+ export function guardFullyWired(settings) {
66
+ return GUARD_EVENTS.every((ev) => guardWiredFor(settings, ev));
67
+ }
68
+ /**
69
+ * Add the guard to any event it does not already own. Mutates and returns `settings`, plus the
70
+ * events that were actually added (empty when it was already wired).
71
+ */
72
+ export function wireGuard(settings) {
73
+ const hooks = (settings.hooks ??= {});
74
+ const added = [];
75
+ for (const ev of GUARD_EVENTS) {
76
+ if (!Array.isArray(hooks[ev]))
77
+ hooks[ev] = [];
78
+ if (!guardWiredFor(settings, ev)) {
79
+ hooks[ev].push(GUARD_HOOKS[ev]);
80
+ added.push(ev);
81
+ }
82
+ }
83
+ return { settings, added };
84
+ }
85
+ //# sourceMappingURL=guard-wiring.js.map
package/dist/lib/guard.js CHANGED
@@ -97,9 +97,16 @@ export function matchAction(db, act) {
97
97
  }
98
98
  }
99
99
  }
100
- // Scope (mirrors the detector): a path-scoped anchor requires the action to touch an in-scope
101
- // file; a global anchor (no affects) fires on topical-term OR forbidden-signal relevance.
102
- const inScope = hasScope ? pathHit : termHit || sigHit != null;
100
+ // Scope. `affects` says WHERE a decision applies; `violation_signal` says WHAT is forbidden.
101
+ // An explicit signal hit is the stronger evidence, so it brings the anchor into scope on its
102
+ // own otherwise a path-scoped anchor is blind to `Bash`, which carries no file path at all.
103
+ // That blindness was measured on a real machine: 21 of 42 active anchors declared forbidden
104
+ // strings and could never fire on a Bash command — including "ALTER TABLE memories DROP" on
105
+ // the anchor that exists to prevent exactly that. Bash is where the destructive things run.
106
+ //
107
+ // (matchViolation already guards the obvious false positives: word boundaries, a negation
108
+ // window, and citation-without-call — a naive substring test produced ~90% noise.)
109
+ const inScope = sigHit != null || (hasScope ? pathHit : termHit);
103
110
  if (!inScope)
104
111
  continue;
105
112
  out.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "mcpName": "io.github.michielinksee/linksee-memory",
5
5
  "description": "Local-first agent memory MCP — cross-agent brain with drift detection, 6-layer structured memory + token-saving file diff cache",
6
6
  "type": "module",