linksee-memory 0.12.1 → 0.13.1

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();
@@ -377,6 +377,25 @@ CREATE TABLE IF NOT EXISTS anchor_touch_log (
377
377
  CREATE INDEX IF NOT EXISTS idx_anchor_touch_time ON anchor_touch_log(occurred_at);
378
378
  CREATE INDEX IF NOT EXISTS idx_anchor_touch_anchor ON anchor_touch_log(anchor_id, occurred_at);
379
379
 
380
+ -- ============================================================
381
+ -- v16: gate_dismissals — the human's "that was a false positive", made durable.
382
+ -- resolve_drift(action:'dismiss') closed drift_edges but the GATE never read the verdict, so
383
+ -- the same wrong match fired again on the next command. Observed: anchor #13 ("don't favour
384
+ -- our own products in rankings", signals = the bare product names) fired 6× in two minutes
385
+ -- because a temp file path contained "Sake-Navi". A verdict that does not change the next
386
+ -- detection is not a feedback loop.
387
+ -- hit_term NULL = silence this anchor at the gate entirely; otherwise only that match term.
388
+ -- ============================================================
389
+ CREATE TABLE IF NOT EXISTS gate_dismissals (
390
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
391
+ anchor_id INTEGER NOT NULL REFERENCES drift_anchors(id) ON DELETE CASCADE,
392
+ hit_term TEXT, -- lowercased match term; NULL = whole anchor
393
+ rationale TEXT,
394
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
395
+ );
396
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_gate_dismissal_uniq
397
+ ON gate_dismissals(anchor_id, COALESCE(hit_term, ''));
398
+
380
399
  -- ============================================================
381
400
  -- v11: Current Truth Map — journey-spine topology (Product Drift OS spec v3).
382
401
  -- map.yaml (git) is the desired-state SOURCE OF TRUTH (anchor #58); these tables
@@ -456,6 +475,6 @@ CREATE TABLE IF NOT EXISTS meta (
456
475
  value TEXT NOT NULL
457
476
  );
458
477
 
459
- INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '15');
478
+ INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '16');
460
479
  INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT));
461
- UPDATE meta SET value = '15' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14');
480
+ UPDATE meta SET value = '16' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15');
@@ -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
@@ -18,6 +18,8 @@ export interface GateMatch {
18
18
  verdict: 'contradicts' | 'in_scope';
19
19
  why: string;
20
20
  gate_mode: GateMode;
21
+ /** The exact term that matched, so the reader can dismiss precisely this match. */
22
+ hit_term?: string | null;
21
23
  }
