linksee-memory 0.13.1 → 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.
@@ -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[];
@@ -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
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.' },
@@ -436,14 +453,74 @@ function handleRemember(args) {
436
453
  .run(entityId, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, args.thread_id ?? null);
437
454
  db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
438
455
  const mom = refreshMomentumForEntity(db, entityId);
439
- return JSON.stringify({
456
+ const memoryId = Number(result.lastInsertRowid);
457
+ const out = {
440
458
  ok: true,
441
- memory_id: Number(result.lastInsertRowid),
459
+ memory_id: memoryId,
442
460
  entity_id: entityId,
443
461
  layer,
444
462
  pinned: importance >= 0.9,
445
463
  momentum: { score: mom.score, band: mom.band },
446
- });
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);
447
524
  }
448
525
  // Sanitize query for FTS5 MATCH (strip chars that break the grammar, quote it).
449
526
  // Note: with trigram tokenizer, tokens shorter than 3 chars cannot match anything.
@@ -1234,14 +1311,77 @@ async function handleRememberUnified(args) {
1234
1311
  if (args.memory_id) {
1235
1312
  return handleUpdateMemory(args);
1236
1313
  }
1237
- // Create mode (default) — validate required fields
1238
- 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()) {
1239
1318
  return JSON.stringify({
1240
1319
  ok: false,
1241
- 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.',
1242
1321
  });
1243
1322
  }
1244
- 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' };
1245
1385
  }
1246
1386
  async function handleRecallUnified(args) {
1247
1387
  // File history mode (path takes priority; if query also provided, include it as context)
@@ -1294,7 +1434,8 @@ function handleDriftStatus(args) {
1294
1434
  by_state.drift > 0 ? `🔴 ${by_state.drift} drifting` : null,
1295
1435
  by_state.review > 0 ? `🟡 ${by_state.review} needs review` : null,
1296
1436
  by_state.held > 0 ? `⚪ ${by_state.held} held` : null,
1297
- `🔵 ${by_state.aligned} aligned`,
1437
+ `🔵 ${by_state.aligned} verified`,
1438
+ by_state.unverified > 0 ? `⚫ ${by_state.unverified} unverified` : null,
1298
1439
  ].filter(Boolean).join(' · ');
1299
1440
  if (args?.verbose) {
1300
1441
  return JSON.stringify({
@@ -1315,6 +1456,13 @@ function handleDriftStatus(args) {
1315
1456
  count: g.nodes.length,
1316
1457
  nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement, reality: n.reality })),
1317
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
+ }));
1318
1466
  const cand = view.candidates;
1319
1467
  return JSON.stringify({
1320
1468
  ok: true,
@@ -1322,9 +1470,10 @@ function handleDriftStatus(args) {
1322
1470
  nextReopen: view.nextReopen,
1323
1471
  attention: view.attention,
1324
1472
  aligned,
1473
+ unverified,
1325
1474
  candidates: { auto: cand?.auto?.length ?? 0, suppressed: cand?.suppressed?.length ?? 0 },
1326
1475
  counts: view.counts,
1327
- 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.',
1328
1477
  });
1329
1478
  }
1330
1479
  // Fix ① (2026-06-17): infer the Map project from the client's workspace roots when the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.13.1",
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",