@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,675 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * mneme MCP Search Server
5
+ *
6
+ * Provides fast, unified search across mneme's knowledge base:
7
+ * - SQLite FTS5 for interactions
8
+ * - JSON file search for sessions, decisions, patterns
9
+ * - Tag alias resolution
10
+ * - Unified scoring
11
+ */
12
+
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
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 SearchResult {
46
+ type: "session" | "decision" | "pattern" | "interaction";
47
+ id: string;
48
+ title: string;
49
+ snippet: string;
50
+ score: number;
51
+ matchedFields: string[];
52
+ }
53
+
54
+ interface TagDefinition {
55
+ id: string;
56
+ label: string;
57
+ aliases?: string[];
58
+ }
59
+
60
+ interface TagsFile {
61
+ tags: TagDefinition[];
62
+ }
63
+
64
+ interface SessionFile {
65
+ id: string;
66
+ title?: string;
67
+ tags?: string[];
68
+ summary?: {
69
+ title?: string;
70
+ goal?: string;
71
+ description?: string;
72
+ };
73
+ discussions?: Array<{
74
+ topic?: string;
75
+ decision?: string;
76
+ reasoning?: string;
77
+ }>;
78
+ errors?: Array<{
79
+ error?: string;
80
+ cause?: string;
81
+ solution?: string;
82
+ }>;
83
+ }
84
+
85
+ interface DecisionFile {
86
+ id: string;
87
+ title?: string;
88
+ decision?: string;
89
+ reasoning?: string;
90
+ tags?: string[];
91
+ }
92
+
93
+ interface PatternFile {
94
+ patterns?: Array<{
95
+ errorPattern?: string;
96
+ solution?: string;
97
+ tags?: string[];
98
+ }>;
99
+ }
100
+
101
+ // Get project path from env or current working directory
102
+ function getProjectPath(): string {
103
+ return process.env.MNEME_PROJECT_PATH || process.cwd();
104
+ }
105
+
106
+ function getMnemeDir(): string {
107
+ return path.join(getProjectPath(), ".mneme");
108
+ }
109
+
110
+ function getLocalDbPath(): string {
111
+ return path.join(getMnemeDir(), "local.db");
112
+ }
113
+
114
+ // Database connection (lazy initialization)
115
+ let db: DatabaseSyncType | null = null;
116
+
117
+ function getDb(): DatabaseSyncType | null {
118
+ if (db) return db;
119
+
120
+ const dbPath = getLocalDbPath();
121
+ if (!fs.existsSync(dbPath)) {
122
+ return null;
123
+ }
124
+
125
+ try {
126
+ db = new DatabaseSync(dbPath);
127
+ db.exec("PRAGMA journal_mode = WAL");
128
+ return db;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ // Tag alias resolution
135
+ function loadTags(): TagsFile | null {
136
+ const tagsPath = path.join(getMnemeDir(), "tags.json");
137
+ if (!fs.existsSync(tagsPath)) return null;
138
+
139
+ try {
140
+ const content = fs.readFileSync(tagsPath, "utf-8");
141
+ return JSON.parse(content) as TagsFile;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function expandKeywordsWithAliases(
148
+ keywords: string[],
149
+ tags: TagsFile | null,
150
+ ): string[] {
151
+ if (!tags) return keywords;
152
+
153
+ const expanded = new Set(keywords.map((k) => k.toLowerCase()));
154
+
155
+ for (const keyword of keywords) {
156
+ const lowerKeyword = keyword.toLowerCase();
157
+
158
+ for (const tag of tags.tags) {
159
+ const matches =
160
+ tag.id.toLowerCase() === lowerKeyword ||
161
+ tag.label.toLowerCase() === lowerKeyword ||
162
+ tag.aliases?.some((a) => a.toLowerCase() === lowerKeyword);
163
+
164
+ if (matches) {
165
+ expanded.add(tag.id.toLowerCase());
166
+ expanded.add(tag.label.toLowerCase());
167
+ for (const alias of tag.aliases || []) {
168
+ expanded.add(alias.toLowerCase());
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ return Array.from(expanded);
175
+ }
176
+
177
+ // Search functions
178
+ function searchInteractions(
179
+ keywords: string[],
180
+ projectPath: string,
181
+ limit = 5,
182
+ ): SearchResult[] {
183
+ const database = getDb();
184
+ if (!database) return [];
185
+
186
+ const results: SearchResult[] = [];
187
+
188
+ try {
189
+ // Try FTS5 first
190
+ const ftsQuery = keywords.join(" OR ");
191
+ const ftsStmt = database.prepare(`
192
+ SELECT
193
+ i.session_id,
194
+ i.content,
195
+ i.thinking,
196
+ i.timestamp,
197
+ highlight(interactions_fts, 0, '[', ']') as content_highlight
198
+ FROM interactions_fts
199
+ JOIN interactions i ON interactions_fts.rowid = i.id
200
+ WHERE interactions_fts MATCH ?
201
+ AND i.project_path = ?
202
+ ORDER BY rank
203
+ LIMIT ?
204
+ `);
205
+
206
+ const rows = ftsStmt.all(ftsQuery, projectPath, limit) as Array<{
207
+ session_id: string;
208
+ content: string;
209
+ thinking: string | null;
210
+ timestamp: string;
211
+ content_highlight: string;
212
+ }>;
213
+
214
+ for (const row of rows) {
215
+ const snippet = row.content_highlight || row.content.substring(0, 100);
216
+ results.push({
217
+ type: "interaction",
218
+ id: row.session_id,
219
+ title: `Interaction from ${row.timestamp}`,
220
+ snippet: snippet.substring(0, 150),
221
+ score: 5,
222
+ matchedFields: ["content"],
223
+ });
224
+ }
225
+ } catch {
226
+ // FTS5 failed, fallback to LIKE
227
+ try {
228
+ const likePattern = `%${keywords.join("%")}%`;
229
+ const stmt = database.prepare(`
230
+ SELECT DISTINCT session_id, substr(content, 1, 100) as snippet, timestamp
231
+ FROM interactions
232
+ WHERE project_path = ?
233
+ AND (content LIKE ? OR thinking LIKE ?)
234
+ ORDER BY timestamp DESC
235
+ LIMIT ?
236
+ `);
237
+
238
+ const rows = stmt.all(
239
+ projectPath,
240
+ likePattern,
241
+ likePattern,
242
+ limit,
243
+ ) as Array<{
244
+ session_id: string;
245
+ snippet: string;
246
+ timestamp: string;
247
+ }>;
248
+
249
+ for (const row of rows) {
250
+ results.push({
251
+ type: "interaction",
252
+ id: row.session_id,
253
+ title: `Interaction from ${row.timestamp}`,
254
+ snippet: row.snippet,
255
+ score: 3,
256
+ matchedFields: ["content"],
257
+ });
258
+ }
259
+ } catch {
260
+ // Database error, return empty
261
+ }
262
+ }
263
+
264
+ return results;
265
+ }
266
+
267
+ function searchSessions(keywords: string[], limit = 5): SearchResult[] {
268
+ const sessionsDir = path.join(getMnemeDir(), "sessions");
269
+ if (!fs.existsSync(sessionsDir)) return [];
270
+
271
+ const results: SearchResult[] = [];
272
+ const pattern = new RegExp(keywords.join("|"), "i");
273
+
274
+ function walkDir(dir: string) {
275
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
276
+ for (const entry of entries) {
277
+ const fullPath = path.join(dir, entry.name);
278
+ if (entry.isDirectory()) {
279
+ walkDir(fullPath);
280
+ } else if (entry.name.endsWith(".json")) {
281
+ try {
282
+ const content = fs.readFileSync(fullPath, "utf-8");
283
+ const session = JSON.parse(content) as SessionFile;
284
+
285
+ let score = 0;
286
+ const matchedFields: string[] = [];
287
+
288
+ // Score title matches higher
289
+ const title = session.title || session.summary?.title || "";
290
+ if (title && pattern.test(title)) {
291
+ score += 3;
292
+ matchedFields.push("title");
293
+ }
294
+
295
+ // Check tags
296
+ if (session.tags?.some((t) => pattern.test(t))) {
297
+ score += 1;
298
+ matchedFields.push("tags");
299
+ }
300
+
301
+ // Check summary
302
+ if (session.summary?.goal && pattern.test(session.summary.goal)) {
303
+ score += 2;
304
+ matchedFields.push("summary.goal");
305
+ }
306
+ if (
307
+ session.summary?.description &&
308
+ pattern.test(session.summary.description)
309
+ ) {
310
+ score += 2;
311
+ matchedFields.push("summary.description");
312
+ }
313
+
314
+ // Check discussions
315
+ if (
316
+ session.discussions?.some(
317
+ (d) =>
318
+ pattern.test(d.topic || "") || pattern.test(d.decision || ""),
319
+ )
320
+ ) {
321
+ score += 2;
322
+ matchedFields.push("discussions");
323
+ }
324
+
325
+ // Check errors
326
+ if (
327
+ session.errors?.some(
328
+ (e) =>
329
+ pattern.test(e.error || "") || pattern.test(e.solution || ""),
330
+ )
331
+ ) {
332
+ score += 2;
333
+ matchedFields.push("errors");
334
+ }
335
+
336
+ if (score > 0) {
337
+ results.push({
338
+ type: "session",
339
+ id: session.id,
340
+ title: title || session.id,
341
+ snippet:
342
+ session.summary?.description || session.summary?.goal || "",
343
+ score,
344
+ matchedFields,
345
+ });
346
+ }
347
+ } catch {
348
+ // Skip invalid files
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ walkDir(sessionsDir);
355
+
356
+ // Sort by score and limit
357
+ return results.sort((a, b) => b.score - a.score).slice(0, limit);
358
+ }
359
+
360
+ function searchDecisions(keywords: string[], limit = 5): SearchResult[] {
361
+ const decisionsDir = path.join(getMnemeDir(), "decisions");
362
+ if (!fs.existsSync(decisionsDir)) return [];
363
+
364
+ const results: SearchResult[] = [];
365
+ const pattern = new RegExp(keywords.join("|"), "i");
366
+
367
+ function walkDir(dir: string) {
368
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
369
+ for (const entry of entries) {
370
+ const fullPath = path.join(dir, entry.name);
371
+ if (entry.isDirectory()) {
372
+ walkDir(fullPath);
373
+ } else if (entry.name.endsWith(".json")) {
374
+ try {
375
+ const content = fs.readFileSync(fullPath, "utf-8");
376
+ const decision = JSON.parse(content) as DecisionFile;
377
+
378
+ let score = 0;
379
+ const matchedFields: string[] = [];
380
+
381
+ if (decision.title && pattern.test(decision.title)) {
382
+ score += 3;
383
+ matchedFields.push("title");
384
+ }
385
+
386
+ if (decision.decision && pattern.test(decision.decision)) {
387
+ score += 2;
388
+ matchedFields.push("decision");
389
+ }
390
+
391
+ if (decision.reasoning && pattern.test(decision.reasoning)) {
392
+ score += 2;
393
+ matchedFields.push("reasoning");
394
+ }
395
+
396
+ if (decision.tags?.some((t) => pattern.test(t))) {
397
+ score += 1;
398
+ matchedFields.push("tags");
399
+ }
400
+
401
+ if (score > 0) {
402
+ results.push({
403
+ type: "decision",
404
+ id: decision.id,
405
+ title: decision.title || decision.id,
406
+ snippet: decision.decision || "",
407
+ score,
408
+ matchedFields,
409
+ });
410
+ }
411
+ } catch {
412
+ // Skip invalid files
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ walkDir(decisionsDir);
419
+
420
+ return results.sort((a, b) => b.score - a.score).slice(0, limit);
421
+ }
422
+
423
+ function searchPatterns(keywords: string[], limit = 5): SearchResult[] {
424
+ const patternsDir = path.join(getMnemeDir(), "patterns");
425
+ if (!fs.existsSync(patternsDir)) return [];
426
+
427
+ const results: SearchResult[] = [];
428
+ const pattern = new RegExp(keywords.join("|"), "i");
429
+
430
+ const files = fs.readdirSync(patternsDir).filter((f) => f.endsWith(".json"));
431
+
432
+ for (const file of files) {
433
+ try {
434
+ const content = fs.readFileSync(path.join(patternsDir, file), "utf-8");
435
+ const patternFile = JSON.parse(content) as PatternFile;
436
+
437
+ for (const p of patternFile.patterns || []) {
438
+ let score = 0;
439
+ const matchedFields: string[] = [];
440
+
441
+ if (p.errorPattern && pattern.test(p.errorPattern)) {
442
+ score += 3;
443
+ matchedFields.push("errorPattern");
444
+ }
445
+
446
+ if (p.solution && pattern.test(p.solution)) {
447
+ score += 2;
448
+ matchedFields.push("solution");
449
+ }
450
+
451
+ if (p.tags?.some((t) => pattern.test(t))) {
452
+ score += 1;
453
+ matchedFields.push("tags");
454
+ }
455
+
456
+ if (score > 0) {
457
+ results.push({
458
+ type: "pattern",
459
+ id: `${file}:${p.errorPattern?.substring(0, 20)}`,
460
+ title: p.errorPattern?.substring(0, 50) || "Pattern",
461
+ snippet: p.solution || "",
462
+ score,
463
+ matchedFields,
464
+ });
465
+ }
466
+ }
467
+ } catch {
468
+ // Skip invalid files
469
+ }
470
+ }
471
+
472
+ return results.sort((a, b) => b.score - a.score).slice(0, limit);
473
+ }
474
+
475
+ // Unified search
476
+ function search(
477
+ query: string,
478
+ options: { types?: string[]; limit?: number } = {},
479
+ ): SearchResult[] {
480
+ const {
481
+ types = ["session", "decision", "pattern", "interaction"],
482
+ limit = 10,
483
+ } = options;
484
+
485
+ // Extract keywords
486
+ const keywords = query
487
+ .toLowerCase()
488
+ .split(/\s+/)
489
+ .filter((w) => w.length > 2);
490
+
491
+ if (keywords.length === 0) return [];
492
+
493
+ // Expand with tag aliases
494
+ const tags = loadTags();
495
+ const expandedKeywords = expandKeywordsWithAliases(keywords, tags);
496
+
497
+ const results: SearchResult[] = [];
498
+ const projectPath = getProjectPath();
499
+
500
+ // Search each type
501
+ if (types.includes("session")) {
502
+ results.push(...searchSessions(expandedKeywords, limit));
503
+ }
504
+
505
+ if (types.includes("decision")) {
506
+ results.push(...searchDecisions(expandedKeywords, limit));
507
+ }
508
+
509
+ if (types.includes("pattern")) {
510
+ results.push(...searchPatterns(expandedKeywords, limit));
511
+ }
512
+
513
+ if (types.includes("interaction")) {
514
+ results.push(...searchInteractions(expandedKeywords, projectPath, limit));
515
+ }
516
+
517
+ // Sort by score and deduplicate
518
+ const seen = new Set<string>();
519
+ return results
520
+ .sort((a, b) => b.score - a.score)
521
+ .filter((r) => {
522
+ const key = `${r.type}:${r.id}`;
523
+ if (seen.has(key)) return false;
524
+ seen.add(key);
525
+ return true;
526
+ })
527
+ .slice(0, limit);
528
+ }
529
+
530
+ // Get specific items
531
+ function getSession(sessionId: string): SessionFile | null {
532
+ const sessionsDir = path.join(getMnemeDir(), "sessions");
533
+ if (!fs.existsSync(sessionsDir)) return null;
534
+
535
+ function findSession(dir: string): SessionFile | null {
536
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
537
+ for (const entry of entries) {
538
+ const fullPath = path.join(dir, entry.name);
539
+ if (entry.isDirectory()) {
540
+ const result = findSession(fullPath);
541
+ if (result) return result;
542
+ } else if (entry.name.endsWith(".json")) {
543
+ try {
544
+ const content = fs.readFileSync(fullPath, "utf-8");
545
+ const session = JSON.parse(content) as SessionFile;
546
+ if (session.id === sessionId) return session;
547
+ } catch {
548
+ // Skip
549
+ }
550
+ }
551
+ }
552
+ return null;
553
+ }
554
+
555
+ return findSession(sessionsDir);
556
+ }
557
+
558
+ function getDecision(decisionId: string): DecisionFile | null {
559
+ const decisionsDir = path.join(getMnemeDir(), "decisions");
560
+ if (!fs.existsSync(decisionsDir)) return null;
561
+
562
+ function findDecision(dir: string): DecisionFile | null {
563
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
564
+ for (const entry of entries) {
565
+ const fullPath = path.join(dir, entry.name);
566
+ if (entry.isDirectory()) {
567
+ const result = findDecision(fullPath);
568
+ if (result) return result;
569
+ } else if (entry.name.endsWith(".json")) {
570
+ try {
571
+ const content = fs.readFileSync(fullPath, "utf-8");
572
+ const decision = JSON.parse(content) as DecisionFile;
573
+ if (decision.id === decisionId) return decision;
574
+ } catch {
575
+ // Skip
576
+ }
577
+ }
578
+ }
579
+ return null;
580
+ }
581
+
582
+ return findDecision(decisionsDir);
583
+ }
584
+
585
+ // MCP Server setup
586
+ const server = new McpServer({
587
+ name: "mneme-search",
588
+ version: "0.1.0",
589
+ });
590
+
591
+ // Tool: mneme_search
592
+ server.registerTool(
593
+ "mneme_search",
594
+ {
595
+ description:
596
+ "Search mneme's knowledge base for sessions, decisions, patterns, and interactions. Returns scored results with matched fields.",
597
+ inputSchema: {
598
+ query: z.string().describe("Search query (keywords)"),
599
+ types: z
600
+ .array(z.enum(["session", "decision", "pattern", "interaction"]))
601
+ .optional()
602
+ .describe("Types to search (default: all)"),
603
+ limit: z.number().optional().describe("Maximum results (default: 10)"),
604
+ },
605
+ },
606
+ async ({ query, types, limit }) => {
607
+ const results = search(query, { types, limit });
608
+ return {
609
+ content: [
610
+ {
611
+ type: "text",
612
+ text: JSON.stringify(results, null, 2),
613
+ },
614
+ ],
615
+ };
616
+ },
617
+ );
618
+
619
+ // Tool: mneme_get_session
620
+ server.registerTool(
621
+ "mneme_get_session",
622
+ {
623
+ description: "Get full details of a specific session by ID",
624
+ inputSchema: {
625
+ sessionId: z.string().describe("Session ID"),
626
+ },
627
+ },
628
+ async ({ sessionId }) => {
629
+ const session = getSession(sessionId);
630
+ if (!session) {
631
+ return {
632
+ content: [{ type: "text", text: `Session not found: ${sessionId}` }],
633
+ isError: true,
634
+ };
635
+ }
636
+ return {
637
+ content: [{ type: "text", text: JSON.stringify(session, null, 2) }],
638
+ };
639
+ },
640
+ );
641
+
642
+ // Tool: mneme_get_decision
643
+ server.registerTool(
644
+ "mneme_get_decision",
645
+ {
646
+ description: "Get full details of a specific decision by ID",
647
+ inputSchema: {
648
+ decisionId: z.string().describe("Decision ID"),
649
+ },
650
+ },
651
+ async ({ decisionId }) => {
652
+ const decision = getDecision(decisionId);
653
+ if (!decision) {
654
+ return {
655
+ content: [{ type: "text", text: `Decision not found: ${decisionId}` }],
656
+ isError: true,
657
+ };
658
+ }
659
+ return {
660
+ content: [{ type: "text", text: JSON.stringify(decision, null, 2) }],
661
+ };
662
+ },
663
+ );
664
+
665
+ // Start server
666
+ async function main() {
667
+ const transport = new StdioServerTransport();
668
+ await server.connect(transport);
669
+ console.error("mneme-search MCP server running");
670
+ }
671
+
672
+ main().catch((error) => {
673
+ console.error("Server error:", error);
674
+ process.exit(1);
675
+ });
@@ -0,0 +1,5 @@
1
+ # Skills ローカルルール
2
+
3
+ - 各スキルは `skills/*/skill.md` に定義する。
4
+ - `/mneme:*` コマンドの命名と振る舞いを揃える。
5
+ - `.mneme/` スキーマ変更時は `docs/plans/design.md` を先に更新する。