22
24
  export interface GateResult {
23
25
  gate: GateLevel;
package/dist/lib/guard.js CHANGED
@@ -67,9 +67,35 @@ function buildActionCtx(input) {
67
67
  const haystack = [...files.map(normPath), ...rawParts].join('\n').toLowerCase();
68
68
  return { tool: input.tool ?? 'unknown', files, lines, haystack };
69
69
  }
70
+ /**
71
+ * What the human has already called a false positive.
72
+ *
73
+ * Keyed by anchor and (optionally) the exact term that matched, so dismissing "this anchor
74
+ * matched my file path" does not throw away the anchor's real detections.
75
+ */
76
+ function dismissedFor(db) {
77
+ const out = new Map();
78
+ let rows = [];
79
+ try {
80
+ rows = db.prepare('SELECT anchor_id, hit_term FROM gate_dismissals').all();
81
+ }
82
+ catch {
83
+ return out; // table not migrated yet → nothing dismissed
84
+ }
85
+ for (const r of rows) {
86
+ if (!out.has(r.anchor_id))
87
+ out.set(r.anchor_id, new Set());
88
+ out.get(r.anchor_id).add((r.hit_term ?? '*').toLowerCase());
89
+ }
90
+ return out;
91
+ }
70
92
  export function matchAction(db, act) {
71
93
  const out = [];
94
+ const dismissed = dismissedFor(db);
72
95
  for (const a of acceptedAnchors(db)) {
96
+ const dis = dismissed.get(a.id);
97
+ if (dis?.has('*'))
98
+ continue; // whole anchor silenced at the gate
73
99
  const gate_mode = jsonGet(a.card_policy, 'gate_mode', 'soft');
74
100
  if (gate_mode === 'off')
75
101
  continue;
@@ -97,11 +123,32 @@ export function matchAction(db, act) {
97
123
  }
98
124
  }
99
125
  }
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;
126
+ // Scope. `affects` says WHERE a decision applies; `violation_signal` says WHAT is forbidden.
127
+ //
128
+ // A path-scoped anchor is blind to `Bash`, which carries no file path — measured on a real
129
+ // machine, 21 of 42 active anchors could never fire on a Bash command, including "ALTER
130
+ // TABLE memories DROP" on the anchor that exists to prevent exactly that. So a signal hit
131
+ // counts on its own WHEN THERE IS NO PATH TO CHECK.
132
+ //
133
+ // But only then. Making signal hits scope-free outright (0.13.0) traded one failure for
134
+ // another: an anchor scoped to one repo started firing in every repo that happened to
135
+ // contain its term — "insert or ignore" is ordinary SQLite everywhere, and a temp file path
136
+ // containing "Sake-Navi" is not favouritism in a ranking. When the action names files, the
137
+ // anchor's own scope is the better evidence and it decides.
138
+ //
139
+ // (matchViolation already guards the obvious false positives: word boundaries, a negation
140
+ // window, and citation-without-call — a naive substring test produced ~90% noise.)
141
+ const actionNamesFiles = act.files.length > 0;
142
+ const inScope = hasScope
143
+ ? pathHit || (!actionNamesFiles && sigHit != null)
144
+ : termHit || sigHit != null;
103
145
  if (!inScope)
104
146
  continue;
147
+ // A dismissed term stops firing; the anchor's other terms keep working.
148
+ if (sigHit && dis?.has(sigHit.toLowerCase()))
149
+ continue;
150
+ if (!sigHit && dis?.has('*'))
151
+ continue;
105
152
  out.push({
106
153
  anchor_id: a.id,
107
154
  statement: a.statement,
@@ -113,6 +160,7 @@ export function matchAction(db, act) {
113
160
  ? `touches a file under this decision's scope`
114
161
  : `matches this decision's topic`,
115
162
  gate_mode,
163
+ hit_term: sigHit ?? null,
116
164
  });
117
165
  }
118
166
  // contradicts first (the headline), then in_scope.
@@ -171,9 +219,21 @@ export function formatReinject(matches, gate) {
171
219
  const tail = m.verdict === 'contradicts' ? `${m.why} → contradicts it.` : `${m.why}.`;
172
220
  return `• [#${m.anchor_id}] "${m.statement}"${rationale}\n ↳ ${tail}`;
173
221
  });
222
+ // Offer both exits, because the reader is in one of two situations and only they know which:
223
+ // the decision changed (supersede), or the match was wrong (dismiss). Naming the exact term
224
+ // matters — dismissing a whole anchor to escape one bad match throws away its real work.
174
225
  const firstContra = matches.find((m) => m.verdict === 'contradicts');
175
226
  const foot = firstContra
176
- ? `\nIf you are intentionally changing this decision, supersede it on the record:\n resolve_drift(anchor_id: ${firstContra.anchor_id}, action: 'supersede', superseded_by: <new anchor>).`
227
+ ? `
228
+ If you are intentionally changing this decision, supersede it on the record:
229
+ ` +
230
+ ` resolve_drift(anchor_id: ${firstContra.anchor_id}, action: 'supersede', superseded_by: <new anchor>).
231
+ ` +
232
+ `If this match is simply wrong, say so and it stops firing:
233
+ ` +
234
+ ` resolve_drift(anchor_id: ${firstContra.anchor_id}, action: 'dismiss'` +
235
+ (firstContra.hit_term ? `, hit_term: '${firstContra.hit_term}'` : '') +
236
+ `, rationale: '<why>').`
177
237
  : '';
178
238
  return [head, ...body, foot].filter(Boolean).join('\n');
179
239
  }
