linksee-memory 0.15.1 → 0.16.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
@@ -1,12 +1,12 @@
1
1
  # linksee-memory
2
2
 
3
- > **Your agent forgets everything when a session ends. Worse — it silently drifts from what you decided last week.**
3
+ > **Claude Code forgets everything when you start a new session. Your successor knows even less.**
4
4
  >
5
- > **Linksee Memory catches when your project drifts from its own decisions** — the option abandoned at a fork, the pipeline that quietly stalled, the code that contradicts what you agreed — and a re-injection guard re-surfaces the locked decision **before** the agent acts. Rules you've explicitly hardened get blocked.
5
+ > **Linksee Memory lets you hand a project over — to your next session, to Cursor or Codex, to the person after you — with the reasons attached.** Record a decision once (`remember({ content, anchor: {} })`) and it comes back *before* the agent acts on it: re-injected on session start and ahead of every Edit / Write / Bash that touches it. Change your mind on the record and it stops nagging; leave it and it keeps the next agent from repeating last week's mistake.
6
6
  >
7
- > Underneath sits a local-first cross-LLM memory MCP — one SQLite file that **Claude Code, Cursor, Windsurf, OpenAI Codex, and Gemini CLI** all read from. Not just "what happened" but **WHY**: 6-layer structured memory with precision recall and an AST-aware diff cache (50–99% token savings on re-reads).
7
+ > Underneath: **drift detection** across every project you run in parallel — which ones quietly left their goal (🔴), which were checked and hold (🔵), which nobody has looked at yet (⚫). One local SQLite file that **Claude Code, Cursor, Windsurf, OpenAI Codex and Gemini CLI** all read. Nothing leaves your machine.
8
8
  >
9
- > `npx -y linksee-memory setup` — one command, done.
9
+ > `npx -y linksee-memory setup` — one command, on in every repo.
10
10
 
