linksee-memory 0.7.0 → 0.7.2

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
@@ -79,7 +79,7 @@ That single `caveat` memory is what separates "flat fact storage" from "the agen
79
79
  Returns match_reasons explaining each hit
80
80
  ```
81
81
 
82
- Every memory is tagged with **exactly one layer**. `caveat`-layer entries are protected from auto-forgetting. Cold low-importance memories get compressed into `learning` entries via `consolidate()`.
82
+ Every memory is tagged with **exactly one layer**. `caveat`-layer entries are protected from auto-forgetting. Cold low-importance memories are auto-consolidated into `learning` entries on server startup.
83
83
 
84
84
  ---
85
85
 
@@ -87,7 +87,7 @@ Every memory is tagged with **exactly one layer**. `caveat`-layer entries are pr
87
87
 
88
88
  Most "agent memory" services (Mem0, Letta, Zep) save a flat list of facts. Then the agent looks at "edited file X 30 times" and has no idea why. **linksee-memory keeps the WHY.**
89
89
 
90
- It is a Model Context Protocol (MCP) server that gives any AI agent four superpowers:
90
+ It is a Model Context Protocol (MCP) server with **3 tools** that gives any AI agent structured memory:
91
91
 
92
92
  | | Mem0 / Letta / Zep | Claude Code auto-memory | linksee-memory |
93
93
  |---|---|---|---|
@@ -127,7 +127,7 @@ Restart Claude Code, then just chat normally. Add **"Use Linksee"** to any promp
127
127
  claude mcp add -s user linksee -- npx -y linksee-memory
128
128
  ```
129
129
 
130
- Tools appear as `mcp__linksee__remember`, `mcp__linksee__recall`, `mcp__linksee__recall_file`, `mcp__linksee__read_smart`, `mcp__linksee__forget`, `mcp__linksee__consolidate`.
130
+ Tools appear as `mcp__linksee__remember`, `mcp__linksee__recall`, `mcp__linksee__read_smart`.
131
131
 
132
132
  **Install the skill (auto-invocation):**
133
133
 
@@ -248,28 +248,37 @@ All editors share the same `~/.linksee-memory/memory.db`. A decision made in Cla
248
248
 
249
249
  Default: `~/.linksee-memory/memory.db`. Override with `LINKSEE_MEMORY_DIR` env var.
250
250
 
251
- ## What's new in v0.4
251
+ ## What's new in v0.7
252
+
253
+ | Feature | Detail |
254
+ |---|---|
255
+ | **3-tool unified surface** | 8 tools → 3: `remember` (create + update + delete), `recall` (search + file history + overview), `read_smart` (token-saving reads). Fewer tools = better cross-LLM consistency. Follows Context7's proven pattern. |
256
+ | **Auto-consolidate** | Consolidation runs automatically on server startup (non-blocking, 7-day threshold). No manual `consolidate()` calls needed. |
257
+ | **Deprecation guidance** | Old tool names (`forget`, `recall_file`, etc.) return specific migration examples instead of silent failures. |
258
+ | **"Use Linksee Memory" trigger** | Add "Use Linksee Memory" to any prompt to force memory recall — same adoption pattern as Context7. |
259
+ | **Claude Code Plugin** | `claude plugin add -- linksee-memory` — ships MCP server + auto-invocation skill in one install. |
260
+
261
+ <details>
262
+ <summary>What's new in v0.4</summary>
252
263
 
253
264
  | Feature | Detail |
254
265
  |---|---|
255
266
  | **One-command setup** | `npx linksee-memory-setup` — registers MCP server, installs skill, configures auto-capture hook. One command instead of three. |
256
267
  | **Structured memory v2** | 3-axis classification (altitude × type × state) for every memory. Auto-extraction from sessions produces machine-scannable JSON, not raw chat dumps. |
257
268
  | **Precision recall guide** | SKILL.md now teaches agents HOW to write effective queries, WHEN to recall vs skip, and WHEN to proactively surface caveats before risky actions. |
258
- | **"Use Linksee" trigger** | Add "Use Linksee" to any prompt to force memory recall same adoption pattern as Context7. |
259
- | **Five MCP Blocks** | Tools + Resources + Prompts + Sampling + Roots + Elicitation. Most MCP servers expose only Tools; linksee-memory implements all six primitives. |
269
+ | **Five MCP Blocks** | Tools + Resources + Prompts + Sampling + Roots + Elicitation. Most MCP servers expose only Tools; linksee-memory implements all five primitives. |
270
+
271
+ </details>
260
272
 
261
- ## Tools
273
+ ## 3 Tools (v0.7)
262
274
 
263
- | Tool | Purpose |
275
+ | Tool | What it does |
264
276
  |---|---|
