@hir4ta/mneme 0.17.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/.claude-plugin/plugin.json +29 -0
- package/.mcp.json +18 -0
- package/README.ja.md +400 -0
- package/README.md +410 -0
- package/bin/mneme.js +203 -0
- package/dist/lib/db.js +340 -0
- package/dist/lib/fuzzy-search.js +214 -0
- package/dist/lib/github.js +121 -0
- package/dist/lib/similarity.js +193 -0
- package/dist/lib/utils.js +62 -0
- package/dist/public/apple-touch-icon.png +0 -0
- package/dist/public/assets/index-BgqCALAg.css +1 -0
- package/dist/public/assets/index-EMvn4VEa.js +330 -0
- package/dist/public/assets/react-force-graph-2d-DWoBaKmT.js +46 -0
- package/dist/public/favicon-128-max.png +0 -0
- package/dist/public/favicon-256-max.png +0 -0
- package/dist/public/favicon-32-max.png +0 -0
- package/dist/public/favicon-512-max.png +0 -0
- package/dist/public/favicon-64-max.png +0 -0
- package/dist/public/index.html +15 -0
- package/dist/server.js +4791 -0
- package/dist/servers/db-server.js +30558 -0
- package/dist/servers/search-server.js +30366 -0
- package/hooks/default-tags.json +1055 -0
- package/hooks/hooks.json +61 -0
- package/hooks/post-tool-use.sh +96 -0
- package/hooks/pre-compact.sh +187 -0
- package/hooks/session-end.sh +567 -0
- package/hooks/session-start.sh +380 -0
- package/hooks/user-prompt-submit.sh +253 -0
- package/package.json +77 -0
- package/servers/db-server.ts +993 -0
- package/servers/search-server.ts +675 -0
- package/skills/AGENTS.override.md +5 -0
- package/skills/harvest/skill.md +295 -0
- package/skills/init-mneme/skill.md +101 -0
- package/skills/plan/skill.md +422 -0
- package/skills/report/skill.md +74 -0
- package/skills/resume/skill.md +278 -0
- package/skills/review/skill.md +419 -0
- package/skills/save/skill.md +482 -0
- package/skills/search/skill.md +175 -0
- package/skills/using-mneme/skill.md +185 -0
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mneme MCP Database Server
|
|
4
|
+
*
|
|
5
|
+
* Provides direct database access for:
|
|
6
|
+
* - Cross-project queries
|
|
7
|
+
* - Session/interaction retrieval
|
|
8
|
+
* - Statistics and analytics
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import * as readline from "node:readline";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
|
|
19
|
+
// Suppress Node.js SQLite experimental warning
|
|
20
|
+
const originalEmit = process.emit;
|
|
21
|
+
// @ts-expect-error - Suppressing experimental warning
|
|
22
|
+
process.emit = (event, ...args) => {
|
|
23
|
+
if (
|
|
24
|
+
event === "warning" &&
|
|
25
|
+
typeof args[0] === "object" &&
|
|
26
|
+
args[0] !== null &&
|
|
27
|
+
"name" in args[0] &&
|
|
28
|
+
(args[0] as { name: string }).name === "ExperimentalWarning" &&
|
|
29
|
+
"message" in args[0] &&
|
|
30
|
+
typeof (args[0] as { message: string }).message === "string" &&
|
|
31
|
+
(args[0] as { message: string }).message.includes("SQLite")
|
|
32
|
+
) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return originalEmit.apply(process, [event, ...args] as unknown as Parameters<
|
|
36
|
+
typeof process.emit
|
|
37
|
+
>);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Import after warning suppression is set up
|
|
41
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
42
|
+
type DatabaseSyncType = InstanceType<typeof DatabaseSync>;
|
|
43
|
+
|
|
44
|
+
// Types
|
|
45
|
+
interface ProjectInfo {
|
|
46
|
+
projectPath: string;
|
|
47
|
+
repository: string | null;
|
|
48
|
+
sessionCount: number;
|
|
49
|
+
interactionCount: number;
|
|
50
|
+
lastActivity: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface SessionInfo {
|
|
54
|
+
sessionId: string;
|
|
55
|
+
projectPath: string;
|
|
56
|
+
repository: string | null;
|
|
57
|
+
owner: string;
|
|
58
|
+
messageCount: number;
|
|
59
|
+
startedAt: string;
|
|
60
|
+
lastMessageAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Interaction {
|
|
64
|
+
id: number;
|
|
65
|
+
sessionId: string;
|
|
66
|
+
projectPath: string;
|
|
67
|
+
owner: string;
|
|
68
|
+
role: string;
|
|
69
|
+
content: string;
|
|
70
|
+
thinking: string | null;
|
|
71
|
+
toolCalls: string | null;
|
|
72
|
+
timestamp: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Stats {
|
|
76
|
+
totalProjects: number;
|
|
77
|
+
totalSessions: number;
|
|
78
|
+
totalInteractions: number;
|
|
79
|
+
totalThinkingBlocks: number;
|
|
80
|
+
projectStats: Array<{
|
|
81
|
+
projectPath: string;
|
|
82
|
+
repository: string | null;
|
|
83
|
+
sessions: number;
|
|
84
|
+
interactions: number;
|
|
85
|
+
}>;
|
|
86
|
+
recentActivity: Array<{
|
|
87
|
+
date: string;
|
|
88
|
+
sessions: number;
|
|
89
|
+
interactions: number;
|
|
90
|
+
}>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Get project path from env or current working directory
|
|
94
|
+
function getProjectPath(): string {
|
|
95
|
+
return process.env.MNEME_PROJECT_PATH || process.cwd();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getLocalDbPath(): string {
|
|
99
|
+
return path.join(getProjectPath(), ".mneme", "local.db");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Database connection (lazy initialization)
|
|
103
|
+
let db: DatabaseSyncType | null = null;
|
|
104
|
+
|
|
105
|
+
function getDb(): DatabaseSyncType | null {
|
|
106
|
+
if (db) return db;
|
|
107
|
+
|
|
108
|
+
const dbPath = getLocalDbPath();
|
|
109
|
+
if (!fs.existsSync(dbPath)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
db = new DatabaseSync(dbPath);
|
|
115
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
116
|
+
return db;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Database functions
|
|
123
|
+
function listProjects(): ProjectInfo[] {
|
|
124
|
+
const database = getDb();
|
|
125
|
+
if (!database) return [];
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const stmt = database.prepare(`
|
|
129
|
+
SELECT
|
|
130
|
+
project_path,
|
|
131
|
+
repository,
|
|
132
|
+
COUNT(DISTINCT session_id) as session_count,
|
|
133
|
+
COUNT(*) as interaction_count,
|
|
134
|
+
MAX(timestamp) as last_activity
|
|
135
|
+
FROM interactions
|
|
136
|
+
GROUP BY project_path
|
|
137
|
+
ORDER BY last_activity DESC
|
|
138
|
+
`);
|
|
139
|
+
|
|
140
|
+
const rows = stmt.all() as Array<{
|
|
141
|
+
project_path: string;
|
|
142
|
+
repository: string | null;
|
|
143
|
+
session_count: number;
|
|
144
|
+
interaction_count: number;
|
|
145
|
+
last_activity: string;
|
|
146
|
+
}>;
|
|
147
|
+
|
|
148
|
+
return rows.map((row) => ({
|
|
149
|
+
projectPath: row.project_path,
|
|
150
|
+
repository: row.repository,
|
|
151
|
+
sessionCount: row.session_count,
|
|
152
|
+
interactionCount: row.interaction_count,
|
|
153
|
+
lastActivity: row.last_activity,
|
|
154
|
+
}));
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function listSessions(options: {
|
|
161
|
+
projectPath?: string;
|
|
162
|
+
repository?: string;
|
|
163
|
+
limit?: number;
|
|
164
|
+
}): SessionInfo[] {
|
|
165
|
+
const database = getDb();
|
|
166
|
+
if (!database) return [];
|
|
167
|
+
|
|
168
|
+
const { projectPath, repository, limit = 20 } = options;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
let sql = `
|
|
172
|
+
SELECT
|
|
173
|
+
session_id,
|
|
174
|
+
project_path,
|
|
175
|
+
repository,
|
|
176
|
+
owner,
|
|
177
|
+
COUNT(*) as message_count,
|
|
178
|
+
MIN(timestamp) as started_at,
|
|
179
|
+
MAX(timestamp) as last_message_at
|
|
180
|
+
FROM interactions
|
|
181
|
+
WHERE 1=1
|
|
182
|
+
`;
|
|
183
|
+
const params: (string | number)[] = [];
|
|
184
|
+
|
|
185
|
+
if (projectPath) {
|
|
186
|
+
sql += " AND project_path = ?";
|
|
187
|
+
params.push(projectPath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (repository) {
|
|
191
|
+
sql += " AND repository = ?";
|
|
192
|
+
params.push(repository);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
sql += `
|
|
196
|
+
GROUP BY session_id
|
|
197
|
+
ORDER BY last_message_at DESC
|
|
198
|
+
LIMIT ?
|
|
199
|
+
`;
|
|
200
|
+
params.push(limit);
|
|
201
|
+
|
|
202
|
+
const stmt = database.prepare(sql);
|
|
203
|
+
const rows = stmt.all(...params) as Array<{
|
|
204
|
+
session_id: string;
|
|
205
|
+
project_path: string;
|
|
206
|
+
repository: string | null;
|
|
207
|
+
owner: string;
|
|
208
|
+
message_count: number;
|
|
209
|
+
started_at: string;
|
|
210
|
+
last_message_at: string;
|
|
211
|
+
}>;
|
|
212
|
+
|
|
213
|
+
return rows.map((row) => ({
|
|
214
|
+
sessionId: row.session_id,
|
|
215
|
+
projectPath: row.project_path,
|
|
216
|
+
repository: row.repository,
|
|
217
|
+
owner: row.owner,
|
|
218
|
+
messageCount: row.message_count,
|
|
219
|
+
startedAt: row.started_at,
|
|
220
|
+
lastMessageAt: row.last_message_at,
|
|
221
|
+
}));
|
|
222
|
+
} catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function getInteractions(
|
|
228
|
+
sessionId: string,
|
|
229
|
+
options: { limit?: number; offset?: number } = {},
|
|
230
|
+
): Interaction[] {
|
|
231
|
+
const database = getDb();
|
|
232
|
+
if (!database) return [];
|
|
233
|
+
|
|
234
|
+
const { limit = 50, offset = 0 } = options;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const stmt = database.prepare(`
|
|
238
|
+
SELECT
|
|
239
|
+
id,
|
|
240
|
+
session_id,
|
|
241
|
+
project_path,
|
|
242
|
+
owner,
|
|
243
|
+
role,
|
|
244
|
+
content,
|
|
245
|
+
thinking,
|
|
246
|
+
tool_calls,
|
|
247
|
+
timestamp
|
|
248
|
+
FROM interactions
|
|
249
|
+
WHERE session_id = ?
|
|
250
|
+
ORDER BY timestamp ASC
|
|
251
|
+
LIMIT ? OFFSET ?
|
|
252
|
+
`);
|
|
253
|
+
|
|
254
|
+
const rows = stmt.all(sessionId, limit, offset) as Array<{
|
|
255
|
+
id: number;
|
|
256
|
+
session_id: string;
|
|
257
|
+
project_path: string;
|
|
258
|
+
owner: string;
|
|
259
|
+
role: string;
|
|
260
|
+
content: string;
|
|
261
|
+
thinking: string | null;
|
|
262
|
+
tool_calls: string | null;
|
|
263
|
+
timestamp: string;
|
|
264
|
+
}>;
|
|
265
|
+
|
|
266
|
+
return rows.map((row) => ({
|
|
267
|
+
id: row.id,
|
|
268
|
+
sessionId: row.session_id,
|
|
269
|
+
projectPath: row.project_path,
|
|
270
|
+
owner: row.owner,
|
|
271
|
+
role: row.role,
|
|
272
|
+
content: row.content,
|
|
273
|
+
thinking: row.thinking,
|
|
274
|
+
toolCalls: row.tool_calls,
|
|
275
|
+
timestamp: row.timestamp,
|
|
276
|
+
}));
|
|
277
|
+
} catch {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function getStats(): Stats | null {
|
|
283
|
+
const database = getDb();
|
|
284
|
+
if (!database) return null;
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
// Overall stats
|
|
288
|
+
const overallStmt = database.prepare(`
|
|
289
|
+
SELECT
|
|
290
|
+
COUNT(DISTINCT project_path) as total_projects,
|
|
291
|
+
COUNT(DISTINCT session_id) as total_sessions,
|
|
292
|
+
COUNT(*) as total_interactions,
|
|
293
|
+
COUNT(thinking) as total_thinking
|
|
294
|
+
FROM interactions
|
|
295
|
+
`);
|
|
296
|
+
const overall = overallStmt.get() as {
|
|
297
|
+
total_projects: number;
|
|
298
|
+
total_sessions: number;
|
|
299
|
+
total_interactions: number;
|
|
300
|
+
total_thinking: number;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Per-project stats
|
|
304
|
+
const projectStmt = database.prepare(`
|
|
305
|
+
SELECT
|
|
306
|
+
project_path,
|
|
307
|
+
repository,
|
|
308
|
+
COUNT(DISTINCT session_id) as sessions,
|
|
309
|
+
COUNT(*) as interactions
|
|
310
|
+
FROM interactions
|
|
311
|
+
GROUP BY project_path
|
|
312
|
+
ORDER BY interactions DESC
|
|
313
|
+
LIMIT 10
|
|
314
|
+
`);
|
|
315
|
+
const projectRows = projectStmt.all() as Array<{
|
|
316
|
+
project_path: string;
|
|
317
|
+
repository: string | null;
|
|
318
|
+
sessions: number;
|
|
319
|
+
interactions: number;
|
|
320
|
+
}>;
|
|
321
|
+
|
|
322
|
+
// Recent activity (last 7 days)
|
|
323
|
+
const activityStmt = database.prepare(`
|
|
324
|
+
SELECT
|
|
325
|
+
DATE(timestamp) as date,
|
|
326
|
+
COUNT(DISTINCT session_id) as sessions,
|
|
327
|
+
COUNT(*) as interactions
|
|
328
|
+
FROM interactions
|
|
329
|
+
WHERE timestamp >= datetime('now', '-7 days')
|
|
330
|
+
GROUP BY DATE(timestamp)
|
|
331
|
+
ORDER BY date DESC
|
|
332
|
+
`);
|
|
333
|
+
const activityRows = activityStmt.all() as Array<{
|
|
334
|
+
date: string;
|
|
335
|
+
sessions: number;
|
|
336
|
+
interactions: number;
|
|
337
|
+
}>;
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
totalProjects: overall.total_projects,
|
|
341
|
+
totalSessions: overall.total_sessions,
|
|
342
|
+
totalInteractions: overall.total_interactions,
|
|
343
|
+
totalThinkingBlocks: overall.total_thinking,
|
|
344
|
+
projectStats: projectRows.map((row) => ({
|
|
345
|
+
projectPath: row.project_path,
|
|
346
|
+
repository: row.repository,
|
|
347
|
+
sessions: row.sessions,
|
|
348
|
+
interactions: row.interactions,
|
|
349
|
+
})),
|
|
350
|
+
recentActivity: activityRows,
|
|
351
|
+
};
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function crossProjectSearch(
|
|
358
|
+
query: string,
|
|
359
|
+
options: { limit?: number } = {},
|
|
360
|
+
): Array<{
|
|
361
|
+
sessionId: string;
|
|
362
|
+
projectPath: string;
|
|
363
|
+
repository: string | null;
|
|
364
|
+
snippet: string;
|
|
365
|
+
timestamp: string;
|
|
366
|
+
}> {
|
|
367
|
+
const database = getDb();
|
|
368
|
+
if (!database) return [];
|
|
369
|
+
|
|
370
|
+
const { limit = 10 } = options;
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
// Try FTS5 first
|
|
374
|
+
const ftsStmt = database.prepare(`
|
|
375
|
+
SELECT
|
|
376
|
+
i.session_id,
|
|
377
|
+
i.project_path,
|
|
378
|
+
i.repository,
|
|
379
|
+
snippet(interactions_fts, 0, '[', ']', '...', 32) as snippet,
|
|
380
|
+
i.timestamp
|
|
381
|
+
FROM interactions_fts
|
|
382
|
+
JOIN interactions i ON interactions_fts.rowid = i.id
|
|
383
|
+
WHERE interactions_fts MATCH ?
|
|
384
|
+
ORDER BY rank
|
|
385
|
+
LIMIT ?
|
|
386
|
+
`);
|
|
387
|
+
|
|
388
|
+
const rows = ftsStmt.all(query, limit) as Array<{
|
|
389
|
+
session_id: string;
|
|
390
|
+
project_path: string;
|
|
391
|
+
repository: string | null;
|
|
392
|
+
snippet: string;
|
|
393
|
+
timestamp: string;
|
|
394
|
+
}>;
|
|
395
|
+
|
|
396
|
+
return rows.map((row) => ({
|
|
397
|
+
sessionId: row.session_id,
|
|
398
|
+
projectPath: row.project_path,
|
|
399
|
+
repository: row.repository,
|
|
400
|
+
snippet: row.snippet,
|
|
401
|
+
timestamp: row.timestamp,
|
|
402
|
+
}));
|
|
403
|
+
} catch {
|
|
404
|
+
// FTS5 failed, fallback to LIKE
|
|
405
|
+
try {
|
|
406
|
+
const likeStmt = database.prepare(`
|
|
407
|
+
SELECT DISTINCT
|
|
408
|
+
session_id,
|
|
409
|
+
project_path,
|
|
410
|
+
repository,
|
|
411
|
+
substr(content, 1, 100) as snippet,
|
|
412
|
+
timestamp
|
|
413
|
+
FROM interactions
|
|
414
|
+
WHERE content LIKE ? OR thinking LIKE ?
|
|
415
|
+
ORDER BY timestamp DESC
|
|
416
|
+
LIMIT ?
|
|
417
|
+
`);
|
|
418
|
+
|
|
419
|
+
const pattern = `%${query}%`;
|
|
420
|
+
const rows = likeStmt.all(pattern, pattern, limit) as Array<{
|
|
421
|
+
session_id: string;
|
|
422
|
+
project_path: string;
|
|
423
|
+
repository: string | null;
|
|
424
|
+
snippet: string;
|
|
425
|
+
timestamp: string;
|
|
426
|
+
}>;
|
|
427
|
+
|
|
428
|
+
return rows.map((row) => ({
|
|
429
|
+
sessionId: row.session_id,
|
|
430
|
+
projectPath: row.project_path,
|
|
431
|
+
repository: row.repository,
|
|
432
|
+
snippet: row.snippet,
|
|
433
|
+
timestamp: row.timestamp,
|
|
434
|
+
}));
|
|
435
|
+
} catch {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Helper: Get transcript path from Claude session ID
|
|
442
|
+
function getTranscriptPath(claudeSessionId: string): string | null {
|
|
443
|
+
const projectPath = getProjectPath();
|
|
444
|
+
// Encode project path: replace / with -
|
|
445
|
+
// Claude Code keeps the leading dash (e.g., -Users-user-Projects-mneme)
|
|
446
|
+
const encodedPath = projectPath.replace(/\//g, "-");
|
|
447
|
+
|
|
448
|
+
const transcriptPath = path.join(
|
|
449
|
+
os.homedir(),
|
|
450
|
+
".claude",
|
|
451
|
+
"projects",
|
|
452
|
+
encodedPath,
|
|
453
|
+
`${claudeSessionId}.jsonl`,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
return fs.existsSync(transcriptPath) ? transcriptPath : null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Helper: Parse JSONL transcript and extract interactions
|
|
460
|
+
interface ParsedInteraction {
|
|
461
|
+
id: string;
|
|
462
|
+
timestamp: string;
|
|
463
|
+
user: string;
|
|
464
|
+
thinking: string;
|
|
465
|
+
assistant: string;
|
|
466
|
+
isCompactSummary: boolean;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
interface ParsedTranscript {
|
|
470
|
+
interactions: ParsedInteraction[];
|
|
471
|
+
toolUsage: Array<{ name: string; count: number }>;
|
|
472
|
+
files: Array<{ path: string; action: string }>;
|
|
473
|
+
metrics: {
|
|
474
|
+
userMessages: number;
|
|
475
|
+
assistantResponses: number;
|
|
476
|
+
thinkingBlocks: number;
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function parseTranscript(
|
|
481
|
+
transcriptPath: string,
|
|
482
|
+
): Promise<ParsedTranscript> {
|
|
483
|
+
const fileStream = fs.createReadStream(transcriptPath);
|
|
484
|
+
const rl = readline.createInterface({
|
|
485
|
+
input: fileStream,
|
|
486
|
+
crlfDelay: Number.POSITIVE_INFINITY,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
interface TranscriptEntry {
|
|
490
|
+
type: string;
|
|
491
|
+
timestamp: string;
|
|
492
|
+
message?: {
|
|
493
|
+
role?: string;
|
|
494
|
+
content?:
|
|
495
|
+
| string
|
|
496
|
+
| Array<{
|
|
497
|
+
type: string;
|
|
498
|
+
thinking?: string;
|
|
499
|
+
text?: string;
|
|
500
|
+
name?: string;
|
|
501
|
+
input?: { file_path?: string };
|
|
502
|
+
}>;
|
|
503
|
+
};
|
|
504
|
+
isCompactSummary?: boolean;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const entries: TranscriptEntry[] = [];
|
|
508
|
+
|
|
509
|
+
for await (const line of rl) {
|
|
510
|
+
if (line.trim()) {
|
|
511
|
+
try {
|
|
512
|
+
entries.push(JSON.parse(line));
|
|
513
|
+
} catch {
|
|
514
|
+
// Skip invalid JSON lines
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Extract user messages (text only, exclude tool results and local command outputs)
|
|
520
|
+
const userMessages = entries
|
|
521
|
+
.filter((e) => {
|
|
522
|
+
if (e.type !== "user" || e.message?.role !== "user") return false;
|
|
523
|
+
const content = e.message?.content;
|
|
524
|
+
if (typeof content !== "string") return false;
|
|
525
|
+
if (content.startsWith("<local-command-stdout>")) return false;
|
|
526
|
+
if (content.startsWith("<local-command-caveat>")) return false;
|
|
527
|
+
return true;
|
|
528
|
+
})
|
|
529
|
+
.map((e) => ({
|
|
530
|
+
timestamp: e.timestamp,
|
|
531
|
+
content: e.message?.content as string,
|
|
532
|
+
isCompactSummary: e.isCompactSummary || false,
|
|
533
|
+
}));
|
|
534
|
+
|
|
535
|
+
// Extract assistant messages with thinking and text
|
|
536
|
+
const assistantMessages = entries
|
|
537
|
+
.filter((e) => e.type === "assistant")
|
|
538
|
+
.map((e) => {
|
|
539
|
+
const contentArray = e.message?.content;
|
|
540
|
+
if (!Array.isArray(contentArray)) return null;
|
|
541
|
+
|
|
542
|
+
const thinking = contentArray
|
|
543
|
+
.filter((c) => c.type === "thinking" && c.thinking)
|
|
544
|
+
.map((c) => c.thinking)
|
|
545
|
+
.join("\n");
|
|
546
|
+
|
|
547
|
+
const text = contentArray
|
|
548
|
+
.filter((c) => c.type === "text" && c.text)
|
|
549
|
+
.map((c) => c.text)
|
|
550
|
+
.join("\n");
|
|
551
|
+
|
|
552
|
+
if (!thinking && !text) return null;
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
timestamp: e.timestamp,
|
|
556
|
+
thinking,
|
|
557
|
+
text,
|
|
558
|
+
};
|
|
559
|
+
})
|
|
560
|
+
.filter((m) => m !== null);
|
|
561
|
+
|
|
562
|
+
// Tool usage summary
|
|
563
|
+
const toolUsageMap = new Map<string, number>();
|
|
564
|
+
for (const entry of entries) {
|
|
565
|
+
if (entry.type === "assistant" && Array.isArray(entry.message?.content)) {
|
|
566
|
+
for (const c of entry.message.content) {
|
|
567
|
+
if (c.type === "tool_use" && c.name) {
|
|
568
|
+
toolUsageMap.set(c.name, (toolUsageMap.get(c.name) || 0) + 1);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const toolUsage = Array.from(toolUsageMap.entries())
|
|
574
|
+
.map(([name, count]) => ({ name, count }))
|
|
575
|
+
.sort((a, b) => b.count - a.count);
|
|
576
|
+
|
|
577
|
+
// File changes
|
|
578
|
+
const filesMap = new Map<string, string>();
|
|
579
|
+
for (const entry of entries) {
|
|
580
|
+
if (entry.type === "assistant" && Array.isArray(entry.message?.content)) {
|
|
581
|
+
for (const c of entry.message.content) {
|
|
582
|
+
if (
|
|
583
|
+
c.type === "tool_use" &&
|
|
584
|
+
(c.name === "Edit" || c.name === "Write")
|
|
585
|
+
) {
|
|
586
|
+
const filePath = c.input?.file_path;
|
|
587
|
+
if (filePath) {
|
|
588
|
+
filesMap.set(filePath, c.name === "Write" ? "create" : "edit");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const files = Array.from(filesMap.entries()).map(([p, action]) => ({
|
|
595
|
+
path: p,
|
|
596
|
+
action,
|
|
597
|
+
}));
|
|
598
|
+
|
|
599
|
+
// Build interactions by pairing user messages with assistant responses
|
|
600
|
+
const interactions: ParsedInteraction[] = [];
|
|
601
|
+
for (let i = 0; i < userMessages.length; i++) {
|
|
602
|
+
const user = userMessages[i];
|
|
603
|
+
const nextUserTs =
|
|
604
|
+
i + 1 < userMessages.length
|
|
605
|
+
? userMessages[i + 1].timestamp
|
|
606
|
+
: "9999-12-31T23:59:59Z";
|
|
607
|
+
|
|
608
|
+
// Collect all assistant responses between this user message and next
|
|
609
|
+
const turnResponses = assistantMessages.filter(
|
|
610
|
+
(a) => a.timestamp > user.timestamp && a.timestamp < nextUserTs,
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
if (turnResponses.length > 0) {
|
|
614
|
+
interactions.push({
|
|
615
|
+
id: `int-${String(i + 1).padStart(3, "0")}`,
|
|
616
|
+
timestamp: user.timestamp,
|
|
617
|
+
user: user.content,
|
|
618
|
+
thinking: turnResponses
|
|
619
|
+
.filter((r) => r.thinking)
|
|
620
|
+
.map((r) => r.thinking)
|
|
621
|
+
.join("\n"),
|
|
622
|
+
assistant: turnResponses
|
|
623
|
+
.filter((r) => r.text)
|
|
624
|
+
.map((r) => r.text)
|
|
625
|
+
.join("\n"),
|
|
626
|
+
isCompactSummary: user.isCompactSummary,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
interactions,
|
|
633
|
+
toolUsage,
|
|
634
|
+
files,
|
|
635
|
+
metrics: {
|
|
636
|
+
userMessages: userMessages.length,
|
|
637
|
+
assistantResponses: assistantMessages.length,
|
|
638
|
+
thinkingBlocks: assistantMessages.filter((a) => a.thinking).length,
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Save interactions to SQLite
|
|
644
|
+
interface SaveInteractionsResult {
|
|
645
|
+
success: boolean;
|
|
646
|
+
savedCount: number;
|
|
647
|
+
mergedFromBackup: number;
|
|
648
|
+
message: string;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function saveInteractions(
|
|
652
|
+
claudeSessionId: string,
|
|
653
|
+
mnemeSessionId?: string,
|
|
654
|
+
): Promise<SaveInteractionsResult> {
|
|
655
|
+
const transcriptPath = getTranscriptPath(claudeSessionId);
|
|
656
|
+
if (!transcriptPath) {
|
|
657
|
+
return {
|
|
658
|
+
success: false,
|
|
659
|
+
savedCount: 0,
|
|
660
|
+
mergedFromBackup: 0,
|
|
661
|
+
message: `Transcript not found for session: ${claudeSessionId}`,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const database = getDb();
|
|
666
|
+
if (!database) {
|
|
667
|
+
return {
|
|
668
|
+
success: false,
|
|
669
|
+
savedCount: 0,
|
|
670
|
+
mergedFromBackup: 0,
|
|
671
|
+
message: "Database not available",
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const projectPath = getProjectPath();
|
|
676
|
+
const sessionId = mnemeSessionId || claudeSessionId.slice(0, 8);
|
|
677
|
+
|
|
678
|
+
// Get owner from git or fallback
|
|
679
|
+
let owner = "unknown";
|
|
680
|
+
try {
|
|
681
|
+
const { execSync } = await import("node:child_process");
|
|
682
|
+
owner =
|
|
683
|
+
execSync("git config user.name", {
|
|
684
|
+
encoding: "utf8",
|
|
685
|
+
cwd: projectPath,
|
|
686
|
+
}).trim() || owner;
|
|
687
|
+
} catch {
|
|
688
|
+
try {
|
|
689
|
+
owner = os.userInfo().username || owner;
|
|
690
|
+
} catch {
|
|
691
|
+
// keep default
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Get repository info
|
|
696
|
+
let repository = "";
|
|
697
|
+
let repositoryUrl = "";
|
|
698
|
+
let repositoryRoot = "";
|
|
699
|
+
try {
|
|
700
|
+
const { execSync } = await import("node:child_process");
|
|
701
|
+
repositoryRoot = execSync("git rev-parse --show-toplevel", {
|
|
702
|
+
encoding: "utf8",
|
|
703
|
+
cwd: projectPath,
|
|
704
|
+
}).trim();
|
|
705
|
+
repositoryUrl = execSync("git remote get-url origin", {
|
|
706
|
+
encoding: "utf8",
|
|
707
|
+
cwd: projectPath,
|
|
708
|
+
}).trim();
|
|
709
|
+
// Extract owner/repo from URL
|
|
710
|
+
const match = repositoryUrl.match(/[:/]([^/]+\/[^/]+?)(\.git)?$/);
|
|
711
|
+
if (match) {
|
|
712
|
+
repository = match[1].replace(/\.git$/, "");
|
|
713
|
+
}
|
|
714
|
+
} catch {
|
|
715
|
+
// Not a git repo
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Parse transcript
|
|
719
|
+
const parsed = await parseTranscript(transcriptPath);
|
|
720
|
+
|
|
721
|
+
// Get backup from pre_compact_backups
|
|
722
|
+
let backupInteractions: ParsedInteraction[] = [];
|
|
723
|
+
try {
|
|
724
|
+
const stmt = database.prepare(`
|
|
725
|
+
SELECT interactions FROM pre_compact_backups
|
|
726
|
+
WHERE session_id = ?
|
|
727
|
+
ORDER BY created_at DESC
|
|
728
|
+
LIMIT 1
|
|
729
|
+
`);
|
|
730
|
+
const row = stmt.get(sessionId) as { interactions: string } | undefined;
|
|
731
|
+
if (row?.interactions) {
|
|
732
|
+
backupInteractions = JSON.parse(row.interactions);
|
|
733
|
+
}
|
|
734
|
+
} catch {
|
|
735
|
+
// No backup or parse error
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Merge backup with new interactions
|
|
739
|
+
const lastBackupTs =
|
|
740
|
+
backupInteractions.length > 0
|
|
741
|
+
? backupInteractions[backupInteractions.length - 1].timestamp
|
|
742
|
+
: "1970-01-01T00:00:00Z";
|
|
743
|
+
|
|
744
|
+
const trulyNew = parsed.interactions.filter(
|
|
745
|
+
(i) => i.timestamp > lastBackupTs,
|
|
746
|
+
);
|
|
747
|
+
const merged = [...backupInteractions, ...trulyNew];
|
|
748
|
+
|
|
749
|
+
// Re-number IDs
|
|
750
|
+
const finalInteractions = merged.map((interaction, idx) => ({
|
|
751
|
+
...interaction,
|
|
752
|
+
id: `int-${String(idx + 1).padStart(3, "0")}`,
|
|
753
|
+
}));
|
|
754
|
+
|
|
755
|
+
// Delete existing interactions for this session
|
|
756
|
+
try {
|
|
757
|
+
const deleteStmt = database.prepare(
|
|
758
|
+
"DELETE FROM interactions WHERE session_id = ?",
|
|
759
|
+
);
|
|
760
|
+
deleteStmt.run(sessionId);
|
|
761
|
+
} catch {
|
|
762
|
+
// Ignore delete errors
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Insert interactions
|
|
766
|
+
const insertStmt = database.prepare(`
|
|
767
|
+
INSERT INTO interactions (
|
|
768
|
+
session_id, project_path, repository, repository_url, repository_root,
|
|
769
|
+
owner, role, content, thinking, timestamp, is_compact_summary
|
|
770
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
771
|
+
`);
|
|
772
|
+
|
|
773
|
+
let insertedCount = 0;
|
|
774
|
+
for (const interaction of finalInteractions) {
|
|
775
|
+
try {
|
|
776
|
+
// Insert user message
|
|
777
|
+
insertStmt.run(
|
|
778
|
+
sessionId,
|
|
779
|
+
projectPath,
|
|
780
|
+
repository,
|
|
781
|
+
repositoryUrl,
|
|
782
|
+
repositoryRoot,
|
|
783
|
+
owner,
|
|
784
|
+
"user",
|
|
785
|
+
interaction.user,
|
|
786
|
+
null,
|
|
787
|
+
interaction.timestamp,
|
|
788
|
+
interaction.isCompactSummary ? 1 : 0,
|
|
789
|
+
);
|
|
790
|
+
insertedCount++;
|
|
791
|
+
|
|
792
|
+
// Insert assistant response
|
|
793
|
+
if (interaction.assistant) {
|
|
794
|
+
insertStmt.run(
|
|
795
|
+
sessionId,
|
|
796
|
+
projectPath,
|
|
797
|
+
repository,
|
|
798
|
+
repositoryUrl,
|
|
799
|
+
repositoryRoot,
|
|
800
|
+
owner,
|
|
801
|
+
"assistant",
|
|
802
|
+
interaction.assistant,
|
|
803
|
+
interaction.thinking || null,
|
|
804
|
+
interaction.timestamp,
|
|
805
|
+
0,
|
|
806
|
+
);
|
|
807
|
+
insertedCount++;
|
|
808
|
+
}
|
|
809
|
+
} catch {
|
|
810
|
+
// Skip on insert error
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Clear pre_compact_backups for this session
|
|
815
|
+
try {
|
|
816
|
+
const clearBackupStmt = database.prepare(
|
|
817
|
+
"DELETE FROM pre_compact_backups WHERE session_id = ?",
|
|
818
|
+
);
|
|
819
|
+
clearBackupStmt.run(sessionId);
|
|
820
|
+
} catch {
|
|
821
|
+
// Ignore
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
success: true,
|
|
826
|
+
savedCount: insertedCount,
|
|
827
|
+
mergedFromBackup: backupInteractions.length,
|
|
828
|
+
message: `Saved ${insertedCount} interactions (${finalInteractions.length} turns, ${backupInteractions.length} from backup)`,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// MCP Server setup
|
|
833
|
+
const server = new McpServer({
|
|
834
|
+
name: "mneme-db",
|
|
835
|
+
version: "0.1.0",
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// Tool: mneme_list_projects
|
|
839
|
+
server.registerTool(
|
|
840
|
+
"mneme_list_projects",
|
|
841
|
+
{
|
|
842
|
+
description:
|
|
843
|
+
"List all projects tracked in mneme's local database with session counts and last activity",
|
|
844
|
+
inputSchema: {},
|
|
845
|
+
},
|
|
846
|
+
async () => {
|
|
847
|
+
const projects = listProjects();
|
|
848
|
+
return {
|
|
849
|
+
content: [{ type: "text", text: JSON.stringify(projects, null, 2) }],
|
|
850
|
+
};
|
|
851
|
+
},
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
// Tool: mneme_list_sessions
|
|
855
|
+
server.registerTool(
|
|
856
|
+
"mneme_list_sessions",
|
|
857
|
+
{
|
|
858
|
+
description: "List sessions, optionally filtered by project or repository",
|
|
859
|
+
inputSchema: {
|
|
860
|
+
projectPath: z.string().optional().describe("Filter by project path"),
|
|
861
|
+
repository: z
|
|
862
|
+
.string()
|
|
863
|
+
.optional()
|
|
864
|
+
.describe("Filter by repository (owner/repo)"),
|
|
865
|
+
limit: z.number().optional().describe("Maximum results (default: 20)"),
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
async ({ projectPath, repository, limit }) => {
|
|
869
|
+
const sessions = listSessions({ projectPath, repository, limit });
|
|
870
|
+
return {
|
|
871
|
+
content: [{ type: "text", text: JSON.stringify(sessions, null, 2) }],
|
|
872
|
+
};
|
|
873
|
+
},
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
// Tool: mneme_get_interactions
|
|
877
|
+
server.registerTool(
|
|
878
|
+
"mneme_get_interactions",
|
|
879
|
+
{
|
|
880
|
+
description: "Get conversation interactions for a specific session",
|
|
881
|
+
inputSchema: {
|
|
882
|
+
sessionId: z.string().describe("Session ID (full UUID or short form)"),
|
|
883
|
+
limit: z.number().optional().describe("Maximum messages (default: 50)"),
|
|
884
|
+
offset: z
|
|
885
|
+
.number()
|
|
886
|
+
.optional()
|
|
887
|
+
.describe("Offset for pagination (default: 0)"),
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
async ({ sessionId, limit, offset }) => {
|
|
891
|
+
const interactions = getInteractions(sessionId, { limit, offset });
|
|
892
|
+
if (interactions.length === 0) {
|
|
893
|
+
return {
|
|
894
|
+
content: [
|
|
895
|
+
{
|
|
896
|
+
type: "text",
|
|
897
|
+
text: `No interactions found for session: ${sessionId}`,
|
|
898
|
+
},
|
|
899
|
+
],
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
content: [{ type: "text", text: JSON.stringify(interactions, null, 2) }],
|
|
904
|
+
};
|
|
905
|
+
},
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
// Tool: mneme_stats
|
|
909
|
+
server.registerTool(
|
|
910
|
+
"mneme_stats",
|
|
911
|
+
{
|
|
912
|
+
description:
|
|
913
|
+
"Get statistics across all projects: total counts, per-project breakdown, recent activity",
|
|
914
|
+
inputSchema: {},
|
|
915
|
+
},
|
|
916
|
+
async () => {
|
|
917
|
+
const stats = getStats();
|
|
918
|
+
if (!stats) {
|
|
919
|
+
return {
|
|
920
|
+
content: [{ type: "text", text: "Database not available" }],
|
|
921
|
+
isError: true,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
content: [{ type: "text", text: JSON.stringify(stats, null, 2) }],
|
|
926
|
+
};
|
|
927
|
+
},
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
// Tool: mneme_cross_project_search
|
|
931
|
+
server.registerTool(
|
|
932
|
+
"mneme_cross_project_search",
|
|
933
|
+
{
|
|
934
|
+
description:
|
|
935
|
+
"Search interactions across ALL projects (not just current). Uses FTS5 for fast full-text search.",
|
|
936
|
+
inputSchema: {
|
|
937
|
+
query: z.string().describe("Search query"),
|
|
938
|
+
limit: z.number().optional().describe("Maximum results (default: 10)"),
|
|
939
|
+
},
|
|
940
|
+
},
|
|
941
|
+
async ({ query, limit }) => {
|
|
942
|
+
const results = crossProjectSearch(query, { limit });
|
|
943
|
+
return {
|
|
944
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
945
|
+
};
|
|
946
|
+
},
|
|
947
|
+
);
|
|
948
|
+
|
|
949
|
+
// Tool: mneme_save_interactions
|
|
950
|
+
server.registerTool(
|
|
951
|
+
"mneme_save_interactions",
|
|
952
|
+
{
|
|
953
|
+
description:
|
|
954
|
+
"Save conversation interactions from Claude Code transcript to SQLite. " +
|
|
955
|
+
"Use this during /mneme:save to persist the conversation history. " +
|
|
956
|
+
"Reads the transcript file directly and extracts user/assistant messages.",
|
|
957
|
+
inputSchema: {
|
|
958
|
+
claudeSessionId: z
|
|
959
|
+
.string()
|
|
960
|
+
.describe("Full Claude Code session UUID (36 chars)"),
|
|
961
|
+
mnemeSessionId: z
|
|
962
|
+
.string()
|
|
963
|
+
.optional()
|
|
964
|
+
.describe(
|
|
965
|
+
"Mneme session ID (8 chars). If not provided, uses first 8 chars of claudeSessionId",
|
|
966
|
+
),
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
async ({ claudeSessionId, mnemeSessionId }) => {
|
|
970
|
+
const result = await saveInteractions(claudeSessionId, mnemeSessionId);
|
|
971
|
+
return {
|
|
972
|
+
content: [
|
|
973
|
+
{
|
|
974
|
+
type: "text",
|
|
975
|
+
text: JSON.stringify(result, null, 2),
|
|
976
|
+
},
|
|
977
|
+
],
|
|
978
|
+
isError: !result.success,
|
|
979
|
+
};
|
|
980
|
+
},
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
// Start server
|
|
984
|
+
async function main() {
|
|
985
|
+
const transport = new StdioServerTransport();
|
|
986
|
+
await server.connect(transport);
|
|
987
|
+
console.error("mneme-db MCP server running");
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
main().catch((error) => {
|
|
991
|
+
console.error("Server error:", error);
|
|
992
|
+
process.exit(1);
|
|
993
|
+
});
|