@hir4ta/mneme 0.22.0 → 0.22.3

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.
@@ -76,45 +76,8 @@ const LIST_LIMIT_MIN = 1;
76
76
  const LIST_LIMIT_MAX = 200;
77
77
  const INTERACTION_OFFSET_MIN = 0;
78
78
  const QUERY_MAX_LENGTH = 500;
79
- const UNIT_LIMIT_MAX = 500;
80
79
  const SEARCH_EVAL_DEFAULT_LIMIT = 5;
81
80
 
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
81
  interface RuleDoc {
119
82
  items?: Array<Record<string, unknown>>;
120
83
  rules?: Array<Record<string, unknown>>;
@@ -170,25 +133,6 @@ function listJsonFiles(dir: string): string[] {
170
133
  });
171
134
  }
172
135
 
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
136
  function readRuleItems(
193
137
  ruleType: "dev-rules" | "review-guidelines",
194
138
  ): Array<Record<string, unknown>> {
@@ -198,42 +142,6 @@ function readRuleItems(
198
142
  return Array.isArray(items) ? items : [];
199
143
  }
200
144
 
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
145
  function readSessionsById(): Map<string, Record<string, unknown>> {
238
146
  const sessionsDir = path.join(getMnemeDir(), "sessions");
239
147
  const map = new Map<string, Record<string, unknown>>();
@@ -246,107 +154,6 @@ function readSessionsById(): Map<string, Record<string, unknown>> {
246
154
  return map;
247
155
  }
248
156
 
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
-
350
157
  // Database connection (lazy initialization)
351
158
  let db: DatabaseSyncType | null = null;
352
159
 
@@ -1009,6 +816,17 @@ async function saveInteractions(
1009
816
  id: `int-${String(idx + 1).padStart(3, "0")}`,
1010
817
  }));
1011
818
 
819
+ // Guard: don't delete existing data when there's nothing to insert
820
+ if (finalInteractions.length === 0) {
821
+ return {
822
+ success: true,
823
+ savedCount: 0,
824
+ mergedFromBackup: backupInteractions.length,
825
+ message:
826
+ "No interactions to save (transcript may have no text user messages). Existing data preserved.",
827
+ };
828
+ }
829
+
1012
830
  // Delete existing interactions for this session