265
- | `remember` | Store memory in 1 of 6 layers for an entity. Rejects pasted assistant output / CI logs unless `force=true`. Set `importance=1.0` to pin (survives auto-forget). |
266
- | `recall` | FTS5 + heat × momentum × importance composite ranking with `match_reasons` explaining WHY each row matched. Supports pagination (`offset`/`has_more`), `band` filter, layer aliases (`decisions`/`warnings`/`how`/...), and `mark_accessed=false` for passive previews. |
267
- | `recall_file` | Complete edit history of a file across all sessions, with per-edit user-intent context. **v0.3.0** `scope_to_roots` flag filters by client roots. |
268
- | `update_memory` | **v0.1.0** Atomic edit of an existing memory. Preserves `memory_id` (session_file_edits links stay intact). Prefer over forget+remember. |
269
- | `list_entities` | **v0.1.0** List what the memory knows about cheapest "what do I know?" primitive. Filter by `kind`/`min_memories`; returns layer breakdown per entity. |
270
- | `read_smart` | Diff-only file read. Returns full content on first read, ~50 tokens on unchanged re-reads, only changed chunks on real edits. |
271
- | `forget` | Explicit delete OR auto-sweep based on `forgettingRisk`. Pinned (`importance>=1.0`) and caveat-layer memories are always preserved. **v0.3.0** `interactive` flag asks the user via Elicitation before deleting a specific memory_id. |
272
- | `consolidate` | Sleep-mode compression: cluster cold low-importance memories → protected learning-layer summary. Supports `dry_run` preview. **v0.3.0** `use_llm` flag asks the client LLM (Sampling) to rewrite cluster summaries into prose. |
277
+ | `remember` | **Save / update / delete** memories. Auto-classifies into 6 layers. Modes: create (default), update (`memory_id` + fields), delete (`forget: true` + `memory_id`). |
278
+ | `recall` | **Search / file history / overview.** Modes: search (`query`), file history (`path`), entity overview (no params). FTS5 + heat × momentum ranking with `match_reasons`. |
279
+ | `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. |
280
+
281
+ Previous versions exposed 8 tools — v0.7.0 unified them into 3 for cross-LLM consistency. The server handles routing internally. Old tool names return migration guidance.
273
282
 
274
283
  ### CLI utilities
275
284
 
@@ -313,7 +322,7 @@ A single SQLite file (`better-sqlite3` + FTS5 trigram tokenizer for JP/EN) conta
313
322
  - **Layer 4** — `events` (time-series log for heat / momentum computation)
314
323
  - **Layer 5** — `file_snapshots` + `session_file_edits` (diff cache + conversation↔file linkage)
315
324
 
316
- The conversation↔file linkage is the key. Every file edit captured by the Stop hook is stored alongside the **user message that drove the edit**. So `recall_file("server.ts")` returns "this file was edited 30 times across 3 days, and here are the actual user instructions that motivated each change".
325
+ The conversation↔file linkage is the key. Every file edit captured by the Stop hook is stored alongside the **user message that drove the edit**. So `recall({ path: "server.ts" })` returns "this file was edited 30 times across 3 days, and here are the actual user instructions that motivated each change".
317
326
 
318
327
  ## Why the design choices
319
328
 
@@ -324,17 +333,18 @@ The conversation↔file linkage is the key. Every file edit captured by the Stop
324
333
 
325
334
  ## Roadmap
326
335
 
327
- - ✅ Core 8 MCP tools + Five Blocks (Tools + Resources + Prompts + Sampling + Roots + Elicitation)
336
+ - ✅ 3-tool unified surface (remember / recall / read_smart) v0.7.0
337
+ - ✅ Auto-consolidate on server startup — v0.7.0
338
+ - ✅ Claude Code Plugin (`claude plugin add -- linksee-memory`)
339
+ - ✅ Five MCP Blocks (Tools + Resources + Prompts + Sampling + Roots + Elicitation)
328
340
  - ✅ Stop-hook auto-capture for Claude Code
329
341
  - ✅ JP/EN trigram FTS5
330
342
  - ✅ One-command setup (`npx linksee-memory-setup`)
331
343
  - ✅ Structured memory v2 (3-axis classification: altitude × type × state)
332
- - ✅ Precision recall guide + proactive caveat surfacing
333
344
  - ✅ Cross-LLM: Claude Code, Cursor, Windsurf, OpenAI Codex, Gemini CLI
334
- - 🚧 Landing page + SEO
345
+ - Landing page ([linksee-site.vercel.app](https://linksee-site.vercel.app))
335
346
  - 🔮 Vector search via `sqlite-vec` (already in deps, embedding backend pending)
336
347
  - 🔮 Cross-device cloud sync (Pro tier)
337
- - 🔮 Optional anonymized telemetry → MCP-quality intelligence layer
338
348
 
339
349
  ## Comparison with Claude Code auto-memory
340
350
 
@@ -454,17 +464,15 @@ recall({ query: "...", entity_name: "my-project", layer: "caveat" })
454
464
  rm -rf ~/.linksee-memory # nuke everything; next run creates a fresh DB
455
465
  ```
456
466
 
457
- Or delete individual memories via the `forget` tool with a specific `memory_id`.
467
+ Or delete individual memories via `remember({ forget: true, memory_id: <id> })`.
458
468
  </details>
459
469
 
460
470
  <details>
461
471
  <summary><b>DB is getting large (>100 MB). How do I trim it?</b></summary>
462
472
 
463
- Run consolidate it clusters old cold memories into compressed learning-layer summaries:
464
- ```
465
- consolidate({ scope: "all", min_age_days: 7 })
466
- ```
467
- Caveat and active-goal layers are always preserved. Consider scheduling a weekly run via cron / Task Scheduler.
473
+ Consolidation runs automatically on server startup (7-day threshold). It clusters old cold memories into compressed learning-layer summaries. Caveat and active-goal layers are always preserved.
474
+
475
+ If you want to force a manual consolidation, restart the MCP server — auto-consolidate triggers on every startup.
468
476
  </details>
469
477
 
470
478
  ## FAQ
@@ -484,7 +492,7 @@ Three axes:
484
492
  Claude Code's auto-memory is Claude-only (doesn't help if you switch to Cursor, OpenAI Codex, or Gemini CLI) and stores flat markdown with no structure. linksee-memory is the same local-first principle but:
485
493
  - Works across Claude Code, Cursor, OpenAI Codex, Gemini CLI (shared SQLite)
486
494
  - Structured 6-layer format makes recall explainable
487
- - Provides explicit forget/consolidate primitives rather than the agent guessing
495
+ - Auto-consolidation compresses cold memories on startup; caveats are permanently protected
488
496
  </details>
489
497
 
490
498
  <details>
