linksee-memory 0.13.0 → 0.14.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.
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  import type Database from 'better-sqlite3';
2
- export type DriftState = 'drift' | 'review' | 'held' | 'aligned';
2
+ export type DriftState = 'drift' | 'review' | 'held' | 'aligned' | 'unverified';
3
3
  export type Species = 'hypothesis' | 'constraint' | 'commitment' | 'source_of_truth';
4
4
  export interface TruthNode {
5
5
  id: number;
@@ -39,6 +39,11 @@ export interface TruthCounts {
39
39
  }
40
40
  export interface TruthView {
41
41
  attention: TruthNode[];
42
+ /** Active anchors the detector has no evidence about — neither aligned nor drifting. */
43
+ unverifiedByDomain: Array<{
44
+ domain: string;
45
+ nodes: TruthNode[];
46
+ }>;
42
47
  alignedByDomain: Array<{
43
48
  domain: string;
44
49
  nodes: TruthNode[];
@@ -88,6 +93,8 @@ export declare function getTruthView(db: Database.Database, opts?: {
88
93
  export declare function getDecisionDetail(db: Database.Database, anchorId: number): DecisionDetail | null;
89
94
  export type ResolutionAction = 'fix' | 'supersede' | 'acknowledge' | 'dismiss';
90
95
  export interface ResolveInput {
96
+ /** For dismiss: silence only this match term. Omitted → silence the anchor at the gate. */
97
+ hit_term?: string;
91
98
  anchor_id: number;
92
99
  action: ResolutionAction;
93
100
  rationale?: string;
@@ -18,7 +18,7 @@ const DOMAIN_ORDER = [
18
18
  'growth', 'operations', 'security', 'roadmap', 'memory', 'other',
19
19
  ];
20
20
  const STATE_RANK = {
21
- drift: 0, review: 1, held: 2, aligned: 3,
21
+ drift: 0, review: 1, held: 2, aligned: 3, unverified: 4,
22
22
  };
23
23
  // ── Helpers ──────────────────────────────────────────────────────────────────
24
24
  function safeJsonParse(s, fallback) {
@@ -70,6 +70,9 @@ function summarizeEdges(verdict, edges) {
70
70
  const file = ev.file_path ? String(ev.file_path).replace(/\\/g, '/').split('/').slice(-2).join('/') : null;
71
71
  const hit = ev.hit_term ? ` hit "${ev.hit_term}"` : '';
72
72
  const when = new Date(latest.detected_at * 1000).toISOString().slice(0, 10);
73
+ if (verdict === 'implements') {
74
+ return `Observed in reality${file ? ` — ${file}` : ''} (${when}).`;
75
+ }
73
76
  const head = verdict === 'contradicts'
74
77
  ? `${edges.length} open contradiction${edges.length > 1 ? 's' : ''}`
75
78
  : `declared but not found in reality (${edges.length} absent signal${edges.length > 1 ? 's' : ''})`;
@@ -203,6 +206,7 @@ export function getTruthView(db, opts = {}) {
203
206
  const edges = openEdges.get(r.id) ?? [];
204
207
  const contradicts = edges.filter((e) => e.verdict === 'contradicts');
205
208
  const absent = edges.filter((e) => e.verdict === 'absent');
209
+ const implemented = edges.filter((e) => e.verdict === 'implements');
206
210
  // ── State derivation (the make-or-break logic) ──
207
211
  let state;
208
212
  let accounted;
@@ -251,10 +255,16 @@ export function getTruthView(db, opts = {}) {
251
255
  accounted = false;
252
256
  accountedBy = null;
253
257
  }
254
- else {
255
- // 🔵 convergent — reality matches intent (or no signal)
258
+ else if (implemented.length > 0) {
259
+ // 🔵 the detector saw reality match — this is the only way to be aligned without a verdict
256
260
  state = 'aligned';
257
261
  accounted = true;
262
+ accountedBy = 'observed (implements)';
263
+ }
264
+ else {
265
+ // ⚫ nothing checked, nothing decided. Not a problem — but not "fine" either.
266
+ state = 'unverified';
267
+ accounted = false;
258
268
  accountedBy = null;
259
269
  }
260
270
  // Say what was observed. Never claim convergence when nothing was checked — an agent
@@ -263,9 +273,9 @@ export function getTruthView(db, opts = {}) {
263
273
  ?? pending?.rationale
264
274
  ?? (contradicts.length > 0 ? summarizeEdges('contradicts', contradicts) : null)
265
275
  ?? (absent.length > 0 ? summarizeEdges('absent', absent) : null)
266
- ?? (state === 'aligned'
267
- ? (accountedBy ? 'Accounted for by recorded resolution' : 'No signal observed (not verified against reality)')
268
- : null);
276
+ ?? (state === 'aligned' && implemented.length > 0 ? summarizeEdges('implements', implemented) : null)
277
+ ?? (state === 'aligned' ? 'Accounted for by recorded resolution' : null)
278
+ ?? (state === 'unverified' ? 'No signal observed (not verified against reality)' : null);
269
279
  return {
270
280
  id: r.id,
271
281
  node_type: r.node_type,
@@ -288,8 +298,18 @@ export function getTruthView(db, opts = {}) {
288
298
  });
289
299
  // ── Partition: attention (loud) vs aligned (quiet) ──
290
300
  const attention = nodes
291
- .filter((n) => n.state !== 'aligned')
301
+ .filter((n) => n.state !== 'aligned' && n.state !== 'unverified')
292
302
  .sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state] || b.confidence - a.confidence);
303
+ const unverifiedGroups = new Map();
304
+ for (const n of nodes.filter((n) => n.state === 'unverified')) {
305
+ const d = n.domain ?? 'other';
306
+ if (!unverifiedGroups.has(d))
307
+ unverifiedGroups.set(d, []);
308
+ unverifiedGroups.get(d).push(n);
309
+ }
310
+ const unverifiedByDomain = [...unverifiedGroups.entries()]
311
+ .sort((a, b) => DOMAIN_ORDER.indexOf(a[0]) - DOMAIN_ORDER.indexOf(b[0]))
312
+ .map(([domain, ns]) => ({ domain, nodes: ns }));
293
313
  const alignedGroups = new Map();
294
314
  for (const n of nodes.filter((n) => n.state === 'aligned')) {
295
315
  const d = n.domain ?? 'other';
@@ -311,7 +331,7 @@ export function getTruthView(db, opts = {}) {
311
331
  };
312
332
  for (const n of nodes)
313
333
  by_species[n.species]++;
314
- const by_state = { drift: 0, review: 0, held: 0, aligned: 0 };
334
+ const by_state = { drift: 0, review: 0, held: 0, aligned: 0, unverified: 0 };
315
335
  for (const n of nodes)
316
336
  by_state[n.state]++;
317
337
  const reopenDates = nodes
@@ -335,6 +355,7 @@ export function getTruthView(db, opts = {}) {
335
355
  return {
336
356
  attention,
337
357
  alignedByDomain,
358
+ unverifiedByDomain,
338
359
  candidates: { auto, suppressed },
339
360
  counts: {
340
361
  nodes: nodes.length,
@@ -366,7 +387,14 @@ export function getDecisionDetail(db, anchorId) {
366
387
  catch { /* */ }
367
388
  const res = resolutionFor(row.id);
368
389
  const overdue = row.review_after != null && row.review_after * 1000 < now;
369
- // State derivation (same logic)
390
+ // State derivation — MUST mirror getTruthView. It had drifted: this branch never looked at
391
+ // drift_edges, so check_decision could say "aligned" on the anchor drift_status flagged 🔴.
392
+ const openEdgesHere = db
393
+ .prepare(`SELECT verdict, confidence, evidence, detected_at FROM drift_edges WHERE anchor_id = ? AND status = 'open' ORDER BY detected_at DESC`)
394
+ .all(anchorId);
395
+ const contradictsHere = openEdgesHere.filter((e) => e.verdict === 'contradicts');
396
+ const absentHere = openEdgesHere.filter((e) => e.verdict === 'absent');
397
+ const implementedHere = openEdgesHere.filter((e) => e.verdict === 'implements');
370
398
  let state, accounted, accountedBy;
371
399
  const pendingCand = db
372
400
  .prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
@@ -395,7 +423,17 @@ export function getDecisionDetail(db, anchorId) {
395
423
  accounted = true;
396
424
  accountedBy = 'supersede (intentional evolution)';
397
425
  }
398
- else if (hasPending) {
426
+ else if (res?.action === 'dismiss') {
427
+ state = 'aligned';
428
+ accounted = true;
429
+ accountedBy = 'dismiss (false positive)';
430
+ }
431
+ else if (contradictsHere.length > 0) {
432
+ state = 'drift';
433
+ accounted = false;
434
+ accountedBy = null;
435
+ }
436
+ else if (hasPending || absentHere.length > 0) {
399
437
  state = 'review';
400
438
  accounted = false;
401
439
  accountedBy = null;
@@ -405,13 +443,22 @@ export function getDecisionDetail(db, anchorId) {
405
443
  accounted = false;
406
444
  accountedBy = null;
407
445
  }
408
- else {
446
+ else if (implementedHere.length > 0) {
409
447
  state = 'aligned';
410
448
  accounted = true;
449
+ accountedBy = 'observed (implements)';
450
+ }
451
+ else {
452
+ state = 'unverified';
453
+ accounted = false;
411
454
  accountedBy = null;
412
455
  }
413
456
  const reality = cardCand?.rationale ?? (hasPending ? pendingCand[0].rationale : null)
414
- ?? (state === 'aligned' ? 'Committed reality matches intent (convergent)' : null);
457
+ ?? (contradictsHere.length > 0 ? summarizeEdges('contradicts', contradictsHere) : null)
458
+ ?? (absentHere.length > 0 ? summarizeEdges('absent', absentHere) : null)
459
+ ?? (state === 'aligned' && implementedHere.length > 0 ? summarizeEdges('implements', implementedHere) : null)
460
+ ?? (state === 'aligned' ? 'Accounted for by recorded resolution' : null)
461
+ ?? (state === 'unverified' ? 'No signal observed (not verified against reality)' : null);
415
462
  // Drift edges for this anchor
416
463
  const edges = db
417
464
  .prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
@@ -490,9 +537,18 @@ export function resolveDrift(db, input) {
490
537
  existing[`A${input.anchor_id}`] = resolution;
491
538
  db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?")
492
539
  .run(key, JSON.stringify(existing), JSON.stringify(existing));
493
- // If action is 'dismiss', also mark all open drift_edges for this anchor as dismissed
540
+ // 'dismiss' must change what happens NEXT time, not just tidy the current edges — otherwise
541
+ // the same wrong match fires on the next command and the verdict was theatre. Record it where
542
+ // the gate reads (gate_dismissals), keyed to the exact term when one was given so the anchor's
543
+ // real detections survive.
494
544
  if (input.action === 'dismiss') {
495
545
  db.prepare("UPDATE drift_edges SET status = 'dismissed' WHERE anchor_id = ? AND status = 'open'").run(input.anchor_id);
546
+ try {
547
+ db.prepare(`INSERT INTO gate_dismissals (anchor_id, hit_term, rationale) VALUES (?, ?, ?)
548
+ 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);
549
+ resolution.gate_dismissed = input.hit_term ? input.hit_term.toLowerCase() : 'all matches';
550
+ }
551
+ catch { /* pre-v16 DB → edges-only dismiss, as before */ }
496
552
  }
497
553
  // If action is 'fix', mark open edges as resolved
498
554
  if (input.action === 'fix') {
package/dist/mcp/roots.js CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // MCP semantics: client owns the root list, server is informed. We refresh on demand
7
7
  // (lazily on first use) and on roots/list_changed notification.
8
- import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
+ import { ListRootsResultSchema } from '@modelcontextprotocol/sdk/types.js';
9
9
  let cachedRoots = null;
10
10
  let lastFetched = 0;
11
11
  const STALE_MS = 60_000; // re-fetch at most once a minute
@@ -14,7 +14,11 @@ export async function fetchRoots(server) {
14
14
  if (cachedRoots && now - lastFetched < STALE_MS)
15
15
  return cachedRoots;
16
16
  try {
17
- const res = await server.request({ method: 'roots/list', params: {} }, ListRootsRequestSchema);
17
+ // Protocol.request(request, RESULT schema). This passed the REQUEST schema, so every client's
18
+ // reply — `{ roots: [...] }` — failed validation against a shape expecting `{ method: 'roots/list' }`,
19
+ // was swallowed by the catch below, and cached as "no roots" for a minute. Roots had been empty
20
+ // for every client since this was written; where_am_i's root inference never once fired.
21
+ const res = await server.request({ method: 'roots/list', params: {} }, ListRootsResultSchema);
18
22
  cachedRoots = Array.isArray(res?.roots) ? res.roots : [];
19
23
  lastFetched = now;
20
24
  }
@@ -136,15 +136,32 @@ const LAYER_ENUM = ['goal', 'context', 'emotion', 'implementation', 'caveat', 'l
136
136
  const TOOLS = [
137
137
  {
138
138
  name: 'remember',
139
- description: 'Persist knowledge across sessions and AI tools (Claude, GPT, Cursor, Codex, Gemini). The only cross-agent memory that survives session boundaries.\n\nWHEN TO CALL:\n• The moment an error or failure occurs → layer: "caveat" (auto-protected, never forgotten)\n• When a decision is made or approved → layer: "learning"\n• When a goal is set or updated → layer: "goal"\n• When something new is learned → layer: "learning"\n• When the user says "remember this" / "覚えておいて"\n• After completing a task or receiving user approval\n\nREQUIRED PARAMS BY MODE:\n• Create (default): entity_name + entity_kind + layer + content\n• Update: memory_id (+ optional content, layer, importance)\n• Delete: memory_id + forget: true\n\nImportance ≥ 0.9 pins the memory (protected from auto-forgetting). Supports Japanese (日本語) and English.',
139
+ description: 'Persist knowledge across sessions and AI tools (Claude, GPT, Cursor, Codex, Gemini). The only cross-agent memory that survives session boundaries.\n\nWHEN TO CALL:\n• The moment an error or failure occurs → layer: "caveat" (auto-protected, never forgotten)\n• When a decision is made or approved → content + anchor: {} (remembered AND enforced in one call)\n• When a goal is set or updated → layer: "goal"\n• When something new is learned → layer: "learning"\n• When the user says "remember this" / "覚えておいて"\n• After completing a task or receiving user approval\n\nMODES:\n• Create (default): content is the only required field. entity defaults to the project you are in; layer defaults to "context" (or "learning" when anchor is set).\n• Create + enforce: add anchor: {} — the memory also becomes a decision the guard re-injects before Edit/Write/Bash and on session start. Give anchor.violation_signal (forbidden strings) to make contradictions detectable; anchor.affects (path globs) to scope it.\n• Update: memory_id (+ optional content, layer, importance)\n• Delete: memory_id + forget: true\n\nImportance ≥ 0.9 pins the memory (protected from auto-forgetting). Supports Japanese (日本語) and English.',
140
140
  inputSchema: {
141
141
  type: 'object',
142
142
  properties: {
143
- entity_name: { type: 'string', description: 'Name of the entity this memory is about (required for create)' },
144
- entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'Required for create' },
143
+ entity_name: { type: 'string', description: 'What this memory is about. Optional — defaults to the project you are working in (from workspace roots, else the files edited recently).' },
144
+ entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'Optional — defaults to "project".' },
145
+ anchor: {
146
+ description: 'Also declare this as an enforceable decision. Pass {} for defaults, or an object: { kind?: "decision"|"prohibition"|"constraint", violation_signal?: string[] (forbidden strings — needed for the gate to detect a contradiction), affects?: string[] (path globs that scope it), detect_terms?: string[], domain?: string, rationale?: string }. Without violation_signal the anchor is a constraint: re-injected on session start and when its scope is touched, but no contradiction can be detected.',
147
+ anyOf: [
148
+ { type: 'boolean' },
149
+ {
150
+ type: 'object',
151
+ properties: {
152
+ kind: { type: 'string', enum: ['decision', 'prohibition', 'constraint'] },
153
+ violation_signal: { type: 'array', items: { type: 'string' } },
154
+ affects: { type: 'array', items: { type: 'string' } },
155
+ detect_terms: { type: 'array', items: { type: 'string' } },
156
+ domain: { type: 'string' },
157
+ rationale: { type: 'string' },
158
+ },
159
+ },
160
+ ],
161
+ },
145
162
  entity_key: { type: 'string', description: 'Optional canonical key (email, domain, file path)' },
146
- layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Aliases accepted (why→goal, warnings→caveat, decisions→learning, how→implementation).' },
147
- content: { type: 'string', description: 'The memory content (plain text or structured JSON with altitude/type/state/what/why)' },
163
+ layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Aliases accepted (why→goal, warnings→caveat, decisions→learning, how→implementation). Optional — defaults to "context", or "learning" when anchor is set.' },
164
+ content: { type: 'string', description: 'The memory content (plain text or structured JSON with altitude/type/state/what/why). The only required field for create.' },
148
165
  importance: { type: 'number', minimum: 0, maximum: 1, description: '0.0-1.0. Set ≥0.9 to pin (protects from forgetting).' },
149
166
  thread_id: { type: 'string', description: 'Optional thread ID to group related memories (decision chains, session groups).' },
150
167
  force: { type: 'boolean', default: false, description: 'Bypass paste-back quality check.' },
@@ -255,7 +272,7 @@ const TOOLS = [
255
272
  },
256
273
  {
257
274
  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',
275
+ 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
276
  inputSchema: {
260
277
  type: 'object',
261
278
  properties: {
@@ -264,6 +281,7 @@ const TOOLS = [
264
281
  rationale: { type: 'string', description: 'Why this resolution (recorded for audit trail)' },
265
282
  review_after: { type: 'string', description: 'For acknowledge: ISO date to re-check (e.g. "2026-07-04")' },
266
283
  superseded_by: { type: 'number', description: 'For supersede: the new anchor ID that replaces this one' },
284
+ 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
285
  },
268
286
  required: ['anchor_id', 'action'],
269
287
  },
@@ -435,14 +453,74 @@ function handleRemember(args) {
435
453
  .run(entityId, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, args.thread_id ?? null);
436
454
  db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
437
455
  const mom = refreshMomentumForEntity(db, entityId);
438
- return JSON.stringify({
456
+ const memoryId = Number(result.lastInsertRowid);
457
+ const out = {
439
458
  ok: true,
440
- memory_id: Number(result.lastInsertRowid),
459
+ memory_id: memoryId,
441
460
  entity_id: entityId,
442
461
  layer,
443
462
  pinned: importance >= 0.9,
444
463
  momentum: { score: mom.score, band: mom.band },
445
- });
464
+ };
465
+ // "Remember" and "enforce" used to be two tools with two schemas, and an agent had to pick.
466
+ // Picking remember meant the decision was stored but never re-injected — the exact failure
467
+ // the product exists to prevent. One call now does both when the caller asks for it.
468
+ if (args.anchor) {
469
+ const spec = typeof args.anchor === 'object' && args.anchor !== null ? args.anchor : {};
470
+ let what = rawContent, why = spec.rationale;
471
+ try {
472
+ const c = JSON.parse(rawContent);
473
+ if (c && typeof c === 'object') {
474
+ what = String(c.what ?? c.title ?? rawContent);
475
+ why = why ?? (c.why ? String(c.why) : undefined);
476
+ }
477
+ }
478
+ catch { /* plain text */ }
479
+ const signals = Array.isArray(spec.violation_signal) ? spec.violation_signal : [];
480
+ // A decision/prohibition needs forbidden strings for a contradiction to be detectable; without
481
+ // them the honest shape is a constraint (still re-injected on boot and when its scope is touched).
482
+ const kind = spec.kind ?? (signals.length > 0 ? 'decision' : 'constraint');
483
+ try {
484
+ const anchor = declareAnchor(db, {
485
+ kind,
486
+ statement: what.length >= 8 ? what : `${what} (decision)`,
487
+ rationale: why,
488
+ affects: Array.isArray(spec.affects) ? spec.affects : undefined,
489
+ detect_terms: Array.isArray(spec.detect_terms) ? spec.detect_terms : undefined,
490
+ violation_signal: signals.length > 0 ? signals : undefined,
491
+ tier: 'human',
492
+ source_memory_id: memoryId,
493
+ });
494
+ const nodeFields = {};
495
+ if (spec.domain)
496
+ nodeFields.domain = spec.domain;
497
+ if (spec.confidence !== undefined)
498
+ nodeFields.confidence = spec.confidence;
499
+ if (Object.keys(nodeFields).length > 0)
500
+ setNodeFields(db, anchor.id, nodeFields);
501
+ // Link both ways so recall can show "this memory is enforced as #N" and the anchor can
502
+ // point back at the moment it was decided.
503
+ try {
504
+ const c = JSON.parse(rawContent);
505
+ if (c && typeof c === 'object') {
506
+ c.anchor_id = anchor.id;
507
+ db.prepare('UPDATE memories SET content = ? WHERE id = ?').run(JSON.stringify(c), memoryId);
508
+ }
509
+ }
510
+ catch { /* leave content as-is */ }
511
+ logAnchorTouch(db, { anchorId: anchor.id, tool: 'remember', interaction: 'create' });
512
+ out.anchor_id = anchor.id;
513
+ out.anchor_kind = kind;
514
+ out.enforced = signals.length > 0
515
+ ? 'contradictions are detected at the gate; re-injected on boot and in scope'
516
+ : 'no violation_signal given — re-injected on boot and when its scope is touched, but contradictions cannot be detected';
517
+ }
518
+ catch (e) {
519
+ out.anchor_error = String(e?.message ?? e);
520
+ out.hint = 'The memory was saved. Fix the anchor spec and call declare_anchor, or remember again with a corrected anchor.';
521
+ }
522
+ }
523
+ return JSON.stringify(out);
446
524
  }
447
525
  // Sanitize query for FTS5 MATCH (strip chars that break the grammar, quote it).
448
526
  // Note: with trigram tokenizer, tokens shorter than 3 chars cannot match anything.
@@ -1233,14 +1311,77 @@ async function handleRememberUnified(args) {
1233
1311
  if (args.memory_id) {
1234
1312
  return handleUpdateMemory(args);
1235
1313
  }
1236
- // Create mode (default) — validate required fields
1237
- if (!args.entity_name || !args.entity_kind || !args.layer || !args.content) {
1314
+ // Create mode (default). Only content is required; everything an agent used to have to
1315
+ // invent is defaulted. Inventing an entity name and picking a layer were the two places a
1316
+ // "remember this" call stalled — the taxonomy is for the dashboard, not for the agent.
1317
+ if (!args.content || !String(args.content).trim()) {
1238
1318
  return JSON.stringify({
1239
1319
  ok: false,
1240
- error: 'Create mode requires: entity_name, entity_kind, layer, content. To update, provide memory_id. To delete, set forget: true + memory_id.',
1320
+ error: 'content is required. (entity/layer are optional now.) To update, provide memory_id. To delete, set forget: true + memory_id.',
1241
1321
  });
1242
1322
  }
1243
- return handleRemember(args);
1323
+ const a = { ...args };
1324
+ let entityInferred;
1325
+ if (!a.entity_name) {
1326
+ const inferred = await inferDefaultEntity();
1327
+ a.entity_name = inferred.name;
1328
+ a.entity_kind = a.entity_kind ?? inferred.kind;
1329
+ entityInferred = inferred.from;
1330
+ }
1331
+ if (!a.entity_kind)
1332
+ a.entity_kind = 'project';
1333
+ if (!a.layer)
1334
+ a.layer = a.anchor ? 'learning' : 'context';
1335
+ const out = handleRemember(a);
1336
+ if (!entityInferred)
1337
+ return out;
1338
+ try {
1339
+ const parsed = JSON.parse(out);
1340
+ if (parsed.ok)
1341
+ parsed.entity_inferred_from = entityInferred;
1342
+ return JSON.stringify(parsed);
1343
+ }
1344
+ catch {
1345
+ return out;
1346
+ }
1347
+ }
1348
+ /**
1349
+ * The entity a memory is about when the caller did not say: the project they are in.
1350
+ * Same evidence chain as where_am_i — workspace roots first, then the files edited recently.
1351
+ */
1352
+ async function inferDefaultEntity() {
1353
+ try {
1354
+ const roots = await fetchRoots(server);
1355
+ if (roots.length === 1) {
1356
+ const base = rootPathFromUri(roots[0].uri).replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop();
1357
+ if (base)
1358
+ return { name: base, kind: 'project', from: 'workspace_root' };
1359
+ }
1360
+ }
1361
+ catch { /* roots unsupported by this host */ }
1362
+ try {
1363
+ const rows = db
1364
+ .prepare(`SELECT file_path FROM session_file_edits WHERE occurred_at > unixepoch() - 86400 ORDER BY occurred_at DESC LIMIT 40`)
1365
+ .all();
1366
+ const paths = rows.map((r) => r.file_path.replace(/\\/g, '/').split('/').filter(Boolean));
1367
+ if (paths.length > 0) {
1368
+ // longest common directory prefix of what was touched → its basename is the project
1369
+ let prefix = paths[0].slice(0, -1);
1370
+ for (const p of paths.slice(1)) {
1371
+ let i = 0;
1372
+ while (i < prefix.length && i < p.length - 1 && prefix[i].toLowerCase() === p[i].toLowerCase())
1373
+ i++;
1374
+ prefix = prefix.slice(0, i);
1375
+ if (prefix.length === 0)
1376
+ break;
1377
+ }
1378
+ const base = prefix[prefix.length - 1];
1379
+ if (base && !/^(c:|users|home|[a-z])$/i.test(base))
1380
+ return { name: base, kind: 'project', from: 'recent_edits' };
1381
+ }
1382
+ }
1383
+ catch { /* fall through */ }
1384
+ return { name: 'workspace', kind: 'project', from: 'fallback' };
1244
1385
  }
1245
1386
  async function handleRecallUnified(args) {
1246
1387
  // File history mode (path takes priority; if query also provided, include it as context)
@@ -1293,7 +1434,8 @@ function handleDriftStatus(args) {
1293
1434
  by_state.drift > 0 ? `🔴 ${by_state.drift} drifting` : null,
1294
1435
  by_state.review > 0 ? `🟡 ${by_state.review} needs review` : null,
1295
1436
  by_state.held > 0 ? `⚪ ${by_state.held} held` : null,
1296
- `🔵 ${by_state.aligned} aligned`,
1437
+ `🔵 ${by_state.aligned} verified`,
1438
+ by_state.unverified > 0 ? `⚫ ${by_state.unverified} unverified` : null,
1297
1439
  ].filter(Boolean).join(' · ');
1298
1440
  if (args?.verbose) {
1299
1441
  return JSON.stringify({
@@ -1314,6 +1456,13 @@ function handleDriftStatus(args) {
1314
1456
  count: g.nodes.length,
1315
1457
  nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement, reality: n.reality })),
1316
1458
  }));
1459
+ // Unverified is the quiet majority on most machines. One line each, grouped by domain —
1460
+ // enough to see where the detector has no eyes, not enough to bury the six that matter.
1461
+ const unverified = view.unverifiedByDomain.map((g) => ({
1462
+ domain: g.domain,
1463
+ count: g.nodes.length,
1464
+ nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement })),
1465
+ }));
1317
1466
  const cand = view.candidates;
1318
1467
  return JSON.stringify({
1319
1468
  ok: true,
@@ -1321,9 +1470,10 @@ function handleDriftStatus(args) {
1321
1470
  nextReopen: view.nextReopen,
1322
1471
  attention: view.attention,
1323
1472
  aligned,
1473
+ unverified,
1324
1474
  candidates: { auto: cand?.auto?.length ?? 0, suppressed: cand?.suppressed?.length ?? 0 },
1325
1475
  counts: view.counts,
1326
- hint: 'verbose:true for full aligned nodes and candidate details; check_decision(anchor_id) for one node.',
1476
+ hint: 'verbose:true for full nodes and candidate details; check_decision(anchor_id) for one node. ⚫ unverified = declared, never checked against reality: give it affects/violation_signal so the detector can see it, or leave it as a note.',
1327
1477
  });
1328
1478
  }
1329
1479
  // Fix ① (2026-06-17): infer the Map project from the client's workspace roots when the
@@ -1485,6 +1635,7 @@ function handleResolveDrift(args) {
1485
1635
  rationale: args.rationale,
1486
1636
  review_after: args.review_after,
1487
1637
  superseded_by: args.superseded_by,
1638
+ hit_term: args.hit_term,
1488
1639
  });
1489
1640
  return JSON.stringify(result);
1490
1641
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",