@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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +29 -0
  2. package/.mcp.json +18 -0
  3. package/README.ja.md +400 -0
  4. package/README.md +410 -0
  5. package/bin/mneme.js +203 -0
  6. package/dist/lib/db.js +340 -0
  7. package/dist/lib/fuzzy-search.js +214 -0
  8. package/dist/lib/github.js +121 -0
  9. package/dist/lib/similarity.js +193 -0
  10. package/dist/lib/utils.js +62 -0
  11. package/dist/public/apple-touch-icon.png +0 -0
  12. package/dist/public/assets/index-BgqCALAg.css +1 -0
  13. package/dist/public/assets/index-EMvn4VEa.js +330 -0
  14. package/dist/public/assets/react-force-graph-2d-DWoBaKmT.js +46 -0
  15. package/dist/public/favicon-128-max.png +0 -0
  16. package/dist/public/favicon-256-max.png +0 -0
  17. package/dist/public/favicon-32-max.png +0 -0
  18. package/dist/public/favicon-512-max.png +0 -0
  19. package/dist/public/favicon-64-max.png +0 -0
  20. package/dist/public/index.html +15 -0
  21. package/dist/server.js +4791 -0
  22. package/dist/servers/db-server.js +30558 -0
  23. package/dist/servers/search-server.js +30366 -0
  24. package/hooks/default-tags.json +1055 -0
  25. package/hooks/hooks.json +61 -0
  26. package/hooks/post-tool-use.sh +96 -0
  27. package/hooks/pre-compact.sh +187 -0
  28. package/hooks/session-end.sh +567 -0
  29. package/hooks/session-start.sh +380 -0
  30. package/hooks/user-prompt-submit.sh +253 -0
  31. package/package.json +77 -0
  32. package/servers/db-server.ts +993 -0
  33. package/servers/search-server.ts +675 -0
  34. package/skills/AGENTS.override.md +5 -0
  35. package/skills/harvest/skill.md +295 -0
  36. package/skills/init-mneme/skill.md +101 -0
  37. package/skills/plan/skill.md +422 -0
  38. package/skills/report/skill.md +74 -0
  39. package/skills/resume/skill.md +278 -0
  40. package/skills/review/skill.md +419 -0
  41. package/skills/save/skill.md +482 -0
  42. package/skills/search/skill.md +175 -0
  43. 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
+ });