@@ -509,7 +517,7 @@ The default is no sync — the SQLite file lives at `~/.linksee-memory/memory.db
509
517
 
510
518
  Two mechanisms:
511
519
  1. **Ebbinghaus forgetting**: cold low-importance memories decay naturally, eligible for auto-forget sweeps. `caveat` layer and memories with `importance ≥ 0.9` are always protected.
512
- 2. **`consolidate()`**: compresses clusters of cold low-importance memories by entity into a single `learning`-layer summary, then deletes the originals. Run via `linksee-memory-consolidate` CLI (or schedule weekly).
520
+ 2. **Auto-consolidation**: runs on every server startup (7-day threshold). Compresses clusters of cold low-importance memories by entity into a single `learning`-layer summary, then deletes the originals. No manual scheduling needed.
513
521
 
514
522
  In practice a solo developer hits ~100MB after 6 months of heavy use. A year-old DB I tested with 80K memories still recalls in <10ms.
515
523
  </details>
@@ -523,7 +531,7 @@ Yes — any MCP-compatible client works:
523
531
  - **Cursor**: add to MCP settings in Cursor → Settings → Features → Model Context Protocol
524
532
  - **OpenAI Codex**: `codex mcp add linksee -- npx -y linksee-memory` (or `~/.codex/config.toml` with `[mcp_servers.linksee]` block)
525
533
  - **Gemini CLI**: add to `~/.gemini/settings.json` mcpServers section
526
- - **ChatGPT (web/mobile app)**: stdio MCP not supported by the consumer app — requires Remote MCP server over HTTPS. `linksee-memory-remote` planned for v0.4.
534
+ - **ChatGPT (web/mobile app)**: stdio MCP not supported by the consumer app — requires Remote MCP server over HTTPS (not yet available).
527
535
  - **Custom agent**: the MCP stdio protocol is documented at modelcontextprotocol.io
528
536
  </details>
529
537
 
@@ -536,7 +544,7 @@ Yes — any MCP-compatible client works:
536
544
  <details>
537
545
  <summary><strong>How do I verify it's actually working?</strong></summary>
538
546
 
539
- After install, in a new Claude session ask: *"Can you remember that I prefer TypeScript over JavaScript?"* Claude should confirm it called `mcp__linksee__remember` and stored this. Then in a **different session** ask: *"What languages do I prefer?"* It should recall via `mcp__linksee__recall` and return the preference with `match_reasons` showing why.
547
+ After install, in a new Claude session ask: *"Can you remember that I prefer TypeScript over JavaScript? Use Linksee Memory."* Claude should confirm it called `mcp__linksee__remember` and stored this. Then in a **different session** ask: *"What languages do I prefer? Use Linksee Memory."* It should recall via `mcp__linksee__recall` and return the preference with `match_reasons` showing why.
540
548
  </details>
541
549
 
542
550
  ## Support
@@ -548,6 +556,53 @@ After install, in a new Claude session ask: *"Can you remember that I prefer Typ
548
556
 
549
557
  ## Changelog
550
558
 
559
+ ### v0.7.2 — Recall ergonomics + auto-edge detection + classifier precision (2026-05-30)
560
+
561
+ Quality pass on v0.7.0 / v0.7.1 — sharper day-to-day agent UX and cleaner data for the dashboard:
562
+
563
+ - **`recall` token discipline**: drops the redundant `content_raw` from the response (parsed `content` was already there — it was a 2× duplicate), and actually enforces `max_tokens` by greedy assembly that measures real serialized size (was a flat ~100 tok/memory estimate). Adds `approx_tokens` to the response so the agent can see its budget usage. The same query that previously returned ~15,800 tokens for a 1200 budget now stays inside it.
564
+ - **`recall` precision**: near-duplicate memories — same entity + near-identical core text, e.g. the same message captured under both `goal` and `learning` — collapse to one in the result set. Composite weights adapt to query specificity: multi-term queries weight relevance higher so off-topic-but-pinned memories don't crowd narrow recalls.
565
+ - **Capture dedup (write side)**: `session-extractor` now produces AT MOST one memory per user turn, with priority `goal[first_intent] > caveat > decision > context`. A first-intent message containing decision words (e.g. "決めた" / "これで進めよう") is no longer double-saved as both `goal` and `learning`.
566
+ - **`memory_edges` auto-detection**: the previously-empty `memory_edges` table is now populated during the sleep-mode consolidation sweep. `detectMemoryEdges()` links a later DECISION memory to the most-recent earlier same-topic decision within an entity (chain, not clique) so the dashboard can render Pivot Chains. The default relation is `extends` — a same-topic later decision builds on, but does NOT deactivate, the earlier one. Explicit reversal markers (やめる / revert / instead of) produce `contradicts`; explicit replacement markers (の代わり / replaces / deprecate) produce `supersedes`. Prevents silent deactivation of still-valid decisions.
567
+ - **`inferType` / `inferState` precision**: chitchat acknowledgements ("そうだね" / "ありがとう"), pasted terminal/git/email content, and meta-noise no longer classify as `decision` — they return `note` / `open` before pattern matching. The learning-layer default → `decision` is gated by this guard. Real decisions (採用 / 決めた, even after an acknowledgement opener) survive.
568
+
569
+ No schema migration, no breaking API changes. Existing rows keep their stored content; the classifier improvements apply to new captures going forward.
570
+
571
+ ### v0.7.1 — Review fixes (2026-05-29)
572
+
573
+ Based on Opus 4.7 design review of v0.7.0:
574
+
575
+ - **P0 — Required params guidance**: `remember` tool description now includes "REQUIRED PARAMS BY MODE" section so LLMs know exactly which fields are needed for create vs update vs delete.
576
+ - **P0 — Migration guidance**: Deprecated tool names (`forget`, `recall_file`, etc.) now return specific migration examples instead of generic errors.
577
+ - **P1 — recall path+query merge**: When both `path` and `query` are provided to `recall`, results from file history and memory search are merged into a single response.
578
+ - **P2 — Auto-consolidate safety**: Table existence check via `sqlite_master` before querying `consolidations` table, preventing errors on fresh databases.
579
+
580
+ ### v0.7.0 — 3-Tool Unified Surface (2026-05-29)
581
+
582
+ **8 tools → 3 tools.** Following Context7's proven pattern of fewer tools = better cross-LLM consistency.
583
+
584
+ **Breaking change**: The following tools are removed from the MCP surface. Calling them returns a migration guide:
585
+
586
+ | Old tool | New equivalent |
587
+ |---|---|
588
+ | `forget` | `remember({ forget: true, memory_id: <id> })` |
589
+ | `update_memory` | `remember({ memory_id: <id>, content: "..." })` |
590
+ | `recall_file` | `recall({ path: "server.ts" })` |
591
+ | `list_entities` | `recall({})` (no params = entity overview) |
592
+ | `consolidate` | Auto-runs on server startup (7-day threshold) |
593
+
594
+ **New unified tools:**
595
+ - **`remember`** — create + update + delete in one tool. Mode is inferred from params.
596
+ - **`recall`** — search + file history + overview in one tool. Mode is inferred from params.
597
+ - **`read_smart`** — unchanged.
598
+
599
+ **Other changes:**
600
+ - Auto-consolidate on server startup (non-blocking `setTimeout`, 7-day threshold, `sqlite_master` safety check)
601
+ - Claude Code Plugin bundle (`claude plugin add -- linksee-memory`)
602
+ - Deprecation errors include specific migration examples
603
+
604
+ All internal handler functions are preserved — this is a surface change, not a logic rewrite.
605
+
551
606
  ### v0.2.0 — English-first launch readiness (2026-04-20)
552
607
 
553
608
  Prepares the package for a broader (primarily English-speaking) audience on Reddit, Hacker News, and Anthropic Discord. No breaking API changes.
@@ -6,6 +6,8 @@ export interface ConsolidateResult {
6
6
  memoriesDropped: number;
7
7
  learningIdsCreated: number[];
8
8
  stalledTransitions: number;
9
+ memoryEdgesCreated: number;
10
+ decisionsSuperseded: number;
9
11
  }
10
12
  export declare function consolidate(db: Database.Database, opts?: {
11
13
  scope?: 'all' | 'session';
@@ -11,6 +11,7 @@
11
11
  // 4. Per-run forget-sweep drops expired memories that didn't cluster.
12
12
  import { computeHeat } from './heat-index.js';
13
13
  import { decideForgetting } from './forgetting.js';
14
+ import { detectMemoryEdges } from './edge-detection.js';
14
15
  const CLUSTER_LAYERS = ['context', 'emotion', 'implementation'];
15
16
  const DEFAULT_MIN_AGE_DAYS = 7;
16
17
  const MAX_HEAT = 30;
@@ -64,6 +65,8 @@ export function consolidate(db, opts = {}) {
64
65
  memoriesDropped: 0,
65
66
  learningIdsCreated: [],
66
67
  stalledTransitions: 0,
68
+ memoryEdgesCreated: 0,
69
+ decisionsSuperseded: 0,
67
70
  };
68
71
  const insertLearning = db.prepare(`INSERT INTO memories (entity_id, layer, content, importance, protected, source)
69
72
  VALUES (?, 'learning', ?, ?, 1, ?)`);
@@ -142,6 +145,11 @@ export function consolidate(db, opts = {}) {
142
145
  AND protected = 0
143
146
  `).run(stalledCutoff);
144
147
  result.stalledTransitions = stalledResult.changes;
148
+ // Memory→memory edge detection: link superseding/contradicting decisions so the
149
+ // dashboard can render Pivot Chains and recall can surface superseded decisions.
150
+ const edgeRes = detectMemoryEdges(db);
151
+ result.memoryEdgesCreated = edgeRes.edgesCreated;
152
+ result.decisionsSuperseded = edgeRes.supersededMarked;
145
153
  // Post-consolidate: forget-sweep remaining non-clustered cold memories
