linksee-memory 0.13.1 → 0.15.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
@@ -513,44 +513,34 @@ Nothing ever leaves your machine, so step 3 fully erases everything Linksee stor
513
513
 
514
514
  </details>
515
515
 
516
- ## 11 Tools
516
+ ## 6 Tools
517
517
 
518
- ### Memory tools
518
+ Two pillars, one surface. **Memory** and **drift** each get the minimum; nothing else is exposed.
519
+ Eleven tools bled model-dependent behaviour across Claude / GPT / Cursor / Codex / Gemini — six
520
+ is what an agent can hold without a manual.
519
521
 
520
522
  | Tool | What it does |
521
523
  |---|---|
522
- | `remember` | **Save / update / delete** memories. Auto-classifies into 6 layers. Modes: create (default), update (`memory_id` + fields), delete (`forget: true` + `memory_id`). |
523
- | `recall` | **Search / file history / overview.** Modes: search (`query`), file history (`path`), entity overview (no params). FTS5 + heat × momentum ranking with `match_reasons`. |
524
- | `read_smart` | **Token-saving file reader** with AST diff caching. First read = full content. Re-read unchanged = ~50 tokens. Re-read modified = changed chunks only. |
525
-
526
- ### Drift tools (v0.8.0)
527
-
528
- | Tool | What it does |
529
- |---|---|
530
- | `drift_status` | **"What's drifting right now?"** Returns the truth map with 4-species classification (hypothesis/constraint/commitment/source_of_truth) and per-node state (🔴 drift / 🟡 review / ⚪ held / 🔵 aligned). |
531
- | `check_decision` | **Deep-dive into a specific decision.** Returns the full context: what was decided, why, what reality says, pending candidates, and drift edges. |
532
- | `declare_anchor` | **Record a decision as a truth-map anchor.** The drift detector checks these against committed reality. Supports v9 fields (domain, confidence, lifecycle, review_after). |
533
- | `resolve_drift` | **Close the loop.** Record a resolution: `fix` (reality now matches), `supersede` (intent evolved), `acknowledge` (parking with review date), or `dismiss` (false positive). |
534
- | `where_am_i` | **"Where on the Map am I, and what else does this touch?"** Locates the current topic/file on the Current Truth Map and returns its journey stage + blast radius (the `must-stay-consistent-with` / `should-align-with` dependents) + the decision behind it. The per-turn re-anchor that stops you optimizing one node while silently breaking its neighbors. |
535
-
536
- ### Fork-point tools (v0.10)
537
-
538
- | Tool | What it does |
539
- |---|---|
540
- | `flag_proposals` | **Record orphaned proposals** — options you presented that the user never addressed. Conversations are tree-shaped but experienced linearly; the branches nobody engaged with become unresolved fork points that both you and the user lose track of. |
541
- | `dream` | **Consolidate orphaned proposals against the North Star.** Returns the project's direction/goals/ICP alongside unresolved proposals; the evaluating agent decides per candidate: surface (genuinely important fork) or dismiss (outdated / irrelevant / implicitly resolved). |
542
- | `resolve_proposal` | **Record the verdict** for each dreamed proposal: `surface` (keep visible on the dashboard for human decision) or `dismiss` (remove from the dashboard). |
543
-
544
- Previous versions exposed 3 tools — v0.8.0 added 4 drift tools that let agents query and act on product-level intent ↔ reality divergence; v0.10 added the fork-point trio for orphaned-proposal triage; `where_am_i` adds the Current Truth Map's per-turn positional re-anchor. The memory tools are unchanged.
524
+ | `recall` | **Start here.** No arguments → the **session brief**: what needs attention, where you are on the Map, open loops, top entities. `query` → search; `path` → a file's edit history with the user intent behind each edit; `where: "<topic>"` → your position on the Current Truth Map + blast radius; `dream: true` → the triage session (North Star, orphaned proposals, distill queue, friction). |
525
+ | `remember` | **Save / update / delete.** `content` is the only required field — entity and layer default to the project you're in. Add `anchor: {}` to record a decision **and** enforce it in one call (re-injected before Edit/Write/Bash and on session start). |
526
+ | `read_smart` | **Token-saving file reader** with AST diff caching. Re-read unchanged = ~50 tokens; modified = changed chunks only. |
527
+ | `drift_status` | **"What's drifting right now?"** The truth map: 🔴 drift / 🟡 review / ⚪ held / 🔵 verified / ⚫ unverified, with the evidence for each. `anchor_id` → deep-dive into one decision. |
528
+ | `declare_anchor` | **Record a normative claim** — `decision` / `prohibition` / `constraint` the detector checks against reality, or `proposal`: an option you presented that the user never addressed, parked as a review item. |
529
+ | `resolve_drift` | **Close the loop.** `fix` · `supersede` · `acknowledge` · `dismiss` (with `hit_term`, and the gate stops firing on it) · `harden` / `soften`. With `candidate_id`: `surface` or `dismiss` an orphaned proposal. |
530
+
531
+ The five earlier names — `where_am_i`, `check_decision`, `flag_proposals`, `dream`,
532
+ `resolve_proposal` — are folded into the six above. They are hidden from `tools/list` but **still
533
+ answer if called**, so a skill or agent written against an older version keeps working.
534
+ `LINKSEE_LEGACY_TOOLS=1` lists them.
545
535
 