11
11
  [![npm](https://img.shields.io/npm/v/linksee-memory.svg)](https://www.npmjs.com/package/linksee-memory)
12
12
  [![license](https://img.shields.io/npm/l/linksee-memory.svg)](./LICENSE)
@@ -19,6 +19,46 @@
19
19
  <!-- releases: tag-driven, published from CI via npm trusted publishing (OIDC).
20
20
  See .github/workflows/publish.yml — no token, no OTP, provenance attached automatically. -->
21
21
 
22
+ ## Questions this answers
23
+
24
+ These are the questions people actually type — measured, verbatim (2026-09-04). If yours is here, this is the answer.
25
+
26
+ **Claude Code forgets everything when I start a new session. How do I make it remember decisions across sessions?**
27
+ Run `npx -y linksee-memory setup`. Every new session opens with `recall()` — a brief of what needs attention, where you are, what's unresolved — and your locked decisions are re-injected before the agent acts. Nothing to re-explain.
28
+
29
+ **Claude Codeがセッションをまたぐと過去の経緯を忘れてしまいます。記憶を持たせる方法を教えてください。**
30
+ `npx -y linksee-memory setup` の一回で入ります。新しいセッションは `recall()` の brief(注意すべきこと・いまどこか・未解決)から始まり、固定した決定は行動の直前に再注入されます。説明し直す必要はありません。
31
+
32
+ **What is the best MCP server for persistent memory across AI coding agents?**
33
+ Honest answer: if "remember across sessions" is all you need, a `CLAUDE.md` is enough — we measured it. Linksee is for the three moments it isn't: right before a mistake, when you change your mind, and when you run many projects and need to see which one drifted.
34
+
35
+ **AIコーディングエージェント用の記憶MCPサーバーでおすすめはありますか?**
36
+ 正直に言うと、「セッションを跨いで覚える」だけなら `CLAUDE.md` で足ります(実測しました)。Linksee が要るのは、間違いかけたとき・方針を変えたとき・並行案件のどれが外れたかを知りたいとき、の3場面です。
37
+
38
+ **I want one shared memory between Claude Code, Cursor, and OpenAI Codex. What tools exist for that?**
39
+ One SQLite file. Claude Code, Cursor, Windsurf, OpenAI Codex and Gemini CLI all read and write the same memory — a decision made in one shows up in the others.
40
+
41
+ **Claude CodeとCursorとCodexで同じ記憶を共有したいのですが、どうすればいいですか?**
42
+ SQLite ファイル1つを全員で読みます。Claude Code / Cursor / Windsurf / Codex / Gemini CLI のどこで決めた決定も、他のエージェントの行動の直前に出てきます。
43
+
44
+ **My AI agent keeps re-implementing things we already decided against. How can I detect that a codebase has drifted from past decisions?**
45
+ Declare the decision (`remember({ content, anchor: { violation_signal: [...] } })`). The guard then warns — or blocks, if you hardened it — the moment an edit contradicts it, and `drift_status` shows every decision that reality has quietly left.
46
+
47
+ **過去に決めた設計方針とコードがずれていないかを検出できるツールはありますか?**
48
+ 決定を宣言しておくと(`remember({ content, anchor: {...} })`)、それに反する編集の直前に警告(hardened なら拒否)が出ます。`drift_status` は「宣言と現実がずれた決定」を証拠つきで一覧します。
49
+
50
+ **Is there a local-first, self-hosted alternative to Mem0 for agent memory?**
51
+ Yes. No account, no API key, no cloud — one local file, MIT licensed. `npx -y linksee-memory setup` and it's on.
52
+
53
+ **Mem0 vs Zep vs Letta for a coding agent's long-term memory — which should I pick?**
54
+ We installed them and ran one scenario across all of them. Storing and recalling a decision: everyone passes. The difference appears *before a mistake* and *when you change your mind* — Linksee is built for those two moments; the others leave them to you.
55
+
56
+ **How do I stop Claude Code from repeating the same mistake it made last week?**
57
+ Record it as a caveat (`remember({ content, layer: 'caveat' })`). Caveats are protected from forgetting and come back when the same ground is touched again — and if you anchor it, the guard stops the repeat before it lands.
58
+
59
+ **開発の意思決定履歴をMCPサーバーで残しておく定番のやり方はありますか?**
60
+ `remember({ content, anchor: {} })` の1回で、記録と強制が同時に入ります。`drift_status` がその台帳で、各決定が いま守られているか(🔵)・ずれているか(🔴)・誰も確かめていないか(⚫)を示します。
61
+
22
62
  ## 🪄 Three spells to remember
23
63
 
24
64
  | Say this | What happens |
@@ -0,0 +1,29 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare const SETTLE_SECONDS: number;
3
+ export declare const STALE_DAYS = 90;
4
+ export declare const SHORT_CHARS = 40;
5
+ export type TriageVerdict = 'auto-noise' | 'auto-stale' | 'keep';
6
+ export declare function classifyRaw(what: string, ageDays: number, accessCount: number): {
7
+ verdict: TriageVerdict;
8
+ reason: string;
9
+ };
10
+ export interface TriageReport {
11
+ scanned: number;
12
+ noise: number;
13
+ stale: number;
14
+ remaining: number;
15
+ dryRun: boolean;
16
+ samples: {
17
+ noise: string[];
18
+ stale: string[];
19
+ };
20
+ }
21
+ /**
22
+ * Walk every needs_distill memory outside the settle window and archive the ones a rule can
23
+ * settle. Idempotent — archived rows no longer match the WHERE clause.
24
+ */
25
+ export declare function triageDistillQueue(db: Database.Database, opts?: {
26
+ dryRun?: boolean;
27
+ }): TriageReport;
28
+ /** Count of rows still awaiting a human/agent rewrite (outside the settle window). */
29
+ export declare function distillPending(db: Database.Database): number;
@@ -0,0 +1,104 @@
1
+ // distill-triage — keep the distill queue honest without an LLM.
2
+ //
3
+ // The Stop hook stores raw user utterances with needs_distill:true (no LLM runs in the hook
4
+ // path — anchor #70), and the agent is meant to rewrite them via recall({ dream: true }). On
5
+ // the author's machine that queue reached 389 and nobody drained it, because most of it was
6
+ // never a decision: "OK. Aからいこう", a pasted file path, "はい。そうしましょう". Asking an
7
+ // agent to distill those is asking it to find meaning that is not there.
8
+ //
9
+ // So: classify before asking. Two verdicts need no intelligence, only a rule —
10
+ // auto-noise the utterance carries no decision object (too short, a bare acknowledgement or
11
+ // imperative, a path/URL/attachment reference)
12
+ // auto-stale never recalled and older than STALE_DAYS — whatever it was, nobody came back
13
+ //
14
+ // Both are archived, never deleted: type → note, state → superseded, distill_verdict records
15
+ // why. needs_distill stays TRUE — anchor #70 says the flag must not be dropped, and it stays
16
+ // truthful: no agent ever rewrote this row. The queue, the pending count and the boot digest
17
+ // exclude rows that carry a verdict. Layer and protection are untouched (a caveat stays a
18
+ // caveat — it just stops being asked about). Reversible: delete distill_verdict from the JSON.
19
+ //
20
+ // Measured before shipping (2026-09-07, 389 raw): noise 88, stale 51, queue 250 — and the
21
+ // noise sample was read by a human before the rule was trusted.
22
+ export const SETTLE_SECONDS = 30 * 60; // a session still in motion is not triaged (matches dream)
23
+ export const STALE_DAYS = 90;
24
+ export const SHORT_CHARS = 40;
25
+ const ACK_ONLY = /^(ok|okay|はい|そうだね|そうですね|うん|了解|いいね|ありがとう|お願い|進めて|次いこう|やろう|それで|続けて|よし|では|じゃあ|なるほど)[\s。、.,!!]*$/i;
26
+ const ACK_LEAD = /^(ok|okay|はい|そうだね|そうですね|うん|了解|いいね|ありがとう|よし|では|じゃあ|なるほど)[\s。、.,!!]+/i;
27
+ const BARE_REF = /^(@"|@[A-Za-z]:|"?[A-Za-z]:\\|https?:\/\/)/;
28
+ const DECISION_OBJECT = /決めた|採用|確定|却下|やめ|禁止|方針|に(する|しよう)|でいこう|で行こう|decid|chose|going with|switch(ing)? to|settled on|approved|instead/i;
29
+ export function classifyRaw(what, ageDays, accessCount) {
30
+ const w = (what ?? '').trim();
31
+ if (w.length < SHORT_CHARS)
32
+ return { verdict: 'auto-noise', reason: `shorter than ${SHORT_CHARS} chars` };
33
+ if (ACK_ONLY.test(w))
34
+ return { verdict: 'auto-noise', reason: 'acknowledgement only' };
35
+ if (BARE_REF.test(w) && w.length < 120)
36
+ return { verdict: 'auto-noise', reason: 'bare path/URL reference' };
37
+ if (ACK_LEAD.test(w) && !DECISION_OBJECT.test(w) && w.length < 80)
38
+ return { verdict: 'auto-noise', reason: 'acknowledgement with no decision object' };
39
+ if (ageDays > STALE_DAYS && accessCount === 0)
40
+ return { verdict: 'auto-stale', reason: `never recalled in ${STALE_DAYS}+ days` };
41
+ return { verdict: 'keep', reason: '' };
42
+ }
43
+ /**
44
+ * Walk every needs_distill memory outside the settle window and archive the ones a rule can
45
+ * settle. Idempotent — archived rows no longer match the WHERE clause.
46
+ */
47
+ export function triageDistillQueue(db, opts = {}) {
48
+ const now = Math.floor(Date.now() / 1000);
49
+ const rows = db
50
+ .prepare(`SELECT id, content, access_count, created_at FROM memories
51
+ WHERE json_valid(content)
52
+ AND json_extract(content, '$.needs_distill') = 1
53
+ AND json_extract(content, '$.distill_verdict') IS NULL
54
+ AND created_at < ?`)
55
+ .all(now - SETTLE_SECONDS);
56
+ const report = { scanned: rows.length, noise: 0, stale: 0, remaining: 0, dryRun: !!opts.dryRun, samples: { noise: [], stale: [] } };
57
+ const update = db.prepare('UPDATE memories SET content = ? WHERE id = ?');
58
+ const apply = db.transaction(() => {
59
+ for (const r of rows) {
60
+ let c;
61
+ try {
62
+ c = JSON.parse(r.content);
63
+ }
64
+ catch {
65
+ report.remaining++;
66
+ continue;
67
+ }
68
+ const what = String(c.what ?? '');
69
+ const ageDays = (now - r.created_at) / 86400;
70
+ const { verdict, reason } = classifyRaw(what, ageDays, r.access_count ?? 0);
71
+ if (verdict === 'keep') {
72
+ report.remaining++;
73
+ continue;
74
+ }
75
+ if (verdict === 'auto-noise')
76
+ report.noise++;
77
+ else
78
+ report.stale++;
79
+ const bucket = report.samples[verdict === 'auto-noise' ? 'noise' : 'stale'];
80
+ if (bucket.length < 5)
81
+ bucket.push(what.replace(/\s+/g, ' ').slice(0, 80));
82
+ if (opts.dryRun)
83
+ continue;
84
+ c.distill_verdict = verdict;
85
+ c.distill_reason = reason;
86
+ c.type = 'note';
87
+ c.state = 'superseded';
88
+ update.run(JSON.stringify(c), r.id);
89
+ }
90
+ });
91
+ apply();
92
+ return report;
93
+ }
94
+ /** Count of rows still awaiting a human/agent rewrite (outside the settle window). */
95
+ export function distillPending(db) {
96
+ const now = Math.floor(Date.now() / 1000);
97
+ const r = db
98
+ .prepare(`SELECT COUNT(*) AS c FROM memories
99
+ WHERE json_valid(content) AND json_extract(content, '$.needs_distill') = 1
100
+ AND json_extract(content, '$.distill_verdict') IS NULL AND created_at < ?`)
101
+ .get(now - SETTLE_SECONDS);
102
+ return r?.c ?? 0;
103
+ }
104
+ //# sourceMappingURL=distill-triage.js.map
package/dist/lib/guard.js CHANGED
@@ -264,6 +264,7 @@ export function buildBootDigest(db, opts = {}) {
264
264
  try {
265
265
  distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
266
266
  WHERE layer IN ('learning', 'caveat') AND json_valid(content)
267
+ AND json_extract(content, '$.distill_verdict') IS NULL
267
268
  AND (json_extract(content, '$.needs_distill') = 1
268
269
  OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
269
270
  OR json_extract(content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')`).get().n;
@@ -285,7 +286,7 @@ export function buildBootDigest(db, opts = {}) {
285
286
  parts.push(`• ${f.statement ?? f.rationale}`);
286
287
  }
287
288
  if (distill > 0) {
288
- parts.push('', `🧪 ${distill} auto-captured memories are still raw utterances — call dream() and rewrite the distill_queue via remember(memory_id, content) with "distilled": true.`);
289
+ parts.push('', `🧪 ${distill} auto-captured memories still need a rewrite (rules already settled the acknowledgements and stale ones). Do 3: recall({ dream: true }) → remember({ memory_id, content }) with "distilled": true.`);
289
290
  }
290
291
  if (anchors.length > 0)
291
292
  parts.push('', 'Honor these unless you explicitly supersede them (resolve_drift action=supersede).');
@@ -24,6 +24,7 @@ import { getTruthView, getDecisionDetail, resolveDrift } from '../lib/truth-engi
24
24
  import { declareAnchor, setNodeFields } from '../lib/drift-anchors.js';
25
25
  import { getReinjectionFriction, setGateMode } from '../lib/guard.js';
26
26
  import { logAnchorTouch } from '../lib/anchor-touch.js';
27
+ import { triageDistillQueue, distillPending } from '../lib/distill-triage.js';
27
28
  import { whereAmI, listMapProjects } from '../lib/map-view.js';
28
29
  import { readFileSync, existsSync } from 'node:fs';
29
30
  import { fileURLToPath } from 'node:url';
@@ -90,6 +91,13 @@ setTimeout(() => {
90
91
  if (shouldRun) {
91
92
  runConsolidate(db, { scope: 'all', min_age_days: 7 });
92
93
  process.stderr.write('[linksee-memory] auto-consolidate complete\n');
94
+ // Triage the distill queue by rule before any agent is asked to think about it.
95
+ try {
96
+ const t = triageDistillQueue(db);
97
+ if (t.noise + t.stale > 0)
98
+ process.stderr.write(`[linksee-memory] distill triage: archived ${t.noise} noise + ${t.stale} stale, ${t.remaining} remain\n`);
99
+ }
100
+ catch { /* never block startup */ }
93
101
  }
94
102
  }
95
103
  catch { /* non-fatal */ }
@@ -195,6 +203,7 @@ const TOOLS = [
195
203
  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
204
  project: { type: 'string', description: 'With where: the Map project slug, when several maps are imported.' },
197
205
  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 }).' },
206
+ distill: { type: 'number', description: 'With dream: how many raw memories to return for rewriting (default 8, max 25). The queue is value-ordered; rule-verdicted items never appear.' },
198
207
  overview: { type: 'boolean', default: false, description: 'Return the entity list instead of the session brief when no query is given.' },
199
208
  kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'For overview mode: filter by entity kind.' },
200
209
  min_memories: { type: 'number', description: 'For overview mode: minimum memory count. Default 1.', default: 1 },
@@ -1431,7 +1440,7 @@ async function handleSessionBrief() {
1431
1440
  north_star: d.north_star ? { id: d.north_star.id, statement: String(d.north_star.statement).slice(0, 160) } : null,
1432
1441
  proposals: d.total ?? 0,
1433
1442
  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,
1443
+ distill_queue: d.distill_total ?? 0, // rows still needing a rewrite; rule-verdicted rows are not counted
1435
1444
  friction: d.friction_total ?? 0,
1436
1445
  };
1437
1446
  }
@@ -1855,6 +1864,7 @@ function handleDream(args) {
1855
1864
  // utterances (no LLM there); the agent rewrites them here into clean what/why via
1856
1865
  // remember(memory_id, content). Matches needs_distill (new) AND the legacy hardcoded
1857
1866
  // why-strings so the existing backlog is drainable without a backfill write.
1867
+ const distillLimit = Math.max(1, Math.min(25, Number(args?.distill ?? 8) || 8));
1858
1868
  let distillQueue = [];
1859
1869
  try {
1860
1870
  const rows = db.prepare(`
@@ -1865,10 +1875,15 @@ function handleDream(args) {
1865
1875
  -- settle window: the Stop hook extracts every turn, so the newest rows belong to a
1866
1876
  -- session still in motion. Don't ask the agent to distill the conversation it is in.
1867
1877
  AND m.created_at < unixepoch() - 1800
1878
+ AND json_extract(m.content, '$.distill_verdict') IS NULL
1868
1879
  AND (json_extract(m.content, '$.needs_distill') = 1
1869
1880
  OR json_extract(m.content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
1870
1881
  OR json_extract(m.content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')
1871
- ORDER BY m.created_at DESC LIMIT 8
1882
+ ORDER BY (m.layer = 'caveat') DESC,
1883
+ (e.name <> 'Local') DESC,
1884
+ length(json_extract(m.content, '$.what')) DESC,
1885
+ m.created_at DESC
1886
+ LIMIT ${distillLimit}
1872
1887
  `).all();
1873
1888
  distillQueue = rows.map((r) => {
1874
1889
  let c = {};
@@ -1898,7 +1913,9 @@ function handleDream(args) {
1898
1913
  friction,
1899
1914
  friction_total: friction.length,
1900
1915
  distill_queue: distillQueue,
1901
- distill_total: distillQueue.length,
1916
+ distill_total: distillPending(db),
1917
+ distill_shown: distillQueue.length,
1918
+ distill_hint: 'Ordered by value (caveats first, named entities, longer text). Pass distill: N (max 25) for a longer drain. Rules already set a distill_verdict on acknowledgements, bare paths and 90-day-old never-recalled items — you only see what needs judgement.',
1902
1919
  message: friction.length > 0
1903
1920
  ? 'No North Star declared yet — but the re-injection layer surfaced friction below: anchors being violated despite being re-surfaced. Declare a North Star (declare_anchor node_type:"north_star"), and act on the friction items.'
1904
1921
  : 'No North Star declared yet. Declare one with declare_anchor(node_type: "north_star") before dreaming.',
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "mcpName": "io.github.michielinksee/linksee-memory",
5
- "description": "Local-first agent memory MCP — cross-agent brain with drift detection, 6-layer structured memory + token-saving file diff cache",
5
+ "description": "Hand a project over — to your next session, to Cursor/Codex/Gemini, to your successor — with the reasons attached. Local-first memory MCP for coding agents: decisions re-injected before the agent acts, drift detection across every project you run. One SQLite file, nothing leaves your machine.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "linksee-memory": "dist/mcp/server.js",
@@ -39,14 +39,24 @@
39
39
  "mcp",
40
40
  "model-context-protocol",
41
41
  "memory",
42
- "agent",
43
42
  "agent-memory",
43
+ "persistent-memory",
44
+ "long-term-memory",
45
+ "session-memory",
46
+ "handover",
47
+ "hand-off",
48
+ "decision-log",
49
+ "drift-detection",
50
+ "guardrails",
44
51
  "claude",
45
52
  "claude-code",
46
53
  "cursor",
54
+ "windsurf",
55
+ "codex",
56
+ "gemini-cli",
47
57
  "chatgpt",
48
- "sqlite",
49
58
  "local-first",
59
+ "sqlite",
50
60
  "token-savings",
51
61
  "cross-agent"
52
62
  ],