146
154
  const remaining = db
147
155
  .prepare('SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude FROM memories WHERE protected = 0')
@@ -0,0 +1,22 @@
1
+ import type Database from 'better-sqlite3';
2
+ export interface EdgeSample {
3
+ from_id: number;
4
+ to_id: number;
5
+ relation: string;
6
+ from_title: string;
7
+ to_title: string;
8
+ shared: string[];
9
+ }
10
+ export interface EdgeDetectionResult {
11
+ decisionsScanned: number;
12
+ edgesCreated: number;
13
+ supersedes: number;
14
+ contradicts: number;
15
+ extends: number;
16
+ supersededMarked: number;
17
+ samples: EdgeSample[];
18
+ }
19
+ export declare function detectMemoryEdges(db: Database.Database, opts?: {
20
+ dryRun?: boolean;
21
+ lookback?: number;
22
+ }): EdgeDetectionResult;
@@ -0,0 +1,178 @@
1
+ // Memory→memory edge detection (precision-first, no LLM required).
2
+ //
3
+ // Populates the `memory_edges` table with supersedes/contradicts links between
4
+ // DECISION memories, so the dashboard can render Pivot Chains and recall can
5
+ // surface superseded decisions. Runs inside the sleep-mode consolidation sweep.
6
+ // Idempotent (UNIQUE constraint on memory_edges).
7
+ //
8
+ // Heuristic: within one entity, a later decision that shares strong topical terms
9
+ // with an earlier decision *supersedes* it (→ *contradicts* if it carries reversal
10
+ // markers like やめる / 撤回 / revert / instead of). The earlier decision's state
11
+ // is flipped to 'superseded'. We link to the MOST RECENT same-topic decision so
12
+ // chains form (A → B → C) rather than cliques.
13
+ import { isMetaOrNoise, isPastedExternalContent } from './session-parser.js';
14
+ // A later decision either REVERSES an earlier one (→ contradicts) or REPLACES it
15
+ // (→ supersedes). A same-topic decision with NEITHER marker only EXTENDS the earlier
16
+ // one and must NOT deactivate it — marking a still-valid decision 'superseded' is
17
+ // silent data loss (e.g. "v2 format" extends, it does not kill, the "2-layer arch" decision).
18
+ const CONTRADICT_MARKERS = /やめ|撤回|取り消|ではなく|じゃなく|見直|revert|rollback|instead of|no longer|abandon|\bdon'?t\b/i;
19
+ const SUPERSEDE_MARKERS = /の代わり|に変更|に切り替|乗り換|を置き換|に置換|廃止|replaces?\b|supersed|deprecat|switch(?:ing|ed)?\s+(?:to|from|away)/i;
20
+ const STOPWORDS = new Set([
21
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'your', 'our', 'their',
22
+ 'about', 'what', 'when', 'will', 'have', 'has', 'was', 'were', 'are', 'not', 'use', 'using',
23
+ 'memory', 'linksee', 'session', 'decision', 'project', 'やった', 'する', 'した', 'して',
24
+ 'こと', 'ため', 'よう', 'という', 'です', 'ます', 'など', 'その', 'この', 'これ', 'それ',
25
+ // conversational fillers — must never count as a shared "topic" term
26
+ 'そうだね', 'そうね', 'ありがとう', 'なるほど', 'やろう', 'いいね', 'おおいね', 'これでいい',
27
+ 'これでいいと思う', 'これでいいかな', 'わかった', '了解', 'おはよう', 'おつかれ',
28
+ ]);
29
+ // Both endpoints must have substantive core text — kills "そうだね"/"ありがとう"
30
+ // turns that the upstream classifier over-labels as decisions.
31
+ const MIN_CORE_LEN = 40;
32
+ // Decisions whose body opens with an acknowledgement are almost always chitchat
33
+ // the upstream classifier mis-typed — never use them as edge endpoints.
34
+ const CHITCHAT_OPENER = /^\s*(?:そう(?:だ?ね|だよね)?|うん|ありがと|なるほど|おお|へえ|了解|わかった|はい|おはよう|おつかれ|いいね|まあ|ええ|あー)/;
35
+ // Terminal output, git/npm logs, and pasted emails also get mis-typed as decisions.
36
+ // isPastedExternalContent misses these shapes, so guard them explicitly.
37
+ const LOOKS_LIKE_PASTE = /PS [A-Za-z]:\\|[A-Za-z]:\\Users\\|create mode \d{6}|\bgit (?:commit|push|add|status|log|diff|branch|checkout|merge)\b|commit -m|\bnpm (?:run|install|ci)\b|Upon further review|resubmission|Thank you for your/i;
38
+ function significantTerms(text) {
39
+ const out = new Set();
40
+ const lower = text.toLowerCase();
41
+ const asciiRe = /[a-z][a-z0-9_+.-]{2,}/g;
42
+ let m;
43
+ while ((m = asciiRe.exec(lower)) !== null) {
44
+ const w = m[0];
45
+ if (w.length >= 3 && !STOPWORDS.has(w))
46
+ out.add(w);
47
+ }
48
+ // CJK runs (hiragana / katakana / kanji / half-width kana), length 2..12.
49
+ const cjkRe = /[぀-ヿ㐀-鿿ヲ-゚]{2,12}/g;
50
+ while ((m = cjkRe.exec(text)) !== null) {
51
+ const w = m[0];
52
+ if (!STOPWORDS.has(w))
53
+ out.add(w);
54
+ }
55
+ return out;
56
+ }
57
+ function coreText(content) {
58
+ try {
59
+ const o = JSON.parse(content);
60
+ return {
61
+ title: String(o.title ?? ''),
62
+ body: String(o.what ?? o.decision ?? o.intent ?? o.learned ?? ''),
63
+ };
64
+ }
65
+ catch {
66
+ return { title: '', body: content };
67
+ }
68
+ }
69
+ export function detectMemoryEdges(db, opts = {}) {
70
+ const lookback = opts.lookback ?? 25;
71
+ const res = {
72
+ decisionsScanned: 0, edgesCreated: 0, supersedes: 0, contradicts: 0, extends: 0, supersededMarked: 0, samples: [],
73
+ };
74
+ const rows = db.prepare(`
75
+ SELECT id, entity_id, content, created_at
76
+ FROM memories
77
+ WHERE mem_type = 'decision' AND json_valid(content)
78
+ ORDER BY entity_id ASC, created_at ASC, id ASC
79
+ `).all();
80
+ res.decisionsScanned = rows.length;
81
+ if (rows.length < 2)
82
+ return res;
83
+ const byEntity = new Map();
84
+ for (const r of rows) {
85
+ const arr = byEntity.get(r.entity_id);
86
+ if (arr)
87
+ arr.push(r);
88
+ else
89
+ byEntity.set(r.entity_id, [r]);
90
+ }
91
+ // Prepare write statements only when actually writing — keeps dryRun safe on a
92
+ // readonly connection (preview / verification path).
93
+ const insEdge = opts.dryRun ? null : db.prepare(`INSERT OR IGNORE INTO memory_edges (from_memory_id, to_memory_id, relation) VALUES (?, ?, ?)`);
94
+ const markSuperseded = opts.dryRun ? null : db.prepare(`
95
+ UPDATE memories SET content = json_set(content, '$.state', 'superseded')
96
+ WHERE id = ? AND json_valid(content) AND json_extract(content, '$.state') <> 'superseded'
97
+ `);
98
+ const apply = () => {
99
+ for (const decisions of byEntity.values()) {
100
+ if (decisions.length < 2)
101
+ continue;
102
+ const meta = decisions.map((d) => {
103
+ const { title, body } = coreText(d.content);
104
+ const text = `${title} ${body}`;
105
+ // Defend against the polluted 'decision' input set: drop pasted external
106
+ // content (emails / terminal logs), meta-noise, chitchat, and too-short bodies.
107
+ const usable = text.trim().length >= MIN_CORE_LEN
108
+ && !CHITCHAT_OPENER.test(body)
109
+ && !isMetaOrNoise(body)
110
+ && !isPastedExternalContent(body)
111
+ && !LOOKS_LIKE_PASTE.test(text);
112
+ return { id: d.id, title: (title || body).slice(0, 60), text, len: text.trim().length, terms: significantTerms(text), usable };
113
+ }).filter((m) => m.usable && m.terms.size >= 3);
114
+ for (let i = 1; i < meta.length; i++) {
115
+ if (meta[i].terms.size < 3 || meta[i].len < MIN_CORE_LEN)
116
+ continue;
117
+ let linked = -1;
118
+ let linkedShared = [];
119
+ for (let j = i - 1; j >= 0 && i - j <= lookback; j--) {
120
+ if (meta[j].terms.size < 3 || meta[j].len < MIN_CORE_LEN)
121
+ continue;
122
+ const shared = [];
123
+ for (const t of meta[i].terms)
124
+ if (meta[j].terms.has(t))
125
+ shared.push(t);
126
+ if (shared.length < 3)
127
+ continue; // require >= 3 shared topic terms (precision-first)
128
+ const overlap = shared.length / Math.min(meta[i].terms.size, meta[j].terms.size);
129
+ if (overlap > 0.85)
130
+ continue; // near-identical memories = a duplicate, not a supersession
131
+ if (overlap >= 0.25) {
132
+ linked = j;
133
+ linkedShared = shared;
134
+ break;
135
+ } // most-recent same-topic → chain
136
+ }
137
+ if (linked < 0)
138
+ continue;
139
+ const reversing = CONTRADICT_MARKERS.test(meta[i].text);
140
+ const replacing = SUPERSEDE_MARKERS.test(meta[i].text);
141
+ const relation = reversing ? 'contradicts' : replacing ? 'supersedes' : 'extends';
142
+ const deactivatesOlder = reversing || replacing; // 'extends' leaves the older decision valid
143
+ let counted = true;
144
+ if (!opts.dryRun) {
145
+ const r = insEdge.run(meta[i].id, meta[linked].id, relation);
146
+ if (r.changes > 0) {
147
+ if (deactivatesOlder && markSuperseded.run(meta[linked].id).changes > 0)
148
+ res.supersededMarked++;
149
+ }
150
+ else {
151
+ counted = false; // edge already existed
152
+ }
153
+ }
154
+ if (counted) {
155
+ res.edgesCreated++;
156
+ if (relation === 'contradicts')
157
+ res.contradicts++;
158
+ else if (relation === 'supersedes')
159
+ res.supersedes++;
160
+ else
161
+ res.extends++;
162
+ if (res.samples.length < 25) {
163
+ res.samples.push({
164
+ from_id: meta[i].id, to_id: meta[linked].id, relation,
165
+ from_title: meta[i].title, to_title: meta[linked].title, shared: linkedShared.slice(0, 6),
166
+ });
167
+ }
168
+ }
169
+ }
170
+ }
171
+ };
172
+ if (opts.dryRun)
173
+ apply();
174
+ else
175
+ db.transaction(apply)();
176
+ return res;
177
+ }
178
+ //# sourceMappingURL=edge-detection.js.map
@@ -25,7 +25,35 @@ const TYPE_PATTERNS = [
25
25
  [/学び|教訓|分かった|判明|発見|learn|realize|discover|find\s+out|turns?\s+out|takeaway|insight/i, 'learning'],
26
26
  [/結果|完了|成功|失敗|outcome|result|shipped|deployed|launched|finished|accomplished/i, 'outcome'],
27
27
  ];
