linksee-memory 0.11.4 → 0.12.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
@@ -618,7 +618,7 @@ Use both.
618
618
  linksee-memory runs locally and is built to read — and send — as little as possible.
619
619
 
620
620
  - **Local-first.** Memory is one SQLite file at `~/.linksee-memory/memory.db`. No account, no cloud, no API key.
621
- - **Telemetry is opt-in and OFF by default.** Nothing is sent unless you set `LINKSEE_TELEMETRY=basic`. Even then it never sends your source code, file contents, prompts, conversation, entity/project names, or the memory DB — only anonymous counters ([details](#telemetry-opt-in-off-by-default)).
621
+ - **Telemetry is opt-in and OFF by default.** `setup` asks once; nothing is sent unless you agree there (or set `LINKSEE_TELEMETRY=basic`). Even then it never sends your source code, file contents, prompts, conversation, entity/project names, or the memory DB — only anonymous counters ([details](#telemetry-opt-in-off-by-default)).
622
622
  - **No automatic repo crawling.** linksee reads: memory you explicitly save, your `map.yaml`, the specific files a map reality-check points at, the local SQLite DB, and — when the Stop hook fires — your Claude Code session transcript (locally, to capture what happened). It does **not** crawl your repo, read `.env`/secrets/`node_modules`, or touch your home directory on its own.
623
623
  - **Clean MCP transport.** The server writes only JSON-RPC to stdout; all logs go to stderr.
624
624
  - **Hooks are documented and removable.** `setup` adds a Stop hook (session capture) and an optional guard hook. They make no network calls by default, are time-bounded, fail-open (a hook error never breaks your session), and are listed under [Uninstall](#uninstall).
@@ -636,6 +636,8 @@ linksee-memory ships with **opt-in** anonymous telemetry that helps us understan
636
636
  ```bash
637
637
  export LINKSEE_TELEMETRY=basic # opt in
638
638
  export LINKSEE_TELEMETRY=off # opt out (or just unset the variable)
639
+ # `linksee-memory setup` also asks once and records your choice in
640
+ # ~/.linksee-memory/telemetry-consent (delete that file to be asked again).
639
641
  ```
640
642
 
641
643
  ### Exactly what gets sent (Level 1 contract)
package/dist/bin/setup.js CHANGED
@@ -70,6 +70,9 @@ const GUARD_COMMAND = 'npx -y linksee-memory guard';
70
70
  const PROJECT_DIR = process.cwd();
71
71
  const PROJECT_CLAUDE_DIR = join(PROJECT_DIR, '.claude');
72
72
  const PROJECT_SETTINGS_PATH = join(PROJECT_CLAUDE_DIR, 'settings.json');
73
+ // Opt-in telemetry consent recorded at setup time (read by telemetry.ts getTelemetryMode).
74
+ const TELEMETRY_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(HOME, '.linksee-memory');
75
+ const TELEMETRY_CONSENT_FILE = join(TELEMETRY_DIR, 'telemetry-consent');
73
76
  const CHECK = '\x1b[32m✓\x1b[0m';
74
77
  const SKIP = '\x1b[33m○\x1b[0m';
75
78
  const FAIL = '\x1b[31m✗\x1b[0m';
@@ -225,13 +228,15 @@ const GUARD_HOOKS = {
225
228
  function guardWiredFor(s, ev) {
226
229
  return (s.hooks?.[ev] ?? []).some((entry) => entry?.hooks?.some((h) => typeof h?.command === 'string' && h.command.includes('linksee-memory-guard')));
227
230
  }
228
- function askYesNo(question) {
231
+ function askYesNo(question, defaultYes = true) {
229
232
  return new Promise((resolve) => {
230
233
  const rl = createInterface({ input: process.stdin, output: process.stdout });
231
- rl.question(`${question} [Y/n] `, (ans) => {
234
+ rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `, (ans) => {
232
235
  rl.close();
233
236
  const a = ans.trim().toLowerCase();
234
- resolve(a === '' || a === 'y' || a === 'yes'); // default = yes
237
+ if (a === '')
238
+ return resolve(defaultYes);
239
+ resolve(a === 'y' || a === 'yes');
235
240
  });
236
241
  });
237
242
  }
@@ -288,13 +293,58 @@ async function configureGuard() {
288
293
  }
289
294
  const guardConfigured = await configureGuard();
290
295
  console.log('');
296
+ // ── Telemetry consent (opt-in, anonymous, off unless you agree) ──────────
297
+ async function configureTelemetryConsent() {
298
+ console.log(`${BOLD}Anonymous usage stats${RESET} ${DIM}(optional)${RESET}`);
299
+ const env = (process.env.LINKSEE_TELEMETRY || '').toLowerCase().trim();
300
+ if (env) {
301
+ console.log(` ${SKIP} Controlled by LINKSEE_TELEMETRY=${env} ${DIM}(env overrides)${RESET}`);
302
+ return;
303
+ }
304
+ if (existsSync(TELEMETRY_CONSENT_FILE)) {
305
+ let cur = 'off';
306
+ try {
307
+ cur = (readFileSync(TELEMETRY_CONSENT_FILE, 'utf8').trim().toLowerCase()) || 'off';
308
+ }
309
+ catch { /* ignore */ }
310
+ console.log(` ${SKIP} Already chosen: ${cur === 'basic' ? 'on' : 'off'} ${DIM}(edit ${TELEMETRY_CONSENT_FILE} to change)${RESET}`);
311
+ return;
312
+ }
313
+ if (dryRun) {
314
+ console.log(` ${DIM}[dry-run] Would ask whether to share anonymous usage stats${RESET}`);
315
+ return;
316
+ }
317
+ const dnt = process.env.DO_NOT_TRACK === '1' || process.env.DO_NOT_TRACK === 'true';
318
+ if (dnt || autoYes || !process.stdin.isTTY) {
319
+ try {
320
+ mkdirSync(TELEMETRY_DIR, { recursive: true });
321
+ writeFileSync(TELEMETRY_CONSENT_FILE, 'off');
322
+ }
323
+ catch { /* best-effort */ }
324
+ const why = dnt ? 'DO_NOT_TRACK' : autoYes ? '--yes → off' : 'non-interactive';
325
+ console.log(` ${SKIP} Left off ${DIM}(${why})${RESET}`);
326
+ return;
327
+ }
328
+ console.log(` ${DIM}Helps us see which workflows actually work. Anonymous counts only —`);
329
+ console.log(` never your memory, code, prompts, file contents, entity names, or paths.`);
330
+ console.log(` Off unless you say yes; change anytime with LINKSEE_TELEMETRY=off.${RESET}`);
331
+ const ok = await askYesNo(' Share anonymous usage stats?', false);
332
+ try {
333
+ mkdirSync(TELEMETRY_DIR, { recursive: true });
334
+ writeFileSync(TELEMETRY_CONSENT_FILE, ok ? 'basic' : 'off');
335
+ }
336
+ catch { /* best-effort */ }
337
+ console.log(` ${ok ? CHECK : SKIP} Telemetry ${ok ? 'enabled — thank you!' : 'left off'}`);
338
+ }
339
+ await configureTelemetryConsent();
340
+ console.log('');
291
341
  // ── Summary ──────────────────────────────────────────────
292
342
  console.log(`${BOLD}Setup complete!${RESET}`);
293
343
  console.log('');
294
344
  console.log('How it works:');
295
345
  console.log(` ${DIM}• Every session is auto-captured (decisions, caveats, learnings)${RESET}`);
296
346
  console.log(` ${DIM}• Agent auto-recalls past context when starting a task${RESET}`);
297
- console.log(` ${DIM}• Memory is local-first (nothing leaves your machine)${RESET}`);
347
+ console.log(` ${DIM}• Memory is local-first (your memory never leaves your machine)${RESET}`);
298
348
  console.log(` ${DIM}• Works across Claude Code, Cursor, Windsurf, Codex, Gemini (cross-agent)${RESET}`);
299
349
  if (guardConfigured) {
300
350
  console.log(` ${DIM}• Re-injection guard re-surfaces this project's accepted decisions before edits${RESET}`);
package/dist/lib/guard.js CHANGED
@@ -12,11 +12,12 @@
12
12
  // FAIL-OPEN by construction: every DB op is best-effort; only an explicit 'hard' contradiction yields
13
13
  // a block. Lexical only (no embeddings): reuses lexical-match.ts (the same logic as the detector).
14
14
  import { normPath, compileGlob, parseArray, matchViolation, SCRAPE_ANCHOR } from './lexical-match.js';
15
+ import { supersededAnchorIds } from './truth-engine.js';
15
16
  // "accepted = the only thing the gate compares against": declared (status active), still live
16
17
  // (lifecycle active|experiment — NOT at_risk/superseded/deprecated), and not explicitly card-disabled.
17
18
  // at_risk(stale) anchors deliberately DON'T gate — we don't enforce a rule we're no longer sure of.
18
- const ACCEPTED_SQL = `status = 'active'
19
- AND lifecycle IN ('active', 'experiment')
19
+ const ACCEPTED_SQL = `status = 'active'
20
+ AND lifecycle IN ('active', 'experiment')
20
21
  AND COALESCE(json_extract(card_policy, '$.enabled'), 1) != 0`;
21
22
  const nowSec = () => Math.floor(Date.now() / 1000);
22
23
  function jsonGet(json, key, def) {
@@ -37,10 +38,16 @@ function bestEffort(fn) {
37
38
  }
38
39
  }
39
40
  export function acceptedAnchors(db) {
40
- return db
41
- .prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
41
+ const rows = db
42
+ .prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
42
43
  FROM drift_anchors WHERE ${ACCEPTED_SQL}`)
43
44
  .all();
45
+ // A superseded anchor must stop gating. The block/warn text tells the user to run
46
+ // resolve_drift(action:'supersede') — if the gate ignored that resolution, following the
47
+ // instruction would not lift the block (dead end). status/lifecycle are not touched by
48
+ // supersede, so the resolution record is the only place that knows.
49
+ const retired = supersededAnchorIds(db);
50
+ return retired.size === 0 ? rows : rows.filter((a) => !retired.has(a.id));
44
51
  }
45
52
  function buildActionCtx(input) {
46
53
  const files = [];
@@ -123,7 +130,7 @@ function withinCooldown(db, anchorId, sessionId) {
123
130
  return !!row;
124
131
  }
125
132
  function logInjection(db, matches, act, surface, sessionId) {
126
- const ins = db.prepare(`INSERT INTO injection_log (anchor_id, session_id, trigger, surface, tool_name, action_snip, verdict)
133
+ const ins = db.prepare(`INSERT INTO injection_log (anchor_id, session_id, trigger, surface, tool_name, action_snip, verdict)
127
134
  VALUES (?, ?, 'gate', ?, ?, ?, ?)`);
128
135
  const snip = (act.lines[0] ?? '').slice(0, 120);
129
136
  const tx = db.transaction(() => {
@@ -175,16 +182,19 @@ export function formatReinject(matches, gate) {
175
182
  export function buildBootDigest(db, opts = {}) {
176
183
  const maxAnchors = opts.maxAnchors ?? 8;
177
184
  const maxForks = opts.maxForks ?? 5;
178
- const anchors = db
179
- .prepare(`SELECT id, statement, rationale FROM drift_anchors
180
- WHERE ${ACCEPTED_SQL} AND kind IN ('prohibition', 'decision', 'constraint')
185
+ // Same rule as the gate: never re-surface a decision the user has already superseded.
186
+ const retiredIds = supersededAnchorIds(db);
187
+ const anchorRows = db
188
+ .prepare(`SELECT id, statement, rationale FROM drift_anchors
189
+ WHERE ${ACCEPTED_SQL} AND kind IN ('prohibition', 'decision', 'constraint')
181
190
  ORDER BY confidence DESC, updated_at DESC LIMIT ?`)
182
- .all(maxAnchors);
191
+ .all(maxAnchors + retiredIds.size);
192
+ const anchors = anchorRows.filter((a) => !retiredIds.has(a.id)).slice(0, maxAnchors);
183
193
  const forks = db
184
- .prepare(`SELECT c.id, c.rationale, a.statement
185
- FROM memory_write_candidates c
186
- LEFT JOIN drift_anchors a ON a.id = c.target_node_id
187
- WHERE c.scope = 'orphaned_proposal' AND c.status = 'pending_review'
194
+ .prepare(`SELECT c.id, c.rationale, a.statement
195
+ FROM memory_write_candidates c
196
+ LEFT JOIN drift_anchors a ON a.id = c.target_node_id
197
+ WHERE c.scope = 'orphaned_proposal' AND c.status = 'pending_review'
188
198
  ORDER BY c.created_at DESC LIMIT ?`)
189
199
  .all(maxForks);
190
200
  // Distillation pressure — the routine's structural trigger. The drain must not depend on
@@ -192,10 +202,10 @@ export function buildBootDigest(db, opts = {}) {
192
202
  // EVERY session boot says so. Inflow (new sessions) vs drain (8/dream) stays visible.
193
203
  let distill = 0;
194
204
  try {
195
- distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
196
- WHERE layer IN ('learning', 'caveat') AND json_valid(content)
197
- AND (json_extract(content, '$.needs_distill') = 1
198
- OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
205
+ distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
206
+ WHERE layer IN ('learning', 'caveat') AND json_valid(content)
207
+ AND (json_extract(content, '$.needs_distill') = 1
208
+ OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
199
209
  OR json_extract(content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')`).get().n;
200
210
  }
201
211
  catch {
@@ -228,11 +238,11 @@ export function buildBootDigest(db, opts = {}) {
228
238
  export function backfillHeeded(db) {
229
239
  bestEffort(() => {
230
240
  const live = `SELECT anchor_id FROM drift_edges WHERE verdict = 'contradicts' AND status = 'open'`;
231
- db.prepare(`UPDATE injection_log SET heeded = 0
232
- WHERE heeded IS NULL AND surface IN ('warn', 'inform') AND verdict = 'contradicts'
241
+ db.prepare(`UPDATE injection_log SET heeded = 0
242
+ WHERE heeded IS NULL AND surface IN ('warn', 'inform') AND verdict = 'contradicts'
233
243
  AND anchor_id IN (${live})`).run();
234
- db.prepare(`UPDATE injection_log SET heeded = 1
235
- WHERE heeded IS NULL AND surface IN ('warn', 'inform')
244
+ db.prepare(`UPDATE injection_log SET heeded = 1
245
+ WHERE heeded IS NULL AND surface IN ('warn', 'inform')
236
246
  AND anchor_id NOT IN (${live})`).run();
237
247
  db.prepare(`UPDATE injection_log SET heeded = 1 WHERE heeded IS NULL AND surface = 'block'`).run();
238
248
  });
@@ -244,16 +254,16 @@ export function getReinjectionFriction(db, opts = {}) {
244
254
  const minC = opts.minContradicts ?? 3;
245
255
  backfillHeeded(db);
246
256
  const rows = db
247
- .prepare(`SELECT i.anchor_id,
248
- SUM(CASE WHEN i.verdict = 'contradicts' THEN 1 ELSE 0 END) AS gate_contradicts,
249
- SUM(CASE WHEN i.surface = 'block' THEN 1 ELSE 0 END) AS gate_blocks,
250
- SUM(CASE WHEN i.heeded = 0 THEN 1 ELSE 0 END) AS ignored,
251
- datetime(MAX(i.occurred_at), 'unixepoch') AS last_at,
252
- a.statement, a.lifecycle, a.card_policy
253
- FROM injection_log i
254
- JOIN drift_anchors a ON a.id = i.anchor_id
255
- WHERE a.status = 'active'
256
- GROUP BY i.anchor_id
257
+ .prepare(`SELECT i.anchor_id,
258
+ SUM(CASE WHEN i.verdict = 'contradicts' THEN 1 ELSE 0 END) AS gate_contradicts,
259
+ SUM(CASE WHEN i.surface = 'block' THEN 1 ELSE 0 END) AS gate_blocks,
260
+ SUM(CASE WHEN i.heeded = 0 THEN 1 ELSE 0 END) AS ignored,
261
+ datetime(MAX(i.occurred_at), 'unixepoch') AS last_at,
262
+ a.statement, a.lifecycle, a.card_policy
263
+ FROM injection_log i
264
+ JOIN drift_anchors a ON a.id = i.anchor_id
265
+ WHERE a.status = 'active'
266
+ GROUP BY i.anchor_id
257
267
  HAVING gate_contradicts >= ?`)
258
268
  .all(minC);
259
269
  const realityStmt = db.prepare(`SELECT COUNT(*) AS n FROM drift_edges WHERE anchor_id = ? AND verdict = 'contradicts' AND status = 'open'`);
@@ -29,6 +29,19 @@ export function getTelemetryMode() {
29
29
  const v = (process.env.LINKSEE_TELEMETRY || '').toLowerCase().trim();
30
30
  if (v === 'basic' || v === 'on' || v === '1' || v === 'true')
31
31
  return 'basic';
32
+ if (v)
33
+ return 'off'; // any explicit env value (off/0/false/no/…) always wins
34
+ // No env override → use the consent recorded at setup time. Stays off if absent,
35
+ // so "off by default" holds: nothing is sent unless the user agreed at setup.
36
+ try {
37
+ const consentFile = join(TELEMETRY_DIR, 'telemetry-consent');
38
+ if (existsSync(consentFile)) {
39
+ const c = readFileSync(consentFile, 'utf8').trim().toLowerCase();
40
+ if (c === 'basic' || c === 'on')
41
+ return 'basic';
42
+ }
43
+ }
44
+ catch { /* ignore */ }
32
45
  return 'off';
33
46
  }
34
47
  export function getOrCreateAnonId() {
@@ -65,6 +65,22 @@ export interface DecisionDetail extends TruthNode {
65
65
  detected_at: number;
66
66
  }>;
67
67
  }
68
+ /**
69
+ * Anchors that have been explicitly superseded ("intent evolved, this is the new direction").
70
+ *
71
+ * Resolutions live in meta.t3_resolutions keyed `A<anchor_id>`, one record per anchor
72
+ * (a later resolve_drift overwrites the earlier one), so the current resolution for
73
+ * anchor N is simply t3_resolutions["A"+N].
74
+ *
75
+ * NOTE the asymmetry: a supersede record names BOTH sides (superseded_node = the old
76
+ * anchor, superseded_by = the new one). Only the OLD one is retired — the new anchor must
77
+ * keep enforcing, so we match on `superseded_node` and never on `superseded_by`.
78
+ *
79
+ * Used by the gate (guard.ts) so that the enforcement layer honours the same
80
+ * make-or-break rule as the reporting layer: a divergence accounted for by a recorded
81
+ * resolution is NOT drift.
82
+ */
83
+ export declare function supersededAnchorIds(db: Database.Database): Set<number>;
68
84
  export declare function getTruthView(db: Database.Database, opts?: {
69
85
  domain?: string;
70
86
  decision_mode?: string;
@@ -59,6 +59,22 @@ function classifySpecies(decision_mode) {
59
59
  default: return 'hypothesis';
60
60
  }
61
61
  }
62
+ // ── Edge summary (one line the agent can act on) ─────────────────────────────
63
+ function summarizeEdges(verdict, edges) {
64
+ const latest = edges[0];
65
+ let ev = {};
66
+ try {
67
+ ev = JSON.parse(latest.evidence || '{}');
68
+ }
69
+ catch { /* ignore */ }
70
+ const file = ev.file_path ? String(ev.file_path).replace(/\\/g, '/').split('/').slice(-2).join('/') : null;
71
+ const hit = ev.hit_term ? ` hit "${ev.hit_term}"` : '';
72
+ const when = new Date(latest.detected_at * 1000).toISOString().slice(0, 10);
73
+ const head = verdict === 'contradicts'
74
+ ? `${edges.length} open contradiction${edges.length > 1 ? 's' : ''}`
75
+ : `declared but not found in reality (${edges.length} absent signal${edges.length > 1 ? 's' : ''})`;
76
+ return `${head}${file ? ` — ${file}` : ''}${hit} (${when}). resolve_drift: fix if reality is wrong, dismiss if false positive.`;
77
+ }
62
78
  function buildResolutionLookup(db) {
63
79
  const t3res = safeJsonParse(db.prepare("SELECT value FROM meta WHERE key='t3_resolutions'").get()?.value, {});
64
80
  const t2res = safeJsonParse(db.prepare("SELECT value FROM meta WHERE key='t2_resolutions'").get()?.value, {});
@@ -67,6 +83,11 @@ function buildResolutionLookup(db) {
67
83
  return function resolutionFor(id) {
68
84
  const matches = [];
69
85
  for (const r of Object.values(t3res)) {
86
+ // A supersede record names both sides. Only the OLD anchor is accounted for by it;
87
+ // the replacement must stay checkable (otherwise a new North Star could never drift,
88
+ // because the supersede branch wins over the contradicts branch).
89
+ if (r && r.action === 'supersede' && r.superseded_by === id && r.superseded_node !== id)
90
+ continue;
70
91
  if (r && (r.superseded_node === id || r.superseded_by === id ||
71
92
  r.node === id || r.direction_node === id || r.constraint_node === id)) {
72
93
  matches.push(r);
@@ -91,12 +112,39 @@ function buildResolutionLookup(db) {
91
112
  };
92
113
  }
93
114
  // ── Core: getTruthView ───────────────────────────────────────────────────────
115
+ /**
116
+ * Anchors that have been explicitly superseded ("intent evolved, this is the new direction").
117
+ *
118
+ * Resolutions live in meta.t3_resolutions keyed `A<anchor_id>`, one record per anchor
119
+ * (a later resolve_drift overwrites the earlier one), so the current resolution for
120
+ * anchor N is simply t3_resolutions["A"+N].
121
+ *
122
+ * NOTE the asymmetry: a supersede record names BOTH sides (superseded_node = the old
123
+ * anchor, superseded_by = the new one). Only the OLD one is retired — the new anchor must
124
+ * keep enforcing, so we match on `superseded_node` and never on `superseded_by`.
125
+ *
126
+ * Used by the gate (guard.ts) so that the enforcement layer honours the same
127
+ * make-or-break rule as the reporting layer: a divergence accounted for by a recorded
128
+ * resolution is NOT drift.
129
+ */
130
+ export function supersededAnchorIds(db) {
131
+ const out = new Set();
132
+ for (const key of ['t3_resolutions', 't2_resolutions']) {
133
+ const res = safeJsonParse(db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value, {});
134
+ for (const r of Object.values(res)) {
135
+ if (r && r.action === 'supersede' && typeof r.superseded_node === 'number') {
136
+ out.add(r.superseded_node);
137
+ }
138
+ }
139
+ }
140
+ return out;
141
+ }
94
142
  export function getTruthView(db, opts = {}) {
95
143
  const now = Date.now();
96
144
  const resolutionFor = buildResolutionLookup(db);
97
145
  // ── Candidates (indexed by target node) ──
98
146
  const candRows = db
99
- .prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status, proposed_node
147
+ .prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status, proposed_node
100
148
  FROM memory_write_candidates ORDER BY id DESC`)
101
149
  .all();
102
150
  const pendingByNode = new Map();
@@ -112,9 +160,24 @@ export function getTruthView(db, opts = {}) {
112
160
  cardByNode.set(c.target_node_id, c);
113
161
  }
114
162
  }
163
+ // ── Open drift edges (what the detector actually observed) ──
164
+ // The detector writes drift_edges; a truth view that never reads them will report 🔵 for
165
+ // anchors it has hard evidence against. (2026-09-05: 11 open `contradicts` edges across 4
166
+ // anchors — one of them the PII constraint — all rendered "reality matches intent".)
167
+ const edgeRows = db
168
+ .prepare(`SELECT anchor_id, verdict, confidence, evidence, detected_at
169
+ FROM drift_edges WHERE status = 'open'
170
+ ORDER BY detected_at DESC`)
171
+ .all();
172
+ const openEdges = new Map();
173
+ for (const e of edgeRows) {
174
+ if (!openEdges.has(e.anchor_id))
175
+ openEdges.set(e.anchor_id, []);
176
+ openEdges.get(e.anchor_id).push(e);
177
+ }
115
178
  // ── Active nodes + state derivation ──
116
- let sql = `SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle,
117
- card_policy, review_after
179
+ let sql = `SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle,
180
+ card_policy, review_after
118
181
  FROM drift_anchors WHERE status = 'active'`;
119
182
  const params = [];
120
183
  if (opts.domain) {
@@ -137,6 +200,9 @@ export function getTruthView(db, opts = {}) {
137
200
  const pending = pendingByNode.get(r.id);
138
201
  const card = cardByNode.get(r.id);
139
202
  const overdue = r.review_after != null && r.review_after * 1000 < now;
203
+ const edges = openEdges.get(r.id) ?? [];
204
+ const contradicts = edges.filter((e) => e.verdict === 'contradicts');
205
+ const absent = edges.filter((e) => e.verdict === 'absent');
140
206
  // ── State derivation (the make-or-break logic) ──
141
207
  let state;
142
208
  let accounted;
@@ -159,8 +225,22 @@ export function getTruthView(db, opts = {}) {
159
225
  accounted = true;
160
226
  accountedBy = 'supersede (intentional evolution)';
161
227
  }
162
- else if (pending) {
163
- // 🟡 Soft signal awaiting human decision
228
+ else if (res?.action === 'dismiss') {
229
+ // 🔵 a human looked at the signal and called it a false positive — that IS an answer.
230
+ state = 'aligned';
231
+ accounted = true;
232
+ accountedBy = 'dismiss (false positive)';
233
+ }
234
+ else if (contradicts.length > 0) {
235
+ // 🔴 the detector has hard evidence against this anchor and nobody has answered it.
236
+ // resolve_drift(fix|dismiss) closes the edges; supersede/acknowledge are handled above.
237
+ state = 'drift';
238
+ accounted = false;
239
+ accountedBy = null;
240
+ }
241
+ else if (pending || absent.length > 0) {
242
+ // 🟡 Soft signal awaiting human decision (a pending candidate, or "declared but not
243
+ // found in reality" — absent is weaker than contradicts, so it asks rather than alarms)
164
244
  state = 'review';
165
245
  accounted = false;
166
246
  accountedBy = null;
@@ -177,9 +257,15 @@ export function getTruthView(db, opts = {}) {
177
257
  accounted = true;
178
258
  accountedBy = null;
179
259
  }
260
+ // Say what was observed. Never claim convergence when nothing was checked — an agent
261
+ // reading "reality matches intent" will trust it; "no signal observed" it will verify.
180
262
  const reality = card?.rationale
181
263
  ?? pending?.rationale
182
- ?? (state === 'aligned' ? 'Committed reality matches intent (convergent)' : null);
264
+ ?? (contradicts.length > 0 ? summarizeEdges('contradicts', contradicts) : null)
265
+ ?? (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);
183
269
  return {
184
270
  id: r.id,
185
271
  node_type: r.node_type,
@@ -264,8 +350,8 @@ export function getTruthView(db, opts = {}) {
264
350
  // ── check_decision: single-node deep view ────────────────────────────────────
265
351
  export function getDecisionDetail(db, anchorId) {
266
352
  const row = db
267
- .prepare(`SELECT id, kind, node_type, domain, decision_mode, statement, rationale, confidence,
268
- lifecycle, card_policy, review_after, affects, detect_terms, violation_signal, tier
353
+ .prepare(`SELECT id, kind, node_type, domain, decision_mode, statement, rationale, confidence,
354
+ lifecycle, card_policy, review_after, affects, detect_terms, violation_signal, tier
269
355
  FROM drift_anchors WHERE id = ? AND status = 'active'`)
270
356
  .get(anchorId);
271
357
  if (!row)
@@ -283,14 +369,14 @@ export function getDecisionDetail(db, anchorId) {
283
369
  // State derivation (same logic)
284
370
  let state, accounted, accountedBy;
285
371
  const pendingCand = db
286
- .prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
287
- FROM memory_write_candidates
288
- WHERE target_node_id = ? AND status = 'pending_review'
372
+ .prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
373
+ FROM memory_write_candidates
374
+ WHERE target_node_id = ? AND status = 'pending_review'
289
375
  ORDER BY id DESC`)
290
376
  .all(anchorId);
291
377
  const cardCand = db
292
- .prepare(`SELECT rationale FROM memory_write_candidates
293
- WHERE target_node_id = ? AND proposed_node LIKE '%"src":"t%'
378
+ .prepare(`SELECT rationale FROM memory_write_candidates
379
+ WHERE target_node_id = ? AND proposed_node LIKE '%"src":"t%'
294
380
  ORDER BY id DESC LIMIT 1`)
295
381
  .get(anchorId);
296
382
  const hasPending = pendingCand.length > 0;
@@ -328,7 +414,7 @@ export function getDecisionDetail(db, anchorId) {
328
414
  ?? (state === 'aligned' ? 'Committed reality matches intent (convergent)' : null);
329
415
  // Drift edges for this anchor
330
416
  const edges = db
331
- .prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
417
+ .prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
332
418
  FROM drift_edges WHERE anchor_id = ? ORDER BY detected_at DESC`)
333
419
  .all(anchorId);
334
420
  return {
@@ -174,6 +174,7 @@ const TOOLS = [
174
174
  path: { type: 'string', description: 'File path or substring. When set, returns file edit history with per-edit user-intent context instead of memory search.' },
175
175
  max_intents: { type: 'number', description: 'For file mode: max user-intent snippets. Default 10.', default: 10 },
176
176
  scope_to_roots: { type: 'boolean', default: false, description: 'For file mode: filter to client-provided roots.' },
177
+ explain: { type: 'boolean', default: false, description: 'Include ranking internals (composite, heat, band, momentum, match_reasons, score_breakdown). Off by default so more memories fit the token budget.' },
177
178
  kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'For overview mode: filter by entity kind.' },
178
179
  min_memories: { type: 'number', description: 'For overview mode: minimum memory count. Default 1.', default: 1 },
179
180
  },
@@ -200,6 +201,7 @@ const TOOLS = [
200
201
  properties: {
201
202
  domain: { type: 'string', description: 'Filter by domain (strategy, product, engineering, growth, etc.)' },
202
203
  decision_mode: { type: 'string', description: 'Filter by decision_mode (hypothesis, constraint, commitment, source_of_truth)' },
204
+ verbose: { type: 'boolean', default: false, description: 'Return full aligned nodes and all candidates. Default is compact: attention items in full, aligned as id+statement per domain, candidates as counts.' },
203
205
  },
204
206
  },
205
207
  },
@@ -767,12 +769,18 @@ function handleRecall(args) {
767
769
  content: parsedContent,
768
770
  importance: r.importance,
769
771
  pinned: r.importance >= 0.9,
770
- heat: Number(r.heat_score.toFixed(1)),
771
- band: r.heat_band,
772
- composite: Number(r.composite_score.toFixed(3)),
773
- match_reasons: r._reasons,
774
- score_breakdown: r._breakdown,
772
+ // Ranking internals only on request — they roughly double the per-memory cost and an
773
+ // agent acting on the memory needs what/why/state, not the scorer's arithmetic.
774
+ ...(args?.explain ? {
775
+ heat: Number(r.heat_score.toFixed(1)),
776
+ band: r.heat_band,
777
+ composite: Number(r.composite_score.toFixed(3)),
778
+ match_reasons: r._reasons,
779
+ score_breakdown: r._breakdown,
780
+ } : {}),
775
781
  };
782
+ if (!args?.explain)
783
+ delete memObj.entity.momentum;
776
784
  const memTokens = estimateTokens(JSON.stringify(memObj));
777
785
  if (memoriesOut.length > 0 && accTokens + memTokens > maxTokens) {
778
786
  stoppedBy = 'tokens';
@@ -1287,14 +1295,35 @@ function handleDriftStatus(args) {
1287
1295
  by_state.held > 0 ? `⚪ ${by_state.held} held` : null,
1288
1296
  `🔵 ${by_state.aligned} aligned`,
1289
1297
  ].filter(Boolean).join(' · ');
1298
+ if (args?.verbose) {
1299
+ return JSON.stringify({
1300
+ ok: true,
1301
+ triage: `${nodes} anchors: ${triage}`,
1302
+ nextReopen: view.nextReopen,
1303
+ attention: view.attention,
1304
+ alignedByDomain: view.alignedByDomain,
1305
+ candidates: view.candidates,
1306
+ counts: view.counts,
1307
+ });
1308
+ }
1309
+ // Compact by default. The agent asked "what needs attention" — answer that in full and keep
1310
+ // the rest to one line per node. (Full form was ~15k tokens for 43 anchors; the 2 drifting
1311
+ // items were buried under 41 aligned ones with rationale.)
1312
+ const aligned = view.alignedByDomain.map((g) => ({
1313
+ domain: g.domain,
1314
+ count: g.nodes.length,
1315
+ nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement, reality: n.reality })),
1316
+ }));
1317
+ const cand = view.candidates;
1290
1318
  return JSON.stringify({
1291
1319
  ok: true,
1292
1320
  triage: `${nodes} anchors: ${triage}`,
1293
1321
  nextReopen: view.nextReopen,
1294
1322
  attention: view.attention,
1295
- alignedByDomain: view.alignedByDomain,
1296
- candidates: view.candidates,
1323
+ aligned,
1324
+ candidates: { auto: cand?.auto?.length ?? 0, suppressed: cand?.suppressed?.length ?? 0 },
1297
1325
  counts: view.counts,
1326
+ hint: 'verbose:true for full aligned nodes and candidate details; check_decision(anchor_id) for one node.',
1298
1327
  });
1299
1328
  }
1300
1329
  // Fix ① (2026-06-17): infer the Map project from the client's workspace roots when the
@@ -1315,9 +1344,39 @@ async function resolveProjectFromRoots() {
1315
1344
  });
1316
1345
  return matches.length === 1 ? matches[0] : undefined; // unique repo match only; else ② handles it
1317
1346
  }
1347
+ // ①b (2026-09-05): when the client sends no roots (common — not every MCP host implements
1348
+ // roots), infer the map from the files edited most recently. The DB already knows what you
1349
+ // touched; the map slug is a path segment of those files.
1350
+ function resolveProjectFromRecentEdits() {
1351
+ const projects = listMapProjects(db);
1352
+ if (projects.length <= 1)
1353
+ return { project: projects[0] };
1354
+ const rows = db
1355
+ .prepare(`SELECT file_path FROM session_file_edits
1356
+ WHERE occurred_at > unixepoch() - 86400
1357
+ ORDER BY occurred_at DESC LIMIT 50`)
1358
+ .all();
1359
+ if (rows.length === 0)
1360
+ return {};
1361
+ const hits = new Map();
1362
+ for (const r of rows) {
1363
+ const segs = r.file_path.toLowerCase().replace(/\\/g, '/').split('/');
1364
+ for (const p of projects)
1365
+ if (segs.includes(p.toLowerCase()))
1366
+ hits.set(p, (hits.get(p) ?? 0) + 1);
1367
+ }
1368
+ if (hits.size === 0)
1369
+ return {};
1370
+ const [best, second] = [...hits.entries()].sort((a, b) => b[1] - a[1]);
1371
+ if (second && second[1] === best[1])
1372
+ return {}; // tie → still ambiguous
1373
+ return { project: best[0], from: 'recent_edits' };
1374
+ }
1318
1375
  async function handleWhereAmI(args) {
1319
- // ① resolve project from cwd/roots when not explicitly passed; ② (inside whereAmI) is the fallback.
1320
- const project = args?.project ?? (await resolveProjectFromRoots());
1376
+ // ① roots; ①b recent edits; ② (inside whereAmI) is the last fallback.
1377
+ let project = args?.project ?? (await resolveProjectFromRoots());
1378
+ if (!project)
1379
+ project = resolveProjectFromRecentEdits().project;
1321
1380
  const res = whereAmI(db, {
1322
1381
  query: args?.query, node_id: args?.node_id, project, limit: args?.limit,
1323
1382
  });
@@ -1325,7 +1384,7 @@ async function handleWhereAmI(args) {
1325
1384
  return JSON.stringify({
1326
1385
  ok: true, located: false, reason: 'ambiguous_project',
1327
1386
  available_projects: res.ambiguous.available,
1328
- hint: `Multiple maps imported — pass project: one of [${res.ambiguous.available.join(', ')}]. (No-arg can't yet tell which repo you mean; cwd/roots match is coming.)`,
1387
+ hint: `Multiple maps imported and none matched your workspace roots or the files you edited in the last 24h — pass project: one of [${res.ambiguous.available.join(', ')}].`,
1329
1388
  });
1330
1389
  }
1331
1390
  if (res.matched.length === 0) {
@@ -1535,6 +1594,9 @@ function handleDream(args) {
1535
1594
  FROM memories m JOIN entities e ON e.id = m.entity_id
1536
1595
  WHERE m.layer IN ('learning', 'caveat')
1537
1596
  AND json_valid(m.content)
1597
+ -- settle window: the Stop hook extracts every turn, so the newest rows belong to a
1598
+ -- session still in motion. Don't ask the agent to distill the conversation it is in.
1599
+ AND m.created_at < unixepoch() - 1800
1538
1600
  AND (json_extract(m.content, '$.needs_distill') = 1
1539
1601
  OR json_extract(m.content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
1540
1602
  OR json_extract(m.content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.11.4",
3
+ "version": "0.12.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",
@@ -32,7 +32,7 @@
32
32
  "dev": "tsx src/mcp/server.ts",
33
33
  "migrate": "node dist/db/migrate.js",
34
34
  "migrate:dev": "tsx src/db/migrate.ts",
35
- "prepublishOnly": "npm run build"
35
+ "prepublishOnly": "node scripts/release-gate.mjs && npm run build"
36
36
  },
37
37
  "keywords": [
38
38
  "mcp",