@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.
- package/.claude-plugin/plugin.json +2 -5
- package/README.ja.md +45 -283
- package/README.md +48 -280
- package/dist/lib/db.js +7 -5
- package/dist/lib/incremental-save.js +122 -28
- package/dist/lib/prompt-search.js +570 -0
- package/dist/lib/search-core.js +516 -0
- package/dist/lib/session-finalize.js +983 -0
- package/dist/lib/session-init.js +397 -0
- package/dist/lib/suppress-sqlite-warning.js +8 -0
- package/dist/public/assets/index-Bvl_IrPy.css +1 -0
- package/dist/public/assets/index-k5JYSPV6.js +351 -0
- package/dist/public/assets/{react-force-graph-2d-CGnpkwRw.js → react-force-graph-2d-Dlcfvz01.js} +1 -1
- package/dist/public/index.html +2 -2
- package/dist/server.js +565 -37
- package/dist/servers/db-server.js +1301 -98
- package/dist/servers/search-server.js +613 -333
- package/hooks/hooks.json +1 -0
- package/hooks/lib/common.sh +55 -0
- package/hooks/post-tool-use.sh +52 -58
- package/hooks/pre-compact.sh +30 -42
- package/hooks/session-end.sh +30 -142
- package/hooks/session-start.sh +32 -337
- package/hooks/stop.sh +31 -42
- package/hooks/user-prompt-submit.sh +58 -212
- package/package.json +10 -3
- package/scripts/export-weekly-knowledge-html.ts +906 -0
- package/scripts/search-benchmark.queries.json +78 -0
- package/scripts/search-benchmark.ts +120 -0
- package/scripts/validate-source-artifacts.mjs +378 -0
- package/servers/db-server.ts +995 -65
- package/servers/search-server.ts +117 -528
- package/skills/harvest/SKILL.md +78 -0
- package/skills/init-mneme/{skill.md → SKILL.md} +7 -1
- package/skills/resume/{skill.md → SKILL.md} +24 -9
- package/skills/save/SKILL.md +131 -0
- package/skills/search/SKILL.md +76 -0
- package/skills/using-mneme/SKILL.md +38 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/dist/public/assets/index-CeHiZXwl.js +0 -345
- package/dist/public/assets/index-t_srr1OD.css +0 -1
- package/learn_claude_code/figma_exports/claude_code_map.svg +0 -107
- package/learn_claude_code/figma_exports/claude_code_whiteboard.excalidraw +0 -2578
- package/skills/AGENTS.override.md +0 -5
- package/skills/harvest/skill.md +0 -295
- package/skills/plan/skill.md +0 -422
- package/skills/report/skill.md +0 -74
- package/skills/review/skill.md +0 -419
- package/skills/save/skill.md +0 -496
- package/skills/search/skill.md +0 -175
- package/skills/using-mneme/skill.md +0 -185
package/servers/db-server.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1481
|
+
count: units.length,
|
|
1482
|
+
items: units,
|
|
1088
1483
|
},
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
|