1013
831
  try {
1014
832
  const deleteStmt = database.prepare(
@@ -1227,26 +1045,6 @@ function runSearchBenchmark(limit = SEARCH_EVAL_DEFAULT_LIMIT): {
1227
1045
  };
1228
1046
  }
1229
1047
 
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
-
1250
1048
  // MCP Server setup
1251
1049
  const server = new McpServer({
1252
1050
  name: "mneme-db",
@@ -1422,160 +1220,177 @@ server.registerTool(
1422
1220
  },
1423
1221
  );
1424
1222
 
1425
- // Tool: mneme_mark_session_committed
1223
+ // Tool: mneme_update_session_summary
1426
1224
  server.registerTool(
1427
- "mneme_mark_session_committed",
1225
+ "mneme_update_session_summary",
1428
1226
  {
1429
1227
  description:
1430
- "Mark a session as committed (saved with /mneme:save). " +
1431
- "This prevents the session's interactions from being deleted on SessionEnd. " +
1432
- "Call this after successfully saving session data.",
1228
+ "Update session JSON file with summary data. " +
1229
+ "MUST be called during /mneme:save Phase 3 to persist session metadata. " +
1230
+ "Creates the session file if it does not exist (e.g. when SessionStart hook was skipped).",
1433
1231
  inputSchema: {
1434
1232
  claudeSessionId: z
1435
1233
  .string()
1436
1234
  .min(8)
1437
1235
  .describe("Full Claude Code session UUID (36 chars)"),
1236
+ title: z.string().describe("Session title"),
1237
+ summary: z
1238
+ .object({
1239
+ goal: z.string().describe("What the session aimed to accomplish"),
1240
+ outcome: z.string().describe("What was actually accomplished"),
1241
+ description: z
1242
+ .string()
1243
+ .optional()
1244
+ .describe("Detailed description of the session"),
1245
+ })
1246
+ .describe("Session summary object"),
1247
+ tags: z
1248
+ .array(z.string())
1249
+ .optional()
1250
+ .describe("Semantic tags for the session"),
1251
+ sessionType: z
1252
+ .string()
1253
+ .optional()
1254
+ .describe(
1255
+ "Session type (e.g. implementation, research, bugfix, refactor)",
1256
+ ),
1438
1257
  },
1439
1258
  },
1440
- async ({ claudeSessionId }) => {
1259
+ async ({ claudeSessionId, title, summary, tags, sessionType }) => {
1441
1260
  if (!claudeSessionId.trim()) {
1442
1261
  return fail("claudeSessionId must not be empty.");
1443
1262
  }
1444
- const success = markSessionCommitted(claudeSessionId);
1445
- return {
1446
- ...ok(JSON.stringify({ success, claudeSessionId }, null, 2)),
1447
- isError: !success,
1448
- };
1449
- },
1450
- );
1451
1263
 
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(
1480
- {
1481
- count: units.length,
1482
- items: units,
1264
+ const projectPath = getProjectPath();
1265
+ const sessionsDir = path.join(projectPath, ".mneme", "sessions");
1266
+ const shortId = claudeSessionId.slice(0, 8);
1267
+
1268
+ // Find existing session file
1269
+ let sessionFile: string | null = null;
1270
+ const searchDir = (dir: string): string | null => {
1271
+ if (!fs.existsSync(dir)) return null;
1272
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1273
+ const fullPath = path.join(dir, entry.name);
1274
+ if (entry.isDirectory()) {
1275
+ const result = searchDir(fullPath);
1276
+ if (result) return result;
1277
+ } else if (entry.name === `${shortId}.json`) {
1278
+ return fullPath;
1279
+ }
1280
+ }
1281
+ return null;
1282
+ };
1283
+ sessionFile = searchDir(sessionsDir);
1284
+
1285
+ // Create session file if not found (SessionStart hook may not have run)
1286
+ if (!sessionFile) {
1287
+ const now = new Date();
1288
+ const yearMonth = path.join(
1289
+ sessionsDir,
1290
+ String(now.getFullYear()),
1291
+ String(now.getMonth() + 1).padStart(2, "0"),
1292
+ );
1293
+ if (!fs.existsSync(yearMonth)) {
1294
+ fs.mkdirSync(yearMonth, { recursive: true });
1295
+ }
1296
+ sessionFile = path.join(yearMonth, `${shortId}.json`);
1297
+ // Write minimal session JSON
1298
+ const initial = {
1299
+ id: shortId,
1300
+ sessionId: claudeSessionId,
1301
+ createdAt: now.toISOString(),
1302
+ title: "",
1303
+ tags: [],
1304
+ context: {
1305
+ projectDir: projectPath,
1306
+ projectName: path.basename(projectPath),
1483
1307
  },
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,
1308
+ metrics: {
1309
+ userMessages: 0,
1310
+ assistantResponses: 0,
1311
+ thinkingBlocks: 0,
1312
+ toolUsage: [],
1313
+ },
1314
+ files: [],
1315
+ status: null,
1519
1316
  };
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
- );
1317
+ fs.writeFileSync(sessionFile, JSON.stringify(initial, null, 2));
1318
+ }
1532
1319
 
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);
1320
+ // Read, update, write
1321
+ const data = readJsonFile<Record<string, unknown>>(sessionFile) ?? {};
1322
+ data.title = title;
1323
+ data.summary = summary;
1324
+ data.updatedAt = new Date().toISOString();
1325
+ if (tags) data.tags = tags;
1326
+ if (sessionType) data.sessionType = sessionType;
1327
+
1328
+ // Enrich with transcript metrics/files if available
1329
+ const transcriptPath = getTranscriptPath(claudeSessionId);
1330
+ if (transcriptPath) {
1331
+ try {
1332
+ const parsed = await parseTranscript(transcriptPath);
1333
+ data.metrics = {
1334
+ userMessages: parsed.metrics.userMessages,
1335
+ assistantResponses: parsed.metrics.assistantResponses,
1336
+ thinkingBlocks: parsed.metrics.thinkingBlocks,
1337
+ toolUsage: parsed.toolUsage,
1338
+ };
1339
+ if (parsed.files.length > 0) {
1340
+ data.files = parsed.files;
1341
+ }
1342
+ } catch {
1343
+ // Transcript parse failed, keep existing values
1344
+ }
1345
+ }
1346
+
1347
+ // Enrich context if minimal (missing repository info)
1348
+ const ctx = data.context as Record<string, unknown> | undefined;
1349
+ if (ctx && !ctx.repository) {
1350
+ try {
1351
+ const { execSync } = await import("node:child_process");
1352
+ const cwd = (ctx.projectDir as string) || projectPath;
1353
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1354
+ encoding: "utf8",
1355
+ cwd,
1356
+ }).trim();
1357
+ if (branch) ctx.branch = branch;
1358
+ const remoteUrl = execSync("git remote get-url origin", {
1359
+ encoding: "utf8",
1360
+ cwd,
1361
+ }).trim();
1362
+ const repoMatch = remoteUrl.match(/[:/]([^/]+\/[^/]+?)(\.git)?$/);
1363
+ if (repoMatch) ctx.repository = repoMatch[1].replace(/\.git$/, "");
1364
+ const userName = execSync("git config user.name", {
1365
+ encoding: "utf8",
1366
+ cwd,
1367
+ }).trim();
1368
+ const userEmail = execSync("git config user.email", {
1369
+ encoding: "utf8",
1370
+ cwd,
1371
+ }).trim();
1372
+ if (userName)
1373
+ ctx.user = {
1374
+ name: userName,
1375
+ ...(userEmail ? { email: userEmail } : {}),
1376
+ };
1377
+ } catch {
1378
+ // Not a git repo or git not available
1379
+ }
1380
+ }
1381
+
1382
+ fs.writeFileSync(sessionFile, JSON.stringify(data, null, 2));
1383
+
1384
+ // Auto-commit: mark session as committed after successful summary write
1385
+ // This ensures committed flag is set even if mneme_mark_session_committed is not called explicitly
1386
+ markSessionCommitted(claudeSessionId);
1567
1387
 