@@ -88,6 +88,8 @@ export declare function getTruthView(db: Database.Database, opts?: {
88
88
  export declare function getDecisionDetail(db: Database.Database, anchorId: number): DecisionDetail | null;
89
89
  export type ResolutionAction = 'fix' | 'supersede' | 'acknowledge' | 'dismiss';
90
90
  export interface ResolveInput {
91
+ /** For dismiss: silence only this match term. Omitted → silence the anchor at the gate. */
92
+ hit_term?: string;
91
93
  anchor_id: number;
92
94
  action: ResolutionAction;
93
95
  rationale?: string;
@@ -490,9 +490,18 @@ export function resolveDrift(db, input) {
490
490
  existing[`A${input.anchor_id}`] = resolution;
491
491
  db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?")
492
492
  .run(key, JSON.stringify(existing), JSON.stringify(existing));
493
- // If action is 'dismiss', also mark all open drift_edges for this anchor as dismissed
493
+ // 'dismiss' must change what happens NEXT time, not just tidy the current edges — otherwise
494
+ // the same wrong match fires on the next command and the verdict was theatre. Record it where
495
+ // the gate reads (gate_dismissals), keyed to the exact term when one was given so the anchor's
496
+ // real detections survive.
494
497
  if (input.action === 'dismiss') {
495
498
  db.prepare("UPDATE drift_edges SET status = 'dismissed' WHERE anchor_id = ? AND status = 'open'").run(input.anchor_id);
499
+ try {
500
+ db.prepare(`INSERT INTO gate_dismissals (anchor_id, hit_term, rationale) VALUES (?, ?, ?)
501
+ ON CONFLICT(anchor_id, COALESCE(hit_term, '')) DO UPDATE SET rationale = excluded.rationale`).run(input.anchor_id, input.hit_term ? input.hit_term.toLowerCase() : null, input.rationale ?? null);
502
+ resolution.gate_dismissed = input.hit_term ? input.hit_term.toLowerCase() : 'all matches';
503
+ }
504
+ catch { /* pre-v16 DB → edges-only dismiss, as before */ }
496
505
  }
497
506
  // If action is 'fix', mark open edges as resolved
498
507
  if (input.action === 'fix') {
@@ -255,7 +255,7 @@ const TOOLS = [
255
255
  },
256
256
  {
257
257
  name: 'resolve_drift',
258
- description: 'Record a resolution for a drifting anchor — the human feedback loop.\n\n6 actions:\n• fix — "we fixed the code/reality to match intent" → state becomes aligned\n• supersede — "intent evolved, this is the new direction" → state becomes aligned\n• acknowledge — "we know, parking it for now" → state becomes held (with optional review date)\n• dismiss — "false positive, not actually drifting" → edges dismissed\n• harden — "re-injected but still violated, enforce it" → card_policy.gate_mode=hard (PreToolUse will BLOCK)\n• soften — "back off to a warning" → gate_mode=soft\n\nWHEN TO CALL:\n• After drift_status shows 🔴 drift or 🟡 review items\n• When the user says "that\'s fixed" / "ignore that" / "we changed direction"\n• When acknowledging a known gap with a review date',
258
+ description: 'Record a resolution for a drifting anchor — the human feedback loop.\n\n6 actions:\n• fix — "we fixed the code/reality to match intent" → state becomes aligned\n• supersede — "intent evolved, this is the new direction" → state becomes aligned\n• acknowledge — "we know, parking it for now" → state becomes held (with optional review date)\n• dismiss — "false positive, not actually drifting" → edges dismissed AND the gate stops firing on it (pass hit_term to silence just that word)\n• harden — "re-injected but still violated, enforce it" → card_policy.gate_mode=hard (PreToolUse will BLOCK)\n• soften — "back off to a warning" → gate_mode=soft\n\nWHEN TO CALL:\n• After drift_status shows 🔴 drift or 🟡 review items\n• When the user says "that\'s fixed" / "ignore that" / "we changed direction"\n• When acknowledging a known gap with a review date',
259
259
  inputSchema: {
260
260
  type: 'object',
261
261
  properties: {
@@ -264,6 +264,7 @@ const TOOLS = [
264
264
  rationale: { type: 'string', description: 'Why this resolution (recorded for audit trail)' },
265
265
  review_after: { type: 'string', description: 'For acknowledge: ISO date to re-check (e.g. "2026-07-04")' },
266
266
  superseded_by: { type: 'number', description: 'For supersede: the new anchor ID that replaces this one' },
267
+ hit_term: { type: 'string', description: "For dismiss: silence only this match term (the word the gate quoted back at you). Omit to silence the whole anchor at the gate — prefer the term, so the anchor's real detections keep working." },
267
268
  },
268
269
  required: ['anchor_id', 'action'],
269
270
  },
@@ -1485,6 +1486,7 @@ function handleResolveDrift(args) {
1485
1486
  rationale: args.rationale,
1486
1487
  review_after: args.review_after,
1487
1488
  superseded_by: args.superseded_by,
1489
+ hit_term: args.hit_term,
1488
1490
  });
1489
1491
  return JSON.stringify(result);
1490
1492
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
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",