linksee-memory 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/bin/import-sessions.d.ts +2 -0
- package/dist/bin/import-sessions.js +258 -0
- package/dist/bin/sync-session.d.ts +2 -0
- package/dist/bin/sync-session.js +97 -0
- package/dist/db/migrate.d.ts +4 -0
- package/dist/db/migrate.js +48 -0
- package/dist/db/schema.sql +199 -0
- package/dist/lib/consolidate.d.ts +12 -0
- package/dist/lib/consolidate.js +148 -0
- package/dist/lib/file-chunker.d.ts +12 -0
- package/dist/lib/file-chunker.js +250 -0
- package/dist/lib/forgetting.d.ts +12 -0
- package/dist/lib/forgetting.js +31 -0
- package/dist/lib/heat-index.d.ts +13 -0
- package/dist/lib/heat-index.js +29 -0
- package/dist/lib/momentum.d.ts +19 -0
- package/dist/lib/momentum.js +75 -0
- package/dist/lib/session-extractor.d.ts +34 -0
- package/dist/lib/session-extractor.js +263 -0
- package/dist/lib/session-parser.d.ts +40 -0
- package/dist/lib/session-parser.js +303 -0
- package/dist/mcp/read-smart.d.ts +5 -0
- package/dist/mcp/read-smart.js +132 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +449 -0
- package/package.json +67 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// linksee-memory MCP server (stdio transport).
|
|
3
|
+
// 5 tools: remember / recall / forget / consolidate / read_smart
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { openDb, runMigrations } from '../db/migrate.js';
|
|
8
|
+
import { computeHeat } from '../lib/heat-index.js';
|
|
9
|
+
import { decideForgetting } from '../lib/forgetting.js';
|
|
10
|
+
import { refreshMomentumForEntity } from '../lib/momentum.js';
|
|
11
|
+
import { consolidate as runConsolidate } from '../lib/consolidate.js';
|
|
12
|
+
import { handleReadSmart as handleReadSmartImpl } from './read-smart.js';
|
|
13
|
+
const db = openDb();
|
|
14
|
+
runMigrations(db);
|
|
15
|
+
const server = new Server({ name: 'linksee-memory', version: '0.0.2' }, { capabilities: { tools: {} } });
|
|
16
|
+
// ============================================================
|
|
17
|
+
// Tool schema declarations
|
|
18
|
+
// ============================================================
|
|
19
|
+
const TOOLS = [
|
|
20
|
+
{
|
|
21
|
+
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.',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
entity_name: { type: 'string', description: 'Name of the entity this memory is about' },
|
|
27
|
+
entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'] },
|
|
28
|
+
entity_key: { type: 'string', description: 'Optional canonical key (email, domain, file path)' },
|
|
29
|
+
layer: { type: 'string', enum: ['goal', 'context', 'emotion', 'implementation', 'caveat', 'learning'] },
|
|
30
|
+
content: { type: 'string', description: 'The memory content (plain text or JSON)' },
|
|
31
|
+
importance: { type: 'number', minimum: 0, maximum: 1 },
|
|
32
|
+
},
|
|
33
|
+
required: ['entity_name', 'entity_kind', 'layer', 'content'],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
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.',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
properties: {
|
|
42
|
+
query: { type: 'string', description: 'What you want to remember (free-text, entity name, or FTS5 MATCH expression)' },
|
|
43
|
+
entity_name: { type: 'string', description: 'Optional — narrow to a specific entity' },
|
|
44
|
+
layer: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
enum: ['goal', 'context', 'emotion', 'implementation', 'caveat', 'learning'],
|
|
47
|
+
},
|
|
48
|
+
max_tokens: { type: 'number', description: 'Approx token budget. Default 2000.', default: 2000 },
|
|
49
|
+
},
|
|
50
|
+
required: ['query'],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
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.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
memory_id: { type: 'number' },
|
|
60
|
+
dry_run: { type: 'boolean', default: false },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
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.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
scope: { type: 'string', enum: ['all', 'session'], default: 'session' },
|
|
71
|
+
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 },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'recall_file',
|
|
77
|
+
description: 'Get the COMPLETE edit history of a file across all sessions, with per-edit user-intent context. Returns: total edit count, daily breakdown, list of distinct user intents that drove the edits, and the linked memories. Use this when you need to understand WHY a file was modified historically — far more accurate than recall() for file-centric questions because it queries session_file_edits (every physical edit) instead of summary memories.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
path_substring: { type: 'string', description: 'Substring to match against file_path (e.g. "search-services.ts" or full absolute path)' },
|
|
82
|
+
max_intents: { type: 'number', description: 'Max distinct user-intent snippets to return. Default 10.', default: 10 },
|
|
83
|
+
},
|
|
84
|
+
required: ['path_substring'],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'read_smart',
|
|
89
|
+
description: 'Read a file with diff-only caching. Returns: (1) full content + chunk metadata on first read, (2) "unchanged" + cached chunk list (~50 tokens) if mtime matches, (3) "unchanged_content" if mtime changed but sha256 matches (touched but not modified), (4) changed chunks with content + unchanged chunks as metadata-only if the file was truly modified. Use INSTEAD of Read for files you have read before — saves 50%+ tokens on re-reads.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
path: { type: 'string', description: 'Absolute file path' },
|
|
94
|
+
force: { type: 'boolean', description: 'If true, return full content regardless of cache state', default: false },
|
|
95
|
+
},
|
|
96
|
+
required: ['path'],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
// ============================================================
|
|
101
|
+
// Handlers
|
|
102
|
+
// ============================================================
|
|
103
|
+
function upsertEntity(args) {
|
|
104
|
+
if (args.key) {
|
|
105
|
+
const byKey = db.prepare('SELECT id FROM entities WHERE canonical_key = ?').get(args.key);
|
|
106
|
+
if (byKey)
|
|
107
|
+
return byKey.id;
|
|
108
|
+
}
|
|
109
|
+
const byName = db
|
|
110
|
+
.prepare('SELECT id FROM entities WHERE kind = ? AND LOWER(name) = LOWER(?)')
|
|
111
|
+
.get(args.kind, args.name);
|
|
112
|
+
if (byName) {
|
|
113
|
+
if (args.key) {
|
|
114
|
+
db.prepare('UPDATE entities SET canonical_key = ?, updated_at = unixepoch() WHERE id = ? AND canonical_key IS NULL').run(args.key, byName.id);
|
|
115
|
+
}
|
|
116
|
+
return byName.id;
|
|
117
|
+
}
|
|
118
|
+
const result = db
|
|
119
|
+
.prepare('INSERT INTO entities (kind, name, canonical_key) VALUES (?, ?, ?)')
|
|
120
|
+
.run(args.kind, args.name, args.key ?? null);
|
|
121
|
+
return Number(result.lastInsertRowid);
|
|
122
|
+
}
|
|
123
|
+
function handleRemember(args) {
|
|
124
|
+
const entityId = upsertEntity({ name: args.entity_name, kind: args.entity_kind, key: args.entity_key });
|
|
125
|
+
const importance = args.importance ?? 0.5;
|
|
126
|
+
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.
|
|
131
|
+
const mom = refreshMomentumForEntity(db, entityId);
|
|
132
|
+
return JSON.stringify({
|
|
133
|
+
ok: true,
|
|
134
|
+
memory_id: Number(result.lastInsertRowid),
|
|
135
|
+
entity_id: entityId,
|
|
136
|
+
layer: args.layer,
|
|
137
|
+
momentum: { score: mom.score, band: mom.band },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// Sanitize query for FTS5 MATCH (strip chars that break the grammar, quote it).
|
|
141
|
+
// Note: with trigram tokenizer, tokens shorter than 3 chars cannot match anything.
|
|
142
|
+
// Such tokens are dropped here and the caller falls back to LIKE if FTS yields no hits.
|
|
143
|
+
function toFtsQuery(raw) {
|
|
144
|
+
const cleaned = raw
|
|
145
|
+
.replace(/["*:()]/g, ' ')
|
|
146
|
+
.replace(/\s+/g, ' ')
|
|
147
|
+
.trim();
|
|
148
|
+
if (!cleaned)
|
|
149
|
+
return '';
|
|
150
|
+
const tokens = cleaned.split(' ').filter((t) => t.length >= 3);
|
|
151
|
+
if (tokens.length === 0)
|
|
152
|
+
return '';
|
|
153
|
+
return tokens.map((t) => `"${t}"`).join(' OR ');
|
|
154
|
+
}
|
|
155
|
+
function runFtsQuery(query, layer, limit) {
|
|
156
|
+
let sql = `
|
|
157
|
+
SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
|
|
158
|
+
m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
|
|
159
|
+
bm25(memories_fts) as bm25_score
|
|
160
|
+
FROM memories_fts
|
|
161
|
+
JOIN memories m ON m.id = memories_fts.rowid
|
|
162
|
+
JOIN entities e ON e.id = m.entity_id
|
|
163
|
+
WHERE memories_fts MATCH ?
|
|
164
|
+
`;
|
|
165
|
+
const params = [query];
|
|
166
|
+
if (layer) {
|
|
167
|
+
sql += ' AND m.layer = ?';
|
|
168
|
+
params.push(layer);
|
|
169
|
+
}
|
|
170
|
+
sql += ' ORDER BY bm25_score ASC LIMIT ?';
|
|
171
|
+
params.push(limit);
|
|
172
|
+
return db.prepare(sql).all(...params);
|
|
173
|
+
}
|
|
174
|
+
function runLikeQuery(query, entityName, layer, limit) {
|
|
175
|
+
let sql = `
|
|
176
|
+
SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
|
|
177
|
+
m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
|
|
178
|
+
0 as bm25_score
|
|
179
|
+
FROM memories m
|
|
180
|
+
JOIN entities e ON e.id = m.entity_id
|
|
181
|
+
WHERE 1=1
|
|
182
|
+
`;
|
|
183
|
+
const params = [];
|
|
184
|
+
if (entityName) {
|
|
185
|
+
sql += ' AND e.name LIKE ?';
|
|
186
|
+
params.push(`%${entityName}%`);
|
|
187
|
+
}
|
|
188
|
+
if (layer) {
|
|
189
|
+
sql += ' AND m.layer = ?';
|
|
190
|
+
params.push(layer);
|
|
191
|
+
}
|
|
192
|
+
if (query && !entityName) {
|
|
193
|
+
sql += ' AND (e.name LIKE ? OR m.content LIKE ?)';
|
|
194
|
+
params.push(`%${query}%`, `%${query}%`);
|
|
195
|
+
}
|
|
196
|
+
sql += ' ORDER BY m.importance DESC, m.last_accessed_at DESC LIMIT ?';
|
|
197
|
+
params.push(limit);
|
|
198
|
+
return db.prepare(sql).all(...params);
|
|
199
|
+
}
|
|
200
|
+
function handleRecall(args) {
|
|
201
|
+
const maxTokens = args.max_tokens ?? 2000;
|
|
202
|
+
const approxTokensPerMemory = 100;
|
|
203
|
+
const limit = Math.max(1, Math.floor(maxTokens / approxTokensPerMemory));
|
|
204
|
+
const fetchLimit = limit * 2; // extra for composite re-rank
|
|
205
|
+
let rows = [];
|
|
206
|
+
let searchMethod = 'like';
|
|
207
|
+
const ftsQuery = toFtsQuery(args.query ?? '');
|
|
208
|
+
const canUseFts = !!ftsQuery && !args.entity_name;
|
|
209
|
+
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)
|
|
216
|
+
const seen = new Map();
|
|
217
|
+
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);
|
|
222
|
+
rows = Array.from(seen.values());
|
|
223
|
+
if (ftsRows.length > 0 && likeRows.length > 0)
|
|
224
|
+
searchMethod = 'fts5+like';
|
|
225
|
+
else if (ftsRows.length > 0)
|
|
226
|
+
searchMethod = 'fts5';
|
|
227
|
+
else
|
|
228
|
+
searchMethod = 'like';
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
rows = runLikeQuery(args.query, args.entity_name, args.layer, fetchLimit);
|
|
232
|
+
searchMethod = 'like';
|
|
233
|
+
}
|
|
234
|
+
const useFts = searchMethod !== 'like';
|
|
235
|
+
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.
|
|
238
|
+
const bm25Values = rows.map((r) => r.bm25_score);
|
|
239
|
+
const minBm = Math.min(...bm25Values, 0);
|
|
240
|
+
const maxBm = Math.max(...bm25Values, 1);
|
|
241
|
+
const bmSpan = Math.max(0.001, maxBm - minBm);
|
|
242
|
+
const scored = rows.map((r) => {
|
|
243
|
+
const daysSince = (now - r.last_accessed_at) / 86400;
|
|
244
|
+
const heat = computeHeat({
|
|
245
|
+
accessesLast30d: daysSince < 30 ? r.access_count : 0,
|
|
246
|
+
accessesLast90d: daysSince < 90 ? r.access_count : 0,
|
|
247
|
+
daysSinceLastAccess: daysSince,
|
|
248
|
+
totalAccesses: r.access_count,
|
|
249
|
+
baseImportance: r.importance,
|
|
250
|
+
});
|
|
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;
|
|
253
|
+
const heatNorm = heat.score / 100;
|
|
254
|
+
const momNorm = Math.min(1, (r.momentum_score ?? 0) / 10);
|
|
255
|
+
const composite = 0.5 * relevance + 0.3 * heatNorm + 0.2 * momNorm;
|
|
256
|
+
return {
|
|
257
|
+
...r,
|
|
258
|
+
heat_score: heat.score,
|
|
259
|
+
heat_band: heat.band,
|
|
260
|
+
composite_score: composite,
|
|
261
|
+
relevance_score: relevance,
|
|
262
|
+
};
|
|
263
|
+
});
|
|
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));
|
|
274
|
+
}
|
|
275
|
+
return JSON.stringify({
|
|
276
|
+
ok: true,
|
|
277
|
+
count: top.length,
|
|
278
|
+
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
|
+
})),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
function handleForget(args) {
|
|
292
|
+
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 });
|
|
295
|
+
}
|
|
296
|
+
const rows = db
|
|
297
|
+
.prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected FROM memories WHERE protected = 0`)
|
|
298
|
+
.all();
|
|
299
|
+
const now = Math.floor(Date.now() / 1000);
|
|
300
|
+
const actions = [];
|
|
301
|
+
for (const r of rows) {
|
|
302
|
+
const daysSince = (now - r.last_accessed_at) / 86400;
|
|
303
|
+
const heat = computeHeat({
|
|
304
|
+
accessesLast30d: daysSince < 30 ? r.access_count : 0,
|
|
305
|
+
accessesLast90d: daysSince < 90 ? r.access_count : 0,
|
|
306
|
+
daysSinceLastAccess: daysSince,
|
|
307
|
+
totalAccesses: r.access_count,
|
|
308
|
+
baseImportance: r.importance,
|
|
309
|
+
});
|
|
310
|
+
const action = decideForgetting({
|
|
311
|
+
daysSinceLastAccess: daysSince,
|
|
312
|
+
importance: r.importance,
|
|
313
|
+
heatScore: heat.score,
|
|
314
|
+
protected: r.protected === 1,
|
|
315
|
+
layer: r.layer,
|
|
316
|
+
});
|
|
317
|
+
if (action !== 'keep')
|
|
318
|
+
actions.push({ id: r.id, action });
|
|
319
|
+
}
|
|
320
|
+
if (!args.dry_run) {
|
|
321
|
+
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);
|
|
328
|
+
}
|
|
329
|
+
return JSON.stringify({
|
|
330
|
+
ok: true,
|
|
331
|
+
dry_run: !!args.dry_run,
|
|
332
|
+
scanned: rows.length,
|
|
333
|
+
to_drop: actions.filter((a) => a.action === 'drop').length,
|
|
334
|
+
to_compress: actions.filter((a) => a.action === 'compress').length,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function handleConsolidate(args) {
|
|
338
|
+
const result = runConsolidate(db, {
|
|
339
|
+
scope: args?.scope ?? 'session',
|
|
340
|
+
min_age_days: typeof args?.min_age_days === 'number' ? args.min_age_days : undefined,
|
|
341
|
+
});
|
|
342
|
+
return JSON.stringify({ ok: true, ...result });
|
|
343
|
+
}
|
|
344
|
+
function handleRecallFile(args) {
|
|
345
|
+
const sub = String(args.path_substring ?? '').trim();
|
|
346
|
+
if (!sub)
|
|
347
|
+
return JSON.stringify({ ok: false, error: 'path_substring required' });
|
|
348
|
+
const maxIntents = Math.max(1, Math.min(50, args.max_intents ?? 10));
|
|
349
|
+
const totalRow = db.prepare(`SELECT COUNT(*) as c, MIN(occurred_at) as first_at, MAX(occurred_at) as last_at, COUNT(DISTINCT session_id) as sessions FROM session_file_edits WHERE file_path LIKE ?`).get(`%${sub}%`);
|
|
350
|
+
if (!totalRow || totalRow.c === 0) {
|
|
351
|
+
return JSON.stringify({ ok: true, count: 0, note: 'No edits found for that path substring.' });
|
|
352
|
+
}
|
|
353
|
+
// Daily breakdown
|
|
354
|
+
const daily = db.prepare(`
|
|
355
|
+
SELECT DATE(occurred_at, 'unixepoch') as day, operation, COUNT(*) as edits
|
|
356
|
+
FROM session_file_edits WHERE file_path LIKE ?
|
|
357
|
+
GROUP BY day, operation ORDER BY day
|
|
358
|
+
`).all(`%${sub}%`);
|
|
359
|
+
// Distinct context_snippets (intents) — deduped, ordered by recency
|
|
360
|
+
const intents = db.prepare(`
|
|
361
|
+
SELECT DISTINCT context_snippet, MAX(occurred_at) as last_at, COUNT(*) as freq
|
|
362
|
+
FROM session_file_edits
|
|
363
|
+
WHERE file_path LIKE ? AND context_snippet IS NOT NULL AND LENGTH(context_snippet) > 20
|
|
364
|
+
GROUP BY context_snippet
|
|
365
|
+
ORDER BY last_at DESC
|
|
366
|
+
LIMIT ?
|
|
367
|
+
`).all(`%${sub}%`, maxIntents);
|
|
368
|
+
// Linked memories
|
|
369
|
+
const memories = db.prepare(`
|
|
370
|
+
SELECT DISTINCT m.id, m.layer, m.content, m.importance, e.name as entity_name
|
|
371
|
+
FROM session_file_edits sfe
|
|
372
|
+
JOIN memories m ON m.id = sfe.memory_id
|
|
373
|
+
JOIN entities e ON e.id = m.entity_id
|
|
374
|
+
WHERE sfe.file_path LIKE ?
|
|
375
|
+
ORDER BY m.importance DESC
|
|
376
|
+
LIMIT 20
|
|
377
|
+
`).all(`%${sub}%`);
|
|
378
|
+
// Distinct file paths matched (the substring may match multiple files)
|
|
379
|
+
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}%`);
|
|
380
|
+
return JSON.stringify({
|
|
381
|
+
ok: true,
|
|
382
|
+
path_substring: sub,
|
|
383
|
+
paths_matched: paths,
|
|
384
|
+
summary: {
|
|
385
|
+
total_edits: totalRow.c,
|
|
386
|
+
first_edit_at: new Date(totalRow.first_at * 1000).toISOString(),
|
|
387
|
+
last_edit_at: new Date(totalRow.last_at * 1000).toISOString(),
|
|
388
|
+
sessions_involved: totalRow.sessions,
|
|
389
|
+
},
|
|
390
|
+
daily_breakdown: daily,
|
|
391
|
+
user_intents: intents.map((i) => ({
|
|
392
|
+
when: new Date(i.last_at * 1000).toISOString(),
|
|
393
|
+
occurrences: i.freq,
|
|
394
|
+
intent: i.context_snippet,
|
|
395
|
+
})),
|
|
396
|
+
linked_memories: memories.map((m) => ({
|
|
397
|
+
id: m.id,
|
|
398
|
+
entity: m.entity_name,
|
|
399
|
+
layer: m.layer,
|
|
400
|
+
importance: m.importance,
|
|
401
|
+
preview: m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content,
|
|
402
|
+
})),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
function handleReadSmart(args) {
|
|
406
|
+
return handleReadSmartImpl(db, { path: args.path, force: args.force });
|
|
407
|
+
}
|
|
408
|
+
// ============================================================
|
|
409
|
+
// MCP wiring
|
|
410
|
+
// ============================================================
|
|
411
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
412
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
413
|
+
const { name, arguments: args } = req.params;
|
|
414
|
+
try {
|
|
415
|
+
let text;
|
|
416
|
+
switch (name) {
|
|
417
|
+
case 'remember':
|
|
418
|
+
text = handleRemember(args);
|
|
419
|
+
break;
|
|
420
|
+
case 'recall':
|
|
421
|
+
text = handleRecall(args);
|
|
422
|
+
break;
|
|
423
|
+
case 'forget':
|
|
424
|
+
text = handleForget(args);
|
|
425
|
+
break;
|
|
426
|
+
case 'consolidate':
|
|
427
|
+
text = handleConsolidate(args);
|
|
428
|
+
break;
|
|
429
|
+
case 'recall_file':
|
|
430
|
+
text = handleRecallFile(args);
|
|
431
|
+
break;
|
|
432
|
+
case 'read_smart':
|
|
433
|
+
text = handleReadSmart(args);
|
|
434
|
+
break;
|
|
435
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
436
|
+
}
|
|
437
|
+
return { content: [{ type: 'text', text }] };
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
return {
|
|
441
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: err?.message ?? String(err) }) }],
|
|
442
|
+
isError: true,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
const transport = new StdioServerTransport();
|
|
447
|
+
await server.connect(transport);
|
|
448
|
+
process.stderr.write('[linksee-memory] MCP server ready on stdio (v0.0.2)\n');
|
|
449
|
+
//# sourceMappingURL=server.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "linksee-memory",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Local-first agent memory MCP — cross-agent brain with 6-layer structured memory + token-saving file diff cache",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"linksee-memory": "./dist/mcp/server.js",
|
|
8
|
+
"linksee-memory-import": "./dist/bin/import-sessions.js",
|
|
9
|
+
"linksee-memory-sync": "./dist/bin/sync-session.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/mcp/server.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/**/*.js",
|
|
14
|
+
"dist/**/*.d.ts",
|
|
15
|
+
"dist/db/schema.sql",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc && node -e \"require('fs').copyFileSync('src/db/schema.sql','dist/db/schema.sql')\"",
|
|
21
|
+
"start": "node dist/mcp/server.js",
|
|
22
|
+
"dev": "tsx src/mcp/server.ts",
|
|
23
|
+
"migrate": "node dist/db/migrate.js",
|
|
24
|
+
"migrate:dev": "tsx src/db/migrate.ts",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"model-context-protocol",
|
|
30
|
+
"memory",
|
|
31
|
+
"agent",
|
|
32
|
+
"agent-memory",
|
|
33
|
+
"claude",
|
|
34
|
+
"claude-code",
|
|
35
|
+
"cursor",
|
|
36
|
+
"chatgpt",
|
|
37
|
+
"sqlite",
|
|
38
|
+
"local-first",
|
|
39
|
+
"token-savings",
|
|
40
|
+
"cross-agent"
|
|
41
|
+
],
|
|
42
|
+
"author": "Synapse Arrows PTE. LTD.",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"homepage": "https://github.com/synapse-arrows/linksee-memory",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/synapse-arrows/linksee-memory.git"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/synapse-arrows/linksee-memory/issues"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@babel/parser": "^7.25.0",
|
|
54
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
55
|
+
"better-sqlite3": "^11.3.0",
|
|
56
|
+
"sqlite-vec": "^0.1.6"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
60
|
+
"@types/node": "^22.7.0",
|
|
61
|
+
"tsx": "^4.19.0",
|
|
62
|
+
"typescript": "^5.6.0"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20"
|
|
66
|
+
}
|
|
67
|
+
}
|