linksee-memory 0.0.9 → 0.1.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,6 +1,8 @@
1
1
  # linksee-memory
2
2
 
3
3
  > Local-first agent memory MCP. A cross-agent brain for Claude Code, Cursor, and ChatGPT Desktop — with a token-saving file diff cache that nobody else does.
4
+ >
5
+ > **v0.1.0** adds `update_memory`, `list_entities`, `match_reasons` on recall, pagination, pin-via-importance, layer aliases, consolidate dry-run, `linksee-memory-stats` CLI, and a momentum-refresh fix. See [CHANGELOG](#changelog).
4
6
 
5
7
  [![npm](https://img.shields.io/npm/v/linksee-memory.svg)](https://www.npmjs.com/package/linksee-memory)
6
8
  [![license](https://img.shields.io/npm/l/linksee-memory.svg)](./LICENSE)
@@ -85,12 +87,24 @@ Each turn end takes ~100 ms. Failures are silent (Claude Code never blocks). Log
85
87
 
86
88
  | Tool | Purpose |
87
89
  |---|---|
88
- | `remember` | Store memory in 1 of 6 layers for an entity |
89
- | `recall` | FTS5 + heat-score + momentum composite ranking, JP/EN trigram search |
90
- | `recall_file` | Get the COMPLETE edit history of a file across all sessions, with per-edit user-intent context |
91
- | `read_smart` | Diff-only file read. Returns full content on first read, ~50 tokens on unchanged re-reads, only changed chunks on real edits |
92
- | `forget` | Explicit delete OR auto-sweep based on forgettingRisk (importance × heat × age) |
93
- | `consolidate` | Sleep-mode compression: cluster cold low-importance memories protected learning-layer summary |
90
+ | `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). |
91
+ | `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. |
92
+ | `recall_file` | Complete edit history of a file across all sessions, with per-edit user-intent context. |
93
+ | `update_memory` | **v0.1.0** Atomic edit of an existing memory. Preserves `memory_id` (session_file_edits links stay intact). Prefer over forget+remember. |
94
+ | `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. |
95
+ | `read_smart` | Diff-only file read. Returns full content on first read, ~50 tokens on unchanged re-reads, only changed chunks on real edits. |
96
+ | `forget` | Explicit delete OR auto-sweep based on `forgettingRisk`. Pinned (`importance>=1.0`) and caveat-layer memories are always preserved. |
97
+ | `consolidate` | Sleep-mode compression: cluster cold low-importance memories → protected learning-layer summary. Supports `dry_run` preview. |
98
+
99
+ ### CLI utilities
100
+
101
+ | Command | Purpose |
102
+ |---|---|
103
+ | `npx linksee-memory` | MCP server (stdio) |
104
+ | `npx linksee-memory-sync` | Claude Code Stop-hook entry point |
105
+ | `npx linksee-memory-import` | Batch-import Claude Code session JSONL history |
106
+ | `npx linksee-memory-install-skill` | Install the Claude Code Skill that teaches the agent when to call recall/remember/read_smart |
107
+ | `npx linksee-memory-stats` | **v0.1.0** Summary of the local DB (entity count / layer breakdown / top entities / top edited files). Add `--json` for machine-readable output. |
94
108
 
95
109
  ## The 6 memory layers
96
110
 
@@ -280,6 +294,49 @@ Caveat and active-goal layers are always preserved. Consider scheduling a weekly
280
294
  - **Security concerns**: see [SECURITY.md](./SECURITY.md) if present, or file a private advisory on GitHub
281
295
  - **Company**: Synapse Arrows PTE. LTD. (Singapore)
282
296
 
297
+ ## Changelog
298
+
299
+ ### v0.1.0 — Major UX update (2026-04-18)
300
+
301
+ Based on one week of dogfooding, here's what changed:
302
+
303
+ **New tools**
304
+ - `update_memory` — atomic edit with preserved `memory_id`. Solves the "forget+remember breaks session_file_edits links" bug.
305
+ - `list_entities` — fast "what do I know about?" primitive for session init. Supports `kind`/`min_memories` filters and returns layer breakdown.
306
+ - `npx linksee-memory-stats` — local DB summary CLI.
307
+
308
+ **`recall` enhancements**
309
+ - `match_reasons` array on each memory: e.g. `["content_match_fts", "heat:hot", "pinned"]`.
310
+ - `score_breakdown` with per-dimension scores (relevance / heat / momentum / importance).
311
+ - Pagination via `offset` / `has_more` / `stopped_by`.
312
+ - `limit` parameter (hard cap, complements `max_tokens` budget).
313
+ - `band` filter to request only hot/warm/cold/frozen memories.
314
+ - `mark_accessed=false` for preview queries that shouldn't bump heat.
315
+ - **Layer aliases**: `decisions` → `learning`, `warnings` → `caveat`, `how` → `implementation`, etc.
316
+ - **Fix**: opportunistic refresh of stale entity momentum scores. Entities recalled >1 h after last remember() no longer return stale momentum.
317
+
318
+ **`remember` enhancements**
319
+ - Quality check: rejects pasted assistant output / CI logs / stack traces unless `force=true`.
320
+ - `importance=1.0` now implicitly pins the memory (survives auto-forget).
321
+ - Layer aliases accepted.
322
+
323
+ **`forget` changes**
324
+ - Pinned memories (importance=1.0) now preserved alongside caveat-layer memories.
325
+ - Clear error response when attempting to delete a protected or missing memory.
326
+ - dry-run now includes `sample_ids_to_drop`.
327
+
328
+ **`consolidate` changes**
329
+ - `dry_run: true` preview mode — reports cluster count + candidates without writing.
330
+
331
+ **Infra**
332
+ - Fixed fresh-DB migration bug (was querying `meta` table before it existed).
333
+ - Bumped to Node 20+ for structured language feature usage.
334
+
335
+ All changes are **backward compatible** — existing integrations continue to work. Server.ts version banner now reports `v0.1.0`.
336
+
337
+ ### Older versions
338
+ See [GitHub Releases](https://github.com/michielinksee/linksee-memory/releases).
339
+
283
340
  ## License
284
341
 
285
342
  MIT — Synapse Arrows PTE. LTD.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ // linksee-memory-stats — summary of the local memory DB.
3
+ // Usage:
4
+ // npx linksee-memory-stats
5
+ // npx linksee-memory-stats --json
6
+ // npx linksee-memory-stats --per-entity 10
7
+ //
8
+ // Safe to run anytime (read-only).
9
+ import { statSync } from 'node:fs';
10
+ import { openDb, getDbPath } from '../db/migrate.js';
11
+ function parseArgs() {
12
+ const argv = process.argv.slice(2);
13
+ const a = { json: false, perEntity: 5, help: false };
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const v = argv[i];
16
+ if (v === '--json')
17
+ a.json = true;
18
+ else if (v === '--per-entity')
19
+ a.perEntity = Math.max(0, Number(argv[++i] || 5));
20
+ else if (v === '-h' || v === '--help')
21
+ a.help = true;
22
+ }
23
+ return a;
24
+ }
25
+ function humanBytes(n) {
26
+ if (n < 1024)
27
+ return `${n} B`;
28
+ if (n < 1024 * 1024)
29
+ return `${(n / 1024).toFixed(1)} KB`;
30
+ if (n < 1024 * 1024 * 1024)
31
+ return `${(n / 1024 / 1024).toFixed(2)} MB`;
32
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
33
+ }
34
+ function humanDate(unix) {
35
+ if (!unix)
36
+ return '-';
37
+ return new Date(unix * 1000).toISOString().slice(0, 16).replace('T', ' ');
38
+ }
39
+ function humanAge(unix) {
40
+ if (!unix)
41
+ return '-';
42
+ const diff = Math.floor(Date.now() / 1000) - unix;
43
+ if (diff < 60)
44
+ return 'just now';
45
+ if (diff < 3600)
46
+ return `${Math.floor(diff / 60)}m ago`;
47
+ if (diff < 86400)
48
+ return `${Math.floor(diff / 3600)}h ago`;
49
+ if (diff < 86400 * 30)
50
+ return `${Math.floor(diff / 86400)}d ago`;
51
+ return `${Math.floor(diff / 86400 / 30)}mo ago`;
52
+ }
53
+ function main() {
54
+ const args = parseArgs();
55
+ if (args.help) {
56
+ console.log(`linksee-memory-stats — summary of the local memory DB
57
+
58
+ --json Output machine-readable JSON
59
+ --per-entity N Show top N entities (default 5, 0 to skip)
60
+ -h, --help This message
61
+ `);
62
+ return;
63
+ }
64
+ const dbPath = getDbPath();
65
+ let sizeBytes = 0;
66
+ try {
67
+ sizeBytes = statSync(dbPath).size;
68
+ }
69
+ catch { /* no db yet */ }
70
+ const db = openDb();
71
+ const counts = {
72
+ entities: db.prepare('SELECT COUNT(*) as c FROM entities').get().c,
73
+ memories: db.prepare('SELECT COUNT(*) as c FROM memories').get().c,
74
+ file_edits: db.prepare('SELECT COUNT(*) as c FROM session_file_edits').get().c,
75
+ unique_files: db.prepare('SELECT COUNT(DISTINCT file_path) as c FROM session_file_edits').get().c,
76
+ sessions_seen: db.prepare('SELECT COUNT(DISTINCT session_id) as c FROM session_file_edits').get().c,
77
+ consolidations: db.prepare('SELECT COUNT(*) as c FROM consolidations').get().c,
78
+ events: db.prepare('SELECT COUNT(*) as c FROM events').get().c,
79
+ };
80
+ const layerBreakdown = db
81
+ .prepare("SELECT layer, COUNT(*) as c FROM memories GROUP BY layer ORDER BY c DESC")
82
+ .all();
83
+ const entityKinds = db
84
+ .prepare("SELECT kind, COUNT(*) as c FROM entities GROUP BY kind ORDER BY c DESC")
85
+ .all();
86
+ const pinned = db.prepare('SELECT COUNT(*) as c FROM memories WHERE importance >= 1.0').get().c;
87
+ const protectedCount = db.prepare('SELECT COUNT(*) as c FROM memories WHERE protected = 1').get().c;
88
+ const oldest = db.prepare('SELECT MIN(created_at) as t FROM memories').get().t;
89
+ const newest = db.prepare('SELECT MAX(created_at) as t FROM memories').get().t;
90
+ const topEntities = args.perEntity > 0
91
+ ? db.prepare(`
92
+ SELECT e.name, e.kind, e.momentum_score, COUNT(m.id) as memory_count,
93
+ MAX(m.last_accessed_at) as last_access
94
+ FROM entities e
95
+ LEFT JOIN memories m ON m.entity_id = e.id
96
+ GROUP BY e.id
97
+ ORDER BY memory_count DESC, e.momentum_score DESC
98
+ LIMIT ?
99
+ `).all(args.perEntity)
100
+ : [];
101
+ const topFiles = db.prepare(`
102
+ SELECT file_path, COUNT(*) as edits, COUNT(DISTINCT session_id) as in_sessions
103
+ FROM session_file_edits
104
+ WHERE operation IN ('edit', 'write')
105
+ GROUP BY file_path
106
+ ORDER BY edits DESC
107
+ LIMIT 5
108
+ `).all();
109
+ const result = {
110
+ db_path: dbPath,
111
+ db_size: sizeBytes,
112
+ db_size_human: humanBytes(sizeBytes),
113
+ counts,
114
+ pinned,
115
+ caveat_protected: protectedCount,
116
+ layer_breakdown: layerBreakdown,
117
+ entity_kinds: entityKinds,
118
+ date_range: {
119
+ oldest: oldest ? new Date(oldest * 1000).toISOString() : null,
120
+ newest: newest ? new Date(newest * 1000).toISOString() : null,
121
+ },
122
+ top_entities: topEntities.map((e) => ({
123
+ name: e.name,
124
+ kind: e.kind,
125
+ momentum: Number((e.momentum_score ?? 0).toFixed(2)),
126
+ memory_count: e.memory_count,
127
+ last_access: e.last_access ? new Date(e.last_access * 1000).toISOString() : null,
128
+ })),
129
+ top_files: topFiles.map((f) => ({
130
+ path: f.file_path,
131
+ edits: f.edits,
132
+ in_sessions: f.in_sessions,
133
+ })),
134
+ };
135
+ if (args.json) {
136
+ console.log(JSON.stringify(result, null, 2));
137
+ db.close();
138
+ return;
139
+ }
140
+ // Human-readable output
141
+ console.log('');
142
+ console.log(' linksee-memory — local brain status');
143
+ console.log(' ' + '═'.repeat(55));
144
+ console.log(` DB: ${dbPath}`);
145
+ console.log(` Size: ${humanBytes(sizeBytes)}`);
146
+ console.log(` Oldest memory: ${humanAge(oldest)}`);
147
+ console.log(` Newest memory: ${humanAge(newest)}`);
148
+ console.log('');
149
+ console.log(' Counts');
150
+ console.log(` entities: ${counts.entities}`);
151
+ console.log(` memories: ${counts.memories} (${pinned} pinned, ${protectedCount} caveat-protected)`);
152
+ console.log(` file edits: ${counts.file_edits} across ${counts.unique_files} unique files`);
153
+ console.log(` sessions seen: ${counts.sessions_seen}`);
154
+ console.log(` consolidations: ${counts.consolidations}`);
155
+ console.log('');
156
+ if (layerBreakdown.length > 0) {
157
+ console.log(' Memories by layer');
158
+ for (const r of layerBreakdown) {
159
+ const bar = '█'.repeat(Math.min(40, Math.round((r.c / counts.memories) * 40)));
160
+ console.log(` ${r.layer.padEnd(15)} ${String(r.c).padStart(5)} ${bar}`);
161
+ }
162
+ console.log('');
163
+ }
164
+ if (entityKinds.length > 0) {
165
+ console.log(' Entities by kind');
166
+ for (const r of entityKinds) {
167
+ console.log(` ${r.kind.padEnd(15)} ${r.c}`);
168
+ }
169
+ console.log('');
170
+ }
171
+ if (topEntities.length > 0) {
172
+ console.log(` Top ${topEntities.length} entities by memory count`);
173
+ for (const e of topEntities) {
174
+ console.log(` ${String(e.memory_count).padStart(4)} ${e.name.padEnd(30)} [${e.kind}] momentum ${Number(e.momentum_score ?? 0).toFixed(1)} last ${humanAge(e.last_access)}`);
175
+ }
176
+ console.log('');
177
+ }
178
+ if (topFiles.length > 0) {
179
+ console.log(' Top 5 most-edited files');
180
+ for (const f of topFiles) {
181
+ const p = f.file_path || '';
182
+ const shortPath = p.length > 65 ? '…' + p.slice(-64) : p;
183
+ console.log(` ${String(f.edits).padStart(4)} edits ${shortPath}`);
184
+ }
185
+ console.log('');
186
+ }
187
+ console.log(' Run with --json for machine-readable output.');
188
+ console.log('');
189
+ db.close();
190
+ }
191
+ main();
192
+ //# sourceMappingURL=stats.js.map
@@ -20,10 +20,18 @@ export function runMigrations(db) {
20
20
  const __filename = fileURLToPath(import.meta.url);
21
21
  const schemaPath = join(dirname(__filename), 'schema.sql');
22
22
  const sql = readFileSync(schemaPath, 'utf8');
23
- // v3 v4: rebuild memories_fts with trigram tokenizer for JP/CJK support
24
- const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
25
- if (versionRow && Number(versionRow.value) < 4) {
26
- // Drop old triggers + table; CREATE statements below will recreate them
23
+ // Safely read current schema version. On a fresh DB the meta table doesn't
24
+ // exist yet, which is fine treat that as "version 0, full schema apply".
25
+ let currentVersion = 0;
26
+ const metaTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
27
+ if (metaTable?.name) {
28
+ const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
29
+ if (versionRow)
30
+ currentVersion = Number(versionRow.value) || 0;
31
+ }
32
+ // v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support.
33
+ // Only runs when upgrading an existing DB from schema v1-3.
34
+ if (currentVersion > 0 && currentVersion < 4) {
27
35
  db.exec(`
28
36
  DROP TRIGGER IF EXISTS trg_memories_fts_ai;
29
37
  DROP TRIGGER IF EXISTS trg_memories_fts_ad;
@@ -32,8 +40,7 @@ export function runMigrations(db) {
32
40
  `);
33
41
  }
34
42
  db.exec(sql);
35
- // After v4 schema is applied, repopulate FTS index from existing memories
36
- if (versionRow && Number(versionRow.value) < 4) {
43
+ if (currentVersion > 0 && currentVersion < 4) {
37
44
  db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
38
45
  }
39
46
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // linksee-memory MCP server (stdio transport).
3
- // 5 tools: remember / recall / forget / consolidate / read_smart
3
+ // Tools: remember / recall / recall_file / update_memory / list_entities /
4
+ // forget / consolidate / read_smart
4
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
7
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
@@ -9,33 +10,62 @@ import { computeHeat } from '../lib/heat-index.js';
9
10
  import { decideForgetting } from '../lib/forgetting.js';
10
11
  import { refreshMomentumForEntity } from '../lib/momentum.js';
11
12
  import { consolidate as runConsolidate } from '../lib/consolidate.js';
13
+ import { isPastedExternalContent } from '../lib/session-parser.js';
12
14
  import { handleReadSmart as handleReadSmartImpl } from './read-smart.js';
15
+ const SERVER_VERSION = '0.1.0';
13
16
  const db = openDb();
14
17
  runMigrations(db);
15
- const server = new Server({ name: 'linksee-memory', version: '0.0.2' }, { capabilities: { tools: {} } });
18
+ const server = new Server({ name: 'linksee-memory', version: SERVER_VERSION }, { capabilities: { tools: {} } });
19
+ // ============================================================
20
+ // Layer alias map — natural language → canonical layer
21
+ // (agents can say layer="decisions" and we resolve to "learning")
22
+ // ============================================================
23
+ const LAYER_ALIASES = {
24
+ // canonical — identity
25
+ goal: 'goal', context: 'context', emotion: 'emotion',
26
+ implementation: 'implementation', caveat: 'caveat', learning: 'learning',
27
+ // natural-language aliases
28
+ why: 'goal', goals: 'goal', target: 'goal', targets: 'goal', intent: 'goal',
29
+ background: 'context', reason: 'context', situation: 'context', timing: 'context',
30
+ tone: 'emotion', feelings: 'emotion', mood: 'emotion',
31
+ impl: 'implementation', success: 'implementation', failure: 'implementation',
32
+ how: 'implementation', tried: 'implementation', attempts: 'implementation',
33
+ warning: 'caveat', warnings: 'caveat', pain: 'caveat', rule: 'caveat',
34
+ rules: 'caveat', pitfall: 'caveat', pitfalls: 'caveat', dont: 'caveat',
35
+ decision: 'learning', decisions: 'learning', learned: 'learning',
36
+ insight: 'learning', insights: 'learning', growth: 'learning',
37
+ };
38
+ function resolveLayer(input) {
39
+ if (!input)
40
+ return undefined;
41
+ const k = String(input).toLowerCase().trim();
42
+ return LAYER_ALIASES[k] ?? k;
43
+ }
44
+ const LAYER_ENUM = ['goal', 'context', 'emotion', 'implementation', 'caveat', 'learning'];
16
45
  // ============================================================
17
46
  // Tool schema declarations
18
47
  // ============================================================
19
48
  const TOOLS = [
20
49
  {
21
50
  name: 'remember',
22
- description: 'Store a memory about an entity (person/company/project/concept/file) in one of 6 layers: goal (WHY), context (WHY-THIS-NOW), emotion (USER tone), implementation (HOW — success/failure), caveat (PAIN lesson, never forgotten), learning (GROWTH log). Use this when you discover non-obvious goals, unexpected failures, user preferences, or decisions worth preserving.',
51
+ description: 'Store a memory about an entity (person/company/project/concept/file) in one of 6 layers: goal (WHY), context (WHY-THIS-NOW), emotion (USER tone), implementation (HOW — success/failure), caveat (PAIN lesson, never forgotten), learning (GROWTH log). Use this when you discover non-obvious goals, unexpected failures, user preferences, or decisions worth preserving. Pasted assistant output or CI logs are rejected (use force=true only if you are sure).',
23
52
  inputSchema: {
24
53
  type: 'object',
25
54
  properties: {
26
55
  entity_name: { type: 'string', description: 'Name of the entity this memory is about' },
27
56
  entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'] },
28
57
  entity_key: { type: 'string', description: 'Optional canonical key (email, domain, file path)' },
29
- layer: { type: 'string', enum: ['goal', 'context', 'emotion', 'implementation', 'caveat', 'learning'] },
58
+ layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Common aliases (why, decisions, warnings, how, ...) are accepted.' },
30
59
  content: { type: 'string', description: 'The memory content (plain text or JSON)' },
31
- importance: { type: 'number', minimum: 0, maximum: 1 },
60
+ importance: { type: 'number', minimum: 0, maximum: 1, description: '0.0-1.0. Use 1.0 to "pin" a memory (protects from forgetting even outside caveat layer).' },
61
+ force: { type: 'boolean', default: false, description: 'Bypass the paste-back/CI-log quality check. Only set when you are sure the content is original user or agent thought.' },
32
62
  },
33
63
  required: ['entity_name', 'entity_kind', 'layer', 'content'],
34
64
  },
35
65
  },
36
66
  {
37
67
  name: 'recall',
38
- description: 'Retrieve memories relevant to the current context using full-text search (BM25), re-ranked by a composite score of semantic_relevance * heat_score * momentum. Returns only what fits in the token budget. Use at the start of a task to check existing context about the entities involved.',
68
+ description: 'Retrieve memories relevant to the current context using full-text search (BM25) + entity-name match, re-ranked by a composite score (relevance × heat × momentum × importance). Returns only what fits in the token budget, with match_reasons explaining WHY each memory was returned. Opportunistically refreshes stale momentum scores for entities in the result set. Supports pagination via offset/has_more. Layer aliases accepted. Use at the start of any task that might involve prior work.',
39
69
  inputSchema: {
40
70
  type: 'object',
41
71
  properties: {
@@ -43,32 +73,64 @@ const TOOLS = [
43
73
  entity_name: { type: 'string', description: 'Optional — narrow to a specific entity' },
44
74
  layer: {
45
75
  type: 'string',
46
- enum: ['goal', 'context', 'emotion', 'implementation', 'caveat', 'learning'],
76
+ description: 'Optional layer filter. Accepts aliases (decisions/warnings/how/etc.) as well as canonical names.',
47
77
  },
48
- max_tokens: { type: 'number', description: 'Approx token budget. Default 2000.', default: 2000 },
78
+ band: { type: 'string', enum: ['hot', 'warm', 'cold', 'frozen'], description: 'Optional — only return memories whose heat_band matches.' },
79
+ max_tokens: { type: 'number', description: 'Approx token budget. Default 2000. Either max_tokens or limit stops iteration (whichever fires first).', default: 2000 },
80
+ limit: { type: 'number', description: 'Optional hard cap on number of memories. Stops at min(max_tokens-budget, limit).' },
81
+ offset: { type: 'number', description: 'Skip this many top results (pagination). Use has_more from prior response to decide next offset.', default: 0 },
82
+ mark_accessed: { type: 'boolean', default: true, description: 'Set false for preview / listing queries that should not bump heat.' },
49
83
  },
50
84
  required: ['query'],
51
85
  },
52
86
  },
87
+ {
88
+ name: 'update_memory',
89
+ description: 'Atomically edit an existing memory in-place. Preferred over forget+remember because it preserves memory_id, which matters for session_file_edits links and referential integrity. Use to correct facts, update deadlines in goal entries, refine caveats, or re-score importance. Caveat-layer memories can be updated but cannot have their protected flag removed.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ memory_id: { type: 'number', description: 'The memory.id to update' },
94
+ content: { type: 'string', description: 'New content (plain text or JSON). If omitted, content is kept.' },
95
+ layer: { type: 'string', description: 'Move to a different layer (aliases accepted). If omitted, layer is kept.' },
96
+ importance: { type: 'number', minimum: 0, maximum: 1, description: 'New importance 0-1. Set to 1.0 to pin.' },
97
+ },
98
+ required: ['memory_id'],
99
+ },
100
+ },
101
+ {
102
+ name: 'list_entities',
103
+ description: 'List the entities currently known to this memory store, sorted by recent activity. Use at the start of a new session ("what do I know about?") before issuing specific recall queries. Cheaper than recall for the "give me an overview" question.',
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'Filter by entity kind.' },
108
+ min_memories: { type: 'number', description: 'Only include entities with at least N memories. Default 1.', default: 1 },
109
+ limit: { type: 'number', description: 'Max entities to return. Default 30.', default: 30 },
110
+ offset: { type: 'number', default: 0 },
111
+ },
112
+ },
113
+ },
53
114
  {
54
115
  name: 'forget',
55
- description: 'Explicitly delete a memory by id, OR run auto-forgetting across all memories based on forgettingRisk (importance + heat + age). Caveat and goal layer are always preserved.',
116
+ description: 'Explicitly delete a memory by id, OR run auto-forgetting across all memories based on forgettingRisk (importance + heat + age). Caveat-layer, goal-layer, and importance=1.0 (pinned) memories are always preserved. Prefer update_memory for corrections — forget is destructive.',
56
117
  inputSchema: {
57
118
  type: 'object',
58
119
  properties: {
59
120
  memory_id: { type: 'number' },
60
- dry_run: { type: 'boolean', default: false },
121
+ dry_run: { type: 'boolean', default: false, description: 'Report what would be deleted without actually deleting.' },
61
122
  },
62
123
  },
63
124
  },
64
125
  {
65
126
  name: 'consolidate',
66
- description: 'Sleep-mode compression. Clusters cold low-importance memories by (entity, layer), summarizes each cluster into a single protected learning-layer entry, deletes originals, and runs a forget-sweep. Run at session end or on demand. Returns a summary of what was compressed.',
127
+ description: 'Sleep-mode compression. Clusters cold low-importance memories by (entity, layer), summarizes each cluster into a single protected learning-layer entry, deletes originals, and runs a forget-sweep. Run at session end or on demand. Set dry_run=true to preview without writing.',
67
128
  inputSchema: {
68
129
  type: 'object',
69
130
  properties: {
70
131
  scope: { type: 'string', enum: ['all', 'session'], default: 'session' },
71
132
  min_age_days: { type: 'number', description: 'Override the default 7-day minimum age for clustering (set to 0 to consolidate everything immediately, useful right after a bulk import).', default: 7 },
133
+ dry_run: { type: 'boolean', default: false, description: 'Preview what would be compressed without modifying the DB.' },
72
134
  },
73
135
  },
74
136
  },
@@ -121,19 +183,37 @@ function upsertEntity(args) {
121
183
  return Number(result.lastInsertRowid);
122
184
  }
123
185
  function handleRemember(args) {
186
+ // Layer alias → canonical
187
+ const layer = resolveLayer(args.layer);
188
+ if (!layer || !LAYER_ENUM.includes(layer)) {
189
+ return JSON.stringify({
190
+ ok: false,
191
+ error: `unknown layer "${args.layer}". Known: ${LAYER_ENUM.join(', ')} (aliases: decisions, warnings, how, why, ...)`,
192
+ });
193
+ }
194
+ // Quality check — reject pasted external content unless force=true
195
+ const rawContent = String(args.content ?? '');
196
+ if (!args.force && isPastedExternalContent(rawContent)) {
197
+ return JSON.stringify({
198
+ ok: false,
199
+ rejected: 'quality_check',
200
+ reason: 'Content looks like pasted assistant output, CI log, or external paste. Pass force:true if this really is original thought worth keeping.',
201
+ hint: 'If you meant to save an extracted insight from that paste, summarize it in your own words first.',
202
+ });
203
+ }
124
204
  const entityId = upsertEntity({ name: args.entity_name, kind: args.entity_kind, key: args.entity_key });
125
- const importance = args.importance ?? 0.5;
205
+ const importance = Math.min(1, Math.max(0, Number(args.importance ?? 0.5)));
126
206
  const result = db
127
- .prepare('INSERT INTO memories (entity_id, layer, content, importance) VALUES (?, ?, ?, ?)')
128
- .run(entityId, args.layer, args.content, importance);
129
- db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer: args.layer, memory_id: result.lastInsertRowid }));
130
- // Refresh momentum — Day 2 addition. Cheap per-call since it's one entity.
207
+ .prepare('INSERT INTO memories (entity_id, layer, content, importance, protected) VALUES (?, ?, ?, ?, ?)')
208
+ .run(entityId, layer, rawContent, importance, importance >= 1.0 ? 1 : 0);
209
+ db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
131
210
  const mom = refreshMomentumForEntity(db, entityId);
132
211
  return JSON.stringify({
133
212
  ok: true,
134
213
  memory_id: Number(result.lastInsertRowid),
135
214
  entity_id: entityId,
136
- layer: args.layer,
215
+ layer,
216
+ pinned: importance >= 1.0,
137
217
  momentum: { score: mom.score, band: mom.band },
138
218
  });
139
219
  }
@@ -197,28 +277,58 @@ function runLikeQuery(query, entityName, layer, limit) {
197
277
  params.push(limit);
198
278
  return db.prepare(sql).all(...params);
199
279
  }
280
+ // Opportunistically refresh momentum_score cache for entities in a result set.
281
+ // Momentum is computed from events but stored in the entities row; without this,
282
+ // a row can become stale (e.g. no new remember() for weeks while events decay).
283
+ // Only refreshes entries older than MOMENTUM_STALE_SECS to avoid per-call cost.
284
+ const MOMENTUM_STALE_SECS = 3600; // 1 hour
285
+ function refreshStaleMomentum(entityIds) {
286
+ if (entityIds.length === 0)
287
+ return;
288
+ const unique = Array.from(new Set(entityIds));
289
+ const now = Math.floor(Date.now() / 1000);
290
+ const cutoff = now - MOMENTUM_STALE_SECS;
291
+ const placeholders = unique.map(() => '?').join(',');
292
+ const rows = db
293
+ .prepare(`SELECT id FROM entities WHERE id IN (${placeholders}) AND (momentum_at IS NULL OR momentum_at < ?)`)
294
+ .all(...unique, cutoff);
295
+ for (const r of rows) {
296
+ try {
297
+ refreshMomentumForEntity(db, r.id);
298
+ }
299
+ catch { /* non-fatal */ }
300
+ }
301
+ }
200
302
  function handleRecall(args) {
201
- const maxTokens = args.max_tokens ?? 2000;
303
+ const maxTokens = Math.max(100, Number(args.max_tokens ?? 2000));
202
304
  const approxTokensPerMemory = 100;
203
- const limit = Math.max(1, Math.floor(maxTokens / approxTokensPerMemory));
204
- const fetchLimit = limit * 2; // extra for composite re-rank
305
+ const tokenBudgetLimit = Math.max(1, Math.floor(maxTokens / approxTokensPerMemory));
306
+ const hardLimit = Number.isFinite(args.limit) ? Math.max(1, Math.min(200, args.limit)) : tokenBudgetLimit;
307
+ const returnLimit = Math.min(tokenBudgetLimit, hardLimit);
308
+ const offset = Math.max(0, Number(args.offset ?? 0));
309
+ const markAccessed = args.mark_accessed !== false;
310
+ const layer = resolveLayer(args.layer);
311
+ const band = args.band;
312
+ // Fetch extra rows for composite re-rank, pagination, and band filtering
313
+ const fetchLimit = Math.max(returnLimit * 3, 30) + offset;
205
314
  let rows = [];
206
315
  let searchMethod = 'like';
207
316
  const ftsQuery = toFtsQuery(args.query ?? '');
208
317
  const canUseFts = !!ftsQuery && !args.entity_name;
209
318
  if (canUseFts) {
210
- // 1st pass: FTS5 over content
211
- const ftsRows = runFtsQuery(ftsQuery, args.layer, fetchLimit);
212
- // 2nd pass: entity-name LIKE — catches queries that ARE entity names
213
- // (FTS only indexes memory content, not entity names)
214
- const likeRows = runLikeQuery(args.query, undefined, args.layer, fetchLimit);
215
- // merge, dedup by memory id (prefer the FTS row for its bm25_score)
319
+ const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit);
320
+ const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit);
216
321
  const seen = new Map();
217
322
  for (const r of ftsRows)
218
- seen.set(r.id, r);
219
- for (const r of likeRows)
220
- if (!seen.has(r.id))
221
- seen.set(r.id, r);
323
+ seen.set(r.id, { ...r, _via: 'fts' });
324
+ for (const r of likeRows) {
325
+ if (seen.has(r.id)) {
326
+ seen.get(r.id)._via = 'fts+like'; // both matched
327
+ }
328
+ else {
329
+ seen.set(r.id, { ...r, _via: 'like' });
330
+ }
331
+ }
222
332
  rows = Array.from(seen.values());
223
333
  if (ftsRows.length > 0 && likeRows.length > 0)
224
334
  searchMethod = 'fts5+like';
@@ -228,13 +338,22 @@ function handleRecall(args) {
228
338
  searchMethod = 'like';
229
339
  }
230
340
  else {
231
- rows = runLikeQuery(args.query, args.entity_name, args.layer, fetchLimit);
341
+ rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit).map((r) => ({ ...r, _via: 'like' }));
232
342
  searchMethod = 'like';
233
343
  }
234
344
  const useFts = searchMethod !== 'like';
235
345
  const now = Math.floor(Date.now() / 1000);
236
- // Composite score: 0.5 * relevance + 0.3 * heat + 0.2 * momentum
237
- // relevance: if FTS used, normalize bm25 (lower = better → invert). Otherwise 0.5 baseline.
346
+ // Opportunistically refresh momentum for entities about to be surfaced
347
+ refreshStaleMomentum(rows.map((r) => r.entity_id));
348
+ // Re-fetch momentum after refresh (cheap single-pass update)
349
+ if (rows.length > 0) {
350
+ const ids = Array.from(new Set(rows.map((r) => r.entity_id)));
351
+ const ph = ids.map(() => '?').join(',');
352
+ const fresh = db.prepare(`SELECT id, momentum_score FROM entities WHERE id IN (${ph})`).all(...ids);
353
+ const byId = new Map(fresh.map((f) => [f.id, f.momentum_score]));
354
+ for (const r of rows)
355
+ r.momentum_score = byId.get(r.entity_id) ?? r.momentum_score;
356
+ }
238
357
  const bm25Values = rows.map((r) => r.bm25_score);
239
358
  const minBm = Math.min(...bm25Values, 0);
240
359
  const maxBm = Math.max(...bm25Values, 1);
@@ -248,53 +367,133 @@ function handleRecall(args) {
248
367
  totalAccesses: r.access_count,
249
368
  baseImportance: r.importance,
250
369
  });
251
- // Normalize BM25: lower = more relevant. Map to 0..1 (1 = most relevant).
252
- const relevance = useFts ? 1 - (r.bm25_score - minBm) / bmSpan : 0.5;
370
+ // Individual weight contributions (for transparency)
371
+ const relevance = useFts && r._via !== 'like' ? 1 - (r.bm25_score - minBm) / bmSpan : 0.5;
253
372
  const heatNorm = heat.score / 100;
254
373
  const momNorm = Math.min(1, (r.momentum_score ?? 0) / 10);
255
- const composite = 0.5 * relevance + 0.3 * heatNorm + 0.2 * momNorm;
374
+ const importanceBoost = r.importance; // 0-1
375
+ // Composite: give a bit to importance so pinned (1.0) memories always rank high
376
+ const w_rel = 0.45, w_heat = 0.25, w_mom = 0.15, w_imp = 0.15;
377
+ const composite = w_rel * relevance + w_heat * heatNorm + w_mom * momNorm + w_imp * importanceBoost;
378
+ // match_reasons: human-readable WHY this row is here
379
+ const reasons = [];
380
+ if (r._via === 'fts' || r._via === 'fts+like')
381
+ reasons.push(`content_match_${r._via === 'fts+like' ? 'dual' : 'fts'}`);
382
+ if (r._via === 'like' || r._via === 'fts+like') {
383
+ if (args.entity_name || (args.query && String(r.entity_name || '').toLowerCase().includes(String(args.query).toLowerCase()))) {
384
+ reasons.push('entity_name_match');
385
+ }
386
+ else {
387
+ reasons.push('content_substring');
388
+ }
389
+ }
390
+ if (heat.band === 'hot')
391
+ reasons.push('heat:hot');
392
+ else if (heat.band === 'warm')
393
+ reasons.push('heat:warm');
394
+ if (r.momentum_score >= 5)
395
+ reasons.push('entity_active');
396
+ if (r.importance >= 1.0)
397
+ reasons.push('pinned');
398
+ else if (r.importance >= 0.8)
399
+ reasons.push('high_importance');
400
+ if (r.protected === 1 && r.layer === 'caveat')
401
+ reasons.push('caveat_protected');
256
402
  return {
257
403
  ...r,
258
404
  heat_score: heat.score,
259
405
  heat_band: heat.band,
260
406
  composite_score: composite,
261
407
  relevance_score: relevance,
408
+ _reasons: reasons,
409
+ _breakdown: {
410
+ relevance: Number(relevance.toFixed(3)),
411
+ heat: Number(heatNorm.toFixed(3)),
412
+ momentum: Number(momNorm.toFixed(3)),
413
+ importance: Number(importanceBoost.toFixed(3)),
414
+ },
262
415
  };
263
416
  });
264
- scored.sort((a, b) => b.composite_score - a.composite_score);
265
- const top = scored.slice(0, limit);
266
- // Mark accessed
267
- if (top.length > 0) {
268
- const markAccessed = db.prepare('UPDATE memories SET last_accessed_at = ?, access_count = access_count + 1 WHERE id = ?');
269
- const tx = db.transaction((ids) => {
270
- for (const id of ids)
271
- markAccessed.run(now, id);
272
- });
273
- tx(top.map((r) => r.id));
417
+ // Apply band filter AFTER scoring (needs heat.band)
418
+ const filtered = band ? scored.filter((s) => s.heat_band === band) : scored;
419
+ // Sort by composite
420
+ filtered.sort((a, b) => b.composite_score - a.composite_score);
421
+ // Pagination: skip offset, take returnLimit
422
+ const total = filtered.length;
423
+ const windowed = filtered.slice(offset, offset + returnLimit);
424
+ const hasMore = total > offset + windowed.length;
425
+ // Mark accessed (only for the returned window, and only if asked)
426
+ if (markAccessed && windowed.length > 0) {
427
+ const mark = db.prepare('UPDATE memories SET last_accessed_at = ?, access_count = access_count + 1 WHERE id = ?');
428
+ const tx = db.transaction((ids) => { for (const id of ids)
429
+ mark.run(now, id); });
430
+ tx(windowed.map((r) => r.id));
431
+ }
432
+ // Determine what stopped iteration — max_tokens vs limit vs offset+n=total
433
+ let stoppedBy = 'end';
434
+ if (windowed.length === returnLimit && total > offset + returnLimit) {
435
+ stoppedBy = hardLimit <= tokenBudgetLimit ? 'limit' : 'tokens';
274
436
  }
275
437
  return JSON.stringify({
276
438
  ok: true,
277
- count: top.length,
439
+ count: windowed.length,
440
+ total_candidates: total,
441
+ offset,
442
+ has_more: hasMore,
443
+ stopped_by: stoppedBy,
278
444
  search: searchMethod,
279
- memories: top.map((r) => ({
280
- id: r.id,
281
- entity: { name: r.entity_name, kind: r.entity_kind, momentum: r.momentum_score },
282
- layer: r.layer,
283
- content: r.content,
284
- importance: r.importance,
285
- heat: Number(r.heat_score.toFixed(1)),
286
- band: r.heat_band,
287
- composite: Number(r.composite_score.toFixed(3)),
288
- })),
445
+ resolved_layer: layer ?? null,
446
+ memories: windowed.map((r) => {
447
+ let parsedContent = r.content;
448
+ try {
449
+ parsedContent = JSON.parse(r.content);
450
+ }
451
+ catch { /* leave as string */ }
452
+ return {
453
+ id: r.id,
454
+ entity: {
455
+ id: r.entity_id,
456
+ name: r.entity_name,
457
+ kind: r.entity_kind,
458
+ momentum: Number((r.momentum_score ?? 0).toFixed(2)),
459
+ },
460
+ layer: r.layer,
461
+ content: parsedContent,
462
+ content_raw: r.content,
463
+ importance: r.importance,
464
+ pinned: r.importance >= 1.0,
465
+ heat: Number(r.heat_score.toFixed(1)),
466
+ band: r.heat_band,
467
+ composite: Number(r.composite_score.toFixed(3)),
468
+ match_reasons: r._reasons,
469
+ score_breakdown: r._breakdown,
470
+ };
471
+ }),
289
472
  });
290
473
  }
291
474
  function handleForget(args) {
292
475
  if (args.memory_id) {
293
- const res = db.prepare('DELETE FROM memories WHERE id = ? AND protected = 0').run(args.memory_id);
294
- return JSON.stringify({ ok: true, deleted: res.changes });
476
+ // Explicit delete by id respect protected AND pinned (importance >= 1.0)
477
+ const target = db.prepare('SELECT id, layer, importance, protected FROM memories WHERE id = ?').get(args.memory_id);
478
+ if (!target) {
479
+ return JSON.stringify({ ok: false, error: `memory_id ${args.memory_id} not found` });
480
+ }
481
+ if (target.protected === 1 || target.importance >= 1.0) {
482
+ return JSON.stringify({
483
+ ok: false,
484
+ preserved: true,
485
+ reason: target.protected === 1 ? `${target.layer}-layer is auto-protected` : 'pinned (importance=1.0)',
486
+ hint: 'Use update_memory to lower importance below 1.0 first, then forget.',
487
+ });
488
+ }
489
+ const res = db.prepare('DELETE FROM memories WHERE id = ?').run(args.memory_id);
490
+ return JSON.stringify({ ok: true, deleted: res.changes, memory_id: args.memory_id });
295
491
  }
492
+ // Auto-sweep — also respect importance=1.0 as protection
296
493
  const rows = db
297
- .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected FROM memories WHERE protected = 0`)
494
+ .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected
495
+ FROM memories
496
+ WHERE protected = 0 AND importance < 1.0`)
298
497
  .all();
299
498
  const now = Math.floor(Date.now() / 1000);
300
499
  const actions = [];
@@ -317,30 +516,168 @@ function handleForget(args) {
317
516
  if (action !== 'keep')
318
517
  actions.push({ id: r.id, action });
319
518
  }
519
+ const toDropIds = actions.filter((a) => a.action === 'drop').map((a) => a.id);
320
520
  if (!args.dry_run) {
321
521
  const del = db.prepare('DELETE FROM memories WHERE id = ?');
322
- const tx = db.transaction((items) => {
323
- for (const a of items)
324
- if (a.action === 'drop')
325
- del.run(a.id);
326
- });
327
- tx(actions);
522
+ const tx = db.transaction((ids) => { for (const id of ids)
523
+ del.run(id); });
524
+ tx(toDropIds);
328
525
  }
329
526
  return JSON.stringify({
330
527
  ok: true,
331
528
  dry_run: !!args.dry_run,
332
529
  scanned: rows.length,
333
- to_drop: actions.filter((a) => a.action === 'drop').length,
530
+ to_drop: toDropIds.length,
334
531
  to_compress: actions.filter((a) => a.action === 'compress').length,
532
+ sample_ids_to_drop: toDropIds.slice(0, 10),
335
533
  });
336
534
  }
337
535
  function handleConsolidate(args) {
536
+ const dryRun = !!args?.dry_run;
537
+ if (dryRun) {
538
+ // Preview: simulate by running against a transaction we roll back
539
+ // We don't have a clean "preview" path in lib/consolidate, so do a best-effort
540
+ // read-only audit: count candidates using the same rules.
541
+ const now = Math.floor(Date.now() / 1000);
542
+ const ageCutoff = now - (args.min_age_days ?? 7) * 86400;
543
+ const candidates = db.prepare(`
544
+ SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
545
+ FROM memories m
546
+ JOIN entities e ON e.id = m.entity_id
547
+ WHERE m.protected = 0
548
+ AND m.importance < 1.0
549
+ AND m.layer IN ('context', 'emotion', 'implementation')
550
+ AND m.created_at <= ?
551
+ GROUP BY m.entity_id, m.layer
552
+ HAVING c >= 2
553
+ ORDER BY c DESC
554
+ `).all(ageCutoff);
555
+ const totalReplaced = candidates.reduce((s, c) => s + c.c, 0);
556
+ return JSON.stringify({
557
+ ok: true,
558
+ dry_run: true,
559
+ clusters: candidates.length,
560
+ memories_replaced_if_run: totalReplaced,
561
+ preview: candidates.slice(0, 20).map((c) => ({
562
+ entity: c.entity_name,
563
+ layer: c.layer,
564
+ count: c.c,
565
+ })),
566
+ hint: 'Set dry_run=false to actually consolidate.',
567
+ });
568
+ }
338
569
  const result = runConsolidate(db, {
339
570
  scope: args?.scope ?? 'session',
340
571
  min_age_days: typeof args?.min_age_days === 'number' ? args.min_age_days : undefined,
341
572
  });
342
573
  return JSON.stringify({ ok: true, ...result });
343
574
  }
575
+ function handleUpdateMemory(args) {
576
+ const memoryId = Number(args.memory_id);
577
+ if (!Number.isFinite(memoryId)) {
578
+ return JSON.stringify({ ok: false, error: 'memory_id (number) required' });
579
+ }
580
+ const existing = db.prepare('SELECT id, entity_id, layer, content, importance, protected FROM memories WHERE id = ?').get(memoryId);
581
+ if (!existing) {
582
+ return JSON.stringify({ ok: false, error: `memory_id ${memoryId} not found` });
583
+ }
584
+ const patch = {};
585
+ if (typeof args.content === 'string')
586
+ patch.content = args.content;
587
+ if (typeof args.layer === 'string') {
588
+ const resolved = resolveLayer(args.layer);
589
+ if (!resolved || !LAYER_ENUM.includes(resolved)) {
590
+ return JSON.stringify({ ok: false, error: `unknown layer "${args.layer}"` });
591
+ }
592
+ // Cannot demote a caveat out of caveat (caveat is auto-protected by trigger)
593
+ if (existing.layer === 'caveat' && resolved !== 'caveat' && existing.protected === 1) {
594
+ return JSON.stringify({
595
+ ok: false,
596
+ error: 'Cannot move a protected caveat memory to another layer. Create a new memory in the target layer instead.',
597
+ });
598
+ }
599
+ patch.layer = resolved;
600
+ }
601
+ if (args.importance !== undefined) {
602
+ const imp = Math.min(1, Math.max(0, Number(args.importance)));
603
+ patch.importance = imp;
604
+ patch.protected = imp >= 1.0 || existing.protected === 1 ? 1 : 0;
605
+ }
606
+ const keys = Object.keys(patch);
607
+ if (keys.length === 0) {
608
+ return JSON.stringify({ ok: false, error: 'no fields to update (provide content, layer, or importance)' });
609
+ }
610
+ const setClause = keys.map((k) => `${k} = ?`).join(', ');
611
+ const values = keys.map((k) => patch[k]);
612
+ db.prepare(`UPDATE memories SET ${setClause} WHERE id = ?`).run(...values, memoryId);
613
+ // Record the update as an event for audit trail
614
+ db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(existing.entity_id, 'memory_updated', JSON.stringify({ memory_id: memoryId, changed: keys }));
615
+ return JSON.stringify({
616
+ ok: true,
617
+ memory_id: memoryId,
618
+ updated_fields: keys,
619
+ pinned: (patch.importance ?? existing.importance) >= 1.0,
620
+ });
621
+ }
622
+ function handleListEntities(args) {
623
+ const kind = args?.kind;
624
+ const minMemories = Math.max(1, Number(args?.min_memories ?? 1));
625
+ const limit = Math.max(1, Math.min(200, Number(args?.limit ?? 30)));
626
+ const offset = Math.max(0, Number(args?.offset ?? 0));
627
+ let sql = `
628
+ SELECT e.id, e.name, e.kind, e.canonical_key, e.momentum_score,
629
+ e.updated_at, e.created_at,
630
+ COUNT(m.id) as memory_count,
631
+ MAX(m.last_accessed_at) as last_memory_access,
632
+ SUM(CASE WHEN m.layer = 'goal' THEN 1 ELSE 0 END) as goal_count,
633
+ SUM(CASE WHEN m.layer = 'caveat' THEN 1 ELSE 0 END) as caveat_count,
634
+ SUM(CASE WHEN m.layer = 'learning' THEN 1 ELSE 0 END) as learning_count,
635
+ SUM(CASE WHEN m.layer = 'implementation' THEN 1 ELSE 0 END) as impl_count,
636
+ SUM(CASE WHEN m.importance >= 1.0 THEN 1 ELSE 0 END) as pinned_count
637
+ FROM entities e
638
+ LEFT JOIN memories m ON m.entity_id = e.id
639
+ WHERE 1=1
640
+ `;
641
+ const params = [];
642
+ if (kind) {
643
+ sql += ' AND e.kind = ?';
644
+ params.push(kind);
645
+ }
646
+ sql += ' GROUP BY e.id';
647
+ if (minMemories > 1)
648
+ sql += ' HAVING memory_count >= ?';
649
+ if (minMemories > 1)
650
+ params.push(minMemories);
651
+ // Sort: active (high momentum) first, then most memories, then most recent
652
+ sql += ' ORDER BY (COALESCE(e.momentum_score,0) * 10 + memory_count * 0.5 + (last_memory_access / 86400.0 / 365) * 2) DESC';
653
+ sql += ' LIMIT ? OFFSET ?';
654
+ params.push(limit, offset);
655
+ const rows = db.prepare(sql).all(...params);
656
+ const totalRow = db.prepare(kind ? 'SELECT COUNT(*) as c FROM entities WHERE kind = ?' : 'SELECT COUNT(*) as c FROM entities').get(...(kind ? [kind] : []));
657
+ return JSON.stringify({
658
+ ok: true,
659
+ total: totalRow.c,
660
+ returned: rows.length,
661
+ offset,
662
+ has_more: offset + rows.length < totalRow.c,
663
+ entities: rows.map((r) => ({
664
+ id: r.id,
665
+ name: r.name,
666
+ kind: r.kind,
667
+ canonical_key: r.canonical_key,
668
+ momentum: Number((r.momentum_score ?? 0).toFixed(2)),
669
+ memory_count: r.memory_count,
670
+ last_memory_access: r.last_memory_access ? new Date(r.last_memory_access * 1000).toISOString() : null,
671
+ layer_breakdown: {
672
+ goal: r.goal_count,
673
+ caveat: r.caveat_count,
674
+ learning: r.learning_count,
675
+ implementation: r.impl_count,
676
+ },
677
+ pinned_count: r.pinned_count,
678
+ })),
679
+ });
680
+ }
344
681
  function handleRecallFile(args) {
345
682
  const sub = String(args.path_substring ?? '').trim();
346
683
  if (!sub)
@@ -420,6 +757,12 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
420
757
  case 'recall':
421
758
  text = handleRecall(args);
422
759
  break;
760
+ case 'update_memory':
761
+ text = handleUpdateMemory(args);
762
+ break;
763
+ case 'list_entities':
764
+ text = handleListEntities(args);
765
+ break;
423
766
  case 'forget':
424
767
  text = handleForget(args);
425
768
  break;
@@ -445,5 +788,5 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
445
788
  });
446
789
  const transport = new StdioServerTransport();
447
790
  await server.connect(transport);
448
- process.stderr.write('[linksee-memory] MCP server ready on stdio (v0.0.2)\n');
791
+ process.stderr.write(`[linksee-memory] MCP server ready on stdio (v${SERVER_VERSION})\n`);
449
792
  //# sourceMappingURL=server.js.map
@@ -43,6 +43,27 @@ Claude Code は session が終わると全部忘れる。Michieさんが昨日
43
43
 
44
44
  **重要:** `caveat` は自動で忘却保護される。痛みの記録は絶対消えない。
45
45
 
46
+ **v0.1.0: 別のpin方法** — `importance: 1.0` で remember すれば、caveat層じゃなくても auto-forget から保護される。「絶対忘れさせたくない goal」「重要な判断」等で使う:
47
+
48
+ ```
49
+ remember({
50
+ entity_name: "KanseiLink", entity_kind: "project",
51
+ layer: "goal", content: "Plugin Marketplace 申請中",
52
+ importance: 1.0 // pin
53
+ })
54
+ ```
55
+
56
+ **Layer alias 対応** — canonical layer 名覚えなくても、自然な言い方で呼べる:
57
+
58
+ | 自然語 | → canonical |
59
+ |---|---|
60
+ | `decisions` / `insights` / `learned` | `learning` |
61
+ | `warnings` / `rules` / `pitfalls` / `dont` | `caveat` |
62
+ | `how` / `tried` / `attempts` / `success` / `failure` | `implementation` |
63
+ | `why` / `intent` / `goals` / `targets` | `goal` |
64
+ | `background` / `reason` / `situation` / `timing` | `context` |
65
+ | `tone` / `feelings` / `mood` | `emotion` |
66
+
46
67
  ---
47
68
 
48
69
  ## 🔄 実行フロー(5つのタイミング)
@@ -51,6 +72,18 @@ Claude Code は session が終わると全部忘れる。Michieさんが昨日
51
72
 
52
73
  新しい作業を始める**前**に、過去のコンテキストを注入する。
53
74
 
75
+ **v0.1.0 新フロー: 超-セッション冒頭は `list_entities` から**
76
+
77
+ 会話の一番最初(ユーザーが最初の発話をしてきた時点)で、「自分が何を知ってるか」を把握する:
78
+
79
+ ```
80
+ mcp__linksee__list_entities({ min_memories: 5, limit: 10 })
81
+ ```
82
+
83
+ これで返ってくる "momentum 高い entity" = 今まさに話題に上りそうなプロジェクト。各entityの `layer_breakdown` を見れば「このprojectはcaveatが多い」「goalが未完」等が分かる。
84
+
85
+ その後、具体的な作業が始まったら recall:
86
+
54
87
  ```
55
88
  mcp__linksee__recall({
56
89
  query: "<現タスクのキーワード。プロジェクト名 + 技術名>",
@@ -106,6 +139,20 @@ mcp__linksee__read_smart({
106
139
 
107
140
  特に大きなファイル(1000行超えるもの)で効果絶大。
108
141
 
142
+ ### ③.5 Updating existing memory — v0.1.0 新: forget+remember の代わりに update_memory
143
+
144
+ 事実が変わった / 目標が更新された / caveat の細部を修正したい場合、**forget して remember すると memory_id が変わって session_file_edits のリンクが切れる**。代わりに `update_memory`:
145
+
146
+ ```
147
+ update_memory({
148
+ memory_id: 1234,
149
+ content: '{"primary": "Plugin Marketplace 申請中(審査7日目)", "deadline": "2026-04-25"}',
150
+ importance: 1.0 // pin 強化
151
+ })
152
+ ```
153
+
154
+ `layer` も変更可能だが、caveat → 他への demotion は不可(auto-protectedのため)。
155
+
109
156
  ### ④ Failure — エラー発生時に caveat 記録
110
157
 
111
158
  エラー・失敗・「うまくいかない」が発生した瞬間、すぐ記録する:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.0.9",
3
+ "version": "0.1.0",
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",
@@ -8,7 +8,8 @@
8
8
  "linksee-memory": "./dist/mcp/server.js",
9
9
  "linksee-memory-import": "./dist/bin/import-sessions.js",
10
10
  "linksee-memory-sync": "./dist/bin/sync-session.js",
11
- "linksee-memory-install-skill": "./dist/bin/install-skill.js"
11
+ "linksee-memory-install-skill": "./dist/bin/install-skill.js",
12
+ "linksee-memory-stats": "./dist/bin/stats.js"
12
13
  },
13
14
  "main": "./dist/mcp/server.js",
14
15
  "files": [