@hir4ta/mneme 0.20.2 → 0.22.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 (51) hide show
  1. package/.claude-plugin/plugin.json +2 -5
  2. package/README.ja.md +45 -283
  3. package/README.md +48 -280
  4. package/dist/lib/db.js +7 -5
  5. package/dist/lib/incremental-save.js +122 -28
  6. package/dist/lib/prompt-search.js +570 -0
  7. package/dist/lib/search-core.js +516 -0
  8. package/dist/lib/session-finalize.js +983 -0
  9. package/dist/lib/session-init.js +397 -0
  10. package/dist/lib/suppress-sqlite-warning.js +8 -0
  11. package/dist/public/assets/index-Bvl_IrPy.css +1 -0
  12. package/dist/public/assets/index-k5JYSPV6.js +351 -0
  13. package/dist/public/assets/{react-force-graph-2d-CGnpkwRw.js → react-force-graph-2d-Dlcfvz01.js} +1 -1
  14. package/dist/public/index.html +2 -2
  15. package/dist/server.js +565 -37
  16. package/dist/servers/db-server.js +1301 -98
  17. package/dist/servers/search-server.js +613 -333
  18. package/hooks/hooks.json +1 -0
  19. package/hooks/lib/common.sh +55 -0
  20. package/hooks/post-tool-use.sh +52 -58
  21. package/hooks/pre-compact.sh +30 -42
  22. package/hooks/session-end.sh +30 -142
  23. package/hooks/session-start.sh +32 -337
  24. package/hooks/stop.sh +31 -42
  25. package/hooks/user-prompt-submit.sh +58 -212
  26. package/package.json +10 -3
  27. package/scripts/export-weekly-knowledge-html.ts +906 -0
  28. package/scripts/search-benchmark.queries.json +78 -0
  29. package/scripts/search-benchmark.ts +120 -0
  30. package/scripts/validate-source-artifacts.mjs +378 -0
  31. package/servers/db-server.ts +995 -65
  32. package/servers/search-server.ts +117 -528
  33. package/skills/harvest/SKILL.md +78 -0
  34. package/skills/init-mneme/{skill.md → SKILL.md} +7 -1
  35. package/skills/resume/{skill.md → SKILL.md} +24 -9
  36. package/skills/save/SKILL.md +131 -0
  37. package/skills/search/SKILL.md +76 -0
  38. package/skills/using-mneme/SKILL.md +38 -0
  39. package/tsconfig.tsbuildinfo +1 -0
  40. package/dist/public/assets/index-CeHiZXwl.js +0 -345
  41. package/dist/public/assets/index-t_srr1OD.css +0 -1
  42. package/learn_claude_code/figma_exports/claude_code_map.svg +0 -107
  43. package/learn_claude_code/figma_exports/claude_code_whiteboard.excalidraw +0 -2578
  44. package/skills/AGENTS.override.md +0 -5
  45. package/skills/harvest/skill.md +0 -295
  46. package/skills/plan/skill.md +0 -422
  47. package/skills/report/skill.md +0 -74
  48. package/skills/review/skill.md +0 -419
  49. package/skills/save/skill.md +0 -496
  50. package/skills/search/skill.md +0 -175
  51. package/skills/using-mneme/skill.md +0 -185
@@ -8,6 +8,8 @@
8
8
  * - Statistics and analytics
9
9
  */
10
10
 
11
+ import "../lib/suppress-sqlite-warning.js";
12
+
11
13
  import * as fs from "node:fs";
12
14
  import * as os from "node:os";
13
15
  import * as path from "node:path";
@@ -15,29 +17,8 @@ import * as readline from "node:readline";
15
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
19
  import { z } from "zod";
20
+ import { searchKnowledge } from "../lib/search-core.js";
18
21
 
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
22
  const { DatabaseSync } = await import("node:sqlite");
42
23
  type DatabaseSyncType = InstanceType<typeof DatabaseSync>;
43
24
 
@@ -91,6 +72,70 @@ interface Stats {
91
72
  }>;
92
73
  }
93
74
 
75
+ const LIST_LIMIT_MIN = 1;
76
+ const LIST_LIMIT_MAX = 200;
77
+ const INTERACTION_OFFSET_MIN = 0;
78
+ const QUERY_MAX_LENGTH = 500;
79
+ const UNIT_LIMIT_MAX = 500;
80
+ const SEARCH_EVAL_DEFAULT_LIMIT = 5;
81
+
82
+ type UnitStatus = "pending" | "approved" | "rejected";
83
+ type UnitType = "decision" | "pattern" | "rule";
84
+ type RuleKind = "policy" | "pitfall" | "playbook";
85
+
86
+ interface Unit {
87
+ id: string;
88
+ type: UnitType;
89
+ kind: RuleKind;
90
+ title: string;
91
+ summary: string;
92
+ tags: string[];
93
+ sourceId: string;
94
+ sourceType: UnitType;
95
+ sourceRefs: Array<{ type: UnitType; id: string }>;
96
+ status: UnitStatus;
97
+ createdAt: string;
98
+ updatedAt: string;
99
+ reviewedAt?: string;
100
+ reviewedBy?: string;
101
+ }
102
+
103
+ interface UnitsFile {
104
+ schemaVersion: number;
105
+ updatedAt: string;
106
+ items: Unit[];
107
+ }
108
+
109
+ interface AuditEntry {
110
+ timestamp: string;
111
+ actor?: string;
112
+ entity: "session" | "decision" | "pattern" | "rule" | "unit";
113
+ action: "create" | "update" | "delete";
114
+ targetId: string;
115
+ detail?: Record<string, unknown>;
116
+ }
117
+
118
+ interface RuleDoc {
119
+ items?: Array<Record<string, unknown>>;
120
+ rules?: Array<Record<string, unknown>>;
121
+ }
122
+
123
+ interface BenchmarkQuery {
124
+ query: string;
125
+ expectedTerms: string[];
126
+ }
127
+
128
+ function ok(text: string) {
129
+ return { content: [{ type: "text" as const, text }] };
130
+ }
131
+
132
+ function fail(message: string) {
133
+ return {
134
+ content: [{ type: "text" as const, text: message }],
135
+ isError: true as const,
136
+ };
137
+ }
138
+
94
139
  // Get project path from env or current working directory
