linksee-memory 0.2.0 → 0.3.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
@@ -2,7 +2,7 @@
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
4
  >
5
- > **v0.2.0** makes the package English-first for global launch: the bundled auto-invocation skill is now bilingual (EN + JP), session-extractor patterns cover common English keywords (`let's go`, `pivot`, `doesn't work`, `same error again`, etc.), and the install CLI shows test examples in both languages. No API changes. See [CHANGELOG](#changelog).
5
+ > **v0.3.0** ships the **Five Blocks**: Tools + Resources + Prompts + Sampling + Roots, plus the newer **Elicitation** primitive. Most public MCP servers expose only Tools; v0.3.0 moves linksee-memory into the differentiated tier. Backward compatible all 8 v0.2.x tools keep their signatures. See [CHANGELOG.md](./CHANGELOG.md).
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/linksee-memory.svg)](https://www.npmjs.com/package/linksee-memory)
8
8
  [![license](https://img.shields.io/npm/l/linksee-memory.svg)](./LICENSE)
@@ -147,18 +147,29 @@ Add to `~/.claude/settings.json` to record every Claude Code session to your loc
147
147
 
148
148
  Each turn end takes ~100 ms. Failures are silent (Claude Code never blocks). Logs at `~/.linksee-memory/hook.log`.
149
149
 
150
+ ## v0.3.0 — Five Blocks at a glance
151
+
152
+ | MCP Block | Surface |
153
+ |---|---|
154
+ | **Tools** | 8 tools (unchanged signatures since v0.2). |
155
+ | **Resources** | 4 static URIs (`memory://stats`, `memory://hot`, `memory://recent`, `memory://caveats`) + 3 templates (`memory://entity/{name}`, `memory://layer/{layer}`, `memory://memory/{id}`). Browseable via `@-mention` in clients that support it. |
156
+ | **Prompts** | 5 reusable templates: `summarize-session`, `extract-caveats`, `weekly-consolidation`, `recall-and-write`, `entity-handoff`. |
157
+ | **Sampling** *(client opt-in)* | `consolidate{use_llm:true}` asks the client LLM to rewrite consolidated cluster summaries into prose. Falls back to the heuristic when the client declines. |
158
+ | **Roots** *(client opt-in)* | `recall_file{scope_to_roots:true}` filters path matches to files inside any client-provided working root. |
159
+ | **Elicitation** *(client opt-in, newer primitive)* | `forget{interactive:true, memory_id:N}` asks the user to confirm via the client UI before deleting. |
160
+
150
161
  ## Tools
151
162
 
152
163
  | Tool | Purpose |
153
164
  |---|---|
154
165
  | `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). |
155
166
  | `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. |
156
- | `recall_file` | Complete edit history of a file across all sessions, with per-edit user-intent context. |
167
+ | `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. |
157
168
  | `update_memory` | **v0.1.0** Atomic edit of an existing memory. Preserves `memory_id` (session_file_edits links stay intact). Prefer over forget+remember. |
158
169
  | `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. |
159
170
  | `read_smart` | Diff-only file read. Returns full content on first read, ~50 tokens on unchanged re-reads, only changed chunks on real edits. |
160
- | `forget` | Explicit delete OR auto-sweep based on `forgettingRisk`. Pinned (`importance>=1.0`) and caveat-layer memories are always preserved. |
161
- | `consolidate` | Sleep-mode compression: cluster cold low-importance memories → protected learning-layer summary. Supports `dry_run` preview. |
171
+ | `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. |
172
+ | `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. |
162
173
 
163
174
  ### CLI utilities
164
175
 
@@ -0,0 +1,24 @@
1
+ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ interface ElicitResult {
3
+ action: 'accept' | 'decline' | 'cancel' | 'unsupported';
4
+ content?: Record<string, unknown>;
5
+ reason?: string;
6
+ }
7
+ interface ElicitParams {
8
+ message: string;
9
+ requestedSchema: {
10
+ type: 'object';
11
+ properties: Record<string, unknown>;
12
+ required?: string[];
13
+ };
14
+ }
15
+ export declare function elicit(server: Server, p: ElicitParams): Promise<ElicitResult>;
16
+ export declare function confirmForget(server: Server, candidate: {
17
+ id: number;
18
+ entity: string;
19
+ layer: string;
20
+ importance: number;
21
+ preview: string;
22
+ }): Promise<boolean>;
23
+ export declare function confirmPin(server: Server, memoryId: number, preview: string, newImportance: number): Promise<boolean>;
24
+ export {};
@@ -0,0 +1,58 @@
1
+ // Elicitation block — server asks the client (and ultimately the user) a structured question.
2
+ //
3
+ // MCP semantics: server.request({method: 'elicitation/create', params: {message, requestedSchema}})
4
+ // The user responds via the client UI. Used by:
5
+ // - Stale-memory cleanup (forget candidates require confirmation)
6
+ // - Pin/unpin confirmation when importance crosses 0.9
7
+ //
8
+ // Clients without elicitation support fail gracefully — caller falls back to "decline = skip".
9
+ import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
10
+ export async function elicit(server, p) {
11
+ try {
12
+ const res = await server.request({ method: 'elicitation/create', params: p }, ElicitRequestSchema);
13
+ return {
14
+ action: res?.action ?? 'decline',
15
+ content: res?.content,
16
+ };
17
+ }
18
+ catch (err) {
19
+ return { action: 'unsupported', reason: err?.message ?? String(err) };
20
+ }
21
+ }
22
+ export async function confirmForget(server, candidate) {
23
+ const res = await elicit(server, {
24
+ message: `Forget memory #${candidate.id} for "${candidate.entity}"?\n\nLayer: ${candidate.layer} Importance: ${candidate.importance.toFixed(2)}\nPreview: ${candidate.preview.slice(0, 200)}`,
25
+ requestedSchema: {
26
+ type: 'object',
27
+ properties: {
28
+ confirm: {
29
+ type: 'boolean',
30
+ title: 'Forget this memory',
31
+ description: 'Yes = delete permanently. No = keep.',
32
+ },
33
+ },
34
+ required: ['confirm'],
35
+ },
36
+ });
37
+ if (res.action === 'accept' && res.content && typeof res.content.confirm === 'boolean') {
38
+ return res.content.confirm;
39
+ }
40
+ return false;
41
+ }
42
+ export async function confirmPin(server, memoryId, preview, newImportance) {
43
+ const res = await elicit(server, {
44
+ message: `Pin memory #${memoryId}? (importance ${newImportance.toFixed(2)})\n\nPreview: ${preview.slice(0, 200)}\n\nPinned memories survive forget-sweeps and consolidation.`,
45
+ requestedSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ confirm: { type: 'boolean', title: 'Pin this memory', description: 'Yes = pin. No = save without pinning.' },
49
+ },
50
+ required: ['confirm'],
51
+ },
52
+ });
53
+ if (res.action === 'accept' && res.content && typeof res.content.confirm === 'boolean') {
54
+ return res.content.confirm;
55
+ }
56
+ return false;
57
+ }
58
+ //# sourceMappingURL=elicitation.js.map
@@ -0,0 +1,21 @@
1
+ export declare const PROMPTS: {
2
+ name: string;
3
+ description: string;
4
+ arguments: {
5
+ name: string;
6
+ description: string;
7
+ required: boolean;
8
+ }[];
9
+ }[];
10
+ interface PromptMessage {
11
+ role: 'user' | 'assistant';
12
+ content: {
13
+ type: 'text';
14
+ text: string;
15
+ };
16
+ }
17
+ export declare function getPrompt(name: string, args: Record<string, string> | undefined): {
18
+ description?: string;
19
+ messages: PromptMessage[];
20
+ };
21
+ export {};
@@ -0,0 +1,201 @@
1
+ // Prompts block — reusable prompt templates that agents can pull from the server.
2
+ //
3
+ // Templates:
4
+ // summarize-session — turn a chat transcript into structured memories (1 per layer)
5
+ // extract-caveats — read text and produce caveat-layer entries (pain lessons)
6
+ // weekly-consolidation — sleep-mode summary of the past week's memories
7
+ // recall-and-write — recall first, then write — anti-pattern guard
8
+ // entity-handoff — produce a handoff doc for an entity (name + memories + next steps)
9
+ //
10
+ // Each prompt accepts arguments and returns a list of messages the client can feed to its LLM.
11
+ export const PROMPTS = [
12
+ {
13
+ name: 'summarize-session',
14
+ description: 'Turn a chat session transcript into structured memories. Produces up to 6 memories (one per layer) capturing goal/context/emotion/implementation/caveat/learning. Use at session end.',
15
+ arguments: [
16
+ { name: 'transcript', description: 'The session transcript text. Free-form.', required: true },
17
+ { name: 'entity_hint', description: 'Optional canonical entity name to attach the memories to.', required: false },
18
+ ],
19
+ },
20
+ {
21
+ name: 'extract-caveats',
22
+ description: 'Scan a body of text (post-mortem, error log, decision doc) and propose caveat-layer memories — concise pain lessons starting with verbs ("Never", "Always", "Watch out"). Returns JSON list of caveats.',
23
+ arguments: [
24
+ { name: 'text', description: 'Source text (post-mortem, debug session, retro). Free-form.', required: true },
25
+ { name: 'entity_hint', description: 'Optional canonical entity name for the caveats.', required: false },
26
+ ],
27
+ },
28
+ {
29
+ name: 'weekly-consolidation',
30
+ description: 'Sleep-mode summary of the past week\'s memories for an entity. Produces a single learning-layer entry that captures the trajectory. Use as input to the consolidate tool, or to write a Friday digest.',
31
+ arguments: [
32
+ { name: 'entity_name', description: 'The entity to consolidate.', required: true },
33
+ { name: 'week_offset', description: 'Weeks-ago offset (0 = this week, 1 = last week). Default 0.', required: false },
34
+ ],
35
+ },
36
+ {
37
+ name: 'recall-and-write',
38
+ description: 'Anti-pattern guard. Before writing code, draft a doc, or making a decision: recall relevant memories first, then produce the answer with explicit citations to the recalled memory_ids. Forces "memory before action" discipline.',
39
+ arguments: [
40
+ { name: 'task', description: 'What you are about to do (code task, decision, doc draft). One sentence.', required: true },
41
+ { name: 'entity_hint', description: 'Optional entity to focus recall on.', required: false },
42
+ ],
43
+ },
44
+ {
45
+ name: 'entity-handoff',
46
+ description: 'Produce a handoff document for an entity: name, kind, key memories per layer, current open questions, and next steps. Use when transferring context to a new session, a new agent, or a new collaborator.',
47
+ arguments: [
48
+ { name: 'entity_name', description: 'The entity to hand off.', required: true },
49
+ { name: 'audience', description: 'Who receives the handoff (e.g. "new claude session", "human teammate"). Default "new claude session".', required: false },
50
+ ],
51
+ },
52
+ ];
53
+ export function getPrompt(name, args) {
54
+ const a = args ?? {};
55
+ switch (name) {
56
+ case 'summarize-session': {
57
+ const transcript = a.transcript ?? '';
58
+ const entityHint = a.entity_hint ? `\n\nFocus entity: ${a.entity_hint}` : '';
59
+ return {
60
+ description: 'Summarize the session into 6-layer structured memories.',
61
+ messages: [
62
+ {
63
+ role: 'user',
64
+ content: {
65
+ type: 'text',
66
+ text: `You are an agent-memory writer. Read the session transcript below and propose memories to save. Output a JSON array; each item has {entity_name, entity_kind, layer, content, importance}.
67
+
68
+ Layers (use exactly one per memory):
69
+ - goal: WHY this work exists, target outcome
70
+ - context: WHY THIS NOW, situation, timing
71
+ - emotion: USER tone, feelings expressed
72
+ - implementation: HOW it was done, what worked, what failed
73
+ - caveat: PAIN lesson, "never X" / "always Y" — these are protected from forgetting
74
+ - learning: GROWTH, decisions made, insights
75
+
76
+ Rules:
77
+ - Max 6 entries (one per layer). Skip layers with nothing worth saving.
78
+ - Importance 0-1. Set 0.9+ to pin (use sparingly).
79
+ - Caveats must be 1 sentence and start with a verb.
80
+ - Quote nothing verbatim; summarize.
81
+
82
+ Transcript:
83
+ ${transcript}${entityHint}`,
84
+ },
85
+ },
86
+ ],
87
+ };
88
+ }
89
+ case 'extract-caveats': {
90
+ const text = a.text ?? '';
91
+ const entityHint = a.entity_hint ? `\n\nFocus entity: ${a.entity_hint}` : '';
92
+ return {
93
+ description: 'Extract caveat-layer pain lessons.',
94
+ messages: [
95
+ {
96
+ role: 'user',
97
+ content: {
98
+ type: 'text',
99
+ text: `You are a caveat extractor. Read the source text below and output a JSON list of caveats. Each caveat:
100
+ - Starts with a verb ("Never", "Always", "Watch out", "Reject", "Confirm")
101
+ - Is one sentence
102
+ - Is concrete (a specific failure mode, not abstract advice)
103
+ - Captures something the reader does NOT want to relearn the hard way
104
+
105
+ Output format: [{"content": "Never X when Y, because Z", "importance": 0.7-1.0}]
106
+
107
+ Source text:
108
+ ${text}${entityHint}`,
109
+ },
110
+ },
111
+ ],
112
+ };
113
+ }
114
+ case 'weekly-consolidation': {
115
+ const entityName = a.entity_name ?? '<entity>';
116
+ const weekOffset = a.week_offset ?? '0';
117
+ return {
118
+ description: `Consolidate the last week of memories for ${entityName}.`,
119
+ messages: [
120
+ {
121
+ role: 'user',
122
+ content: {
123
+ type: 'text',
124
+ text: `Consolidate the past week's memories for entity "${entityName}" (week_offset=${weekOffset}).
125
+
126
+ Step 1: Call recall(query="${entityName}", entity_name="${entityName}", max_tokens=4000) to retrieve recent memories.
127
+ Step 2: Read the returned memories and produce ONE learning-layer summary that captures:
128
+ - What we set out to do (goal trajectory)
129
+ - What actually happened (implementation summary)
130
+ - What we learned (1-3 insights)
131
+ - Any caveats we should never forget (preserve these — do NOT consolidate them away)
132
+
133
+ Step 3: Output a JSON object {entity_name, layer:"learning", content, importance:0.7}.
134
+
135
+ Do not write to memory directly — just return the JSON. The user will choose whether to save.`,
136
+ },
137
+ },
138
+ ],
139
+ };
140
+ }
141
+ case 'recall-and-write': {
142
+ const task = a.task ?? '<task>';
143
+ const entityHint = a.entity_hint ?? '';
144
+ const recallCmd = entityHint
145
+ ? `recall(query="${task}", entity_name="${entityHint}", max_tokens=2000)`
146
+ : `recall(query="${task}", max_tokens=2000)`;
147
+ return {
148
+ description: 'Memory-before-action discipline.',
149
+ messages: [
150
+ {
151
+ role: 'user',
152
+ content: {
153
+ type: 'text',
154
+ text: `Before doing this task, recall first.
155
+
156
+ Task: ${task}
157
+
158
+ Step 1: Call ${recallCmd}.
159
+ Step 2: Skim the returned memories. Identify any caveats that apply.
160
+ Step 3: Produce your output with INLINE citations to relevant memory_ids: e.g. "Use better-sqlite3 v12+ [memory:1234] because v11 breaks on Node 24 [memory:5678]."
161
+ Step 4: If you found NO relevant memories, say so explicitly: "No prior memories on this — proceeding from first principles."
162
+
163
+ Goal: never solve a problem twice without checking.`,
164
+ },
165
+ },
166
+ ],
167
+ };
168
+ }
169
+ case 'entity-handoff': {
170
+ const entityName = a.entity_name ?? '<entity>';
171
+ const audience = a.audience ?? 'new claude session';
172
+ return {
173
+ description: `Produce a handoff document for ${entityName}.`,
174
+ messages: [
175
+ {
176
+ role: 'user',
177
+ content: {
178
+ type: 'text',
179
+ text: `Produce a handoff document for entity "${entityName}", aimed at: ${audience}.
180
+
181
+ Step 1: Call recall(query="${entityName}", entity_name="${entityName}", max_tokens=6000).
182
+ Step 2: Skim memories grouped by layer.
183
+ Step 3: Output a markdown doc with these sections:
184
+ - **Identity**: name, kind, canonical key (if any)
185
+ - **Goal** (from goal-layer memories): what this entity is for
186
+ - **State** (from latest implementation memories): where things stand right now
187
+ - **Caveats** (from caveat-layer memories): what NEVER to do, with memory_id citations
188
+ - **Open questions**: things the prior session left unresolved
189
+ - **Suggested next steps**: 3 concrete actions for the audience
190
+
191
+ Keep it under 1 page. Cite memory_ids inline like [memory:1234].`,
192
+ },
193
+ },
194
+ ],
195
+ };
196
+ }
197
+ default:
198
+ throw new Error(`unknown prompt: ${name}`);
199
+ }
200
+ }
201
+ //# sourceMappingURL=prompts.js.map
@@ -0,0 +1,18 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare const STATIC_RESOURCES: {
3
+ uri: string;
4
+ name: string;
5
+ description: string;
6
+ mimeType: string;
7
+ }[];
8
+ export declare const RESOURCE_TEMPLATES: {
9
+ uriTemplate: string;
10
+ name: string;
11
+ description: string;
12
+ mimeType: string;
13
+ }[];
14
+ export declare function readResource(db: Database.Database, uri: string): {
15
+ uri: string;
16
+ mimeType: string;
17
+ text: string;
18
+ };
@@ -0,0 +1,162 @@
1
+ // Resources block — expose memories as URI-addressable resources.
2
+ //
3
+ // Static resources (always present):
4
+ // memory://stats — DB statistics summary
5
+ // memory://hot — currently hot memories (top heat band)
6
+ // memory://recent — recently accessed memories (last 7 days)
7
+ // memory://caveats — all caveat-layer memories (the never-forget pile)
8
+ //
9
+ // Resource templates (parameterized):
10
+ // memory://entity/{name} — all memories for an entity
11
+ // memory://layer/{layer} — all memories in a layer (goal/context/emotion/implementation/caveat/learning)
12
+ // memory://memory/{id} — single memory by ID
13
+ export const STATIC_RESOURCES = [
14
+ {
15
+ uri: 'memory://stats',
16
+ name: 'Memory store statistics',
17
+ description: 'Summary counts: entities, memories, layer breakdown, heat distribution.',
18
+ mimeType: 'application/json',
19
+ },
20
+ {
21
+ uri: 'memory://hot',
22
+ name: 'Hot memories',
23
+ description: 'Memories currently in the "hot" heat band — what the agent is actively working with.',
24
+ mimeType: 'application/json',
25
+ },
26
+ {
27
+ uri: 'memory://recent',
28
+ name: 'Recently accessed memories',
29
+ description: 'Memories accessed in the last 7 days, ordered by recency.',
30
+ mimeType: 'application/json',
31
+ },
32
+ {
33
+ uri: 'memory://caveats',
34
+ name: 'All caveats',
35
+ description: 'Every caveat-layer memory — the protected "never forget" pile of pain lessons.',
36
+ mimeType: 'application/json',
37
+ },
38
+ ];
39
+ export const RESOURCE_TEMPLATES = [
40
+ {
41
+ uriTemplate: 'memory://entity/{name}',
42
+ name: 'Memories for an entity',
43
+ description: 'All memories about a specific entity (person/company/project/concept/file). Replace {name} with the entity name.',
44
+ mimeType: 'application/json',
45
+ },
46
+ {
47
+ uriTemplate: 'memory://layer/{layer}',
48
+ name: 'Memories in a layer',
49
+ description: 'All memories in a specific layer. Replace {layer} with one of: goal, context, emotion, implementation, caveat, learning.',
50
+ mimeType: 'application/json',
51
+ },
52
+ {
53
+ uriTemplate: 'memory://memory/{id}',
54
+ name: 'A single memory',
55
+ description: 'Read a single memory by its numeric id. Replace {id} with the memory_id.',
56
+ mimeType: 'application/json',
57
+ },
58
+ ];
59
+ const VALID_LAYERS = new Set(['goal', 'context', 'emotion', 'implementation', 'caveat', 'learning']);
60
+ function fmtMemory(row) {
61
+ return {
62
+ id: row.id,
63
+ entity: row.entity_name,
64
+ entity_kind: row.entity_kind,
65
+ layer: row.layer,
66
+ importance: row.importance,
67
+ pinned: row.protected === 1 || row.importance >= 0.9,
68
+ content: row.content,
69
+ created_at: row.created_at ? new Date(row.created_at * 1000).toISOString() : null,
70
+ last_accessed_at: row.last_accessed_at ? new Date(row.last_accessed_at * 1000).toISOString() : null,
71
+ access_count: row.access_count,
72
+ };
73
+ }
74
+ const SELECT_MEMORY_BASE = `
75
+ SELECT m.id, m.layer, m.content, m.importance, m.protected, m.created_at, m.last_accessed_at, m.access_count,
76
+ e.name as entity_name, e.kind as entity_kind
77
+ FROM memories m JOIN entities e ON e.id = m.entity_id
78
+ `;
79
+ export function readResource(db, uri) {
80
+ // Static endpoints
81
+ if (uri === 'memory://stats') {
82
+ const entityCount = db.prepare('SELECT COUNT(*) as c FROM entities').get().c;
83
+ const memCount = db.prepare('SELECT COUNT(*) as c FROM memories').get().c;
84
+ const byLayer = db.prepare('SELECT layer, COUNT(*) as c FROM memories GROUP BY layer').all();
85
+ const byKind = db.prepare('SELECT kind, COUNT(*) as c FROM entities GROUP BY kind').all();
86
+ const pinned = db.prepare('SELECT COUNT(*) as c FROM memories WHERE importance >= 0.9 OR protected = 1').get().c;
87
+ return {
88
+ uri,
89
+ mimeType: 'application/json',
90
+ text: JSON.stringify({
91
+ entity_count: entityCount,
92
+ memory_count: memCount,
93
+ pinned_count: pinned,
94
+ by_layer: Object.fromEntries(byLayer.map((r) => [r.layer, r.c])),
95
+ by_entity_kind: Object.fromEntries(byKind.map((r) => [r.kind, r.c])),
96
+ // Note: heat_band is computed at recall-time, not stored — see recall tool for live heat.
97
+ }, null, 2),
98
+ };
99
+ }
100
+ if (uri === 'memory://hot') {
101
+ // heat_band is dynamic; approximate "hot" via access_count + recent access desc.
102
+ // For exact heat scoring, use the recall tool with band='hot'.
103
+ const rows = db
104
+ .prepare(`${SELECT_MEMORY_BASE}
105
+ WHERE m.last_accessed_at IS NOT NULL
106
+ ORDER BY m.access_count DESC, m.last_accessed_at DESC
107
+ LIMIT 50`)
108
+ .all();
109
+ return {
110
+ uri,
111
+ mimeType: 'application/json',
112
+ text: JSON.stringify({ count: rows.length, note: 'Approximation by access_count + last_accessed_at. Use the recall tool with band="hot" for exact heat scoring.', memories: rows.map(fmtMemory) }, null, 2),
113
+ };
114
+ }
115
+ if (uri === 'memory://recent') {
116
+ const cutoff = Math.floor(Date.now() / 1000) - 7 * 24 * 3600;
117
+ const rows = db.prepare(`${SELECT_MEMORY_BASE} WHERE m.last_accessed_at >= ? ORDER BY m.last_accessed_at DESC LIMIT 50`).all(cutoff);
118
+ return { uri, mimeType: 'application/json', text: JSON.stringify({ count: rows.length, memories: rows.map(fmtMemory) }, null, 2) };
119
+ }
120
+ if (uri === 'memory://caveats') {
121
+ const rows = db.prepare(`${SELECT_MEMORY_BASE} WHERE m.layer = 'caveat' ORDER BY m.importance DESC, m.created_at DESC`).all();
122
+ return { uri, mimeType: 'application/json', text: JSON.stringify({ count: rows.length, memories: rows.map(fmtMemory) }, null, 2) };
123
+ }
124
+ // Templates
125
+ const entityMatch = uri.match(/^memory:\/\/entity\/(.+)$/);
126
+ if (entityMatch) {
127
+ const name = decodeURIComponent(entityMatch[1]);
128
+ const rows = db
129
+ .prepare(`${SELECT_MEMORY_BASE} WHERE LOWER(e.name) = LOWER(?) ORDER BY m.importance DESC, m.created_at DESC`)
130
+ .all(name);
131
+ return {
132
+ uri,
133
+ mimeType: 'application/json',
134
+ text: JSON.stringify({ entity: name, count: rows.length, memories: rows.map(fmtMemory) }, null, 2),
135
+ };
136
+ }
137
+ const layerMatch = uri.match(/^memory:\/\/layer\/(.+)$/);
138
+ if (layerMatch) {
139
+ const layer = decodeURIComponent(layerMatch[1]).toLowerCase();
140
+ if (!VALID_LAYERS.has(layer)) {
141
+ throw new Error(`unknown layer "${layer}". Known: goal, context, emotion, implementation, caveat, learning`);
142
+ }
143
+ const rows = db
144
+ .prepare(`${SELECT_MEMORY_BASE} WHERE m.layer = ? ORDER BY m.importance DESC, m.created_at DESC LIMIT 200`)
145
+ .all(layer);
146
+ return {
147
+ uri,
148
+ mimeType: 'application/json',
149
+ text: JSON.stringify({ layer, count: rows.length, memories: rows.map(fmtMemory) }, null, 2),
150
+ };
151
+ }
152
+ const idMatch = uri.match(/^memory:\/\/memory\/(\d+)$/);
153
+ if (idMatch) {
154
+ const id = Number(idMatch[1]);
155
+ const row = db.prepare(`${SELECT_MEMORY_BASE} WHERE m.id = ?`).get(id);
156
+ if (!row)
157
+ throw new Error(`memory id ${id} not found`);
158
+ return { uri, mimeType: 'application/json', text: JSON.stringify(fmtMemory(row), null, 2) };
159
+ }
160
+ throw new Error(`unknown resource URI: ${uri}`);
161
+ }
162
+ //# sourceMappingURL=resources.js.map
@@ -0,0 +1,10 @@
1
+ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ interface Root {
3
+ uri: string;
4
+ name?: string;
5
+ }
6
+ export declare function fetchRoots(server: Server): Promise<Root[]>;
7
+ export declare function invalidateRootsCache(): void;
8
+ export declare function rootPathFromUri(uri: string): string;
9
+ export declare function isInsideRoots(filePath: string, roots: Root[]): boolean;
10
+ export {};
@@ -0,0 +1,64 @@
1
+ // Roots block — track the client's working roots (directories the agent is operating in).
2
+ //
3
+ // The server pulls roots from the client via server.request({method: 'roots/list'}) and caches them.
4
+ // Used by recall_file and read_smart to bias path-substring matches toward files inside a current root.
5
+ //
6
+ // MCP semantics: client owns the root list, server is informed. We refresh on demand
7
+ // (lazily on first use) and on roots/list_changed notification.
8
+ import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
+ let cachedRoots = null;
10
+ let lastFetched = 0;
11
+ const STALE_MS = 60_000; // re-fetch at most once a minute
12
+ export async function fetchRoots(server) {
13
+ const now = Date.now();
14
+ if (cachedRoots && now - lastFetched < STALE_MS)
15
+ return cachedRoots;
16
+ try {
17
+ const res = await server.request({ method: 'roots/list', params: {} }, ListRootsRequestSchema);
18
+ cachedRoots = Array.isArray(res?.roots) ? res.roots : [];
19
+ lastFetched = now;
20
+ }
21
+ catch {
22
+ // Client may not support roots; treat as empty.
23
+ cachedRoots = [];
24
+ lastFetched = now;
25
+ }
26
+ return cachedRoots ?? [];
27
+ }
28
+ export function invalidateRootsCache() {
29
+ cachedRoots = null;
30
+ lastFetched = 0;
31
+ }
32
+ // Convert "file:///C:/Users/HP/foo" → "C:/Users/HP/foo" (or "/Users/foo" on POSIX)
33
+ export function rootPathFromUri(uri) {
34
+ if (uri.startsWith('file:///')) {
35
+ let p = uri.slice('file:///'.length);
36
+ // Windows: convert "C:/Users/..." (already POSIX-ish) — leave as-is, caller normalizes.
37
+ if (process.platform === 'win32') {
38
+ // also handle "C%3A/" encoding
39
+ p = p.replace(/^([A-Za-z])(?:%3A|:)\//, '$1:/');
40
+ }
41
+ else {
42
+ p = '/' + p;
43
+ }
44
+ try {
45
+ return decodeURIComponent(p);
46
+ }
47
+ catch {
48
+ return p;
49
+ }
50
+ }
51
+ return uri;
52
+ }
53
+ // Returns true if filePath is inside any of the roots (case-insensitive on win32).
54
+ export function isInsideRoots(filePath, roots) {
55
+ if (roots.length === 0)
56
+ return true; // no roots → no filtering
57
+ const norm = process.platform === 'win32' ? filePath.toLowerCase().replace(/\\/g, '/') : filePath;
58
+ return roots.some((r) => {
59
+ const rp = rootPathFromUri(r.uri);
60
+ const rpn = process.platform === 'win32' ? rp.toLowerCase().replace(/\\/g, '/') : rp;
61
+ return norm.startsWith(rpn);
62
+ });
63
+ }
64
+ //# sourceMappingURL=roots.js.map
@@ -0,0 +1,16 @@
1
+ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ interface SampleParams {
3
+ systemPrompt?: string;
4
+ userPrompt: string;
5
+ maxTokens?: number;
6
+ temperature?: number;
7
+ }
8
+ interface SampleResult {
9
+ ok: boolean;
10
+ text?: string;
11
+ reason?: string;
12
+ }
13
+ export declare function sample(server: Server, p: SampleParams): Promise<SampleResult>;
14
+ export declare function sampleConsolidation(server: Server, memoryTexts: string[], entityName: string): Promise<SampleResult>;
15
+ export declare function sampleLayerClassification(server: Server, content: string): Promise<SampleResult>;
16
+ export {};
@@ -0,0 +1,78 @@
1
+ // Sampling block — server requests text generation from the client's LLM.
2
+ //
3
+ // MCP semantics: server.request({method: 'sampling/createMessage', params: {messages, ...}})
4
+ // Used by:
5
+ // - LLM-assisted consolidate (auto-summarize a memory cluster into a learning entry)
6
+ // - LLM-assisted layer classification (when the agent uses a vague layer alias)
7
+ //
8
+ // The client may decline (no LLM access, user opted out). All sampling calls are best-effort
9
+ // with a graceful fallback to non-LLM behavior.
10
+ import { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js';
11
+ export async function sample(server, p) {
12
+ try {
13
+ const params = {
14
+ messages: [{ role: 'user', content: { type: 'text', text: p.userPrompt } }],
15
+ maxTokens: p.maxTokens ?? 800,
16
+ temperature: p.temperature ?? 0.3,
17
+ modelPreferences: {
18
+ // Prefer fast, cheap, good-enough for summarization.
19
+ speedPriority: 0.7,
20
+ costPriority: 0.7,
21
+ intelligencePriority: 0.4,
22
+ },
23
+ includeContext: 'none',
24
+ };
25
+ if (p.systemPrompt)
26
+ params.systemPrompt = p.systemPrompt;
27
+ const res = await server.request({ method: 'sampling/createMessage', params }, CreateMessageRequestSchema);
28
+ const text = res?.content?.text ?? res?.content?.[0]?.text ?? '';
29
+ if (!text)
30
+ return { ok: false, reason: 'empty response from sampling' };
31
+ return { ok: true, text };
32
+ }
33
+ catch (err) {
34
+ return { ok: false, reason: err?.message ?? String(err) };
35
+ }
36
+ }
37
+ export async function sampleConsolidation(server, memoryTexts, entityName) {
38
+ if (memoryTexts.length === 0)
39
+ return { ok: false, reason: 'no input memories' };
40
+ const numbered = memoryTexts.map((t, i) => `[${i + 1}] ${t}`).join('\n\n');
41
+ const userPrompt = `Consolidate these ${memoryTexts.length} memories about "${entityName}" into a single learning-layer summary. The summary must:
42
+ - Preserve the trajectory (what was attempted, what worked, what changed)
43
+ - Be 2-4 sentences max
44
+ - Not invent facts not in the originals
45
+ - Not include verbatim quotes
46
+
47
+ Memories:
48
+ ${numbered}
49
+
50
+ Output the summary text only — no preamble, no JSON wrapper.`;
51
+ return sample(server, {
52
+ systemPrompt: 'You are a memory consolidator. Compress without distorting.',
53
+ userPrompt,
54
+ maxTokens: 400,
55
+ temperature: 0.2,
56
+ });
57
+ }
58
+ export async function sampleLayerClassification(server, content) {
59
+ const userPrompt = `Classify this memory content into exactly one layer:
60
+ - goal: WHY this work exists, target outcome
61
+ - context: WHY THIS NOW, situation, timing
62
+ - emotion: USER tone, feelings expressed
63
+ - implementation: HOW it was done, what worked, what failed
64
+ - caveat: PAIN lesson, "never X" / "always Y"
65
+ - learning: GROWTH, decisions made, insights
66
+
67
+ Content:
68
+ ${content}
69
+
70
+ Output just the layer name — no explanation, no JSON.`;
71
+ return sample(server, {
72
+ systemPrompt: 'You are a layer classifier. Output one word.',
73
+ userPrompt,
74
+ maxTokens: 16,
75
+ temperature: 0,
76
+ });
77
+ }
78
+ //# sourceMappingURL=sampling.js.map
@@ -4,7 +4,7 @@
4
4
  // forget / consolidate / read_smart
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { openDb, runMigrations } from '../db/migrate.js';
9
9
  import { computeHeat } from '../lib/heat-index.js';
10
10
  import { decideForgetting } from '../lib/forgetting.js';
@@ -12,10 +12,24 @@ import { refreshMomentumForEntity } from '../lib/momentum.js';
12
12
  import { consolidate as runConsolidate } from '../lib/consolidate.js';
13
13
  import { isPastedExternalContent } from '../lib/session-parser.js';
14
14
  import { handleReadSmart as handleReadSmartImpl } from './read-smart.js';
15
- const SERVER_VERSION = '0.1.1';
15
+ import { STATIC_RESOURCES, RESOURCE_TEMPLATES, readResource } from './resources.js';
16
+ import { PROMPTS, getPrompt } from './prompts.js';
17
+ import { fetchRoots, isInsideRoots } from './roots.js';
18
+ import { sampleConsolidation } from './sampling.js';
19
+ import { confirmForget } from './elicitation.js';
20
+ const SERVER_VERSION = '0.3.0';
16
21
  const db = openDb();
17
22
  runMigrations(db);
18
- const server = new Server({ name: 'linksee-memory', version: SERVER_VERSION }, { capabilities: { tools: {} } });
23
+ const server = new Server({ name: 'linksee-memory', version: SERVER_VERSION }, {
24
+ capabilities: {
25
+ tools: {},
26
+ resources: { subscribe: false, listChanged: false },
27
+ prompts: { listChanged: false },
28
+ // Sampling, Roots, Elicitation are CLIENT capabilities the server consumes.
29
+ // We don't declare them under "capabilities" — we just call them via server.request
30
+ // and gracefully degrade when the client doesn't support them.
31
+ },
32
+ });
19
33
  // ============================================================
20
34
  // Layer alias map — natural language → canonical layer
21
35
  // (agents can say layer="decisions" and we resolve to "learning")
@@ -233,14 +247,14 @@ function toFtsQuery(raw) {
233
247
  return tokens.map((t) => `"${t}"`).join(' OR ');
234
248
  }
235
249
  function runFtsQuery(query, layer, limit) {
236
- let sql = `
237
- SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
238
- m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
239
- bm25(memories_fts) as bm25_score
240
- FROM memories_fts
241
- JOIN memories m ON m.id = memories_fts.rowid
242
- JOIN entities e ON e.id = m.entity_id
243
- WHERE memories_fts MATCH ?
250
+ let sql = `
251
+ SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
252
+ m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
253
+ bm25(memories_fts) as bm25_score
254
+ FROM memories_fts
255
+ JOIN memories m ON m.id = memories_fts.rowid
256
+ JOIN entities e ON e.id = m.entity_id
257
+ WHERE memories_fts MATCH ?
244
258
  `;
245
259
  const params = [query];
246
260
  if (layer) {
@@ -252,13 +266,13 @@ function runFtsQuery(query, layer, limit) {
252
266
  return db.prepare(sql).all(...params);
253
267
  }
254
268
  function runLikeQuery(query, entityName, layer, limit) {
255
- let sql = `
256
- SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
257
- m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
258
- 0 as bm25_score
259
- FROM memories m
260
- JOIN entities e ON e.id = m.entity_id
261
- WHERE 1=1
269
+ let sql = `
270
+ SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
271
+ m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
272
+ 0 as bm25_score
273
+ FROM memories m
274
+ JOIN entities e ON e.id = m.entity_id
275
+ WHERE 1=1
262
276
  `;
263
277
  const params = [];
264
278
  if (entityName) {
@@ -494,8 +508,8 @@ function handleForget(args) {
494
508
  }
495
509
  // Auto-sweep — also respect pin (importance >= 0.9) as protection
496
510
  const rows = db
497
- .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected
498
- FROM memories
511
+ .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected
512
+ FROM memories
499
513
  WHERE protected = 0 AND importance < 0.9`)
500
514
  .all();
501
515
  const now = Math.floor(Date.now() / 1000);
@@ -543,17 +557,17 @@ function handleConsolidate(args) {
543
557
  // read-only audit: count candidates using the same rules.
544
558
  const now = Math.floor(Date.now() / 1000);
545
559
  const ageCutoff = now - (args.min_age_days ?? 7) * 86400;
546
- const candidates = db.prepare(`
547
- SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
548
- FROM memories m
549
- JOIN entities e ON e.id = m.entity_id
550
- WHERE m.protected = 0
551
- AND m.importance < 0.9
552
- AND m.layer IN ('context', 'emotion', 'implementation')
553
- AND m.created_at <= ?
554
- GROUP BY m.entity_id, m.layer
555
- HAVING c >= 2
556
- ORDER BY c DESC
560
+ const candidates = db.prepare(`
561
+ SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
562
+ FROM memories m
563
+ JOIN entities e ON e.id = m.entity_id
564
+ WHERE m.protected = 0
565
+ AND m.importance < 0.9
566
+ AND m.layer IN ('context', 'emotion', 'implementation')
567
+ AND m.created_at <= ?
568
+ GROUP BY m.entity_id, m.layer
569
+ HAVING c >= 2
570
+ ORDER BY c DESC
557
571
  `).all(ageCutoff);
558
572
  const totalReplaced = candidates.reduce((s, c) => s + c.c, 0);
559
573
  return JSON.stringify({
@@ -627,19 +641,19 @@ function handleListEntities(args) {
627
641
  const minMemories = Math.max(1, Number(args?.min_memories ?? 1));
628
642
  const limit = Math.max(1, Math.min(200, Number(args?.limit ?? 30)));
629
643
  const offset = Math.max(0, Number(args?.offset ?? 0));
630
- let sql = `
631
- SELECT e.id, e.name, e.kind, e.canonical_key, e.momentum_score,
632
- e.updated_at, e.created_at,
633
- COUNT(m.id) as memory_count,
634
- MAX(m.last_accessed_at) as last_memory_access,
635
- SUM(CASE WHEN m.layer = 'goal' THEN 1 ELSE 0 END) as goal_count,
636
- SUM(CASE WHEN m.layer = 'caveat' THEN 1 ELSE 0 END) as caveat_count,
637
- SUM(CASE WHEN m.layer = 'learning' THEN 1 ELSE 0 END) as learning_count,
638
- SUM(CASE WHEN m.layer = 'implementation' THEN 1 ELSE 0 END) as impl_count,
639
- SUM(CASE WHEN m.importance >= 0.9 THEN 1 ELSE 0 END) as pinned_count
640
- FROM entities e
641
- LEFT JOIN memories m ON m.entity_id = e.id
642
- WHERE 1=1
644
+ let sql = `
645
+ SELECT e.id, e.name, e.kind, e.canonical_key, e.momentum_score,
646
+ e.updated_at, e.created_at,
647
+ COUNT(m.id) as memory_count,
648
+ MAX(m.last_accessed_at) as last_memory_access,
649
+ SUM(CASE WHEN m.layer = 'goal' THEN 1 ELSE 0 END) as goal_count,
650
+ SUM(CASE WHEN m.layer = 'caveat' THEN 1 ELSE 0 END) as caveat_count,
651
+ SUM(CASE WHEN m.layer = 'learning' THEN 1 ELSE 0 END) as learning_count,
652
+ SUM(CASE WHEN m.layer = 'implementation' THEN 1 ELSE 0 END) as impl_count,
653
+ SUM(CASE WHEN m.importance >= 0.9 THEN 1 ELSE 0 END) as pinned_count
654
+ FROM entities e
655
+ LEFT JOIN memories m ON m.entity_id = e.id
656
+ WHERE 1=1
643
657
  `;
644
658
  const params = [];
645
659
  if (kind) {
@@ -691,29 +705,29 @@ function handleRecallFile(args) {
691
705
  return JSON.stringify({ ok: true, count: 0, note: 'No edits found for that path substring.' });
692
706
  }
693
707
  // Daily breakdown
694
- const daily = db.prepare(`
695
- SELECT DATE(occurred_at, 'unixepoch') as day, operation, COUNT(*) as edits
696
- FROM session_file_edits WHERE file_path LIKE ?
697
- GROUP BY day, operation ORDER BY day
708
+ const daily = db.prepare(`
709
+ SELECT DATE(occurred_at, 'unixepoch') as day, operation, COUNT(*) as edits
710
+ FROM session_file_edits WHERE file_path LIKE ?
711
+ GROUP BY day, operation ORDER BY day
698
712
  `).all(`%${sub}%`);
699
713
  // Distinct context_snippets (intents) — deduped, ordered by recency
700
- const intents = db.prepare(`
701
- SELECT DISTINCT context_snippet, MAX(occurred_at) as last_at, COUNT(*) as freq
702
- FROM session_file_edits
703
- WHERE file_path LIKE ? AND context_snippet IS NOT NULL AND LENGTH(context_snippet) > 20
704
- GROUP BY context_snippet
705
- ORDER BY last_at DESC
706
- LIMIT ?
714
+ const intents = db.prepare(`
715
+ SELECT DISTINCT context_snippet, MAX(occurred_at) as last_at, COUNT(*) as freq
716
+ FROM session_file_edits
717
+ WHERE file_path LIKE ? AND context_snippet IS NOT NULL AND LENGTH(context_snippet) > 20
718
+ GROUP BY context_snippet
719
+ ORDER BY last_at DESC
720
+ LIMIT ?
707
721
  `).all(`%${sub}%`, maxIntents);
708
722
  // Linked memories
709
- const memories = db.prepare(`
710
- SELECT DISTINCT m.id, m.layer, m.content, m.importance, e.name as entity_name
711
- FROM session_file_edits sfe
712
- JOIN memories m ON m.id = sfe.memory_id
713
- JOIN entities e ON e.id = m.entity_id
714
- WHERE sfe.file_path LIKE ?
715
- ORDER BY m.importance DESC
716
- LIMIT 20
723
+ const memories = db.prepare(`
724
+ SELECT DISTINCT m.id, m.layer, m.content, m.importance, e.name as entity_name
725
+ FROM session_file_edits sfe
726
+ JOIN memories m ON m.id = sfe.memory_id
727
+ JOIN entities e ON e.id = m.entity_id
728
+ WHERE sfe.file_path LIKE ?
729
+ ORDER BY m.importance DESC
730
+ LIMIT 20
717
731
  `).all(`%${sub}%`);
718
732
  // Distinct file paths matched (the substring may match multiple files)
719
733
  const paths = db.prepare(`SELECT file_path, COUNT(*) as edits FROM session_file_edits WHERE file_path LIKE ? GROUP BY file_path ORDER BY edits DESC`).all(`%${sub}%`);
@@ -746,6 +760,158 @@ function handleReadSmart(args) {
746
760
  return handleReadSmartImpl(db, { path: args.path, force: args.force });
747
761
  }
748
762
  // ============================================================
763
+ // v0.3.0 — five-blocks helpers (sampling / roots / elicitation in handlers)
764
+ // ============================================================
765
+ async function handleRecallFileWithRoots(args) {
766
+ const baseJson = handleRecallFile(args);
767
+ if (!args?.scope_to_roots)
768
+ return baseJson;
769
+ let parsed;
770
+ try {
771
+ parsed = JSON.parse(baseJson);
772
+ }
773
+ catch {
774
+ return baseJson;
775
+ }
776
+ if (!parsed?.ok || !Array.isArray(parsed.paths_matched))
777
+ return baseJson;
778
+ const roots = await fetchRoots(server);
779
+ if (roots.length === 0) {
780
+ parsed.roots_filter = { applied: false, reason: 'client provided no roots' };
781
+ return JSON.stringify(parsed);
782
+ }
783
+ const filtered = parsed.paths_matched.filter((p) => isInsideRoots(p.file_path, roots));
784
+ parsed.roots_filter = { applied: true, root_count: roots.length, before: parsed.paths_matched.length, after: filtered.length };
785
+ parsed.paths_matched = filtered;
786
+ return JSON.stringify(parsed);
787
+ }
788
+ async function handleConsolidateWithSampling(args) {
789
+ // Sampling only applies on a real run (not dry-run) and only when explicitly opted in.
790
+ if (!args?.use_llm || args?.dry_run)
791
+ return handleConsolidate(args);
792
+ // 1. Snapshot all candidate memories BEFORE consolidate runs so we can recover
793
+ // their content (the originals are deleted by consolidate).
794
+ const ageCutoff = Math.floor(Date.now() / 1000) - (typeof args?.min_age_days === 'number' ? args.min_age_days : 7) * 86400;
795
+ const snapshot = new Map();
796
+ const candidateRows = db
797
+ .prepare(`SELECT m.id, m.content, e.name as entity_name
798
+ FROM memories m JOIN entities e ON e.id = m.entity_id
799
+ WHERE m.protected = 0
800
+ AND m.layer IN ('context','emotion','implementation')
801
+ AND m.created_at <= ?`)
802
+ .all(ageCutoff);
803
+ for (const r of candidateRows)
804
+ snapshot.set(r.id, r);
805
+ // 2. Run the normal heuristic consolidate (creates learning entries, deletes source).
806
+ const baseJson = handleConsolidate(args);
807
+ let parsed;
808
+ try {
809
+ parsed = JSON.parse(baseJson);
810
+ }
811
+ catch {
812
+ return baseJson;
813
+ }
814
+ if (!parsed?.ok || !Array.isArray(parsed.learningIdsCreated)) {
815
+ parsed = parsed ?? {};
816
+ parsed.sampling = { applied: false, reason: 'consolidate returned no learning entries' };
817
+ return JSON.stringify(parsed);
818
+ }
819
+ // 3. For each new learning entry, look up its replaced_ids from the audit table,
820
+ // gather source contents from the snapshot, and request a sampled summary.
821
+ let upgraded = 0;
822
+ let declined = 0;
823
+ const declineReasons = [];
824
+ for (const learningId of parsed.learningIdsCreated) {
825
+ const audit = db.prepare('SELECT replaced_ids FROM consolidations WHERE learning_id = ?').get(learningId);
826
+ if (!audit) {
827
+ declined++;
828
+ continue;
829
+ }
830
+ let replaced;
831
+ try {
832
+ replaced = JSON.parse(audit.replaced_ids);
833
+ }
834
+ catch {
835
+ declined++;
836
+ continue;
837
+ }
838
+ if (!Array.isArray(replaced) || replaced.length < 2) {
839
+ declined++;
840
+ continue;
841
+ }
842
+ const sources = replaced
843
+ .map((id) => snapshot.get(id))
844
+ .filter((s) => Boolean(s));
845
+ if (sources.length < 2) {
846
+ declined++;
847
+ continue;
848
+ }
849
+ const entityName = sources[0]?.entity_name ?? '<entity>';
850
+ const result = await sampleConsolidation(server, sources.map((s) => s.content), entityName);
851
+ if (result.ok && result.text) {
852
+ db.prepare('UPDATE memories SET content = ? WHERE id = ?').run(result.text.trim(), learningId);
853
+ upgraded++;
854
+ }
855
+ else {
856
+ declined++;
857
+ if (result.reason && declineReasons.length < 3)
858
+ declineReasons.push(result.reason);
859
+ }
860
+ }
861
+ parsed.sampling = {
862
+ applied: true,
863
+ upgraded,
864
+ declined,
865
+ ...(declineReasons.length ? { decline_reasons: declineReasons } : {}),
866
+ };
867
+ return JSON.stringify(parsed);
868
+ }
869
+ async function handleForgetInteractive(args) {
870
+ if (!args?.interactive || !args?.memory_id)
871
+ return handleForget(args);
872
+ const id = Number(args.memory_id);
873
+ const row = db
874
+ .prepare(`SELECT m.id, m.layer, m.content, m.importance, e.name as entity FROM memories m JOIN entities e ON e.id = m.entity_id WHERE m.id = ?`)
875
+ .get(id);
876
+ if (!row)
877
+ return JSON.stringify({ ok: false, error: `memory ${id} not found` });
878
+ const ok = await confirmForget(server, {
879
+ id: row.id,
880
+ entity: row.entity,
881
+ layer: row.layer,
882
+ importance: row.importance,
883
+ preview: row.content,
884
+ });
885
+ if (!ok)
886
+ return JSON.stringify({ ok: false, declined: true, memory_id: id, reason: 'user declined elicitation' });
887
+ return handleForget({ memory_id: id });
888
+ }
889
+ // Append new optional flags to existing tools (backward-compatible).
890
+ const RECALL_FILE_TOOL = TOOLS.find((t) => t.name === 'recall_file');
891
+ if (RECALL_FILE_TOOL && RECALL_FILE_TOOL.inputSchema.properties) {
892
+ RECALL_FILE_TOOL.inputSchema.properties.scope_to_roots = {
893
+ type: 'boolean',
894
+ default: false,
895
+ description: 'If true, filter results to files inside the client-provided roots (Roots block). Skip silently when client provides no roots.',
896
+ };
897
+ }
898
+ const CONSOLIDATE_TOOL = TOOLS.find((t) => t.name === 'consolidate');
899
+ if (CONSOLIDATE_TOOL && CONSOLIDATE_TOOL.inputSchema.properties) {
900
+ CONSOLIDATE_TOOL.inputSchema.properties.use_llm = {
901
+ type: 'boolean',
902
+ default: false,
903
+ description: 'If true, request the client LLM (Sampling block) to write the consolidated summary instead of the heuristic. Falls back gracefully if the client refuses.',
904
+ };
905
+ }
906
+ const FORGET_TOOL = TOOLS.find((t) => t.name === 'forget');
907
+ if (FORGET_TOOL && FORGET_TOOL.inputSchema.properties) {
908
+ FORGET_TOOL.inputSchema.properties.interactive = {
909
+ type: 'boolean',
910
+ default: false,
911
+ description: 'If true, ask the user to confirm via Elicitation before deleting. Only applies when memory_id is set.',
912
+ };
913
+ }
914
+ // ============================================================
749
915
  // MCP wiring
750
916
  // ============================================================
751
917
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
@@ -767,13 +933,13 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
767
933
  text = handleListEntities(args);
768
934
  break;
769
935
  case 'forget':
770
- text = handleForget(args);
936
+ text = await handleForgetInteractive(args);
771
937
  break;
772
938
  case 'consolidate':
773
- text = handleConsolidate(args);
939
+ text = await handleConsolidateWithSampling(args);
774
940
  break;
775
941
  case 'recall_file':
776
- text = handleRecallFile(args);
942
+ text = await handleRecallFileWithRoots(args);
777
943
  break;
778
944
  case 'read_smart':
779
945
  text = handleReadSmart(args);
@@ -789,6 +955,24 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
789
955
  };
790
956
  }
791
957
  });
958
+ // ============================================================
959
+ // Resources block
960
+ // ============================================================
961
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: STATIC_RESOURCES }));
962
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: RESOURCE_TEMPLATES }));
963
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
964
+ const { uri } = req.params;
965
+ const result = readResource(db, uri);
966
+ return { contents: [result] };
967
+ });
968
+ // ============================================================
969
+ // Prompts block
970
+ // ============================================================
971
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
972
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
973
+ const { name, arguments: promptArgs } = req.params;
974
+ return getPrompt(name, promptArgs);
975
+ });
792
976
  const transport = new StdioServerTransport();