546
536
  ### CLI utilities
547
537
 
548
538
  | Command | Purpose |
549
539
  |---|---|
550
- | `npx -y linksee-memory setup` | One-command setup: MCP server + skill + Stop hook, then offers to wire the re-injection guard into this project. Idempotent — skips what's already done. |
540
+ | `npx -y linksee-memory setup` | One-command setup: MCP server + skill + Stop hook + the re-injection guard for every repo (`--project-guard` for this repo only, `--no-guard` to skip). Idempotent — skips what's already done. |
551
541
  | `npx linksee-memory` | MCP server (stdio) |
552
542
  | `npx -y linksee-memory sync` | Claude Code Stop-hook entry point |
553
- | `npx -y linksee-memory guard` | Re-injection guard hook: `PreToolUse` gate (`Edit`/`Write`/`Bash`) + `SessionStart` boot digest. Wired per-project (see [Re-injection Guard](#reinjection-guard)); fail-open. |
543
+ | `npx -y linksee-memory guard` | Re-injection guard hook: `PreToolUse` gate (`Edit`/`Write`/`Bash`) + `SessionStart` boot digest. Wired by `setup` for every repo (see [Re-injection Guard](#reinjection-guard)); fail-open. |
554
544
  | `npx -y linksee-memory import` | Batch-import Claude Code session JSONL history |
555
545
  | `npx -y linksee-memory install-skill` | Install the Claude Code Skill that teaches the agent when to call recall/remember/read_smart |
556
546
  | `npx -y linksee-memory stats` | Summary of the local DB (entity count / layer breakdown / top entities / top edited files). Add `--json` for machine-readable output. |
@@ -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.' },
@@ -155,7 +172,7 @@ const TOOLS = [
155
172
  },
156
173
  {
157
174
  name: 'recall',
158
- description: 'Your persistent memory across all AI tools. CALL THIS BEFORE STARTING ANY TASK to check for past caveats (pain records), decisions, and learnings — prevents repeating mistakes across sessions.\n\nTypical usage: recall({ query: "keywords" }) for search, recall({ path: "file.ts" }) for file history, recall() for overview.\n\nWHEN TO CALL:\n• Before starting any new task or touching a file\n• When the user mentions "before" / "前に" / "last time" / "remember when"\n• When an error occurs — check if you\'ve seen it before\n• When making a decision — check for prior decisions on the same topic\n\nTHREE MODES (auto-detected):\n• Search (default): provide query → returns memories ranked by relevance + heat\n• File history: provide path → returns complete edit history with user-intent context\n• Overview: omit all params → returns entity list sorted by momentum\n\nWorks across Claude, GPT, Cursor, Codex, Gemini — one local SQLite file, nothing leaves your machine.',
175
+ description: 'Your persistent memory across all AI tools. CALL THIS BEFORE STARTING ANY TASK to check for past caveats (pain records), decisions, and learnings — prevents repeating mistakes across sessions.\n\nTypical usage: recall({ query: "keywords" }) for search, recall({ path: "file.ts" }) for file history, recall() for overview.\n\nWHEN TO CALL:\n• Before starting any new task or touching a file\n• When the user mentions "before" / "前に" / "last time" / "remember when"\n• When an error occurs — check if you\'ve seen it before\n• When making a decision — check for prior decisions on the same topic\n\nTHREE MODES (auto-detected):\n• Search (default): provide query → returns memories ranked by relevance + heat\n• File history: provide path → returns complete edit history with user-intent context\n• Session brief: omit all params → what needs attention (🔴/🟡), where you are on the Map, open loops (proposals / distill queue / friction), top entities. CALL THIS FIRST in a new session.\n• Locate: where: "<topic>" → your position on the Current Truth Map + blast radius (what else moves if you touch this). where: true → auto-locate from recent edits\n• Dream: dream: true → North Star + orphaned proposals to triage + distill_queue + friction (the full triage session; the brief carries only the counts)\n• Entity list: overview: true (the old no-arg behaviour)\n\nWorks across Claude, GPT, Cursor, Codex, Gemini — one local SQLite file, nothing leaves your machine.',
159
176
  inputSchema: {
160
177
  type: 'object',
161
178
  properties: {
@@ -175,6 +192,10 @@ const TOOLS = [
175
192
  max_intents: { type: 'number', description: 'For file mode: max user-intent snippets. Default 10.', default: 10 },
176
193
  scope_to_roots: { type: 'boolean', default: false, description: 'For file mode: filter to client-provided roots.' },
177
194
  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.' },
195
+ where: { description: 'Locate on the Current Truth Map. A topic string, or true to auto-locate from the files you edited recently. Returns your node, journey stage, blast radius, and the decision behind it.', anyOf: [{ type: 'string' }, { type: 'boolean' }] },
196
+ project: { type: 'string', description: 'With where: the Map project slug, when several maps are imported.' },
197
+ dream: { type: 'boolean', default: false, description: 'Return the full triage set: North Star, orphaned proposals (each with candidate_id), distill_queue, friction. Resolve proposals with resolve_drift({ candidate_id, action }); rewrite distill items with remember({ memory_id, content }).' },
198
+ overview: { type: 'boolean', default: false, description: 'Return the entity list instead of the session brief when no query is given.' },
178
199
  kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'For overview mode: filter by entity kind.' },
179
200
  min_memories: { type: 'number', description: 'For overview mode: minimum memory count. Default 1.', default: 1 },
180
201
  },
@@ -202,6 +223,7 @@ const TOOLS = [
202
223
  domain: { type: 'string', description: 'Filter by domain (strategy, product, engineering, growth, etc.)' },
203
224
  decision_mode: { type: 'string', description: 'Filter by decision_mode (hypothesis, constraint, commitment, source_of_truth)' },
204
225
  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.' },
226
+ anchor_id: { type: 'number', description: 'Deep-dive into ONE decision instead of the map: its state, premises, drift edges, pending candidates. (Absorbs check_decision.)' },
205
227
  },
206
228
  },
207
229
  },
@@ -235,7 +257,10 @@ const TOOLS = [
235
257
  inputSchema: {
236
258
  type: 'object',
237
259
  properties: {
238
- kind: { type: 'string', enum: ['prohibition', 'decision', 'constraint'], description: 'Anchor type' },
260
+ kind: { type: 'string', enum: ['prohibition', 'decision', 'constraint', 'proposal'], description: "Anchor type. 'proposal' = an option you presented that the user never addressed (an orphaned fork) — it lands as a review item and can be triaged later via recall({ dream: true }). (Absorbs flag_proposals.)" },
261
+ decided: { type: 'string', description: "For kind 'proposal': what the user chose or engaged with instead." },
262
+ siblings: { type: 'array', items: { type: 'string' }, description: "For kind 'proposal': the other options from the same set, for context." },
263
+ session_context: { type: 'string', description: "For kind 'proposal': one line on the conversation it came from." },
239
264
  statement: { type: 'string', description: 'The normative claim (>= 8 chars)' },
240
265
  rationale: { type: 'string', description: 'Why this was decided' },
241
266
  affects: { type: 'array', items: { type: 'string' }, description: 'Path globs that scope this anchor' },
@@ -260,7 +285,8 @@ const TOOLS = [
260
285
  type: 'object',
261
286
  properties: {
262
287
  anchor_id: { type: 'number', description: 'The drift_anchor ID to resolve' },
263
- action: { type: 'string', enum: ['fix', 'supersede', 'acknowledge', 'dismiss', 'harden', 'soften'], description: 'Resolution action' },
288
+ action: { type: 'string', enum: ['fix', 'supersede', 'acknowledge', 'dismiss', 'harden', 'soften', 'surface'], description: "Resolution action. With candidate_id (an orphaned proposal): 'surface' keeps it visible for the human, 'dismiss' retires it." },
289
+ candidate_id: { type: 'number', description: 'Resolve an orphaned proposal (from recall({ dream: true })) instead of an anchor: pass its candidate_id with action surface | dismiss and a rationale that references the North Star. (Absorbs resolve_proposal.)' },
264
290
  rationale: { type: 'string', description: 'Why this resolution (recorded for audit trail)' },
265
291
  review_after: { type: 'string', description: 'For acknowledge: ISO date to re-check (e.g. "2026-07-04")' },
266
292
  superseded_by: { type: 'number', description: 'For supersede: the new anchor ID that replaces this one' },
@@ -436,14 +462,74 @@ function handleRemember(args) {
436
462
  .run(entityId, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, args.thread_id ?? null);
437
463
  db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
438
464
  const mom = refreshMomentumForEntity(db, entityId);
439
- return JSON.stringify({
465
+ const memoryId = Number(result.lastInsertRowid);
466
+ const out = {
440
467
  ok: true,
441
- memory_id: Number(result.lastInsertRowid),
468
+ memory_id: memoryId,
442
469
  entity_id: entityId,
443
470
  layer,
444
471
  pinned: importance >= 0.9,
445
472
  momentum: { score: mom.score, band: mom.band },
446
- });
473
+ };
474
+ // "Remember" and "enforce" used to be two tools with two schemas, and an agent had to pick.
475
+ // Picking remember meant the decision was stored but never re-injected — the exact failure
476
+ // the product exists to prevent. One call now does both when the caller asks for it.
477
+ if (args.anchor) {
478
+ const spec = typeof args.anchor === 'object' && args.anchor !== null ? args.anchor : {};
479
+ let what = rawContent, why = spec.rationale;
480
+ try {
481
+ const c = JSON.parse(rawContent);
482
+ if (c && typeof c === 'object') {
483
+ what = String(c.what ?? c.title ?? rawContent);
484
+ why = why ?? (c.why ? String(c.why) : undefined);
485
+ }
486
+ }
487
+ catch { /* plain text */ }
488
+ const signals = Array.isArray(spec.violation_signal) ? spec.violation_signal : [];
489
+ // A decision/prohibition needs forbidden strings for a contradiction to be detectable; without
490
+ // them the honest shape is a constraint (still re-injected on boot and when its scope is touched).
491
+ const kind = spec.kind ?? (signals.length > 0 ? 'decision' : 'constraint');
492
+ try {
493
+ const anchor = declareAnchor(db, {
494
+ kind,
495
+ statement: what.length >= 8 ? what : `${what} (decision)`,
496
+ rationale: why,
497
+ affects: Array.isArray(spec.affects) ? spec.affects : undefined,
498
+ detect_terms: Array.isArray(spec.detect_terms) ? spec.detect_terms : undefined,
499
+ violation_signal: signals.length > 0 ? signals : undefined,
500
+ tier: 'human',
501
+ source_memory_id: memoryId,
502
+ });
503
+ const nodeFields = {};
504
+ if (spec.domain)
505
+ nodeFields.domain = spec.domain;
506
+ if (spec.confidence !== undefined)
507
+ nodeFields.confidence = spec.confidence;
508
+ if (Object.keys(nodeFields).length > 0)
509
+ setNodeFields(db, anchor.id, nodeFields);
510
+ // Link both ways so recall can show "this memory is enforced as #N" and the anchor can
511
+ // point back at the moment it was decided.
512
+ try {
513
+ const c = JSON.parse(rawContent);
514
+ if (c && typeof c === 'object') {
515
+ c.anchor_id = anchor.id;
516
+ db.prepare('UPDATE memories SET content = ? WHERE id = ?').run(JSON.stringify(c), memoryId);
517
+ }
518
+ }
519
+ catch { /* leave content as-is */ }
520
+ logAnchorTouch(db, { anchorId: anchor.id, tool: 'remember', interaction: 'create' });
521
+ out.anchor_id = anchor.id;
522
+ out.anchor_kind = kind;
523
+ out.enforced = signals.length > 0
524
+ ? 'contradictions are detected at the gate; re-injected on boot and in scope'
525
+ : 'no violation_signal given — re-injected on boot and when its scope is touched, but contradictions cannot be detected';
526
+ }
527
+ catch (e) {
528
+ out.anchor_error = String(e?.message ?? e);
529
+ out.hint = 'The memory was saved. Fix the anchor spec and call declare_anchor, or remember again with a corrected anchor.';
530
+ }
531
+ }
532
+ return JSON.stringify(out);
447
533
  }
448
534
  // Sanitize query for FTS5 MATCH (strip chars that break the grammar, quote it).
449
535
  // Note: with trigram tokenizer, tokens shorter than 3 chars cannot match anything.
@@ -1234,16 +1320,150 @@ async function handleRememberUnified(args) {
1234
1320
  if (args.memory_id) {
1235
1321
  return handleUpdateMemory(args);
1236
1322
  }
1237
- // Create mode (default) — validate required fields
1238
- if (!args.entity_name || !args.entity_kind || !args.layer || !args.content) {
1323
+ // Create mode (default). Only content is required; everything an agent used to have to
1324
+ // invent is defaulted. Inventing an entity name and picking a layer were the two places a
1325
+ // "remember this" call stalled — the taxonomy is for the dashboard, not for the agent.
1326
+ if (!args.content || !String(args.content).trim()) {
1239
1327
  return JSON.stringify({
1240
1328
  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.',
1329
+ error: 'content is required. (entity/layer are optional now.) To update, provide memory_id. To delete, set forget: true + memory_id.',
1242
1330
  });
1243
1331
  }
1244
- return handleRemember(args);
1332
+ const a = { ...args };
1333
+ let entityInferred;
1334
+ if (!a.entity_name) {
1335
+ const inferred = await inferDefaultEntity();
1336
+ a.entity_name = inferred.name;
1337
+ a.entity_kind = a.entity_kind ?? inferred.kind;
1338
+ entityInferred = inferred.from;
1339
+ }
1340
+ if (!a.entity_kind)
1341
+ a.entity_kind = 'project';
1342
+ if (!a.layer)
1343
+ a.layer = a.anchor ? 'learning' : 'context';
1344
+ const out = handleRemember(a);
1345
+ if (!entityInferred)
1346
+ return out;
1347
+ try {
1348
+ const parsed = JSON.parse(out);
1349
+ if (parsed.ok)
1350
+ parsed.entity_inferred_from = entityInferred;
1351
+ return JSON.stringify(parsed);
1352
+ }
1353
+ catch {
1354
+ return out;
1355
+ }
1356
+ }
1357
+ /**
1358
+ * The entity a memory is about when the caller did not say: the project they are in.
1359
+ * Same evidence chain as where_am_i — workspace roots first, then the files edited recently.
1360
+ */
1361
+ async function inferDefaultEntity() {
1362
+ try {
1363
+ const roots = await fetchRoots(server);
1364
+ if (roots.length === 1) {
1365
+ const base = rootPathFromUri(roots[0].uri).replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop();
1366
+ if (base)
1367
+ return { name: base, kind: 'project', from: 'workspace_root' };
1368
+ }
1369
+ }
1370
+ catch { /* roots unsupported by this host */ }
1371
+ try {
1372
+ const rows = db
1373
+ .prepare(`SELECT file_path FROM session_file_edits WHERE occurred_at > unixepoch() - 86400 ORDER BY occurred_at DESC LIMIT 40`)
1374
+ .all();
1375
+ const paths = rows.map((r) => r.file_path.replace(/\\/g, '/').split('/').filter(Boolean));
1376
+ if (paths.length > 0) {
1377
+ // longest common directory prefix of what was touched → its basename is the project
1378
+ let prefix = paths[0].slice(0, -1);
1379
+ for (const p of paths.slice(1)) {
1380
+ let i = 0;
1381
+ while (i < prefix.length && i < p.length - 1 && prefix[i].toLowerCase() === p[i].toLowerCase())
1382
+ i++;
1383
+ prefix = prefix.slice(0, i);
1384
+ if (prefix.length === 0)
1385
+ break;
1386
+ }
1387
+ const base = prefix[prefix.length - 1];
1388
+ if (base && !/^(c:|users|home|[a-z])$/i.test(base))
1389
+ return { name: base, kind: 'project', from: 'recent_edits' };
1390
+ }
1391
+ }
1392
+ catch { /* fall through */ }
1393
+ return { name: 'workspace', kind: 'project', from: 'fallback' };
1394
+ }
1395
+ /**
1396
+ * The session brief: what an agent needs in the first call of a session, in one call.
1397
+ *
1398
+ * On 2026-09-05 this took four calls (recall / drift_status / dream / where_am_i), ~20k tokens,
1399
+ * and one of them failed. The brief carries the attention items in full and everything else as
1400
+ * counts plus a hint for the drill-down — small enough to call freely, complete enough that the
1401
+ * agent does not have to know which of five tools holds which fact.
1402
+ */
1403
+ async function handleSessionBrief() {
1404
+ const view = getTruthView(db, {});
1405
+ const bs = view.counts.by_state;
1406
+ const triage = `${view.counts.nodes} anchors: ` + [
1407
+ bs.drift > 0 ? `🔴 ${bs.drift} drifting` : null,
1408
+ bs.review > 0 ? `🟡 ${bs.review} needs review` : null,
1409
+ bs.held > 0 ? `⚪ ${bs.held} held` : null,
1410
+ `🔵 ${bs.aligned} verified`,
1411
+ bs.unverified > 0 ? `⚫ ${bs.unverified} unverified` : null,
1412
+ ].filter(Boolean).join(' · ');
1413
+ const attention = view.attention.slice(0, 8).map((n) => ({
1414
+ id: n.id, state: n.state, statement: n.statement.slice(0, 160), reality: (n.reality ?? '').slice(0, 160),
1415
+ }));
1416
+ let where = null;
1417
+ try {
1418
+ const w = JSON.parse(await handleWhereAmI({}));
1419
+ where = w.located
1420
+ ? { project: w.project, you_are_here: w.you_are_here }
1421
+ : w.reason
1422
+ ? { reason: w.reason, available_projects: w.available_projects }
1423
+ : null;
1424
+ }
1425
+ catch { /* no map, or roots unavailable — the brief still stands */ }
1426
+ let open_loops = null;
1427
+ try {
1428
+ const d = JSON.parse(handleDream({}));
1429
+ open_loops = {
1430
+ north_star: d.north_star ? { id: d.north_star.id, statement: String(d.north_star.statement).slice(0, 160) } : null,
1431
+ proposals: d.total ?? 0,
1432
+ proposals_top: (d.candidates ?? []).slice(0, 3).map((c) => ({ candidate_id: c.candidate_id ?? c.id, statement: String(c.statement ?? c.target_statement ?? '').slice(0, 120) })),
1433
+ distill_queue: d.distill_total ?? 0,
1434
+ friction: d.friction_total ?? 0,
1435
+ };
1436
+ }
1437
+ catch { /* fine */ }
1438
+ let entities = [];
1439
+ try {
1440
+ entities = (JSON.parse(handleListEntities({ limit: 8 })).entities ?? []).map((e) => ({ name: e.name, kind: e.kind, memories: e.memory_count }));
1441
+ }
1442
+ catch { /* fine */ }
1443
+ return JSON.stringify({
1444
+ ok: true,
1445
+ brief: true,
1446
+ triage,
1447
+ attention,
1448
+ where,
1449
+ open_loops,
1450
+ entities,
1451
+ next: [
1452
+ 'recall({ query }) to search; recall({ path }) for a file\'s history',
1453
+ 'drift_status() for the full truth map; drift_status({ anchor_id }) for one decision',
1454
+ 'recall({ dream: true }) to triage proposals and drain the distill queue',
1455
+ 'remember({ content, anchor: {} }) to record a decision and enforce it',
1456
+ ],
1457
+ });
1245
1458
  }
1246
1459
  async function handleRecallUnified(args) {
1460
+ // Folded surfaces (roadmap 5): locating yourself and the triage session are ways of
1461
+ // recalling, not separate tools.
1462
+ if (args?.dream)
1463
+ return handleDream({ domain: args.domain });
1464
+ if (args?.where !== undefined && args?.where !== false) {
1465
+ return handleWhereAmI({ query: args.where === true ? undefined : String(args.where), project: args.project, limit: args.limit });
1466
+ }
1247
1467
  // File history mode (path takes priority; if query also provided, include it as context)
1248
1468
  if (args.path) {
1249
1469
  const fileResult = await handleRecallFileWithRoots({
@@ -1264,17 +1484,15 @@ async function handleRecallUnified(args) {
1264
1484
  }
1265
1485
  return fileResult;
1266
1486
  }
1267
- // Detect overview request (no search criteria at all)
1487
+ // No search criteria at all → the session brief (or the entity list on request).
1268
1488
  const hasQuery = args.query && String(args.query).trim().length > 0;
1269
1489
  const hasFilters = args.entity_name || args.layer || args.altitude ||
1270
1490
  args.mem_type || args.mem_state || args.thread_id || args.band;
1271
1491
  if (!hasQuery && !hasFilters) {
1272
- return handleListEntities({
1273
- kind: args.kind,
1274
- min_memories: args.min_memories,
1275
- limit: args.limit,
1276
- offset: args.offset,
1277
- });
1492
+ if (args.overview || args.kind || args.min_memories !== undefined || args.offset) {
1493
+ return handleListEntities({ kind: args.kind, min_memories: args.min_memories, limit: args.limit, offset: args.offset });
1494
+ }
1495
+ return handleSessionBrief();
1278
1496
  }
1279
1497
  // Search mode (default)
1280
1498
  return handleRecall(args);
@@ -1283,6 +1501,8 @@ async function handleRecallUnified(args) {
1283
1501
  // Drift tool handlers (v0.8.0)
1284
1502
  // ============================================================
1285
1503
  function handleDriftStatus(args) {
1504
+ if (args?.anchor_id)
1505
+ return handleCheckDecision({ anchor_id: args.anchor_id });
1286
1506
  const view = getTruthView(db, {
1287
1507
  domain: args?.domain,
1288
1508
  decision_mode: args?.decision_mode,
@@ -1294,7 +1514,8 @@ function handleDriftStatus(args) {
1294
1514
  by_state.drift > 0 ? `🔴 ${by_state.drift} drifting` : null,
1295
1515
  by_state.review > 0 ? `🟡 ${by_state.review} needs review` : null,
1296
1516
  by_state.held > 0 ? `⚪ ${by_state.held} held` : null,
1297
- `🔵 ${by_state.aligned} aligned`,
1517
+ `🔵 ${by_state.aligned} verified`,
1518
+ by_state.unverified > 0 ? `⚫ ${by_state.unverified} unverified` : null,
1298
1519
  ].filter(Boolean).join(' · ');
1299
1520
  if (args?.verbose) {
1300
1521
  return JSON.stringify({
@@ -1315,6 +1536,13 @@ function handleDriftStatus(args) {
1315
1536
  count: g.nodes.length,
1316
1537
  nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement, reality: n.reality })),
1317
1538
  }));
1539
+ // Unverified is the quiet majority on most machines. One line each, grouped by domain —
1540
+ // enough to see where the detector has no eyes, not enough to bury the six that matter.
1541
+ const unverified = view.unverifiedByDomain.map((g) => ({
1542
+ domain: g.domain,
1543
+ count: g.nodes.length,
1544
+ nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement })),
1545
+ }));
1318
1546
  const cand = view.candidates;
1319
1547
  return JSON.stringify({
1320
1548
  ok: true,
@@ -1322,9 +1550,10 @@ function handleDriftStatus(args) {
1322
1550
  nextReopen: view.nextReopen,
1323
1551
  attention: view.attention,
1324
1552
  aligned,
1553
+ unverified,
1325
1554
  candidates: { auto: cand?.auto?.length ?? 0, suppressed: cand?.suppressed?.length ?? 0 },
1326
1555
  counts: view.counts,
1327
- hint: 'verbose:true for full aligned nodes and candidate details; check_decision(anchor_id) for one node.',
1556
+ 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
1557
  });
1329
1558
  }
1330
1559
  // Fix ① (2026-06-17): infer the Map project from the client's workspace roots when the
@@ -1426,6 +1655,26 @@ function handleDeclareAnchor(args) {
1426
1655
  if (!args?.kind || !args?.statement) {
1427
1656
  throw new Error('kind and statement are required');
1428
1657
  }
1658
+ // An orphaned proposal is a fork the user never took. Same tool as any other declaration —
1659
+ // the agent should not need a separate verb for "I noticed something went unaddressed".
1660
+ if (args.kind === 'proposal') {
1661
+ const r = JSON.parse(handleFlagProposals({
1662
+ session_context: args.session_context,
1663
+ proposals: [{
1664
+ statement: args.statement, rationale: args.rationale, domain: args.domain ?? 'general',
1665
+ confidence: args.confidence, decided: args.decided, siblings: args.siblings,
1666
+ }],
1667
+ }));
1668
+ const anchorId = r.proposals?.[0]?.anchor_id;
1669
+ if (!r.ok || !anchorId) {
1670
+ return JSON.stringify({ ok: false, error: r.error ?? 'proposal was not recorded (statement must be >= 10 chars)' });
1671
+ }
1672
+ const cand = db.prepare(`SELECT id FROM memory_write_candidates WHERE target_node_id = ? AND scope = 'orphaned_proposal' ORDER BY id DESC LIMIT 1`).get(anchorId);
1673
+ return JSON.stringify({
1674
+ ok: true, anchor_id: anchorId, candidate_id: cand?.id ?? null, kind: 'proposal', statement: args.statement,
1675
+ message: `Proposal recorded as review item #${anchorId}. Triage later: recall({ dream: true }) → resolve_drift({ candidate_id: ${cand?.id ?? '<id>'}, action: 'surface' | 'dismiss', rationale }).`,
1676
+ });
1677
+ }
1429
1678
  // Create the base anchor
1430
1679
  const anchor = declareAnchor(db, {
1431
1680
  kind: args.kind,
@@ -1464,6 +1713,21 @@ function handleDeclareAnchor(args) {
1464
1713
  });
1465
1714
  }
1466
1715
  function handleResolveDrift(args) {
1716
+ // A proposal verdict is a resolution like any other; it just targets a candidate row.
1717
+ if (args?.candidate_id) {
1718
+ if (!['surface', 'dismiss'].includes(args.action)) {
1719
+ return JSON.stringify({ ok: false, error: `candidate_id takes action 'surface' or 'dismiss' (got '${args.action}')` });
1720
+ }
1721
+ if (!args.rationale) {
1722
+ return JSON.stringify({ ok: false, error: 'rationale is required for a proposal verdict — say what in the North Star decided it' });
1723
+ }
1724
+ try {
1725
+ return handleResolveProposal({ candidate_id: args.candidate_id, verdict: args.action, rationale: args.rationale });
1726
+ }
1727
+ catch (e) {
1728
+ return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
1729
+ }
1730
+ }
1467
1731
  if (!args?.anchor_id || !args?.action) {
1468
1732
  throw new Error('anchor_id and action are required');
1469
1733
  }
@@ -1797,7 +2061,27 @@ function handleResolveProposal(args) {
1797
2061
  // ============================================================
1798
2062
  // MCP wiring
1799
2063
  // ============================================================
1800
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
2064
+ // Six tools on the surface (2026-09-07, roadmap 5). Anchor #1's reason for "3 tools, never a
2065
+ // 4th" was real — eight tools bled model-dependent behaviour across Claude/GPT/Cursor/Codex/
2066
+ // Gemini — and the surface had crept to eleven. The five below are folded into the six
2067
+ // (see LEGACY_TOOLS) and hidden from tools/list, but a call to any of them still works: an
2068
+ // agent or a SKILL.md written against 0.14 does not break. LINKSEE_LEGACY_TOOLS=1 lists them.
2069
+ const LEGACY_TOOLS = {
2070
+ where_am_i: 'recall({ where: "<topic>" }) — or recall() with no arguments for the session brief',
2071
+ check_decision: 'drift_status({ anchor_id })',
2072
+ flag_proposals: "declare_anchor({ kind: 'proposal', statement, rationale, domain, decided?, siblings? })",
2073
+ dream: 'recall({ dream: true }) — recall() with no arguments already includes the counts',
2074
+ resolve_proposal: "resolve_drift({ candidate_id, action: 'surface' | 'dismiss', rationale })",
2075
+ };
2076
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
2077
+ const showLegacy = process.env.LINKSEE_LEGACY_TOOLS === '1';
2078
+ const tools = TOOLS
2079
+ .filter((t) => showLegacy || !(t.name in LEGACY_TOOLS))
2080
+ .map((t) => (t.name in LEGACY_TOOLS
2081
+ ? { ...t, description: `(legacy — folded into ${LEGACY_TOOLS[t.name]}. Still works.)\n\n${t.description}` }
2082
+ : t));
2083
+ return { tools };
2084
+ });
1801
2085
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
1802
2086
  const { name, arguments: args } = req.params;
1803
2087
  try {
@@ -175,6 +175,11 @@ open → decided → in_progress → done
175
175
 
176
176
  ### ① Task Start — Always recall before starting work
177
177
 
178
+ **First call of a session: `recall()` with no arguments.** It returns the session brief — what
179
+ needs attention (🔴/🟡 anchors), where you are on the Map, open loops (proposals to triage,
180
+ raw memories to distill, friction at the gate), and the top entities. One call, small, complete.
181
+ Then search with `recall({ query })` for the task at hand.
182
+
178
183
  Before starting any new task, inject past context.
179
184
 
180
185
  **At the very beginning of a conversation**, use `list_entities` first to understand what you know:
@@ -534,20 +539,18 @@ User: "That's it for today"
534
539
 
535
540
  ```
536
541
  1. Review the session: which proposals did you make that the user never addressed?
537
- 2. flag_proposals({
538
- session_context: "GTM channel strategy discussion",
539
- proposals: [
540
- {
541
- statement: "[未解決] LinkedIn B2B: SaaS企業のCTO/VPE向けDMアウトリーチ",
542
- rationale: "3つのGTMチャネルを提示したがX/Twitterのみ採用。LinkedIn経由の検討が未着手",
543
- domain: "growth",
544
- confidence: 0.5,
545
- decided: "X/Twitter data-driven growth",
546
- siblings: ["X/Twitter", "LinkedIn B2B", "Dev Community"]
547
- },
548
- ...
549
- ]
542
+ 2. For each one:
543
+ declare_anchor({
544
+ kind: "proposal",
545
+ statement: "[未解決] LinkedIn B2B: SaaS企業のCTO/VPE向けDMアウトリーチ",
546
+ rationale: "3つのGTMチャネルを提示したがX/Twitterのみ採用。LinkedIn経由の検討が未着手",
547
+ domain: "growth",
548
+ confidence: 0.5,
549
+ decided: "X/Twitter data-driven growth",
550
+ siblings: ["X/Twitter", "LinkedIn B2B", "Dev Community"],
551
+ session_context: "GTM channel strategy discussion"
550
552
  })
553
+ → { anchor_id, candidate_id }
551
554
  3. Report: "Flagged N unresolved proposals for dashboard review."
552
555
  ```
553
556
 
@@ -555,27 +558,27 @@ Each proposal becomes a review-state anchor on the Linksee Dashboard — visible
555
558
 
556
559
  ### Case F3 — Dream: triage orphaned proposals against the North Star
557
560
 
558
- **Not all orphaned proposals are worth surfacing.** Many are outdated, already implicitly resolved, or irrelevant to the current direction. The `dream` tool returns the project's **North Star** (direction/goals/ICP/phase) alongside accumulated proposals so you can evaluate each one.
561
+ **Not all orphaned proposals are worth surfacing.** Many are outdated, already implicitly resolved, or irrelevant to the current direction. `recall({ dream: true })` returns the project's **North Star** (direction/goals/ICP/phase) alongside accumulated proposals so you can evaluate each one.
559
562
 
560
563
  Think like a General Doctor doing triage: the North Star is the patient's chart, each proposal is a symptom. Not every symptom needs treatment.
561
564
 
562
- **When to dream:**
565
+ **When to run the triage (`recall({ dream: true })`):**
563
566
  - At session start, if there are accumulated proposals
564
567
  - When the user asks "何か見落としてない?" or "what should we revisit?"
565
568
  - Periodically (weekly) to prevent proposal backlog from growing stale
566
569
 
567
570
  ```
568
- 1. dream()
569
- → Returns: north_star + candidates[]
571
+ 1. recall({ dream: true })
572
+ → Returns: north_star + candidates[] (each with candidate_id)
570
573
 
571
574
  2. For each candidate, evaluate against North Star:
572
575
  - Does this affect the current phase/goals? → surface
573
576
  - Is this for a different ICP or future phase? → dismiss
574
577
  - Already implicitly resolved by later decisions? → dismiss
575
578
 
576
- 3. resolve_proposal({
579
+ 3. resolve_drift({
577
580
  candidate_id: <id>,
578
- verdict: "surface" | "dismiss",
581
+ action: "surface" | "dismiss",
579
582
  rationale: "North Star says ICP = solo devs; this is enterprise-only → dismiss"
580
583
  })
581
584
  ```
@@ -596,14 +599,14 @@ Candidate C: "kintone enterprise integration"
596
599
 
597
600
  The North Star is declared via `declare_anchor(node_type: "north_star")` and should be updated when the project enters a new phase (e.g., post-HN → growth phase). This keeps the Doctor's judgment frame current.
598
601
 
599
- ### Case F4 — Distill: rewrite raw auto-captured memories (every dream call)
602
+ ### Case F4 — Distill: rewrite raw auto-captured memories (every `recall({ dream: true })` call)
600
603
 
601
- The session hook captures decisions/caveats as **RAW user utterances** (no LLM runs in the hook path — heuristic extraction is the best it can do). `dream` returns them as `distill_queue`. **You are the distiller.**
604
+ The session hook captures decisions/caveats as **RAW user utterances** (no LLM runs in the hook path — heuristic extraction is the best it can do). `recall({ dream: true })` returns them as `distill_queue`. **You are the distiller.**
602
605
 
603
- **When:** every `dream()` call — drain up to 8 items while triaging proposals. The SessionStart boot digest reminds you while the queue is non-empty.
606
+ **When:** every `recall({ dream: true })` call — drain up to 8 items while triaging proposals. The SessionStart boot digest reminds you while the queue is non-empty.
604
607
 
605
608
  ```
606
- 1. dream() → distill_queue: [{memory_id, layer, raw_what, context_hint, affects, created}]
609
+ 1. recall({ dream: true }) → distill_queue: [{memory_id, layer, raw_what, context_hint, affects, created}]
607
610
 
608
611
  2. For each item, rewrite into ONE clean record:
609
612
  - what = the actual decision/warning in one line — RESOLVE references
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.13.1",
3
+ "version": "0.15.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",