95
140
  function getProjectPath(): string {
96
141
  return process.env.MNEME_PROJECT_PATH || process.cwd();
@@ -100,6 +145,208 @@ function getLocalDbPath(): string {
100
145
  return path.join(getProjectPath(), ".mneme", "local.db");
101
146
  }
102
147
 
148
+ function getMnemeDir(): string {
149
+ return path.join(getProjectPath(), ".mneme");
150
+ }
151
+
152
+ function readJsonFile<T>(filePath: string): T | null {
153
+ if (!fs.existsSync(filePath)) return null;
154
+ try {
155
+ return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ function listJsonFiles(dir: string): string[] {
162
+ if (!fs.existsSync(dir)) return [];
163
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
164
+ return entries.flatMap((entry) => {
165
+ const fullPath = path.join(dir, entry.name);
166
+ if (entry.isDirectory()) {
167
+ return listJsonFiles(fullPath);
168
+ }
169
+ return entry.isFile() && entry.name.endsWith(".json") ? [fullPath] : [];
170
+ });
171
+ }
172
+
173
+ function readUnits(): UnitsFile {
174
+ const unitsPath = path.join(getMnemeDir(), "units", "units.json");
175
+ const parsed = readJsonFile<UnitsFile>(unitsPath);
176
+ if (!parsed || !Array.isArray(parsed.items)) {
177
+ return {
178
+ schemaVersion: 1,
179
+ updatedAt: new Date().toISOString(),
180
+ items: [],
181
+ };
182
+ }
183
+ return parsed;
184
+ }
185
+
186
+ function writeUnits(doc: UnitsFile): void {
187
+ const unitsPath = path.join(getMnemeDir(), "units", "units.json");
188
+ fs.mkdirSync(path.dirname(unitsPath), { recursive: true });
189
+ fs.writeFileSync(unitsPath, JSON.stringify(doc, null, 2));
190
+ }
191
+
192
+ function readRuleItems(
193
+ ruleType: "dev-rules" | "review-guidelines",
194
+ ): Array<Record<string, unknown>> {
195
+ const filePath = path.join(getMnemeDir(), "rules", `${ruleType}.json`);
196
+ const parsed = readJsonFile<RuleDoc>(filePath);
197
+ const items = parsed?.items ?? parsed?.rules;
198
+ return Array.isArray(items) ? items : [];
199
+ }
200
+
201
+ function readAuditEntries(
202
+ options: { from?: string; to?: string; entity?: string } = {},
203
+ ): AuditEntry[] {
204
+ const auditDir = path.join(getMnemeDir(), "audit");
205
+ if (!fs.existsSync(auditDir)) return [];
206
+
207
+ const files = fs
208
+ .readdirSync(auditDir)
209
+ .filter((name) => name.endsWith(".jsonl"))
210
+ .sort();
211
+ const fromTime = options.from ? new Date(options.from).getTime() : null;
212
+ const toTime = options.to ? new Date(options.to).getTime() : null;
213
+
214
+ const entries: AuditEntry[] = [];
215
+ for (const name of files) {
216
+ const fullPath = path.join(auditDir, name);
217
+ const lines = fs.readFileSync(fullPath, "utf-8").split("\n");
218
+ for (const line of lines) {
219
+ if (!line.trim()) continue;
220
+ try {
221
+ const parsed = JSON.parse(line) as AuditEntry;
222
+ const ts = new Date(parsed.timestamp).getTime();
223
+ if (fromTime !== null && ts < fromTime) continue;
224
+ if (toTime !== null && ts > toTime) continue;
225
+ if (options.entity && parsed.entity !== options.entity) continue;
226
+ entries.push(parsed);
227
+ } catch {
228
+ // skip malformed lines
229
+ }
230
+ }
231
+ }
232
+ return entries.sort(
233
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
234
+ );
235
+ }
236
+
237
+ function readSessionsById(): Map<string, Record<string, unknown>> {
238
+ const sessionsDir = path.join(getMnemeDir(), "sessions");
239
+ const map = new Map<string, Record<string, unknown>>();
240
+ for (const filePath of listJsonFiles(sessionsDir)) {
241
+ const parsed = readJsonFile<Record<string, unknown>>(filePath);
242
+ const id = typeof parsed?.id === "string" ? parsed.id : "";
243
+ if (!id) continue;
244
+ map.set(id, parsed);
245
+ }
246
+ return map;
247
+ }
248
+
249
+ function inferUnitPriority(unit: Unit): "p0" | "p1" | "p2" {
250
+ if (unit.sourceType === "rule") {
251
+ const [ruleFile, ruleId] = unit.sourceId.split(":", 2);
252
+ if (
253
+ (ruleFile === "dev-rules" || ruleFile === "review-guidelines") &&
254
+ ruleId
255
+ ) {
256
+ const rule = readRuleItems(ruleFile).find((item) => item.id === ruleId);
257
+ const priority =
258
+ typeof rule?.priority === "string" ? rule.priority.toLowerCase() : "";
259
+ if (priority === "p0" || priority === "p1" || priority === "p2") {
260
+ return priority;
261
+ }
262
+ }
263
+ }
264
+ const text =
265
+ `${unit.title} ${unit.summary} ${unit.tags.join(" ")}`.toLowerCase();
266
+ if (
267
+ /(security|auth|token|secret|password|injection|xss|csrf|compliance|outage|data[- ]?loss)/.test(
268
+ text,
269
+ )
270
+ ) {
271
+ return "p0";
272
+ }
273
+ if (/(crash|error|correct|reliab|timeout|retry|integrity)/.test(text)) {
274
+ return "p1";
275
+ }
276
+ return "p2";
277
+ }
278
+
279
+ function extractChangedFilesFromDiff(diffText: string): string[] {
280
+ const files = new Set<string>();
281
+ const lines = diffText.split("\n");
282
+ for (const line of lines) {
283
+ if (!line.startsWith("diff --git ")) continue;
284
+ const parts = line.split(" ");
285
+ if (parts.length >= 4) {
286
+ const bPath = parts[3].replace(/^b\//, "");
287
+ if (bPath) files.add(bPath);
288
+ }
289
+ }
290
+ return Array.from(files);
291
+ }
292
+
293
+ function scoreUnitAgainstDiff(
294
+ unit: Unit,
295
+ diffText: string,
296
+ changedFiles: string[],
297
+ ): { score: number; reasons: string[] } {
298
+ const reasons: string[] = [];
299
+ let score = 0;
300
+ const corpus = `${unit.title} ${unit.summary}`.toLowerCase();
301
+ const diffLower = diffText.toLowerCase();
302
+
303
+ for (const tag of unit.tags) {
304
+ if (!tag) continue;
305
+ const tagLower = tag.toLowerCase();
306
+ if (diffLower.includes(tagLower)) {
307
+ score += 3;
308
+ reasons.push(`tag:${tag}`);
309
+ }
310
+ }
311
+
312
+ const keywords = corpus
313
+ .split(/[^a-zA-Z0-9_-]+/)
314
+ .filter((token) => token.length >= 5)
315
+ .slice(0, 20);
316
+ for (const token of keywords) {
317
+ if (diffLower.includes(token)) {
318
+ score += 1;
319
+ reasons.push(`keyword:${token}`);
320
+ }
321
+ }
322
+
323
+ for (const filePath of changedFiles) {
324
+ const lower = filePath.toLowerCase();
325
+ if (corpus.includes("test") && lower.includes("test")) {
326
+ score += 1;
327
+ reasons.push("path:test");
328
+ }
329
+ if (
330
+ (corpus.includes("api") || unit.tags.includes("api")) &&
331
+ (lower.includes("api") || lower.includes("route"))
332
+ ) {
333
+ score += 1;
334
+ reasons.push("path:api");
335
+ }
336
+ if (
337
+ (corpus.includes("db") || corpus.includes("sql")) &&
338
+ (lower.includes("db") ||
339
+ lower.includes("prisma") ||
340
+ lower.includes("migration"))
341
+ ) {
342
+ score += 1;
343
+ reasons.push("path:db");
344
+ }
345
+ }
346
+
347
+ return { score, reasons: Array.from(new Set(reasons)) };
348
+ }
349
+
103
350
  // Database connection (lazy initialization)
104
351
  let db: DatabaseSyncType | null = null;
105
352
 
@@ -913,6 +1160,93 @@ function markSessionCommitted(claudeSessionId: string): boolean {
913
1160
  }
914
1161
  }
915
1162
 
1163
+ function runSearchBenchmark(limit = SEARCH_EVAL_DEFAULT_LIMIT): {
1164
+ queryCount: number;
1165
+ hits: number;
1166
+ recall: number;
1167
+ details: Array<{
1168
+ query: string;
1169
+ matched: boolean;
1170
+ topResult: string;
1171
+ resultCount: number;
1172
+ }>;
1173
+ } {
1174
+ const queryPath = path.join(
1175
+ getProjectPath(),
1176
+ "scripts",
1177
+ "search-benchmark.queries.json",
1178
+ );
1179
+ const queryDoc = readJsonFile<{ queries?: BenchmarkQuery[] }>(queryPath);
1180
+ const queries = Array.isArray(queryDoc?.queries) ? queryDoc.queries : [];
1181
+ const details: Array<{
1182
+ query: string;
1183
+ matched: boolean;
1184
+ topResult: string;
1185
+ resultCount: number;
1186
+ }> = [];
1187
+ if (queries.length === 0) {
1188
+ return { queryCount: 0, hits: 0, recall: 0, details };
1189
+ }
1190
+
1191
+ const mnemeDir = getMnemeDir();
1192
+ const database = getDb();
1193
+ let hits = 0;
1194
+
1195
+ for (const item of queries) {
1196
+ const results = searchKnowledge({
1197
+ query: item.query,
1198
+ mnemeDir,
1199
+ projectPath: getProjectPath(),
1200
+ database,
1201
+ limit,
1202
+ });
1203
+ const corpus = results
1204
+ .map(
1205
+ (result) =>
1206
+ `${result.title} ${result.snippet} ${result.matchedFields.join(" ")}`,
1207
+ )
1208
+ .join(" ")
1209
+ .toLowerCase();
1210
+ const matched = item.expectedTerms.some((term) =>
1211
+ corpus.includes(String(term).toLowerCase()),
1212
+ );
1213
+ if (matched) hits += 1;
1214
+ details.push({
1215
+ query: item.query,
1216
+ matched,
1217
+ topResult: results[0] ? `${results[0].type}:${results[0].id}` : "none",
1218
+ resultCount: results.length,
1219
+ });
1220
+ }
1221
+
1222
+ return {
1223
+ queryCount: queries.length,
1224
+ hits,
1225
+ recall: queries.length > 0 ? hits / queries.length : 0,
1226
+ details,
1227
+ };
1228
+ }
1229
+
1230
+ function buildUnitGraph(units: Unit[]) {
1231
+ const approved = units.filter((unit) => unit.status === "approved");
1232
+ const edges: Array<{ source: string; target: string; weight: number }> = [];
1233
+ for (let i = 0; i < approved.length; i++) {
1234
+ for (let j = i + 1; j < approved.length; j++) {
1235
+ const shared = approved[i].tags.filter((tag) =>
1236
+ approved[j].tags.includes(tag),
1237
+ );
1238
+ if (shared.length > 0) {
1239
+ edges.push({
1240
+ source: approved[i].id,
1241
+ target: approved[j].id,
1242
+ weight: shared.length,
1243
+ });
1244
+ }
1245
+ }
1246
+ }
1247
+ return { nodes: approved, edges };
1248
+ }
1249
+
916
1250
  // MCP Server setup
917
1251
  const server = new McpServer({
918
1252
  name: "mneme-db",
@@ -928,10 +1262,11 @@ server.registerTool(
928
1262
  inputSchema: {},
929
1263
  },
930
1264
  async () => {
1265
+ if (!getDb()) {
1266
+ return fail("Database not available.");
1267
+ }
931
1268
  const projects = listProjects();
932
- return {
933
- content: [{ type: "text", text: JSON.stringify(projects, null, 2) }],
934
- };
1269
+ return ok(JSON.stringify(projects, null, 2));
935
1270
  },
936
1271
  );
937
1272
 
@@ -946,14 +1281,23 @@ server.registerTool(
946
1281
  .string()
947
1282
  .optional()
948
1283
  .describe("Filter by repository (owner/repo)"),
949
- limit: z.number().optional().describe("Maximum results (default: 20)"),
1284
+ limit: z
1285
+ .number()
1286
+ .int()
1287
+ .min(LIST_LIMIT_MIN)
1288
+ .max(LIST_LIMIT_MAX)
1289
+ .optional()
1290
+ .describe(
1291
+ `Maximum results (${LIST_LIMIT_MIN}-${LIST_LIMIT_MAX}, default: 20)`,
1292
+ ),
950
1293
  },
951
1294
  },
952
1295
  async ({ projectPath, repository, limit }) => {
1296
+ if (!getDb()) {
1297
+ return fail("Database not available.");
1298
+ }
953
1299
  const sessions = listSessions({ projectPath, repository, limit });
954
- return {
955
- content: [{ type: "text", text: JSON.stringify(sessions, null, 2) }],
956
- };
1300
+ return ok(JSON.stringify(sessions, null, 2));
957
1301
  },
958
1302
  );
959
1303
 
@@ -964,28 +1308,35 @@ server.registerTool(
964
1308
  description: "Get conversation interactions for a specific session",
965
1309
  inputSchema: {
966
1310
  sessionId: z.string().describe("Session ID (full UUID or short form)"),
967
- limit: z.number().optional().describe("Maximum messages (default: 50)"),
1311
+ limit: z
1312
+ .number()
1313
+ .int()
1314
+ .min(LIST_LIMIT_MIN)
1315
+ .max(LIST_LIMIT_MAX)
1316
+ .optional()
1317
+ .describe(
1318
+ `Maximum messages (${LIST_LIMIT_MIN}-${LIST_LIMIT_MAX}, default: 50)`,
1319
+ ),
968
1320
  offset: z
969
1321
  .number()
1322
+ .int()
1323
+ .min(INTERACTION_OFFSET_MIN)
970
1324
  .optional()
971
1325
  .describe("Offset for pagination (default: 0)"),
972
1326
  },
973
1327
  },
974
1328
  async ({ sessionId, limit, offset }) => {
1329
+ if (!sessionId.trim()) {
1330
+ return fail("sessionId must not be empty.");
1331
+ }
1332
+ if (!getDb()) {
1333
+ return fail("Database not available.");
1334
+ }
975
1335
  const interactions = getInteractions(sessionId, { limit, offset });
976
1336
  if (interactions.length === 0) {
977
- return {
978
- content: [
979
- {
980
- type: "text",
981
- text: `No interactions found for session: ${sessionId}`,
982
- },
983
- ],
984
- };
1337
+ return fail(`No interactions found for session: ${sessionId}`);
985
1338
  }
986
- return {
987
- content: [{ type: "text", text: JSON.stringify(interactions, null, 2) }],
988
- };
1339
+ return ok(JSON.stringify(interactions, null, 2));
989
1340
  },
990
1341
  );
991
1342
 
@@ -1000,14 +1351,9 @@ server.registerTool(
1000
1351
  async () => {
1001
1352
  const stats = getStats();
1002
1353
  if (!stats) {
1003
- return {
1004
- content: [{ type: "text", text: "Database not available" }],
1005
- isError: true,
1006
- };
1354
+ return fail("Database not available.");
1007
1355
  }
1008
- return {
1009
- content: [{ type: "text", text: JSON.stringify(stats, null, 2) }],
1010
- };
1356
+ return ok(JSON.stringify(stats, null, 2));
1011
1357
  },
1012
1358
  );
1013
1359
 
@@ -1018,15 +1364,28 @@ server.registerTool(
1018
1364
  description:
1019
1365
  "Search interactions across ALL projects (not just current). Uses FTS5 for fast full-text search.",
1020
1366
  inputSchema: {
1021
- query: z.string().describe("Search query"),
1022
- limit: z.number().optional().describe("Maximum results (default: 10)"),
1367
+ query: z.string().max(QUERY_MAX_LENGTH).describe("Search query"),
1368
+ limit: z
1369
+ .number()
1370
+ .int()
1371
+ .min(LIST_LIMIT_MIN)
1372
+ .max(LIST_LIMIT_MAX)
1373
+ .optional()
1374
+ .describe(
1375
+ `Maximum results (${LIST_LIMIT_MIN}-${LIST_LIMIT_MAX}, default: 10)`,
1376
+ ),
1023
1377
  },
1024
1378
  },
1025
1379
  async ({ query, limit }) => {
1026
- const results = crossProjectSearch(query, { limit });
1027
- return {
1028
- content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
1029
- };
1380
+ const trimmedQuery = query.trim();
1381
+ if (!trimmedQuery) {
1382
+ return fail("query must not be empty.");
1383
+ }
1384
+ if (!getDb()) {
1385
+ return fail("Database not available.");
1386
+ }
1387
+ const results = crossProjectSearch(trimmedQuery, { limit });
1388
+ return ok(JSON.stringify(results, null, 2));
1030
1389
  },
1031
1390
  );