793
977
  await server.connect(transport);
794
978
  process.stderr.write(`[linksee-memory] MCP server ready on stdio (v${SERVER_VERSION})\n`);
package/package.json CHANGED
@@ -1,71 +1,82 @@
1
- {
2
- "name": "linksee-memory",
3
- "version": "0.2.0",
4
- "mcpName": "io.github.michielinksee/linksee-memory",
5
- "description": "Local-first agent memory MCP — cross-agent brain with 6-layer structured memory + token-saving file diff cache",
6
- "type": "module",
7
- "bin": {
8
- "linksee-memory": "dist/mcp/server.js",
9
- "linksee-memory-import": "dist/bin/import-sessions.js",
10
- "linksee-memory-sync": "dist/bin/sync-session.js",
11
- "linksee-memory-install-skill": "dist/bin/install-skill.js",
12
- "linksee-memory-stats": "dist/bin/stats.js"
13
- },
14
- "main": "./dist/mcp/server.js",
15
- "files": [
16
- "dist/**/*.js",
17
- "dist/**/*.d.ts",
18
- "dist/db/schema.sql",
19
- "dist/skill/SKILL.md",
20
- "README.md",
21
- "LICENSE"
22
- ],
23
- "scripts": {
24
- "build": "tsc && node -e \"require('fs').copyFileSync('src/db/schema.sql','dist/db/schema.sql'); require('fs').mkdirSync('dist/skill',{recursive:true}); require('fs').copyFileSync('src/skill/SKILL.md','dist/skill/SKILL.md')\"",
25
- "start": "node dist/mcp/server.js",
26
- "dev": "tsx src/mcp/server.ts",
27
- "migrate": "node dist/db/migrate.js",
28
- "migrate:dev": "tsx src/db/migrate.ts",
29
- "prepublishOnly": "npm run build"
30
- },
31
- "keywords": [
32
- "mcp",
33
- "model-context-protocol",
34
- "memory",
35
- "agent",
36
- "agent-memory",
37
- "claude",
38
- "claude-code",
39
- "cursor",
40
- "chatgpt",
41
- "sqlite",
42
- "local-first",
43
- "token-savings",
44
- "cross-agent"
45
- ],
46
- "author": "Synapse Arrows PTE. LTD.",
47
- "license": "MIT",
48
- "homepage": "https://github.com/michielinksee/linksee-memory",
49
- "repository": {
50
- "type": "git",
51
- "url": "git+https://github.com/michielinksee/linksee-memory.git"
52
- },
53
- "bugs": {
54
- "url": "https://github.com/michielinksee/linksee-memory/issues"
55
- },
56
- "dependencies": {
57
- "@babel/parser": "^7.25.0",
58
- "@modelcontextprotocol/sdk": "^1.0.0",
59
- "better-sqlite3": "^11.3.0",
60
- "sqlite-vec": "^0.1.6"
61
- },
62
- "devDependencies": {
63
- "@types/better-sqlite3": "^7.6.11",
64
- "@types/node": "^22.7.0",
65
- "tsx": "^4.19.0",
66
- "typescript": "^5.6.0"
67
- },
68
- "engines": {
69
- "node": ">=20"
70
- }
71
- }
1
+ {
2
+ "name": "linksee-memory",
3
+ "version": "0.3.0",
4
+ "mcpName": "io.github.michielinksee/linksee-memory",
5
+ "description": "Local-first agent memory MCP — cross-agent brain with 6-layer structured memory + token-saving file diff cache",
6
+ "type": "module",
7
+ "bin": {
8
+ "linksee-memory": "dist/mcp/server.js",
9
+ "linksee-memory-import": "dist/bin/import-sessions.js",
10
+ "linksee-memory-sync": "dist/bin/sync-session.js",
11
+ "linksee-memory-install-skill": "dist/bin/install-skill.js",
12
+ "linksee-memory-stats": "dist/bin/stats.js"
13
+ },
14
+ "main": "./dist/mcp/server.js",
15
+ "files": [
16
+ "dist/**/*.js",
17
+ "dist/**/*.d.ts",
18
+ "dist/db/schema.sql",
19
+ "dist/skill/SKILL.md",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc && node -e \"require('fs').copyFileSync('src/db/schema.sql','dist/db/schema.sql'); require('fs').mkdirSync('dist/skill',{recursive:true}); require('fs').copyFileSync('src/skill/SKILL.md','dist/skill/SKILL.md')\"",
25
+ "start": "node dist/mcp/server.js",
26
+ "dev": "tsx src/mcp/server.ts",
27
+ "migrate": "node dist/db/migrate.js",
28
+ "migrate:dev": "tsx src/db/migrate.ts",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "keywords": [
32
+ "mcp",
33
+ "model-context-protocol",
34
+ "memory",
35
+ "agent",
36
+ "agent-memory",
37
+ "claude",
38
+ "claude-code",
39
+ "cursor",
40
+ "chatgpt",
41
+ "sqlite",
42
+ "local-first",
43
+ "token-savings",
44
+ "cross-agent"
45
+ ],
46
+ "author": "Synapse Arrows PTE. LTD.",
47
+ "license": "MIT",
48
+ "homepage": "https://github.com/michielinksee/linksee-memory",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/michielinksee/linksee-memory.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/michielinksee/linksee-memory/issues"
55
+ },
56
+ "dependencies": {
57
+ "@babel/parser": "^7.25.0",
58
+ "@modelcontextprotocol/sdk": "^1.0.0",
59
+ "better-sqlite3": "^12.9.0",
60
+ "sqlite-vec": "^0.1.6"
61
+ },
62
+ "devDependencies": {
63
+ "@types/better-sqlite3": "^7.6.11",
64
+ "@types/node": "^22.7.0",
65
+ "tsx": "^4.19.0",
66
+ "typescript": "^5.6.0"
67
+ },
68
+ "engines": {
69
+ "node": ">=20"
70
+ },
71
+ "overrides": {
72
+ "ip-address": "^10.2.0"
73
+ },
74
+ "pnpm": {
75
+ "onlyBuiltDependencies": [
76
+ "better-sqlite3"
77
+ ],
78
+ "overrides": {
79
+ "ip-address": "^10.2.0"
80
+ }
81
+ }
82
+ }