linksee-memory 0.13.0 → 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.
@@ -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');
@@ -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;
@@ -98,17 +124,31 @@ export function matchAction(db, act) {
98
124
  }
99
125
  }
100
126
  // 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.
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.
106
138
  //
107
139
  // (matchViolation already guards the obvious false positives: word boundaries, a negation
108
140
  // window, and citation-without-call — a naive substring test produced ~90% noise.)
109
- const inScope = sigHit != null || (hasScope ? pathHit : termHit);
141
+ const actionNamesFiles = act.files.length > 0;
142
+ const inScope = hasScope
143
+ ? pathHit || (!actionNamesFiles && sigHit != null)
144
+ : termHit || sigHit != null;
110
145
  if (!inScope)
111
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;
112
152
  out.push({
113
153
  anchor_id: a.id,
114
154
  statement: a.statement,
@@ -120,6 +160,7 @@ export function matchAction(db, act) {
120
160
  ? `touches a file under this decision's scope`
121
161
  : `matches this decision's topic`,
122
162
  gate_mode,
163
+ hit_term: sigHit ?? null,
123
164
  });
124
165
  }
125
166
  // contradicts first (the headline), then in_scope.
@@ -178,9 +219,21 @@ export function formatReinject(matches, gate) {
178
219
  const tail = m.verdict === 'contradicts' ? `${m.why} → contradicts it.` : `${m.why}.`;
179
220
  return `• [#${m.anchor_id}] "${m.statement}"${rationale}\n ↳ ${tail}`;
180
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.
181
225
  const firstContra = matches.find((m) => m.verdict === 'contradicts');
182
226
  const foot = firstContra
183
- ? `\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>').`
184
237
  : '';
185
238
  return [head, ...body, foot].filter(Boolean).join('\n');
186
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.13.0",
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",