1568
1388
  return ok(
1569
1389
  JSON.stringify(
1570
1390
  {
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
- })),
1391
+ success: true,
1392
+ sessionFile: sessionFile.replace(projectPath, "."),
1393
+ shortId,
1579
1394
  },
1580
1395
  null,
1581
1396
  2,
@@ -1584,35 +1399,30 @@ server.registerTool(
1584
1399
  },
1585
1400
  );
1586
1401
 
1587
- // Tool: mneme_unit_apply_explain_match
1402
+ // Tool: mneme_mark_session_committed
1588
1403
  server.registerTool(
1589
- "mneme_unit_apply_explain_match",
1404
+ "mneme_mark_session_committed",
1590
1405
  {
1591
- description: "Explain why a specific unit matches a diff.",
1406
+ description:
1407
+ "Mark a session as committed (saved with /mneme:save). " +
1408
+ "This prevents the session's interactions from being deleted on SessionEnd. " +
1409
+ "Call this after successfully saving session data.",
1592
1410
  inputSchema: {
1593
- unitId: z.string().min(1).describe("Unit ID"),
1594
- diff: z.string().min(1).describe("Unified diff text"),
1411
+ claudeSessionId: z
1412
+ .string()
1413
+ .min(8)
1414
+ .describe("Full Claude Code session UUID (36 chars)"),
1595
1415
  },
1596
1416
  },
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
- );
1417
+ async ({ claudeSessionId }) => {
1418
+ if (!claudeSessionId.trim()) {
1419
+ return fail("claudeSessionId must not be empty.");
1420
+ }
1421
+ const success = markSessionCommitted(claudeSessionId);
1422
+ return {
1423
+ ...ok(JSON.stringify({ success, claudeSessionId }, null, 2)),
1424
+ isError: !success,
1425
+ };
1616
1426
  },
1617
1427
  );
1618
1428
 
@@ -1815,83 +1625,6 @@ server.registerTool(
1815
1625
  },
1816
1626
  );
1817
1627
 
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
1628
  // Tool: mneme_search_eval
1896
1629
  server.registerTool(
1897
1630
  "mneme_search_eval",
@@ -1954,74 +1687,6 @@ server.registerTool(
1954
1687
  },
1955
1688
  );
1956
1689
 
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
- );
2022
- },
2023
- );
2024
-
2025
1690
  // Start server
2026
1691
  async function main() {
2027
1692
  const transport = new StdioServerTransport();