1032
1391
 
@@ -1041,6 +1400,7 @@ server.registerTool(
1041
1400
  inputSchema: {
1042
1401
  claudeSessionId: z
1043
1402
  .string()
1403
+ .min(8)
1044
1404
  .describe("Full Claude Code session UUID (36 chars)"),
1045
1405
  mnemeSessionId: z
1046
1406
  .string()
@@ -1051,14 +1411,12 @@ server.registerTool(
1051
1411
  },
1052
1412
  },
1053
1413
  async ({ claudeSessionId, mnemeSessionId }) => {
1414
+ if (!claudeSessionId.trim()) {
1415
+ return fail("claudeSessionId must not be empty.");
1416
+ }
1054
1417
  const result = await saveInteractions(claudeSessionId, mnemeSessionId);
1055
1418
  return {
1056
- content: [
1057
- {
1058
- type: "text",
1059
- text: JSON.stringify(result, null, 2),
1060
- },
1061
- ],
1419
+ ...ok(JSON.stringify(result, null, 2)),
1062
1420
  isError: !result.success,
1063
1421
  };
1064
1422
  },
@@ -1075,20 +1433,592 @@ server.registerTool(
1075
1433
  inputSchema: {
1076
1434
  claudeSessionId: z
1077
1435
  .string()
1436
+ .min(8)
1078
1437
  .describe("Full Claude Code session UUID (36 chars)"),
1079
1438
  },
1080
1439
  },
1081
1440
  async ({ claudeSessionId }) => {
1441
+ if (!claudeSessionId.trim()) {
1442
+ return fail("claudeSessionId must not be empty.");
1443
+ }
1082
1444
  const success = markSessionCommitted(claudeSessionId);
1083
1445
  return {
1084
- content: [
1446
+ ...ok(JSON.stringify({ success, claudeSessionId }, null, 2)),
1447
+ isError: !success,
1448
+ };
1449
+ },
1450
+ );
1451
+
1452
+ // Tool: mneme_unit_queue_list_pending
1453
+ server.registerTool(
1454
+ "mneme_unit_queue_list_pending",
1455
+ {
1456
+ description:
1457
+ "List pending units in the approval queue. Use this in save/review flows to surface actionable approvals.",
1458
+ inputSchema: {
1459
+ limit: z
1460
+ .number()
1461
+ .int()
1462
+ .min(LIST_LIMIT_MIN)
1463
+ .max(UNIT_LIMIT_MAX)
1464
+ .optional()
1465
+ .describe(
1466
+ `Maximum items (${LIST_LIMIT_MIN}-${UNIT_LIMIT_MAX}, default: 100)`,
1467
+ ),
1468
+ },
1469
+ },
1470
+ async ({ limit }) => {
1471
+ const units = readUnits()
1472
+ .items.filter((item) => item.status === "pending")
1473
+ .sort(
1474
+ (a, b) =>
1475
+ new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
1476
+ )
1477
+ .slice(0, limit ?? 100);
1478
+ return ok(
1479
+ JSON.stringify(
1085
1480
  {
1086
- type: "text",
1087
- text: JSON.stringify({ success, claudeSessionId }, null, 2),
1481
+ count: units.length,
1482
+ items: units,
1088
1483
  },
1089
- ],
1090
- isError: !success,
1484
+ null,
1485
+ 2,
1486
+ ),
1487
+ );
1488
+ },
1489
+ );
1490
+
1491
+ // Tool: mneme_unit_queue_update_status
1492
+ server.registerTool(
1493
+ "mneme_unit_queue_update_status",
1494
+ {
1495
+ description:
1496
+ "Update unit status (approve/reject/pending) in bulk or single item.",
1497
+ inputSchema: {
1498
+ unitIds: z.array(z.string().min(1)).min(1).describe("Target unit IDs"),
1499
+ status: z
1500
+ .enum(["pending", "approved", "rejected"])
1501
+ .describe("New status"),
1502
+ reviewedBy: z.string().optional().describe("Reviewer name (optional)"),
1503
+ },
1504
+ },
1505
+ async ({ unitIds, status, reviewedBy }) => {
1506
+ const doc = readUnits();
1507
+ const target = new Set(unitIds);
1508
+ const now = new Date().toISOString();
1509
+ let updated = 0;
1510
+ doc.items = doc.items.map((item) => {
1511
+ if (!target.has(item.id)) return item;
1512
+ updated += 1;
1513
+ return {
1514
+ ...item,
1515
+ status,
1516
+ reviewedAt: now,
1517
+ reviewedBy,
1518
+ updatedAt: now,
1519
+ };
1520
+ });
1521
+ doc.updatedAt = now;
1522
+ writeUnits(doc);
1523
+ return ok(
1524
+ JSON.stringify(
1525
+ { updated, status, requested: unitIds.length, updatedAt: now },
1526
+ null,
1527
+ 2,
1528
+ ),
1529
+ );
1530
+ },
1531
+ );
1532
+
1533
+ // Tool: mneme_unit_apply_suggest_for_diff
1534
+ server.registerTool(
1535
+ "mneme_unit_apply_suggest_for_diff",
1536
+ {
1537
+ description:
1538
+ "Suggest top approved units for a given git diff text. Intended for automatic review integration.",
1539
+ inputSchema: {
1540
+ diff: z.string().min(1).describe("Unified diff text"),
1541
+ limit: z
1542
+ .number()
1543
+ .int()
1544
+ .min(LIST_LIMIT_MIN)
1545
+ .max(50)
1546
+ .optional()
1547
+ .describe("Maximum suggested units (default: 10)"),
1548
+ },
1549
+ },
1550
+ async ({ diff, limit }) => {
1551
+ const changedFiles = extractChangedFilesFromDiff(diff);
1552
+ const approved = readUnits().items.filter(
1553
+ (item) => item.status === "approved",
1554
+ );
1555
+ const scored = approved
1556
+ .map((unit) => {
1557
+ const { score, reasons } = scoreUnitAgainstDiff(
1558
+ unit,
1559
+ diff,
1560
+ changedFiles,
1561
+ );
1562
+ return { unit, score, reasons };
1563
+ })
1564
+ .filter((item) => item.score > 0)
1565
+ .sort((a, b) => b.score - a.score)
1566
+ .slice(0, limit ?? 10);
1567
+
1568
+ return ok(
1569
+ JSON.stringify(
1570
+ {
1571
+ changedFiles,
1572
+ suggestions: scored.map((item) => ({
1573
+ id: item.unit.id,
1574
+ title: item.unit.title,
1575
+ score: item.score,
1576
+ reasons: item.reasons,
1577
+ source: `${item.unit.sourceType}:${item.unit.sourceId}`,
1578
+ })),
1579
+ },
1580
+ null,
1581
+ 2,
1582
+ ),
1583
+ );
1584
+ },
1585
+ );
1586
+
1587
+ // Tool: mneme_unit_apply_explain_match
1588
+ server.registerTool(
1589
+ "mneme_unit_apply_explain_match",
1590
+ {
1591
+ description: "Explain why a specific unit matches a diff.",
1592
+ inputSchema: {
1593
+ unitId: z.string().min(1).describe("Unit ID"),
1594
+ diff: z.string().min(1).describe("Unified diff text"),
1595
+ },
1596
+ },
1597
+ async ({ unitId, diff }) => {
1598
+ const unit = readUnits().items.find((item) => item.id === unitId);
1599
+ if (!unit) return fail(`Unit not found: ${unitId}`);
1600
+ const changedFiles = extractChangedFilesFromDiff(diff);
1601
+ const scored = scoreUnitAgainstDiff(unit, diff, changedFiles);
1602
+ return ok(
1603
+ JSON.stringify(
1604
+ {
1605
+ unitId,
1606
+ title: unit.title,
1607
+ score: scored.score,
1608
+ reasons: scored.reasons,
1609
+ priority: inferUnitPriority(unit),
1610
+ changedFiles,
1611
+ },
1612
+ null,
1613
+ 2,
1614
+ ),
1615
+ );
1616
+ },
1617
+ );
1618
+
1619
+ // Tool: mneme_session_timeline
1620
+ server.registerTool(
1621
+ "mneme_session_timeline",
1622
+ {
1623
+ description:
1624
+ "Build timeline for one session or a resume-chain using sessions metadata and interactions.",
1625
+ inputSchema: {
1626
+ sessionId: z.string().min(1).describe("Session ID (short or full)"),
1627
+ includeChain: z
1628
+ .boolean()
1629
+ .optional()
1630
+ .describe("Include resumedFrom chain and workPeriods (default: true)"),
1631
+ },
1632
+ },
1633
+ async ({ sessionId, includeChain }) => {
1634
+ const sessions = readSessionsById();
1635
+ const shortId = sessionId.slice(0, 8);
1636
+ const root = sessions.get(shortId);
1637
+ if (!root) return fail(`Session not found: ${shortId}`);
1638
+
1639
+ const chain: string[] = [shortId];
1640
+ if (includeChain !== false) {
1641
+ let current = root;
1642
+ let guard = 0;
1643
+ while (
1644
+ current &&
1645
+ typeof current.resumedFrom === "string" &&
1646
+ current.resumedFrom &&
1647
+ guard < 30
1648
+ ) {
1649
+ chain.push(current.resumedFrom);
1650
+ current = sessions.get(current.resumedFrom);
1651
+ guard += 1;
1652
+ }
1653
+ }
1654
+
1655
+ const dbAvailable = !!getDb();
1656
+ const timeline = chain.map((id) => {
1657
+ const session = sessions.get(id) || {};
1658
+ let interactionCount = 0;
1659
+ if (dbAvailable) {
1660
+ interactionCount = getInteractions(id, {
1661
+ limit: 1_000,
1662
+ offset: 0,
1663
+ }).length;
1664
+ }
1665
+ return {
1666
+ id,
1667
+ title: typeof session.title === "string" ? session.title : null,
1668
+ createdAt:
1669
+ typeof session.createdAt === "string" ? session.createdAt : null,
1670
+ endedAt: typeof session.endedAt === "string" ? session.endedAt : null,
1671
+ resumedFrom:
1672
+ typeof session.resumedFrom === "string" ? session.resumedFrom : null,
1673
+ interactionCount,
1674
+ };
1675
+ });
1676
+
1677
+ return ok(
1678
+ JSON.stringify(
1679
+ {
1680
+ rootSessionId: shortId,
1681
+ dbAvailable,
1682
+ chainLength: timeline.length,
1683
+ timeline,
1684
+ },
1685
+ null,
1686
+ 2,
1687
+ ),
1688
+ );
1689
+ },
1690
+ );
1691
+
1692
+ // Tool: mneme_rule_linter
1693
+ server.registerTool(
1694
+ "mneme_rule_linter",
1695
+ {
1696
+ description:
1697
+ "Lint rules for schema and quality (required fields, priority, clarity, duplicates).",
1698
+ inputSchema: {
1699
+ ruleType: z
1700
+ .enum(["dev-rules", "review-guidelines", "all"])
1701
+ .optional()
1702
+ .describe("Rule set to lint (default: all)"),
1703
+ },
1704
+ },
1705
+ async ({ ruleType }) => {
1706
+ const targets =
1707
+ ruleType && ruleType !== "all"
1708
+ ? [ruleType]
1709
+ : (["dev-rules", "review-guidelines"] as const);
1710
+ const issues: Array<{
1711
+ ruleType: string;
1712
+ id: string;
1713
+ level: "error" | "warning";
1714
+ message: string;
1715
+ }> = [];
1716
+
1717
+ for (const type of targets) {
1718
+ const items = readRuleItems(type);
1719
+ const seenKeys = new Set<string>();
1720
+ for (const raw of items) {
1721
+ const id = typeof raw.id === "string" ? raw.id : "(unknown)";
1722
+ const text =
1723
+ typeof raw.text === "string"
1724
+ ? raw.text
1725
+ : typeof raw.rule === "string"
1726
+ ? raw.rule
1727
+ : "";
1728
+ const priority =
1729
+ typeof raw.priority === "string" ? raw.priority.toLowerCase() : "";
1730
+ const tags = Array.isArray(raw.tags) ? raw.tags : [];
1731
+ const key = `${type}:${String(raw.key || id)}`;
1732
+
1733
+ if (!raw.id) {
1734
+ issues.push({
1735
+ ruleType: type,
1736
+ id,
1737
+ level: "error",
1738
+ message: "Missing id",
1739
+ });
1740
+ }
1741
+ if (!raw.key) {
1742
+ issues.push({
1743
+ ruleType: type,
1744
+ id,
1745
+ level: "error",
1746
+ message: "Missing key",
1747
+ });
1748
+ }
1749
+ if (!text.trim()) {
1750
+ issues.push({
1751
+ ruleType: type,
1752
+ id,
1753
+ level: "error",
1754
+ message: "Missing text/rule",
1755
+ });
1756
+ }
1757
+ if (!raw.category) {
1758
+ issues.push({
1759
+ ruleType: type,
1760
+ id,
1761
+ level: "warning",
1762
+ message: "Missing category",
1763
+ });
1764
+ }
1765
+ if (tags.length === 0) {
1766
+ issues.push({
1767
+ ruleType: type,
1768
+ id,
1769
+ level: "warning",
1770
+ message: "Missing tags",
1771
+ });
1772
+ }
1773
+ if (!["p0", "p1", "p2"].includes(priority)) {
1774
+ issues.push({
1775
+ ruleType: type,
1776
+ id,
1777
+ level: "error",
1778
+ message: "Invalid priority (p0|p1|p2 required)",
1779
+ });
1780
+ }
1781
+ if (seenKeys.has(key)) {
1782
+ issues.push({
1783
+ ruleType: type,
1784
+ id,
1785
+ level: "warning",
1786
+ message: "Duplicate key",
1787
+ });
1788
+ }
1789
+ seenKeys.add(key);
1790
+
1791
+ if (text.trim().length > 180) {
1792
+ issues.push({
1793
+ ruleType: type,
1794
+ id,
1795
+ level: "warning",
1796
+ message: "Rule text too long (consider splitting)",
1797
+ });
1798
+ }
1799
+ }
1800
+ }
1801
+
1802
+ return ok(
1803
+ JSON.stringify(
1804
+ {
1805
+ checked: targets,
1806
+ totalIssues: issues.length,
1807
+ errors: issues.filter((issue) => issue.level === "error").length,
1808
+ warnings: issues.filter((issue) => issue.level === "warning").length,
1809
+ issues,
1810
+ },
1811
+ null,
1812
+ 2,
1813
+ ),
1814
+ );
1815
+ },
1816
+ );
1817
+
1818
+ // Tool: mneme_graph_insights
1819
+ server.registerTool(
1820
+ "mneme_graph_insights",
1821
+ {
1822
+ description:
1823
+ "Compute graph insights from approved units: central units, tag communities, orphan units.",
1824
+ inputSchema: {
1825
+ limit: z
1826
+ .number()
1827
+ .int()
1828
+ .min(LIST_LIMIT_MIN)
1829
+ .max(100)
1830
+ .optional()
1831
+ .describe("Limit for ranked outputs (default: 10)"),
1832
+ },
1833
+ },
1834
+ async ({ limit }) => {
1835
+ const k = limit ?? 10;
1836
+ const units = readUnits().items.filter(
1837
+ (item) => item.status === "approved",
1838
+ );
1839
+ const graph = buildUnitGraph(units);
1840
+ const degree = new Map<string, number>();
1841
+ for (const unit of graph.nodes) degree.set(unit.id, 0);
1842
+ for (const edge of graph.edges) {
1843
+ degree.set(edge.source, (degree.get(edge.source) || 0) + 1);
1844
+ degree.set(edge.target, (degree.get(edge.target) || 0) + 1);
1845
+ }
1846
+
1847
+ const topCentral = graph.nodes
1848
+ .map((unit) => ({
1849
+ id: unit.id,
1850
+ title: unit.title,
1851
+ degree: degree.get(unit.id) || 0,
1852
+ }))
1853
+ .sort((a, b) => b.degree - a.degree)
1854
+ .slice(0, k);
1855
+
1856
+ const tagCounts = new Map<string, number>();
1857
+ for (const unit of graph.nodes) {
1858
+ for (const tag of unit.tags) {
1859
+ tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
1860
+ }
1861
+ }
1862
+ const communities = Array.from(tagCounts.entries())
1863
+ .map(([tag, count]) => ({
1864
+ tag,
1865
+ count,
1866
+ }))
1867
+ .sort((a, b) => b.count - a.count)
1868
+ .slice(0, k);
1869
+
1870
+ const orphans = graph.nodes
1871
+ .filter((unit) => (degree.get(unit.id) || 0) === 0)
1872
+ .map((unit) => ({
1873
+ id: unit.id,
1874
+ title: unit.title,
1875
+ tags: unit.tags,
1876
+ }))
1877
+ .slice(0, k);
1878
+
1879
+ return ok(
1880
+ JSON.stringify(
1881
+ {
1882
+ approvedUnits: graph.nodes.length,
1883
+ edges: graph.edges.length,
1884
+ topCentral,
1885
+ tagCommunities: communities,
1886
+ orphanUnits: orphans,
1887
+ },
1888
+ null,
1889
+ 2,
1890
+ ),
1891
+ );
1892
+ },
1893
+ );
1894
+
1895
+ // Tool: mneme_search_eval
1896
+ server.registerTool(
1897
+ "mneme_search_eval",
1898
+ {
1899
+ description:
1900
+ "Run/compare search benchmark and emit regression summary. Intended for CI and save-time quality checks.",
1901
+ inputSchema: {
1902
+ mode: z
1903
+ .enum(["run", "compare", "regression"])
1904
+ .optional()
1905
+ .describe(
1906
+ "run=single, compare=against baseline, regression=threshold check",
1907
+ ),
1908
+ baselineRecall: z
1909
+ .number()
1910
+ .min(0)
1911
+ .max(1)
1912
+ .optional()
1913
+ .describe("Baseline recall for compare/regression"),
1914
+ thresholdDrop: z
1915
+ .number()
1916
+ .min(0)
1917
+ .max(1)
1918
+ .optional()
1919
+ .describe("Allowed recall drop for regression (default: 0.05)"),
1920
+ limit: z
1921
+ .number()
1922
+ .int()
1923
+ .min(LIST_LIMIT_MIN)
1924
+ .max(50)
1925
+ .optional()
1926
+ .describe("Top-k results per query (default: 5)"),
1927
+ },
1928
+ },
1929
+ async ({ mode, baselineRecall, thresholdDrop, limit }) => {
1930
+ const run = runSearchBenchmark(limit ?? SEARCH_EVAL_DEFAULT_LIMIT);
1931
+ const payload: Record<string, unknown> = {
1932
+ mode: mode || "run",
1933
+ queryCount: run.queryCount,
1934
+ hits: run.hits,
1935
+ recall: run.recall,
1936
+ details: run.details,
1091
1937
  };
1938
+
1939
+ if (
1940
+ (mode === "compare" || mode === "regression") &&
1941
+ baselineRecall !== undefined
1942
+ ) {
1943
+ const delta = run.recall - baselineRecall;
1944
+ payload.baselineRecall = baselineRecall;
1945
+ payload.delta = delta;
1946
+ if (mode === "regression") {
1947
+ const allowed = thresholdDrop ?? 0.05;
1948
+ payload.thresholdDrop = allowed;
1949
+ payload.regression = delta < -allowed;
1950
+ }
1951
+ }
1952
+
1953
+ return ok(JSON.stringify(payload, null, 2));
1954
+ },
1955
+ );
1956
+
1957
+ // Tool: mneme_audit_query
1958
+ server.registerTool(
1959
+ "mneme_audit_query",
1960
+ {
1961
+ description: "Query unit-related audit logs and summarize change history.",
1962
+ inputSchema: {
1963
+ from: z.string().optional().describe("Start ISO date/time"),
1964
+ to: z.string().optional().describe("End ISO date/time"),
1965
+ targetId: z.string().optional().describe("Filter by target unit ID"),
1966
+ summaryMode: z
1967
+ .enum(["changes", "actors", "target"])
1968
+ .optional()
1969
+ .describe(
1970
+ "changes=list, actors=aggregate by actor, target=single target history",
1971
+ ),
1972
+ },
1973
+ },
1974
+ async ({ from, to, targetId, summaryMode }) => {
1975
+ const entries = readAuditEntries({ from, to, entity: "unit" }).filter(
1976
+ (entry) => (targetId ? entry.targetId === targetId : true),
1977
+ );
1978
+
1979
+ if ((summaryMode || "changes") === "actors") {
1980
+ const byActor = new Map<string, number>();
1981
+ for (const entry of entries) {
1982
+ const actor = entry.actor || "unknown";
1983
+ byActor.set(actor, (byActor.get(actor) || 0) + 1);
1984
+ }
1985
+ return ok(
1986
+ JSON.stringify(
1987
+ {
1988
+ total: entries.length,
1989
+ actors: Array.from(byActor.entries())
1990
+ .map(([actor, count]) => ({ actor, count }))
1991
+ .sort((a, b) => b.count - a.count),
1992
+ },
1993
+ null,
1994
+ 2,
1995
+ ),
1996
+ );
1997
+ }
1998
+
1999
+ if ((summaryMode || "changes") === "target" && targetId) {
2000
+ return ok(
2001
+ JSON.stringify(
2002
+ {
2003
+ targetId,
2004
+ history: entries,
2005
+ },
2006
+ null,
2007
+ 2,
2008
+ ),
2009
+ );
2010
+ }
2011
+
2012
+ return ok(
2013
+ JSON.stringify(
2014
+ {
2015
+ total: entries.length,
2016
+ changes: entries,
2017
+ },
2018
+ null,
2019
+ 2,
2020
+ ),
2021
+ );
1092
2022
  },
1093
2023
  );
1094
2024