linksee-memory 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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. |
@@ -95,6 +95,14 @@ export type ResolutionAction = 'fix' | 'supersede' | 'acknowledge' | 'dismiss';
95
95
  export interface ResolveInput {
96
96
  /** For dismiss: silence only this match term. Omitted → silence the anchor at the gate. */
97
97
  hit_term?: string;
98
+ /**
99
+ * For dismiss: false = close these drift edges as false positives but leave the gate
100
+ * watching. The default (true) also stops the gate. Two different verdicts hide behind
101
+ * "dismiss": "this detection was wrong" and "stop detecting this". Anchor #2 ("no destructive
102
+ * migrations") had a false positive from migrate.ts — the right answer was to close the edge
103
+ * and keep the gate, since "ALTER TABLE memories DROP" is exactly what it exists to catch.
104
+ */
105
+ gate?: boolean;
98
106
  anchor_id: number;
99
107
  action: ResolutionAction;
100
108
  rationale?: string;
@@ -543,12 +543,17 @@ export function resolveDrift(db, input) {
543
543
  // real detections survive.
544
544
  if (input.action === 'dismiss') {
545
545
  db.prepare("UPDATE drift_edges SET status = 'dismissed' WHERE anchor_id = ? AND status = 'open'").run(input.anchor_id);
546
- try {
547
- db.prepare(`INSERT INTO gate_dismissals (anchor_id, hit_term, rationale) VALUES (?, ?, ?)
548
- ON CONFLICT(anchor_id, COALESCE(hit_term, '')) DO UPDATE SET rationale = excluded.rationale`).run(input.anchor_id, input.hit_term ? input.hit_term.toLowerCase() : null, input.rationale ?? null);
549
- resolution.gate_dismissed = input.hit_term ? input.hit_term.toLowerCase() : 'all matches';
546
+ if (input.gate === false) {
547
+ resolution.gate_dismissed = 'none (edges closed; gate still watching)';
548
+ resolution.gate = false;
550
549
  }
551
- catch { /* pre-v16 DB → edges-only dismiss, as before */ }
550
+ else
551
+ try {
552
+ db.prepare(`INSERT INTO gate_dismissals (anchor_id, hit_term, rationale) VALUES (?, ?, ?)
553
+ ON CONFLICT(anchor_id, COALESCE(hit_term, '')) DO UPDATE SET rationale = excluded.rationale`).run(input.anchor_id, input.hit_term ? input.hit_term.toLowerCase() : null, input.rationale ?? null);
554
+ resolution.gate_dismissed = input.hit_term ? input.hit_term.toLowerCase() : 'all matches';
555
+ }
556
+ catch { /* pre-v16 DB → edges-only dismiss, as before */ }
552
557
  }
553
558
  // If action is 'fix', mark open edges as resolved
554
559
  if (input.action === 'fix') {
@@ -172,7 +172,7 @@ const TOOLS = [
172
172
  },
173
173
  {
174
174
  name: 'recall',
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• 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.',
176
176
  inputSchema: {
177
177
  type: 'object',
178
178
  properties: {
@@ -192,6 +192,10 @@ const TOOLS = [
192
192
  max_intents: { type: 'number', description: 'For file mode: max user-intent snippets. Default 10.', default: 10 },
193
193
  scope_to_roots: { type: 'boolean', default: false, description: 'For file mode: filter to client-provided roots.' },
194
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.' },
195
199
  kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'For overview mode: filter by entity kind.' },
196
200
  min_memories: { type: 'number', description: 'For overview mode: minimum memory count. Default 1.', default: 1 },
197
201
  },
@@ -219,6 +223,7 @@ const TOOLS = [
219
223
  domain: { type: 'string', description: 'Filter by domain (strategy, product, engineering, growth, etc.)' },
220
224
  decision_mode: { type: 'string', description: 'Filter by decision_mode (hypothesis, constraint, commitment, source_of_truth)' },
221
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.)' },
222
227
  },
223
228
  },
224
229
  },
@@ -252,7 +257,10 @@ const TOOLS = [
252
257
  inputSchema: {
253
258
  type: 'object',
254
259
  properties: {
255
- 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." },
256
264
  statement: { type: 'string', description: 'The normative claim (>= 8 chars)' },
257
265
  rationale: { type: 'string', description: 'Why this was decided' },
258
266
  affects: { type: 'array', items: { type: 'string' }, description: 'Path globs that scope this anchor' },
@@ -272,12 +280,14 @@ const TOOLS = [
272
280
  },
273
281
  {
274
282
  name: 'resolve_drift',
275
- description: 'Record a resolution for a drifting anchor — the human feedback loop.\n\n6 actions:\n• fix — "we fixed the code/reality to match intent" → state becomes aligned\n• supersede — "intent evolved, this is the new direction" → state becomes aligned\n• acknowledge — "we know, parking it for now" → state becomes held (with optional review date)\n• dismiss — "false positive, not actually drifting" → edges dismissed AND the gate stops firing on it (pass hit_term to silence just that word)\n• harden — "re-injected but still violated, enforce it" → card_policy.gate_mode=hard (PreToolUse will BLOCK)\n• soften — "back off to a warning" → gate_mode=soft\n\nWHEN TO CALL:\n• After drift_status shows 🔴 drift or 🟡 review items\n• When the user says "that\'s fixed" / "ignore that" / "we changed direction"\n• When acknowledging a known gap with a review date',
283
+ description: 'Record a resolution for a drifting anchor — the human feedback loop.\n\n6 actions:\n• fix — "we fixed the code/reality to match intent" → state becomes aligned\n• supersede — "intent evolved, this is the new direction" → state becomes aligned\n• acknowledge — "we know, parking it for now" → state becomes held (with optional review date)\n• dismiss — "false positive, not actually drifting" → edges dismissed AND the gate stops firing on it (hit_term: just that word; gate:false: close the edges but keep the gate watching)\n• harden — "re-injected but still violated, enforce it" → card_policy.gate_mode=hard (PreToolUse will BLOCK)\n• soften — "back off to a warning" → gate_mode=soft\n\nWHEN TO CALL:\n• After drift_status shows 🔴 drift or 🟡 review items\n• When the user says "that\'s fixed" / "ignore that" / "we changed direction"\n• When acknowledging a known gap with a review date',
276
284
  inputSchema: {
277
285
  type: 'object',
278
286
  properties: {
279
287
  anchor_id: { type: 'number', description: 'The drift_anchor ID to resolve' },
280
- 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.)' },
290
+ gate: { type: 'boolean', default: true, description: "For dismiss: false = these detections were false positives, close them, but KEEP the gate watching this anchor (its signals still fire). Use when the anchor's rule is right and only this evidence was wrong — e.g. a lexical hit in a file that is not what the rule is about. Default true also silences the gate (whole anchor, or just hit_term)." },
281
291
  rationale: { type: 'string', description: 'Why this resolution (recorded for audit trail)' },
282
292
  review_after: { type: 'string', description: 'For acknowledge: ISO date to re-check (e.g. "2026-07-04")' },
283
293
  superseded_by: { type: 'number', description: 'For supersede: the new anchor ID that replaces this one' },
@@ -1383,7 +1393,78 @@ async function inferDefaultEntity() {
1383
1393
  catch { /* fall through */ }
1384
1394
  return { name: 'workspace', kind: 'project', from: 'fallback' };
1385
1395
  }
1396
+ /**
1397
+ * The session brief: what an agent needs in the first call of a session, in one call.
1398
+ *
1399
+ * On 2026-09-05 this took four calls (recall / drift_status / dream / where_am_i), ~20k tokens,
1400
+ * and one of them failed. The brief carries the attention items in full and everything else as
1401
+ * counts plus a hint for the drill-down — small enough to call freely, complete enough that the
1402
+ * agent does not have to know which of five tools holds which fact.
1403
+ */
1404
+ async function handleSessionBrief() {
1405
+ const view = getTruthView(db, {});
1406
+ const bs = view.counts.by_state;
1407
+ const triage = `${view.counts.nodes} anchors: ` + [
1408
+ bs.drift > 0 ? `🔴 ${bs.drift} drifting` : null,
1409
+ bs.review > 0 ? `🟡 ${bs.review} needs review` : null,
1410
+ bs.held > 0 ? `⚪ ${bs.held} held` : null,
1411
+ `🔵 ${bs.aligned} verified`,
1412
+ bs.unverified > 0 ? `⚫ ${bs.unverified} unverified` : null,
1413
+ ].filter(Boolean).join(' · ');
1414
+ const attention = view.attention.slice(0, 8).map((n) => ({
1415
+ id: n.id, state: n.state, statement: n.statement.slice(0, 160), reality: (n.reality ?? '').slice(0, 160),
1416
+ }));
1417
+ let where = null;
1418
+ try {
1419
+ const w = JSON.parse(await handleWhereAmI({}));
1420
+ where = w.located
1421
+ ? { project: w.project, you_are_here: w.you_are_here }
1422
+ : w.reason
1423
+ ? { reason: w.reason, available_projects: w.available_projects }
1424
+ : null;
1425
+ }
1426
+ catch { /* no map, or roots unavailable — the brief still stands */ }
1427
+ let open_loops = null;
1428
+ try {
1429
+ const d = JSON.parse(handleDream({}));
1430
+ open_loops = {
1431
+ north_star: d.north_star ? { id: d.north_star.id, statement: String(d.north_star.statement).slice(0, 160) } : null,
1432
+ proposals: d.total ?? 0,
1433
+ 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) })),
1434
+ distill_queue: d.distill_total ?? 0,
1435
+ friction: d.friction_total ?? 0,
1436
+ };
1437
+ }
1438
+ catch { /* fine */ }
1439
+ let entities = [];
1440
+ try {
1441
+ entities = (JSON.parse(handleListEntities({ limit: 8 })).entities ?? []).map((e) => ({ name: e.name, kind: e.kind, memories: e.memory_count }));
1442
+ }
1443
+ catch { /* fine */ }
1444
+ return JSON.stringify({
1445
+ ok: true,
1446
+ brief: true,
1447
+ triage,
1448
+ attention,
1449
+ where,
1450
+ open_loops,
1451
+ entities,
1452
+ next: [
1453
+ 'recall({ query }) to search; recall({ path }) for a file\'s history',
1454
+ 'drift_status() for the full truth map; drift_status({ anchor_id }) for one decision',
1455
+ 'recall({ dream: true }) to triage proposals and drain the distill queue',
1456
+ 'remember({ content, anchor: {} }) to record a decision and enforce it',
1457
+ ],
1458
+ });
1459
+ }
1386
1460
  async function handleRecallUnified(args) {
1461
+ // Folded surfaces (roadmap 5): locating yourself and the triage session are ways of
1462
+ // recalling, not separate tools.
1463
+ if (args?.dream)
1464
+ return handleDream({ domain: args.domain });
1465
+ if (args?.where !== undefined && args?.where !== false) {
1466
+ return handleWhereAmI({ query: args.where === true ? undefined : String(args.where), project: args.project, limit: args.limit });
1467
+ }
1387
1468
  // File history mode (path takes priority; if query also provided, include it as context)
1388
1469
  if (args.path) {
1389
1470
  const fileResult = await handleRecallFileWithRoots({
@@ -1404,17 +1485,15 @@ async function handleRecallUnified(args) {
1404
1485
  }
1405
1486
  return fileResult;
1406
1487
  }
1407
- // Detect overview request (no search criteria at all)
1488
+ // No search criteria at all → the session brief (or the entity list on request).
1408
1489
  const hasQuery = args.query && String(args.query).trim().length > 0;
1409
1490
  const hasFilters = args.entity_name || args.layer || args.altitude ||
1410
1491
  args.mem_type || args.mem_state || args.thread_id || args.band;
1411
1492
  if (!hasQuery && !hasFilters) {
1412
- return handleListEntities({
1413
- kind: args.kind,
1414
- min_memories: args.min_memories,
1415
- limit: args.limit,
1416
- offset: args.offset,
1417
- });
1493
+ if (args.overview || args.kind || args.min_memories !== undefined || args.offset) {
1494
+ return handleListEntities({ kind: args.kind, min_memories: args.min_memories, limit: args.limit, offset: args.offset });
1495
+ }
1496
+ return handleSessionBrief();
1418
1497
  }
1419
1498
  // Search mode (default)
1420
1499
  return handleRecall(args);
@@ -1423,6 +1502,8 @@ async function handleRecallUnified(args) {
1423
1502
  // Drift tool handlers (v0.8.0)
1424
1503
  // ============================================================
1425
1504
  function handleDriftStatus(args) {
1505
+ if (args?.anchor_id)
1506
+ return handleCheckDecision({ anchor_id: args.anchor_id });
1426
1507
  const view = getTruthView(db, {
1427
1508
  domain: args?.domain,
1428
1509
  decision_mode: args?.decision_mode,
@@ -1575,6 +1656,26 @@ function handleDeclareAnchor(args) {
1575
1656
  if (!args?.kind || !args?.statement) {
1576
1657
  throw new Error('kind and statement are required');
1577
1658
  }
1659
+ // An orphaned proposal is a fork the user never took. Same tool as any other declaration —
1660
+ // the agent should not need a separate verb for "I noticed something went unaddressed".
1661
+ if (args.kind === 'proposal') {
1662
+ const r = JSON.parse(handleFlagProposals({
1663
+ session_context: args.session_context,
1664
+ proposals: [{
1665
+ statement: args.statement, rationale: args.rationale, domain: args.domain ?? 'general',
1666
+ confidence: args.confidence, decided: args.decided, siblings: args.siblings,
1667
+ }],
1668
+ }));
1669
+ const anchorId = r.proposals?.[0]?.anchor_id;
1670
+ if (!r.ok || !anchorId) {
1671
+ return JSON.stringify({ ok: false, error: r.error ?? 'proposal was not recorded (statement must be >= 10 chars)' });
1672
+ }
1673
+ 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);
1674
+ return JSON.stringify({
1675
+ ok: true, anchor_id: anchorId, candidate_id: cand?.id ?? null, kind: 'proposal', statement: args.statement,
1676
+ message: `Proposal recorded as review item #${anchorId}. Triage later: recall({ dream: true }) → resolve_drift({ candidate_id: ${cand?.id ?? '<id>'}, action: 'surface' | 'dismiss', rationale }).`,
1677
+ });
1678
+ }
1578
1679
  // Create the base anchor
1579
1680
  const anchor = declareAnchor(db, {
1580
1681
  kind: args.kind,
@@ -1613,6 +1714,21 @@ function handleDeclareAnchor(args) {
1613
1714
  });
1614
1715
  }
1615
1716
  function handleResolveDrift(args) {
1717
+ // A proposal verdict is a resolution like any other; it just targets a candidate row.
1718
+ if (args?.candidate_id) {
1719
+ if (!['surface', 'dismiss'].includes(args.action)) {
1720
+ return JSON.stringify({ ok: false, error: `candidate_id takes action 'surface' or 'dismiss' (got '${args.action}')` });
1721
+ }
1722
+ if (!args.rationale) {
1723
+ return JSON.stringify({ ok: false, error: 'rationale is required for a proposal verdict — say what in the North Star decided it' });
1724
+ }
1725
+ try {
1726
+ return handleResolveProposal({ candidate_id: args.candidate_id, verdict: args.action, rationale: args.rationale });
1727
+ }
1728
+ catch (e) {
1729
+ return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
1730
+ }
1731
+ }
1616
1732
  if (!args?.anchor_id || !args?.action) {
1617
1733
  throw new Error('anchor_id and action are required');
1618
1734
  }
@@ -1636,6 +1752,7 @@ function handleResolveDrift(args) {
1636
1752
  review_after: args.review_after,
1637
1753
  superseded_by: args.superseded_by,
1638
1754
  hit_term: args.hit_term,
1755
+ gate: args.gate,
1639
1756
  });
1640
1757
  return JSON.stringify(result);
1641
1758
  }
@@ -1946,7 +2063,27 @@ function handleResolveProposal(args) {
1946
2063
  // ============================================================
1947
2064
  // MCP wiring
1948
2065
  // ============================================================
1949
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
2066
+ // Six tools on the surface (2026-09-07, roadmap 5). Anchor #1's reason for "3 tools, never a
2067
+ // 4th" was real — eight tools bled model-dependent behaviour across Claude/GPT/Cursor/Codex/
2068
+ // Gemini — and the surface had crept to eleven. The five below are folded into the six
2069
+ // (see LEGACY_TOOLS) and hidden from tools/list, but a call to any of them still works: an
2070
+ // agent or a SKILL.md written against 0.14 does not break. LINKSEE_LEGACY_TOOLS=1 lists them.
2071
+ const LEGACY_TOOLS = {
2072
+ where_am_i: 'recall({ where: "<topic>" }) — or recall() with no arguments for the session brief',
2073
+ check_decision: 'drift_status({ anchor_id })',
2074
+ flag_proposals: "declare_anchor({ kind: 'proposal', statement, rationale, domain, decided?, siblings? })",
2075
+ dream: 'recall({ dream: true }) — recall() with no arguments already includes the counts',
2076
+ resolve_proposal: "resolve_drift({ candidate_id, action: 'surface' | 'dismiss', rationale })",
2077
+ };
2078
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
2079
+ const showLegacy = process.env.LINKSEE_LEGACY_TOOLS === '1';
2080
+ const tools = TOOLS
2081
+ .filter((t) => showLegacy || !(t.name in LEGACY_TOOLS))
2082
+ .map((t) => (t.name in LEGACY_TOOLS
2083
+ ? { ...t, description: `(legacy — folded into ${LEGACY_TOOLS[t.name]}. Still works.)\n\n${t.description}` }
2084
+ : t));
2085
+ return { tools };
2086
+ });
1950
2087
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
1951
2088
  const { name, arguments: args } = req.params;
1952
2089
  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.14.0",
3
+ "version": "0.15.1",
4
4
  "mcpName": "io.github.michielinksee/linksee-memory",
5
5
  "description": "Local-first agent memory MCP — cross-agent brain with drift detection, 6-layer structured memory + token-saving file diff cache",
6
6
  "type": "module",