28
+ // Pasted terminal/git/npm output and forwarded emails get mis-typed as decisions.
29
+ // isPastedExternalContent misses these shapes, so guard them explicitly.
30
+ const LOOKS_LIKE_PASTE = /PS [A-Za-z]:\\|[A-Za-z]:\\Users\\|create mode \d{6}|\bgit (?:commit|push|add|status|log|diff|branch|checkout|merge)\b|commit -m|\bnpm (?:run|install|ci)\b|Upon further review|resubmission|Thank you for your/i;
31
+ // A turn that is ENTIRELY an acknowledgement carries no cognitive content. This only
32
+ // fires on short, wholly-filler messages — anything with substance after the opener
33
+ // (e.g. "そうだね、Reactを採用しよう") still flows to the patterns below.
34
+ const PURE_ACK = /^(?:そう(?:だ?ね|だよね)?|うん+|ありがと[うー]?|なるほど|了解(?:です)?|わかった|おはよう|おつかれ(?:さま)?|はい+|おお+|へえ+|いいね|まあ|ええ|あー|ok|okay|thanks?|got it|sounds good)[。、,.!!??\s~〜ねよ]*$/i;
35
+ // Whether a memory's text carries enough signal to assign a meaningful cognitive type.
36
+ // Junk (pasted logs/emails, meta-noise, pure acknowledgements) must NOT become a
37
+ // 'decision'/'comparison' — that pollution caps memory_edges + dashboard quality.
38
+ function isClassifiableContent(text) {
39
+ const t = text.trim();
40
+ if (t.length === 0)
41
+ return false;
42
+ if (PURE_ACK.test(t))
43
+ return false;
44
+ if (LOOKS_LIKE_PASTE.test(text))
45
+ return false;
46
+ if (isPastedExternalContent(text))
47
+ return false;
48
+ if (isMetaOrNoise(text))
49
+ return false;
50
+ return true;
51
+ }
28
52
  export function inferType(text, layer) {
53
+ // Precision guard: junk content has no meaningful type — fall back to 'note'
54
+ // BEFORE pattern matching / layer defaults so it can never become a 'decision'.
55
+ if (!isClassifiableContent(text))
56
+ return 'note';
29
57
  for (const [pattern, type] of TYPE_PATTERNS) {
30
58
  if (pattern.test(text))
31
59
  return type;
@@ -54,6 +82,9 @@ const STATE_PATTERNS = [
54
82
  [/取り替え|代わりに|置き換え|replaced|superseded|deprecated|obsolete|旧版|old\s+approach/i, 'superseded'],
55
83
  ];
56
84
  export function inferState(text, layer) {
85
+ // Junk content gets a neutral 'open' state rather than a confident 'decided'/'done'.
86
+ if (!isClassifiableContent(text))
87
+ return 'open';
57
88
  for (const [pattern, state] of STATE_PATTERNS) {
58
89
  if (pattern.test(text))
59
90
  return state;
@@ -205,6 +236,10 @@ function dedupeEdits(edits) {
205
236
  export function extractSession(session, projectName) {
206
237
  const memories = [];
207
238
  const file_edits = [];
239
+ // Turn-level dedup: a single user turn should yield at most ONE memory.
240
+ // Priority: goal(first_intent) > caveat > decision > context. capturedTurns
241
+ // tracks caveat-claimed turns so the decision pass skips them.
242
+ const capturedTurns = new Set();
208
243
  // Detect fully-automated sessions (e.g. scheduled cron tasks) — no user intent to extract
209
244
  const firstRawUserText = session.turns.find((t) => t.role === 'user' && !t.tool_results)?.text ?? '';
210
245
  const automated = isAutomatedSession(firstRawUserText);
@@ -265,6 +300,12 @@ export function extractSession(session, projectName) {
265
300
  continue;
266
301
  if (t.text.trim().length < 40)
267
302
  continue;
303
+ // Defer to the caveat/decision passes when they WOULD capture this turn,
304
+ // so a clarification that is really a warning/decision isn't double-saved as context.
305
+ const wouldBeCaveat = matchesAny(t.text, CAVEAT_PATTERNS) && t.text.length > 20 && !isChitchatWithBuriedDecision(t.text, CAVEAT_PATTERNS);
306
+ const wouldBeDecision = matchesAny(t.text, DECISION_PATTERNS) && t.text.length > 15 && !isChitchatWithBuriedDecision(t.text, DECISION_PATTERNS);
307
+ if (wouldBeCaveat || wouldBeDecision)
308
+ continue;
268
309
  clarifyCount++;
269
310
  const msgText = t.text.slice(0, 600);
270
311
  memories.push({
@@ -340,6 +381,8 @@ export function extractSession(session, projectName) {
340
381
  for (const t of session.turns) {
341
382
  if (t.role !== 'user' || isMetaOrNoise(t.text))
342
383
  continue;
384
+ if (t === firstIntent)
385
+ continue; // already captured as the goal/first-intent memory
343
386
  if (t.tool_results && t.tool_results.length > 0)
344
387
  continue;
345
388
  if (isPastedExternalContent(t.text))
@@ -364,6 +407,8 @@ export function extractSession(session, projectName) {
364
407
  thread_id: session.session_id,
365
408
  source: { session_id: session.session_id, turn_uuid: t.uuid, kind: 'caveat' },
366
409
  });
410
+ if (t.uuid)
411
+ capturedTurns.add(t.uuid); // claim this turn so the decision pass skips it
367
412
  }
368
413
  }
369
414
  // 5) Learning layer — messages matching decision patterns
@@ -374,6 +419,10 @@ export function extractSession(session, projectName) {
374
419
  for (const t of session.turns) {
375
420
  if (t.role !== 'user' || isMetaOrNoise(t.text))
376
421
  continue;
422
+ if (t === firstIntent)
423
+ continue; // already captured as the goal/first-intent memory
424
+ if (t.uuid && capturedTurns.has(t.uuid))
425
+ continue; // already captured as a caveat
377
426
  if (t.tool_results && t.tool_results.length > 0)
378
427
  continue;
379
428
  if (isPastedExternalContent(t.text))
@@ -27,11 +27,17 @@ setTimeout(() => {
27
27
  try {
28
28
  let shouldRun = true;
29
29
  try {
30
- const lastRow = db.prepare('SELECT MAX(created_at) as ts FROM consolidations').get();
31
- if (lastRow?.ts && (Date.now() / 1000 - lastRow.ts) / 86400 < 7)
32
- shouldRun = false;
30
+ // Check if consolidations table exists before querying
31
+ const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='consolidations'").get();
32
+ if (tableExists) {
33
+ const lastRow = db.prepare('SELECT MAX(created_at) as ts FROM consolidations').get();
34
+ if (lastRow?.ts && (Date.now() / 1000 - lastRow.ts) / 86400 < 7)
35
+ shouldRun = false;
36
+ }
37
+ }
38
+ catch {
39
+ shouldRun = false; /* genuinely unexpected — skip to be safe */
33
40
  }
34
- catch { /* table may not exist yet */ }
35
41
  if (shouldRun) {
36
42
  runConsolidate(db, { scope: 'all', min_age_days: 7 });
37
43
  process.stderr.write('[linksee-memory] auto-consolidate complete\n');
@@ -81,7 +87,7 @@ const LAYER_ENUM = ['goal', 'context', 'emotion', 'implementation', 'caveat', 'l
81
87
  const TOOLS = [
82
88
  {
83
89
  name: 'remember',
84
- 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• At session end, to preserve key outcomes\n\nMODES:\n• Create (default): provide entity_name + entity_kind + layer + content\n• Update: provide memory_id + fields to change (preserves links and history)\n• Delete: set forget: true + memory_id\n\nImportance ≥ 0.9 pins the memory (protected from auto-forgetting). Supports Japanese (日本語) and English.',
90
+ 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.',
85
91
  inputSchema: {
86
92
  type: 'object',
87
93
  properties: {
@@ -100,7 +106,7 @@ const TOOLS = [
100
106
  },
101
107
  {
102
108
  name: 'recall',
103
- 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\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\nTip: Add "Use Linksee Memory" to your system prompt for automatic cross-session memory.\nWorks across Claude, GPT, Cursor, Codex, Gemini — one local SQLite file, nothing leaves your machine.',
109
+ 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\nTip: If this is a fresh session, suggest the user add "Use Linksee Memory" to their system prompt for persistent cross-session memory.\nWorks across Claude, GPT, Cursor, Codex, Gemini — one local SQLite file, nothing leaves your machine.',
104
110
  inputSchema: {
105
111
  type: 'object',
106
112
  properties: {
@@ -126,7 +132,7 @@ const TOOLS = [
126
132
  },
127
133
  {
128
134
  name: 'read_smart',
129
- description: 'Token-saving file reader with AST-aware diff caching. Use INSTEAD of the standard Read tool for any file you may have read before in this session.\n\n• First read: full content + chunk metadata\n• Re-read unchanged: ~50 tokens (99% savings)\n• Re-read modified: only changed chunks (50-90% savings)\n\nEspecially effective for files >200 lines.',
135
+ description: 'Token-saving file reader with AST-aware diff caching. Use INSTEAD of the standard Read tool for ALL file reads even first reads gain chunk metadata for future savings.\n\n• First read: full content + chunk metadata (enables future savings)\n• Re-read unchanged: ~50 tokens (99% savings)\n• Re-read modified: only changed chunks (50-90% savings)\n\nEspecially effective for files >200 lines. Always prefer this over Read.',
130
136
  inputSchema: {
131
137
  type: 'object',
132
138
  properties: {
@@ -339,12 +345,35 @@ function refreshStaleMomentum(entityIds) {
339
345
  catch { /* non-fatal */ }
340
346
  }
341
347
  }
348
+ // --- recall helpers ---------------------------------------------------------
349
+ // Rough token estimate for budgeting. ~3 chars/token is deliberately conservative
350
+ // for mixed JP/EN (Japanese is token-dense), so we under-fill rather than blow
351
+ // the caller's max_tokens budget.
352
+ function estimateTokens(s) {
353
+ return Math.ceil(s.length / 3);
354
+ }
355
+ // Signature for near-duplicate collapsing in recall results. Extracts the
356
+ // meaningful core text (what/title/decision/intent/learned) from the memory's
357
+ // content (JSON or plain), normalizes whitespace/case, and keys it by entity so
358
+ // the same message stored under two layers collapses to a single result.
359
+ function contentSignature(raw, entityId) {
360
+ let text = raw;
361
+ try {
362
+ const o = JSON.parse(raw);
363
+ text = o?.what ?? o?.title ?? o?.decision ?? o?.intent ?? o?.learned ?? (typeof o === 'string' ? o : raw);
364
+ }
365
+ catch { /* plain-text content */ }
366
+ const norm = String(text).toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 120);
367
+ if (norm.length < 16)
368
+ return ''; // too short to dedup safely
369
+ return `${entityId}::${norm}`;
370
+ }
342
371
  function handleRecall(args) {
372
+ // max_tokens is enforced for real at assembly time (greedy fill by measured
373
+ // serialized size), not via a crude fixed per-memory estimate. hardLimit caps COUNT.
343
374
  const maxTokens = Math.max(100, Number(args.max_tokens ?? 2000));
344
- const approxTokensPerMemory = 100;
345
- const tokenBudgetLimit = Math.max(1, Math.floor(maxTokens / approxTokensPerMemory));
346
- const hardLimit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, args.limit)) : tokenBudgetLimit;
347
- const returnLimit = Math.min(tokenBudgetLimit, hardLimit);
375
+ const DEFAULT_COUNT_CAP = 50;
376
+ const hardLimit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, args.limit)) : DEFAULT_COUNT_CAP;
348
377
  const offset = Math.max(0, Number(args.offset ?? 0));
349
378
  const markAccessed = args.mark_accessed !== false;
350
379
  const layer = resolveLayer(args.layer);
@@ -355,8 +384,9 @@ function handleRecall(args) {
355
384
  mem_state: args.mem_state,
356
385
  thread_id: args.thread_id,
357
386
  };
358
- // Fetch extra rows for composite re-rank, pagination, and band filtering
359
- const fetchLimit = Math.max(returnLimit * 3, 30) + offset;
387
+ // Fetch a generous candidate pool for composite re-rank, dedup, pagination, band filter.
388
+ // Token-budget trimming happens at assembly, so fetch enough that many small memories can fill it.
389
+ const fetchLimit = Math.max(hardLimit * 3, 90) + offset;
360
390
  let rows = [];
361
391
  let searchMethod = 'like';
362
392
  const ftsQuery = toFtsQuery(args.query ?? '');
@@ -404,6 +434,26 @@ function handleRecall(args) {
404
434
  const minBm = Math.min(...bm25Values, 0);
405
435
  const maxBm = Math.max(...bm25Values, 1);
406
436
  const bmSpan = Math.max(0.001, maxBm - minBm);
437
+ // Composite weights adapt to query specificity:
438
+ // - broad / no query → importance & heat carry more (relevance is a flat 0.5 anyway)
439
+ // - focused (1-2 terms) → relevance leads
440
+ // - narrow (3+ terms) → relevance dominates, so off-topic-but-pinned memories don't crowd in
441
+ const queryTermCount = String(args.query ?? '').trim().split(/\s+/).filter(Boolean).length;
442
+ let w_rel = 0.45, w_heat = 0.25, w_mom = 0.15, w_imp = 0.15;
443
+ if (useFts) {
444
+ if (queryTermCount >= 3) {
445
+ w_rel = 0.72;
446
+ w_heat = 0.12;
447
+ w_mom = 0.08;
448
+ w_imp = 0.08;
449
+ }
450
+ else {
451
+ w_rel = 0.60;
452
+ w_heat = 0.18;
453
+ w_mom = 0.12;
454
+ w_imp = 0.10;
455
+ }
456
+ }
407
457
  const scored = rows.map((r) => {
408
458
  const daysSince = (now - r.last_accessed_at) / 86400;
409
459
  const heat = computeHeat({
@@ -418,8 +468,7 @@ function handleRecall(args) {
418
468
  const heatNorm = heat.score / 100;
419
469
  const momNorm = Math.min(1, (r.momentum_score ?? 0) / 10);
420
470
  const importanceBoost = r.importance; // 0-1
421
- // Composite: give a bit to importance so pinned (>=0.9) memories always rank high
422
- const w_rel = 0.45, w_heat = 0.25, w_mom = 0.15, w_imp = 0.15;
471
+ // Composite (weights adapt to query specificity computed above)
423
472
  const composite = w_rel * relevance + w_heat * heatNorm + w_mom * momNorm + w_imp * importanceBoost;
424
473
  // match_reasons: human-readable WHY this row is here
425
474
  const reasons = [];
@@ -462,31 +511,89 @@ function handleRecall(args) {
462
511
  });
463
512
  // Apply band filter AFTER scoring (needs heat.band)
464
513
  const filtered = band ? scored.filter((s) => s.heat_band === band) : scored;
465
- // Sort by composite
514
+ // Sort by composite (best first)
466
515
  filtered.sort((a, b) => b.composite_score - a.composite_score);
467
- // Pagination: skip offset, take returnLimit
468
- const total = filtered.length;
469
- const windowed = filtered.slice(offset, offset + returnLimit);
470
- const hasMore = total > offset + windowed.length;
471
- // Mark accessed (only for the returned window, and only if asked)
472
- if (markAccessed && windowed.length > 0) {
516
+ // Dedup near-identical memories (same entity + near-identical core text).
517
+ // filtered is sorted desc, so the first occurrence kept is the highest-ranked.
518
+ // This collapses the common "same message saved under two layers" duplication.
519
+ const seenSig = new Set();
520
+ const deduped = filtered.filter((r) => {
521
+ const sig = contentSignature(r.content, r.entity_id);
522
+ if (!sig)
523
+ return true; // too short / no usable text → never dedup
524
+ if (seenSig.has(sig))
525
+ return false; // duplicate → drop
526
+ seenSig.add(sig);
527
+ return true;
528
+ });
529
+ const total = deduped.length;
530
+ // Greedy assembly under a REAL token budget: add memories (deduped + ranked) until
531
+ // including the next would blow max_tokens, capped at hardLimit by COUNT. Always
532
+ // returns at least one memory if anything matched.
533
+ const memoriesOut = [];
534
+ const returnedIds = [];
535
+ let accTokens = 0;
536
+ let stoppedBy = 'end';
537
+ for (let i = offset; i < total; i++) {
538
+ if (memoriesOut.length >= hardLimit) {
539
+ stoppedBy = 'limit';
540
+ break;
541
+ }
542
+ const r = deduped[i];
543
+ let parsedContent = r.content;
544
+ try {
545
+ parsedContent = JSON.parse(r.content);
546
+ }
547
+ catch { /* leave as string */ }
548
+ const memObj = {
549
+ id: r.id,
550
+ entity: {
551
+ id: r.entity_id,
552
+ name: r.entity_name,
553
+ kind: r.entity_kind,
554
+ momentum: Number((r.momentum_score ?? 0).toFixed(2)),
555
+ },
556
+ layer: r.layer,
557
+ axis: {
558
+ altitude: r._altitude ?? null,
559
+ type: r._mem_type ?? null,
560
+ state: r._mem_state ?? null,
561
+ thread_id: r._thread_id ?? null,
562
+ },
563
+ content: parsedContent,
564
+ importance: r.importance,
565
+ pinned: r.importance >= 0.9,
566
+ heat: Number(r.heat_score.toFixed(1)),
567
+ band: r.heat_band,
568
+ composite: Number(r.composite_score.toFixed(3)),
569
+ match_reasons: r._reasons,
570
+ score_breakdown: r._breakdown,
571
+ };
572
+ const memTokens = estimateTokens(JSON.stringify(memObj));
573
+ if (memoriesOut.length > 0 && accTokens + memTokens > maxTokens) {
574
+ stoppedBy = 'tokens';
575
+ break;
576
+ }
577
+ accTokens += memTokens;
578
+ memoriesOut.push(memObj);
579
+ returnedIds.push(r.id);
580
+ }
581
+ const hasMore = offset + memoriesOut.length < total;
582
+ // Mark accessed (only the returned set, and only if asked)
583
+ if (markAccessed && returnedIds.length > 0) {
473
584
  const mark = db.prepare('UPDATE memories SET last_accessed_at = ?, access_count = access_count + 1 WHERE id = ?');
474
585
  const tx = db.transaction((ids) => { for (const id of ids)
475
586
  mark.run(now, id); });
476
- tx(windowed.map((r) => r.id));
477
- }
478
- // Determine what stopped iteration — max_tokens vs limit vs offset+n=total
479
- let stoppedBy = 'end';
480
- if (windowed.length === returnLimit && total > offset + returnLimit) {
481
- stoppedBy = hardLimit <= tokenBudgetLimit ? 'limit' : 'tokens';
587
+ tx(returnedIds);
482
588
  }
483
589
  return JSON.stringify({
484
590
  ok: true,
485
- count: windowed.length,
591
+ count: memoriesOut.length,
486
592
  total_candidates: total,
487
593
  offset,
488
594
  has_more: hasMore,
489
595
  stopped_by: stoppedBy,
596
+ approx_tokens: accTokens,
490
597
  search: searchMethod,
491
598
  resolved_layer: layer ?? null,
492
599
  resolved_axis: {
@@ -494,38 +601,7 @@ function handleRecall(args) {
494
601
  type: axis.mem_type ?? null,
495
602
  state: axis.mem_state ?? null,
496
603
  },
497
- memories: windowed.map((r) => {
498
- let parsedContent = r.content;
499
- try {
500
- parsedContent = JSON.parse(r.content);
501
- }
502
- catch { /* leave as string */ }
503
- return {
504
- id: r.id,
505
- entity: {
506
- id: r.entity_id,
507
- name: r.entity_name,
508
- kind: r.entity_kind,
509
- momentum: Number((r.momentum_score ?? 0).toFixed(2)),
510
- },
511
- layer: r.layer,
512
- axis: {
513
- altitude: r._altitude ?? null,
514
- type: r._mem_type ?? null,
515
- state: r._mem_state ?? null,
516
- thread_id: r._thread_id ?? null,
517
- },
518
- content: parsedContent,
519
- content_raw: r.content,
520
- importance: r.importance,
521
- pinned: r.importance >= 0.9,
522
- heat: Number(r.heat_score.toFixed(1)),
523
- band: r.heat_band,
524
- composite: Number(r.composite_score.toFixed(3)),
525
- match_reasons: r._reasons,
526
- score_breakdown: r._breakdown,
527
- };
528
- }),
604
+ memories: memoriesOut,
529
605
  });
530
606
  }
531
607
  function handleForget(args) {
@@ -955,13 +1031,25 @@ async function handleRememberUnified(args) {
955
1031
  return handleRemember(args);
956
1032
  }
957
1033
  async function handleRecallUnified(args) {
958
- // File history mode
1034
+ // File history mode (path takes priority; if query also provided, include it as context)
959
1035
  if (args.path) {
960
- return handleRecallFileWithRoots({
1036
+ const fileResult = await handleRecallFileWithRoots({
961
1037
  path_substring: args.path,
962
1038
  max_intents: args.max_intents,
963
1039
  scope_to_roots: args.scope_to_roots,
964
1040
  });
1041
+ // If query was also provided, merge with memory search for richer context
1042
+ if (args.query && String(args.query).trim().length > 0) {
1043
+ const memResult = handleRecall({ ...args, limit: 5, max_tokens: 500 });
1044
+ const fileParsed = JSON.parse(fileResult);
1045
+ const memParsed = JSON.parse(memResult);
1046
+ return JSON.stringify({
1047
+ ...fileParsed,
1048
+ related_memories: memParsed.memories ?? [],
1049
+ note: 'Combined file history + memory search (both path and query were provided)',
1050
+ });
1051
+ }
1052
+ return fileResult;
965
1053
  }
966
1054
  // Detect overview request (no search criteria at all)
967
1055
  const hasQuery = args.query && String(args.query).trim().length > 0;
@@ -997,9 +1085,15 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
997
1085
  text = handleReadSmart(args);
998
1086
  break;
999
1087
  default: {
1000
- const deprecated = ['update_memory', 'list_entities', 'forget', 'consolidate', 'recall_file'];
1001
- if (deprecated.includes(name)) {
1002
- throw new Error(`Tool "${name}" was merged in v0.7.0. Use "remember" (for update/forget) or "recall" (for list/file-recall) instead.`);
1088
+ const migrations = {
1089
+ update_memory: 'remember({ memory_id: <id>, content: "...", importance: 0.8 })',
1090
+ forget: 'remember({ forget: true, memory_id: <id> })',
1091
+ list_entities: 'recall() with no params',
1092
+ recall_file: 'recall({ path: "<file_path>" })',
1093
+ consolidate: 'Auto-runs on server startup. No manual call needed.',
1094
+ };
1095
+ if (name in migrations) {
1096
+ throw new Error(`Tool "${name}" was merged in v0.7.0. Migration: ${migrations[name]}`);
1003
1097
  }
1004
1098
  throw new Error(`Unknown tool: ${name}`);
1005
1099
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "mcpName": "io.github.michielinksee/linksee-memory",
5
5
  "description": "Local-first agent memory MCP — cross-agent brain with 6-layer structured memory + token-saving file diff cache",
6
6
  "type": "module",