@bartolli/kmd 0.1.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/dist/kmd.mjs +1596 -0
- package/dist/kmd.mjs.map +7 -0
- package/package.json +48 -0
package/dist/kmd.mjs
ADDED
|
@@ -0,0 +1,1596 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ../db/src/database.ts
|
|
18
|
+
import { DatabaseSync } from "node:sqlite";
|
|
19
|
+
function openDatabase(dbPath) {
|
|
20
|
+
const db = new DatabaseSync(dbPath);
|
|
21
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
22
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
23
|
+
db.exec(SCHEMA);
|
|
24
|
+
return db;
|
|
25
|
+
}
|
|
26
|
+
var SCHEMA;
|
|
27
|
+
var init_database = __esm({
|
|
28
|
+
"../db/src/database.ts"() {
|
|
29
|
+
"use strict";
|
|
30
|
+
SCHEMA = `
|
|
31
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
path TEXT UNIQUE NOT NULL,
|
|
34
|
+
title TEXT NOT NULL,
|
|
35
|
+
kind TEXT NOT NULL,
|
|
36
|
+
scope TEXT,
|
|
37
|
+
topic TEXT,
|
|
38
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
39
|
+
summary TEXT,
|
|
40
|
+
tags TEXT,
|
|
41
|
+
updated TEXT,
|
|
42
|
+
body TEXT,
|
|
43
|
+
content_hash TEXT,
|
|
44
|
+
meta TEXT
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE INDEX IF NOT EXISTS pages_scope_kind ON pages(scope, kind, status);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS pages_topic_kind ON pages(topic, kind, status);
|
|
49
|
+
|
|
50
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
|
|
51
|
+
title, summary, body,
|
|
52
|
+
content='pages',
|
|
53
|
+
content_rowid='id'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS links (
|
|
57
|
+
source_path TEXT NOT NULL,
|
|
58
|
+
target_path TEXT NOT NULL,
|
|
59
|
+
link_text TEXT,
|
|
60
|
+
PRIMARY KEY (source_path, target_path)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE INDEX IF NOT EXISTS links_source ON links(source_path);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS links_target ON links(target_path);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
ts TEXT DEFAULT (datetime('now')),
|
|
69
|
+
scope TEXT,
|
|
70
|
+
operation TEXT NOT NULL,
|
|
71
|
+
path TEXT,
|
|
72
|
+
note TEXT
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE INDEX IF NOT EXISTS events_scope_ts ON events(scope, ts DESC);
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ../cli/src/config.ts
|
|
81
|
+
import { readFile } from "node:fs/promises";
|
|
82
|
+
import { join } from "node:path";
|
|
83
|
+
import { parse } from "yaml";
|
|
84
|
+
import { z } from "zod";
|
|
85
|
+
async function loadVaultConfig(vaultRoot2) {
|
|
86
|
+
const path = join(vaultRoot2, "vault.yaml");
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
raw = await readFile(path, "utf8");
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw new Error(`vault.yaml not found at ${path}`, { cause: err });
|
|
92
|
+
}
|
|
93
|
+
const parsed = VaultConfigSchema.safeParse(parse(raw));
|
|
94
|
+
if (!parsed.success) {
|
|
95
|
+
const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
96
|
+
throw new Error(`Invalid vault.yaml at ${path}:
|
|
97
|
+
${issues}`);
|
|
98
|
+
}
|
|
99
|
+
return parsed.data;
|
|
100
|
+
}
|
|
101
|
+
var ScopeSchema, VaultConfigSchema;
|
|
102
|
+
var init_config = __esm({
|
|
103
|
+
"../cli/src/config.ts"() {
|
|
104
|
+
"use strict";
|
|
105
|
+
ScopeSchema = z.object({
|
|
106
|
+
repo: z.string().optional(),
|
|
107
|
+
methodology: z.enum(["sdd", "tdd", "hybrid"]).optional(),
|
|
108
|
+
status: z.string()
|
|
109
|
+
});
|
|
110
|
+
VaultConfigSchema = z.object({
|
|
111
|
+
scopes: z.record(z.string(), ScopeSchema),
|
|
112
|
+
kinds: z.array(z.string()),
|
|
113
|
+
statuses: z.array(z.string()),
|
|
114
|
+
methodologies: z.array(z.string()),
|
|
115
|
+
tags: z.object({
|
|
116
|
+
canonical: z.array(z.string()),
|
|
117
|
+
aliases: z.record(z.string(), z.string())
|
|
118
|
+
})
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ../cli/src/frontmatter.ts
|
|
124
|
+
import { parse as parseYaml } from "yaml";
|
|
125
|
+
function parseFrontmatter(raw) {
|
|
126
|
+
const match = FRONTMATTER_RE.exec(raw);
|
|
127
|
+
if (!match) {
|
|
128
|
+
return { data: {}, content: raw };
|
|
129
|
+
}
|
|
130
|
+
const yamlText = match[1] ?? "";
|
|
131
|
+
const content = raw.slice(match[0].length);
|
|
132
|
+
if (yamlText.trim() === "") {
|
|
133
|
+
return { data: {}, content };
|
|
134
|
+
}
|
|
135
|
+
const parsed = parseYaml(yamlText);
|
|
136
|
+
const data = parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
137
|
+
return { data, content };
|
|
138
|
+
}
|
|
139
|
+
var FRONTMATTER_RE;
|
|
140
|
+
var init_frontmatter = __esm({
|
|
141
|
+
"../cli/src/frontmatter.ts"() {
|
|
142
|
+
"use strict";
|
|
143
|
+
FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n?/;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ../cli/src/sync.ts
|
|
148
|
+
import { createHash } from "node:crypto";
|
|
149
|
+
import { mkdirSync } from "node:fs";
|
|
150
|
+
import { readdir, readFile as readFile2 } from "node:fs/promises";
|
|
151
|
+
import { homedir } from "node:os";
|
|
152
|
+
import { join as join2, relative, sep } from "node:path";
|
|
153
|
+
import { z as z2 } from "zod";
|
|
154
|
+
function loadEnv() {
|
|
155
|
+
const parsed = EnvSchema.safeParse({
|
|
156
|
+
WIKI_VAULT: process.env.WIKI_VAULT
|
|
157
|
+
});
|
|
158
|
+
if (!parsed.success) {
|
|
159
|
+
console.error("invalid env:");
|
|
160
|
+
console.error(parsed.error.format());
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
return parsed.data;
|
|
164
|
+
}
|
|
165
|
+
async function walkMarkdown(root, domain) {
|
|
166
|
+
const out = [];
|
|
167
|
+
async function recurse(dir) {
|
|
168
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
|
169
|
+
if (!entries) return;
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
if (entry.name.startsWith(".")) continue;
|
|
172
|
+
if (entry.isDirectory()) {
|
|
173
|
+
await recurse(join2(dir, entry.name));
|
|
174
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
175
|
+
out.push(join2(dir, entry.name));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
await recurse(join2(root, domain));
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
function toRelativePath(root, absolute) {
|
|
183
|
+
return relative(root, absolute).split(sep).join("/");
|
|
184
|
+
}
|
|
185
|
+
function sha256(content) {
|
|
186
|
+
return createHash("sha256").update(content).digest("hex");
|
|
187
|
+
}
|
|
188
|
+
function extractWikilinks(body) {
|
|
189
|
+
const links = [];
|
|
190
|
+
const seen = /* @__PURE__ */ new Set();
|
|
191
|
+
for (const match of body.matchAll(WIKILINK_RE)) {
|
|
192
|
+
const rawTarget = match[1]?.trim() ?? "";
|
|
193
|
+
if (!rawTarget) continue;
|
|
194
|
+
const hasExt = /\.[a-zA-Z0-9]+$/.test(rawTarget);
|
|
195
|
+
const target = hasExt ? rawTarget : `${rawTarget}.md`;
|
|
196
|
+
const text = match[2]?.trim() ?? null;
|
|
197
|
+
const key = `${target}|${text ?? ""}`;
|
|
198
|
+
if (seen.has(key)) continue;
|
|
199
|
+
seen.add(key);
|
|
200
|
+
links.push({ target, text });
|
|
201
|
+
}
|
|
202
|
+
return links;
|
|
203
|
+
}
|
|
204
|
+
function deriveLocation(relPath) {
|
|
205
|
+
const segments = relPath.split("/");
|
|
206
|
+
const domain = segments[0];
|
|
207
|
+
if (domain === "projects" && segments.length >= 2 && segments[1]) {
|
|
208
|
+
return { scope: segments[1], topic: null };
|
|
209
|
+
}
|
|
210
|
+
if (domain === "research" && segments.length >= 2 && segments[1]) {
|
|
211
|
+
return { scope: null, topic: segments[1] };
|
|
212
|
+
}
|
|
213
|
+
return { scope: null, topic: null };
|
|
214
|
+
}
|
|
215
|
+
function buildPageFields(relPath, raw, parsed, knownScopes) {
|
|
216
|
+
const fm = parsed.data;
|
|
217
|
+
if (!fm.title) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
let kind = fm.kind;
|
|
221
|
+
if (!kind) {
|
|
222
|
+
if (relPath.startsWith("notes/")) {
|
|
223
|
+
kind = "note";
|
|
224
|
+
} else {
|
|
225
|
+
console.warn(` skip: ${relPath} \u2014 has title but missing kind`);
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const { scope, topic } = deriveLocation(relPath);
|
|
230
|
+
if (scope !== null && !knownScopes.has(scope)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`unknown scope "${scope}" for ${relPath} \u2014 add it to vault.yaml or remove the page`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const updated = fm.updated instanceof Date ? fm.updated.toISOString().slice(0, 10) : typeof fm.updated === "string" ? fm.updated.slice(0, 10) : null;
|
|
236
|
+
const metaEntries = Object.entries(parsed.data).filter(
|
|
237
|
+
([k]) => !INDEXED_FRONTMATTER_KEYS.has(k)
|
|
238
|
+
);
|
|
239
|
+
const meta = metaEntries.length > 0 ? Object.fromEntries(metaEntries) : null;
|
|
240
|
+
return {
|
|
241
|
+
path: relPath,
|
|
242
|
+
title: fm.title,
|
|
243
|
+
kind,
|
|
244
|
+
scope,
|
|
245
|
+
topic,
|
|
246
|
+
status: fm.status ?? (kind === "note" ? "active" : "draft"),
|
|
247
|
+
summary: fm.summary ?? null,
|
|
248
|
+
tags: Array.isArray(fm.tags) ? fm.tags : null,
|
|
249
|
+
updated,
|
|
250
|
+
body: parsed.content,
|
|
251
|
+
hash: sha256(raw),
|
|
252
|
+
meta
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function syncPage(db, fields) {
|
|
256
|
+
const existing = db.prepare("SELECT content_hash FROM pages WHERE path = ?").get(fields.path);
|
|
257
|
+
if (existing?.content_hash === fields.hash) {
|
|
258
|
+
return "unchanged";
|
|
259
|
+
}
|
|
260
|
+
const upsert = db.prepare(
|
|
261
|
+
`INSERT INTO pages (path, title, kind, scope, topic, status, summary, tags, updated, body, content_hash, meta)
|
|
262
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
263
|
+
ON CONFLICT (path) DO UPDATE SET
|
|
264
|
+
title = EXCLUDED.title,
|
|
265
|
+
kind = EXCLUDED.kind,
|
|
266
|
+
scope = EXCLUDED.scope,
|
|
267
|
+
topic = EXCLUDED.topic,
|
|
268
|
+
status = EXCLUDED.status,
|
|
269
|
+
summary = EXCLUDED.summary,
|
|
270
|
+
tags = EXCLUDED.tags,
|
|
271
|
+
updated = EXCLUDED.updated,
|
|
272
|
+
body = EXCLUDED.body,
|
|
273
|
+
content_hash = EXCLUDED.content_hash,
|
|
274
|
+
meta = EXCLUDED.meta`
|
|
275
|
+
);
|
|
276
|
+
upsert.run(
|
|
277
|
+
fields.path,
|
|
278
|
+
fields.title,
|
|
279
|
+
fields.kind,
|
|
280
|
+
fields.scope,
|
|
281
|
+
fields.topic,
|
|
282
|
+
fields.status,
|
|
283
|
+
fields.summary,
|
|
284
|
+
fields.tags ? JSON.stringify(fields.tags) : null,
|
|
285
|
+
fields.updated,
|
|
286
|
+
fields.body,
|
|
287
|
+
fields.hash,
|
|
288
|
+
fields.meta ? JSON.stringify(fields.meta) : null
|
|
289
|
+
);
|
|
290
|
+
db.prepare("DELETE FROM links WHERE source_path = ?").run(fields.path);
|
|
291
|
+
const insertLink = db.prepare(
|
|
292
|
+
`INSERT INTO links (source_path, target_path, link_text)
|
|
293
|
+
VALUES (?, ?, ?)
|
|
294
|
+
ON CONFLICT (source_path, target_path) DO NOTHING`
|
|
295
|
+
);
|
|
296
|
+
for (const link of extractWikilinks(fields.body)) {
|
|
297
|
+
insertLink.run(fields.path, link.target, link.text);
|
|
298
|
+
}
|
|
299
|
+
return "changed";
|
|
300
|
+
}
|
|
301
|
+
async function runSync() {
|
|
302
|
+
const env = loadEnv();
|
|
303
|
+
const dbDir = join2(homedir(), ".kmd", "db");
|
|
304
|
+
const dbPath = join2(dbDir, "index.db");
|
|
305
|
+
console.log(`sync: ${env.WIKI_VAULT} \u2192 ${dbPath}`);
|
|
306
|
+
const vaultConfig = await loadVaultConfig(env.WIKI_VAULT);
|
|
307
|
+
const scopes = new Set(Object.keys(vaultConfig.scopes));
|
|
308
|
+
mkdirSync(dbDir, { recursive: true });
|
|
309
|
+
const db = openDatabase(dbPath);
|
|
310
|
+
try {
|
|
311
|
+
const files = [];
|
|
312
|
+
for (const domain of SCAN_DOMAINS) {
|
|
313
|
+
files.push(...await walkMarkdown(env.WIKI_VAULT, domain));
|
|
314
|
+
}
|
|
315
|
+
const indexedPaths = [];
|
|
316
|
+
let changed = 0;
|
|
317
|
+
let unchanged = 0;
|
|
318
|
+
let skipped = 0;
|
|
319
|
+
for (const file of files) {
|
|
320
|
+
const path = toRelativePath(env.WIKI_VAULT, file);
|
|
321
|
+
const raw = await readFile2(file, "utf8");
|
|
322
|
+
const parsed = parseFrontmatter(raw);
|
|
323
|
+
const fields = buildPageFields(path, raw, parsed, scopes);
|
|
324
|
+
if (!fields) {
|
|
325
|
+
skipped++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const result = syncPage(db, fields);
|
|
329
|
+
if (result === "changed") {
|
|
330
|
+
changed++;
|
|
331
|
+
} else {
|
|
332
|
+
unchanged++;
|
|
333
|
+
}
|
|
334
|
+
indexedPaths.push(path);
|
|
335
|
+
}
|
|
336
|
+
let pagesDeleted = 0;
|
|
337
|
+
let linksDeleted = 0;
|
|
338
|
+
if (indexedPaths.length > 0) {
|
|
339
|
+
const placeholders = indexedPaths.map(() => "?").join(", ");
|
|
340
|
+
const pageResult = db.prepare(`DELETE FROM pages WHERE path NOT IN (${placeholders})`).run(...indexedPaths);
|
|
341
|
+
pagesDeleted = Number(pageResult.changes);
|
|
342
|
+
const linkResult = db.prepare("DELETE FROM links WHERE source_path NOT IN (SELECT path FROM pages)").run();
|
|
343
|
+
linksDeleted = Number(linkResult.changes);
|
|
344
|
+
} else {
|
|
345
|
+
console.warn("no indexable pages found; skipping orphan deletion (safety)");
|
|
346
|
+
}
|
|
347
|
+
db.exec("INSERT INTO pages_fts(pages_fts) VALUES('rebuild')");
|
|
348
|
+
console.log(
|
|
349
|
+
`done: ${changed} changed, ${unchanged} unchanged, ${skipped} skipped, ${pagesDeleted} pages deleted, ${linksDeleted} link orphans cleared`
|
|
350
|
+
);
|
|
351
|
+
} finally {
|
|
352
|
+
db.close();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
var EnvSchema, SCAN_DOMAINS, WIKILINK_RE, INDEXED_FRONTMATTER_KEYS;
|
|
356
|
+
var init_sync = __esm({
|
|
357
|
+
"../cli/src/sync.ts"() {
|
|
358
|
+
"use strict";
|
|
359
|
+
init_database();
|
|
360
|
+
init_config();
|
|
361
|
+
init_frontmatter();
|
|
362
|
+
EnvSchema = z2.object({
|
|
363
|
+
WIKI_VAULT: z2.string().min(1)
|
|
364
|
+
});
|
|
365
|
+
SCAN_DOMAINS = ["projects", "research", "notes"];
|
|
366
|
+
WIKILINK_RE = /\[\[([^\]|#^]+)(?:[#^][^\]|]*)?(?:\|([^\]]+))?\]\]/g;
|
|
367
|
+
INDEXED_FRONTMATTER_KEYS = /* @__PURE__ */ new Set([
|
|
368
|
+
"title",
|
|
369
|
+
"kind",
|
|
370
|
+
"status",
|
|
371
|
+
"summary",
|
|
372
|
+
"tags",
|
|
373
|
+
"updated",
|
|
374
|
+
"scope",
|
|
375
|
+
"topic"
|
|
376
|
+
]);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ../cli/src/validate.ts
|
|
381
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
382
|
+
function isIndexed(relPath, data) {
|
|
383
|
+
if (typeof data.title !== "string" || data.title.trim() === "") return false;
|
|
384
|
+
if (typeof data.kind === "string" && data.kind !== "") return true;
|
|
385
|
+
return relPath.startsWith("notes/");
|
|
386
|
+
}
|
|
387
|
+
function isPrimer(relPath) {
|
|
388
|
+
return relPath === "primer.md" || relPath.endsWith("/primer.md");
|
|
389
|
+
}
|
|
390
|
+
function checkRequiredFields(relPath, data) {
|
|
391
|
+
const kind = typeof data.kind === "string" && data.kind !== "" ? data.kind : relPath.startsWith("notes/") ? "note" : void 0;
|
|
392
|
+
const required = kind ? REQUIRED_FIELDS[kind] : void 0;
|
|
393
|
+
if (!required) return [];
|
|
394
|
+
const findings = [];
|
|
395
|
+
for (const field of required) {
|
|
396
|
+
if (!Object.hasOwn(data, field)) {
|
|
397
|
+
findings.push({
|
|
398
|
+
path: relPath,
|
|
399
|
+
rule: "required-fields",
|
|
400
|
+
severity: "error",
|
|
401
|
+
message: `missing required field "${field}" for kind "${kind}"`
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return findings;
|
|
406
|
+
}
|
|
407
|
+
function checkTagsRequired(relPath, data) {
|
|
408
|
+
const kind = typeof data.kind === "string" && data.kind !== "" ? data.kind : relPath.startsWith("notes/") ? "note" : void 0;
|
|
409
|
+
if (kind && TAG_OPTIONAL_KINDS.has(kind)) return [];
|
|
410
|
+
if (Array.isArray(data.tags) && data.tags.length > 0) return [];
|
|
411
|
+
return [
|
|
412
|
+
{
|
|
413
|
+
path: relPath,
|
|
414
|
+
rule: "tags-required",
|
|
415
|
+
severity: "error",
|
|
416
|
+
message: "tags must be present and non-empty (tags are open \u2014 any values, every content page tagged)"
|
|
417
|
+
}
|
|
418
|
+
];
|
|
419
|
+
}
|
|
420
|
+
function checkFolderSlug(relPath, data) {
|
|
421
|
+
const kind = data.kind;
|
|
422
|
+
const pattern = typeof kind === "string" ? FOLDER_PATTERNS[kind] : void 0;
|
|
423
|
+
if (!pattern || pattern.test(relPath)) return [];
|
|
424
|
+
return [
|
|
425
|
+
{
|
|
426
|
+
path: relPath,
|
|
427
|
+
rule: "folder-slug",
|
|
428
|
+
severity: "error",
|
|
429
|
+
message: `path does not match the "${kind}" slug pattern ${pattern.source}`
|
|
430
|
+
}
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
function checkVocabulary(relPath, data, cfg) {
|
|
434
|
+
const findings = [];
|
|
435
|
+
const kind = data.kind;
|
|
436
|
+
if (typeof kind === "string" && !cfg.kinds.includes(kind)) {
|
|
437
|
+
findings.push({
|
|
438
|
+
path: relPath,
|
|
439
|
+
rule: "kind-vocabulary",
|
|
440
|
+
severity: "error",
|
|
441
|
+
message: `kind "${kind}" is not in vault.yaml`
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const status = data.status;
|
|
445
|
+
if (typeof status === "string" && !cfg.statuses.includes(status)) {
|
|
446
|
+
findings.push({
|
|
447
|
+
path: relPath,
|
|
448
|
+
rule: "status-vocabulary",
|
|
449
|
+
severity: "error",
|
|
450
|
+
message: `status "${status}" is not in vault.yaml`
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
const { scope } = deriveLocation(relPath);
|
|
454
|
+
if (scope !== null && !Object.hasOwn(cfg.scopes, scope)) {
|
|
455
|
+
findings.push({
|
|
456
|
+
path: relPath,
|
|
457
|
+
rule: "scope-vocabulary",
|
|
458
|
+
severity: "error",
|
|
459
|
+
message: `scope "${scope}" is not in vault.yaml`
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const methodology = data.methodology;
|
|
463
|
+
if (typeof methodology === "string" && !cfg.methodologies.includes(methodology)) {
|
|
464
|
+
findings.push({
|
|
465
|
+
path: relPath,
|
|
466
|
+
rule: "methodology-vocabulary",
|
|
467
|
+
severity: "error",
|
|
468
|
+
message: `methodology "${methodology}" is not in vault.yaml`
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return findings;
|
|
472
|
+
}
|
|
473
|
+
function checkScopePath(relPath, data) {
|
|
474
|
+
const { scope, topic } = deriveLocation(relPath);
|
|
475
|
+
const findings = [];
|
|
476
|
+
if (scope !== null && typeof data.scope === "string" && data.scope !== "" && data.scope !== scope) {
|
|
477
|
+
findings.push({
|
|
478
|
+
path: relPath,
|
|
479
|
+
rule: "path-authority",
|
|
480
|
+
severity: "error",
|
|
481
|
+
message: `frontmatter scope "${data.scope}" disagrees with path scope "${scope}"`
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (topic !== null && typeof data.topic === "string" && data.topic !== "" && data.topic !== topic) {
|
|
485
|
+
findings.push({
|
|
486
|
+
path: relPath,
|
|
487
|
+
rule: "path-authority",
|
|
488
|
+
severity: "error",
|
|
489
|
+
message: `frontmatter topic "${data.topic}" disagrees with path topic "${topic}"`
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return findings;
|
|
493
|
+
}
|
|
494
|
+
function refTarget(value) {
|
|
495
|
+
if (typeof value !== "string") return null;
|
|
496
|
+
const trimmed = value.trim();
|
|
497
|
+
return trimmed === "" ? null : basename(trimmed);
|
|
498
|
+
}
|
|
499
|
+
function refTargets(value) {
|
|
500
|
+
if (Array.isArray(value)) {
|
|
501
|
+
return value.map(refTarget).filter((t) => t !== null);
|
|
502
|
+
}
|
|
503
|
+
const single = refTarget(value);
|
|
504
|
+
return single === null ? [] : [single];
|
|
505
|
+
}
|
|
506
|
+
function stripCode(body) {
|
|
507
|
+
return body.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
|
|
508
|
+
}
|
|
509
|
+
function checkBodyLinks(relPath, body, refIndex) {
|
|
510
|
+
const findings = [];
|
|
511
|
+
for (const link of extractWikilinks(stripCode(body))) {
|
|
512
|
+
if (!link.target.endsWith(".md")) continue;
|
|
513
|
+
if (!refIndex.has(basename(link.target))) {
|
|
514
|
+
findings.push({
|
|
515
|
+
path: relPath,
|
|
516
|
+
rule: "dangling-link",
|
|
517
|
+
severity: "error",
|
|
518
|
+
message: `wikilink target "${link.target}" does not resolve`
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return findings;
|
|
523
|
+
}
|
|
524
|
+
function checkReferences(relPath, data, body, refIndex) {
|
|
525
|
+
const findings = checkBodyLinks(relPath, body, refIndex);
|
|
526
|
+
const fieldRefs = [];
|
|
527
|
+
const parent = refTarget(data.parent);
|
|
528
|
+
if (parent !== null) fieldRefs.push(parent);
|
|
529
|
+
for (const field of ["supersedes", "superseded_by", "blocked_by"]) {
|
|
530
|
+
fieldRefs.push(...refTargets(data[field]));
|
|
531
|
+
}
|
|
532
|
+
for (const target of fieldRefs) {
|
|
533
|
+
if (!refIndex.has(target)) {
|
|
534
|
+
findings.push({
|
|
535
|
+
path: relPath,
|
|
536
|
+
rule: "ref-resolves",
|
|
537
|
+
severity: "error",
|
|
538
|
+
message: `frontmatter reference "${target}" does not resolve`
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return findings;
|
|
543
|
+
}
|
|
544
|
+
function checkTags(relPath, data, cfg) {
|
|
545
|
+
if (!Array.isArray(data.tags)) return [];
|
|
546
|
+
const findings = [];
|
|
547
|
+
for (const tag of data.tags) {
|
|
548
|
+
if (typeof tag === "string" && Object.hasOwn(cfg.tags.aliases, tag)) {
|
|
549
|
+
findings.push({
|
|
550
|
+
path: relPath,
|
|
551
|
+
rule: "tag-alias",
|
|
552
|
+
severity: "warning",
|
|
553
|
+
message: `tag "${tag}" is an alias; use canonical "${cfg.tags.aliases[tag]}"`
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return findings;
|
|
558
|
+
}
|
|
559
|
+
function checkSupersededLink(relPath, data) {
|
|
560
|
+
if (data.status === "superseded" && refTargets(data.superseded_by).length === 0) {
|
|
561
|
+
return [
|
|
562
|
+
{
|
|
563
|
+
path: relPath,
|
|
564
|
+
rule: "superseded-needs-link",
|
|
565
|
+
severity: "error",
|
|
566
|
+
message: 'status is "superseded" but superseded_by is empty'
|
|
567
|
+
}
|
|
568
|
+
];
|
|
569
|
+
}
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
function checkIndexedPage(relPath, data, body, cfg, refIndex) {
|
|
573
|
+
if (!isIndexed(relPath, data)) return [];
|
|
574
|
+
return [
|
|
575
|
+
...checkRequiredFields(relPath, data),
|
|
576
|
+
...checkTagsRequired(relPath, data),
|
|
577
|
+
...checkFolderSlug(relPath, data),
|
|
578
|
+
...checkVocabulary(relPath, data, cfg),
|
|
579
|
+
...checkScopePath(relPath, data),
|
|
580
|
+
...checkReferences(relPath, data, body, refIndex),
|
|
581
|
+
...checkSupersededLink(relPath, data),
|
|
582
|
+
...checkTags(relPath, data, cfg)
|
|
583
|
+
];
|
|
584
|
+
}
|
|
585
|
+
function validatePage(relPath, raw, cfg, refIndex) {
|
|
586
|
+
let parsed;
|
|
587
|
+
try {
|
|
588
|
+
parsed = parseFrontmatter(raw);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
return [
|
|
591
|
+
{
|
|
592
|
+
path: relPath,
|
|
593
|
+
rule: "frontmatter-parse",
|
|
594
|
+
severity: "error",
|
|
595
|
+
message: err instanceof Error ? err.message : String(err)
|
|
596
|
+
}
|
|
597
|
+
];
|
|
598
|
+
}
|
|
599
|
+
if (isPrimer(relPath)) {
|
|
600
|
+
return checkBodyLinks(relPath, parsed.content, refIndex);
|
|
601
|
+
}
|
|
602
|
+
return checkIndexedPage(relPath, parsed.data, parsed.content, cfg, refIndex);
|
|
603
|
+
}
|
|
604
|
+
function hasErrors(findings) {
|
|
605
|
+
return findings.some((f) => f.severity === "error");
|
|
606
|
+
}
|
|
607
|
+
function basename(relPath) {
|
|
608
|
+
const last = relPath.split("/").pop() ?? relPath;
|
|
609
|
+
return last.replace(/\.md$/, "");
|
|
610
|
+
}
|
|
611
|
+
function validateSupersession(pages) {
|
|
612
|
+
const adrs = /* @__PURE__ */ new Map();
|
|
613
|
+
for (const { path, data } of pages) {
|
|
614
|
+
if (data.kind !== "adr") continue;
|
|
615
|
+
adrs.set(basename(path), {
|
|
616
|
+
path,
|
|
617
|
+
supersedes: refTargets(data.supersedes),
|
|
618
|
+
supersededBy: refTargets(data.superseded_by)
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
const findings = [];
|
|
622
|
+
for (const [name, adr] of adrs) {
|
|
623
|
+
for (const target of adr.supersededBy) {
|
|
624
|
+
const other = adrs.get(target);
|
|
625
|
+
if (other && !other.supersedes.includes(name)) {
|
|
626
|
+
findings.push({
|
|
627
|
+
path: adr.path,
|
|
628
|
+
rule: "supersession-reciprocal",
|
|
629
|
+
severity: "error",
|
|
630
|
+
message: `superseded_by "${target}" which does not declare supersedes "${name}"`
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
for (const target of adr.supersedes) {
|
|
635
|
+
const other = adrs.get(target);
|
|
636
|
+
if (other && !other.supersededBy.includes(name)) {
|
|
637
|
+
findings.push({
|
|
638
|
+
path: adr.path,
|
|
639
|
+
rule: "supersession-reciprocal",
|
|
640
|
+
severity: "error",
|
|
641
|
+
message: `supersedes "${target}" which does not declare superseded_by "${name}"`
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return findings;
|
|
647
|
+
}
|
|
648
|
+
function locationKey(relPath) {
|
|
649
|
+
const seg = relPath.split("/");
|
|
650
|
+
if ((seg[0] === "projects" || seg[0] === "research") && seg[1]) {
|
|
651
|
+
return `${seg[0]}/${seg[1]}`;
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
function validateAmbiguousLinks(pages, basenameToPaths) {
|
|
656
|
+
const findings = [];
|
|
657
|
+
for (const { path, body } of pages) {
|
|
658
|
+
const here = locationKey(path);
|
|
659
|
+
for (const link of extractWikilinks(stripCode(body))) {
|
|
660
|
+
if (!link.target.endsWith(".md") || link.target.includes("/")) continue;
|
|
661
|
+
const base = basename(link.target);
|
|
662
|
+
const owners = basenameToPaths.get(base);
|
|
663
|
+
if (!owners || owners.length < 2) continue;
|
|
664
|
+
if (here !== null && owners.some((p) => locationKey(p) === here)) continue;
|
|
665
|
+
findings.push({
|
|
666
|
+
path,
|
|
667
|
+
rule: "ambiguous-link",
|
|
668
|
+
severity: "warning",
|
|
669
|
+
message: `bare link "[[${base}]]" is ambiguous \u2014 basename owned by ${owners.length} files, none in this scope`
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return findings;
|
|
674
|
+
}
|
|
675
|
+
async function validateVault(root) {
|
|
676
|
+
const cfg = await loadVaultConfig(root);
|
|
677
|
+
const all = (await walkMarkdown(root, ".")).map((abs) => ({
|
|
678
|
+
relPath: toRelativePath(root, abs),
|
|
679
|
+
abs
|
|
680
|
+
}));
|
|
681
|
+
const basenameToPaths = /* @__PURE__ */ new Map();
|
|
682
|
+
for (const f of all) {
|
|
683
|
+
const b = basename(f.relPath);
|
|
684
|
+
const owners = basenameToPaths.get(b);
|
|
685
|
+
if (owners) owners.push(f.relPath);
|
|
686
|
+
else basenameToPaths.set(b, [f.relPath]);
|
|
687
|
+
}
|
|
688
|
+
const refIndex = new Set(basenameToPaths.keys());
|
|
689
|
+
const files = all.filter((f) => SCAN_DOMAINS.some((d) => f.relPath.startsWith(`${d}/`)));
|
|
690
|
+
const findings = [];
|
|
691
|
+
const pages = [];
|
|
692
|
+
const linkPages = [];
|
|
693
|
+
for (const { relPath, abs } of files) {
|
|
694
|
+
const raw = await readFile3(abs, "utf8");
|
|
695
|
+
findings.push(...validatePage(relPath, raw, cfg, refIndex));
|
|
696
|
+
try {
|
|
697
|
+
const parsed = parseFrontmatter(raw);
|
|
698
|
+
pages.push({ path: relPath, data: parsed.data });
|
|
699
|
+
linkPages.push({ path: relPath, body: parsed.content });
|
|
700
|
+
} catch {
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
findings.push(...validateSupersession(pages));
|
|
704
|
+
findings.push(...validateAmbiguousLinks(linkPages, basenameToPaths));
|
|
705
|
+
return findings;
|
|
706
|
+
}
|
|
707
|
+
var REQUIRED_FIELDS, TAG_OPTIONAL_KINDS, FOLDER_PATTERNS;
|
|
708
|
+
var init_validate = __esm({
|
|
709
|
+
"../cli/src/validate.ts"() {
|
|
710
|
+
"use strict";
|
|
711
|
+
init_config();
|
|
712
|
+
init_frontmatter();
|
|
713
|
+
init_sync();
|
|
714
|
+
REQUIRED_FIELDS = {
|
|
715
|
+
project: [
|
|
716
|
+
"title",
|
|
717
|
+
"kind",
|
|
718
|
+
"scope",
|
|
719
|
+
"status",
|
|
720
|
+
"summary",
|
|
721
|
+
"updated",
|
|
722
|
+
"methodology",
|
|
723
|
+
"phase",
|
|
724
|
+
"repo"
|
|
725
|
+
],
|
|
726
|
+
spec: ["title", "kind", "scope", "status", "summary", "updated", "sources"],
|
|
727
|
+
adr: ["title", "kind", "scope", "status", "updated"],
|
|
728
|
+
plan: ["title", "kind", "scope", "status", "summary", "updated"],
|
|
729
|
+
ops: ["title", "kind", "scope", "status", "summary", "updated"],
|
|
730
|
+
story: [
|
|
731
|
+
"title",
|
|
732
|
+
"kind",
|
|
733
|
+
"scope",
|
|
734
|
+
"status",
|
|
735
|
+
"updated",
|
|
736
|
+
"parent",
|
|
737
|
+
"triage_state",
|
|
738
|
+
"category",
|
|
739
|
+
"blocked_by",
|
|
740
|
+
"sources"
|
|
741
|
+
],
|
|
742
|
+
topic: ["title", "kind", "status", "summary", "updated", "confidence"],
|
|
743
|
+
article: ["title", "kind", "status", "updated"],
|
|
744
|
+
src: ["title", "kind", "topic", "status", "summary", "updated"],
|
|
745
|
+
note: ["title", "updated"]
|
|
746
|
+
};
|
|
747
|
+
TAG_OPTIONAL_KINDS = /* @__PURE__ */ new Set(["artifact", "prompt", "glossary-entry", "pso-roster"]);
|
|
748
|
+
FOLDER_PATTERNS = {
|
|
749
|
+
spec: /^projects\/[^/]+\/spec\/spec-[^/]+\.md$/,
|
|
750
|
+
adr: /^projects\/[^/]+\/adr\/adr-[^/]+\.md$/,
|
|
751
|
+
ops: /^projects\/[^/]+\/ops\/ops-[^/]+\.md$/,
|
|
752
|
+
plan: /^projects\/[^/]+\/plan\/plan-[^/]+\.md$/,
|
|
753
|
+
story: /^projects\/[^/]+\/plan\/[^/]+\/story-[^/]+\.md$/,
|
|
754
|
+
project: /^projects\/[^/]+\/index\.md$/,
|
|
755
|
+
topic: /^research\/[^/]+\/index\.md$/,
|
|
756
|
+
src: /^research\/[^/]+\/src-[^/]+\.md$/
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// ../cli/src/cli.ts
|
|
762
|
+
var cli_exports = {};
|
|
763
|
+
__export(cli_exports, {
|
|
764
|
+
main: () => main,
|
|
765
|
+
resolveCli: () => resolveCli,
|
|
766
|
+
runSyncCommand: () => runSyncCommand,
|
|
767
|
+
runValidate: () => runValidate,
|
|
768
|
+
vaultRoot: () => vaultRoot
|
|
769
|
+
});
|
|
770
|
+
import { parseArgs } from "node:util";
|
|
771
|
+
function resolveCli(argv) {
|
|
772
|
+
const { positionals: positionals2 } = parseArgs({ args: argv, allowPositionals: true, strict: false });
|
|
773
|
+
const command2 = positionals2[0];
|
|
774
|
+
if (command2 === "sync") {
|
|
775
|
+
return { kind: "run", command: "sync" };
|
|
776
|
+
}
|
|
777
|
+
if (command2 === "validate") {
|
|
778
|
+
return { kind: "run", command: "validate" };
|
|
779
|
+
}
|
|
780
|
+
if (command2 === void 0) {
|
|
781
|
+
return { kind: "error", message: "usage: wiki <sync|validate>" };
|
|
782
|
+
}
|
|
783
|
+
return { kind: "error", message: `unknown command: ${command2}` };
|
|
784
|
+
}
|
|
785
|
+
function vaultRoot() {
|
|
786
|
+
const root = process.env.WIKI_VAULT;
|
|
787
|
+
if (!root) {
|
|
788
|
+
console.error("WIKI_VAULT is not set");
|
|
789
|
+
process.exit(1);
|
|
790
|
+
}
|
|
791
|
+
return root;
|
|
792
|
+
}
|
|
793
|
+
function reportFindings(findings) {
|
|
794
|
+
for (const f of findings) {
|
|
795
|
+
console.error(`${f.severity}: ${f.path} [${f.rule}] ${f.message}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
async function runValidate() {
|
|
799
|
+
const findings = await validateVault(vaultRoot());
|
|
800
|
+
reportFindings(findings);
|
|
801
|
+
console.log(`validate: ${findings.length} finding(s)`);
|
|
802
|
+
process.exit(hasErrors(findings) ? 1 : 0);
|
|
803
|
+
}
|
|
804
|
+
async function runSyncCommand() {
|
|
805
|
+
const findings = await validateVault(vaultRoot());
|
|
806
|
+
reportFindings(findings);
|
|
807
|
+
if (hasErrors(findings)) {
|
|
808
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
809
|
+
console.error(`sync aborted: ${errors} validation error(s); no database writes`);
|
|
810
|
+
process.exit(1);
|
|
811
|
+
}
|
|
812
|
+
await runSync();
|
|
813
|
+
}
|
|
814
|
+
async function main() {
|
|
815
|
+
const resolution = resolveCli(process.argv.slice(2));
|
|
816
|
+
if (resolution.kind === "error") {
|
|
817
|
+
console.error(resolution.message);
|
|
818
|
+
process.exit(1);
|
|
819
|
+
}
|
|
820
|
+
if (resolution.command === "sync") {
|
|
821
|
+
await runSyncCommand();
|
|
822
|
+
} else {
|
|
823
|
+
await runValidate();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
var init_cli = __esm({
|
|
827
|
+
"../cli/src/cli.ts"() {
|
|
828
|
+
"use strict";
|
|
829
|
+
init_sync();
|
|
830
|
+
init_validate();
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// ../mcp/src/config.ts
|
|
835
|
+
import { z as z3 } from "zod";
|
|
836
|
+
function loadConfig(env = process.env) {
|
|
837
|
+
const parsed = EnvSchema2.safeParse(env);
|
|
838
|
+
if (!parsed.success) {
|
|
839
|
+
const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
840
|
+
throw new Error(`Invalid environment configuration:
|
|
841
|
+
${issues}`);
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
wikiVault: parsed.data.WIKI_VAULT,
|
|
845
|
+
logLevel: parsed.data.LOG_LEVEL,
|
|
846
|
+
serverName: parsed.data.SERVER_NAME,
|
|
847
|
+
serverVersion: parsed.data.SERVER_VERSION
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
var EnvSchema2;
|
|
851
|
+
var init_config2 = __esm({
|
|
852
|
+
"../mcp/src/config.ts"() {
|
|
853
|
+
"use strict";
|
|
854
|
+
EnvSchema2 = z3.object({
|
|
855
|
+
WIKI_VAULT: z3.string().min(1).describe("Absolute path to the Obsidian vault root"),
|
|
856
|
+
LOG_LEVEL: z3.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]).default("info").describe("Pino log level. Logs go to stderr to keep the stdio JSON-RPC stream clean."),
|
|
857
|
+
SERVER_NAME: z3.string().default("wiki-mcp"),
|
|
858
|
+
SERVER_VERSION: z3.string().default("0.0.0")
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
// ../mcp/src/db.ts
|
|
864
|
+
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
865
|
+
import { homedir as homedir2 } from "node:os";
|
|
866
|
+
import { join as join3 } from "node:path";
|
|
867
|
+
function createDatabase() {
|
|
868
|
+
const dbDir = join3(homedir2(), ".kmd", "db");
|
|
869
|
+
const dbPath = join3(dbDir, "index.db");
|
|
870
|
+
mkdirSync2(dbDir, { recursive: true });
|
|
871
|
+
return openDatabase(dbPath);
|
|
872
|
+
}
|
|
873
|
+
var init_db = __esm({
|
|
874
|
+
"../mcp/src/db.ts"() {
|
|
875
|
+
"use strict";
|
|
876
|
+
init_database();
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// ../mcp/src/lib/diag.ts
|
|
881
|
+
import { appendFileSync, mkdirSync as mkdirSync3 } from "node:fs";
|
|
882
|
+
import { homedir as homedir3 } from "node:os";
|
|
883
|
+
import { join as join4 } from "node:path";
|
|
884
|
+
function diag(msg, data) {
|
|
885
|
+
try {
|
|
886
|
+
const line = data ? `${(/* @__PURE__ */ new Date()).toISOString()} pid=${process.pid} ${msg} ${JSON.stringify(data)}
|
|
887
|
+
` : `${(/* @__PURE__ */ new Date()).toISOString()} pid=${process.pid} ${msg}
|
|
888
|
+
`;
|
|
889
|
+
appendFileSync(DIAG_LOG_PATH, line);
|
|
890
|
+
} catch {
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
var DIAG_DIR, DIAG_LOG_PATH;
|
|
894
|
+
var init_diag = __esm({
|
|
895
|
+
"../mcp/src/lib/diag.ts"() {
|
|
896
|
+
"use strict";
|
|
897
|
+
DIAG_DIR = join4(homedir3(), ".local", "state", "wiki-mcp");
|
|
898
|
+
DIAG_LOG_PATH = join4(DIAG_DIR, "server.log");
|
|
899
|
+
try {
|
|
900
|
+
mkdirSync3(DIAG_DIR, { recursive: true });
|
|
901
|
+
} catch {
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// ../mcp/src/lib/logger.ts
|
|
907
|
+
import pino from "pino";
|
|
908
|
+
function createLogger(level, name) {
|
|
909
|
+
const streams = [
|
|
910
|
+
{ level, stream: pino.destination(2) },
|
|
911
|
+
{
|
|
912
|
+
level,
|
|
913
|
+
stream: pino.destination({ dest: DIAG_LOG_PATH, sync: false, mkdir: true })
|
|
914
|
+
}
|
|
915
|
+
];
|
|
916
|
+
return pino({ name, level, base: { pid: process.pid } }, pino.multistream(streams));
|
|
917
|
+
}
|
|
918
|
+
var init_logger = __esm({
|
|
919
|
+
"../mcp/src/lib/logger.ts"() {
|
|
920
|
+
"use strict";
|
|
921
|
+
init_diag();
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// ../mcp/src/resources/templates.ts
|
|
926
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
927
|
+
import { join as join5 } from "node:path";
|
|
928
|
+
function registerTemplateResources(mcp, vaultRoot2) {
|
|
929
|
+
const dir = join5(vaultRoot2, "templates");
|
|
930
|
+
for (const tmpl of TEMPLATES) {
|
|
931
|
+
mcp.registerResource(
|
|
932
|
+
tmpl.name,
|
|
933
|
+
tmpl.uri,
|
|
934
|
+
{ description: tmpl.description, mimeType: "text/markdown" },
|
|
935
|
+
async (uri) => {
|
|
936
|
+
const text = await readFile4(join5(dir, tmpl.file), "utf8");
|
|
937
|
+
return {
|
|
938
|
+
contents: [
|
|
939
|
+
{
|
|
940
|
+
uri: uri.toString(),
|
|
941
|
+
mimeType: "text/markdown",
|
|
942
|
+
text
|
|
943
|
+
}
|
|
944
|
+
]
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
var TEMPLATES;
|
|
951
|
+
var init_templates = __esm({
|
|
952
|
+
"../mcp/src/resources/templates.ts"() {
|
|
953
|
+
"use strict";
|
|
954
|
+
TEMPLATES = [
|
|
955
|
+
{
|
|
956
|
+
uri: "wiki://template/project/index",
|
|
957
|
+
name: "Project index",
|
|
958
|
+
file: "project-index.md",
|
|
959
|
+
description: "Identity card for a project. Frontmatter-heavy: methodology, phase, repo, scope, summary, tags. Short body."
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
uri: "wiki://template/project/primer",
|
|
963
|
+
name: "Project primer",
|
|
964
|
+
file: "project-primer.md",
|
|
965
|
+
description: "Human-authored narrative context for a project. Free-form body. Inlined into the prime tool response."
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
uri: "wiki://template/project/spec",
|
|
969
|
+
name: "Project spec",
|
|
970
|
+
file: "project-spec.md",
|
|
971
|
+
description: "Specification page \u2014 how a system or feature works (state of the world, not decision)."
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
uri: "wiki://template/project/adr",
|
|
975
|
+
name: "Project ADR",
|
|
976
|
+
file: "project-adr.md",
|
|
977
|
+
description: "Architecture Decision Record \u2014 pinned moment of choice. Sections: Status, Context, Decision, Rationale, Consequences."
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
uri: "wiki://template/project/plan",
|
|
981
|
+
name: "Project plan",
|
|
982
|
+
file: "project-plan.md",
|
|
983
|
+
description: "Plan for a phase or initiative. Sections: Goal, Scope, Milestones, Dependencies, Status Log."
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
uri: "wiki://template/project/ops",
|
|
987
|
+
name: "Project ops",
|
|
988
|
+
file: "project-ops.md",
|
|
989
|
+
description: "Operational runbook \u2014 how to run or operate a system. Procedural."
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
uri: "wiki://template/project/story",
|
|
993
|
+
name: "Project story",
|
|
994
|
+
file: "project-story.md",
|
|
995
|
+
description: "User story with Gherkin scenarios and inline implementation slices. Lives at plan/{plan-name}/story-N-{slug}.md. Frontmatter carries triage_state, category, blocked_by, parent."
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
uri: "wiki://template/research/index",
|
|
999
|
+
name: "Research index",
|
|
1000
|
+
file: "research-index.md",
|
|
1001
|
+
description: "Topic identity card for a research area. Frontmatter: confidence, source_count, summary, tags."
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
uri: "wiki://template/research/article",
|
|
1005
|
+
name: "Research article",
|
|
1006
|
+
file: "research-article.md",
|
|
1007
|
+
description: "Wikipedia-style article about a concept, entity, or system. Original synthesis with sources."
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
uri: "wiki://template/research/src",
|
|
1011
|
+
name: "Research source",
|
|
1012
|
+
file: "research-src.md",
|
|
1013
|
+
description: "Summary of an external source (paper, talk, doc) with citation. The only `src-` prefix in research."
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
uri: "wiki://template/note",
|
|
1017
|
+
name: "Note",
|
|
1018
|
+
file: "note.md",
|
|
1019
|
+
description: "Low-ceremony everyday note. Capture fast, sort later."
|
|
1020
|
+
}
|
|
1021
|
+
];
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
// ../mcp/src/frontmatter.ts
|
|
1026
|
+
import { parse as parseYaml2 } from "yaml";
|
|
1027
|
+
function parseFrontmatter2(raw) {
|
|
1028
|
+
const match = FRONTMATTER_RE2.exec(raw);
|
|
1029
|
+
if (!match) {
|
|
1030
|
+
return { data: {}, content: raw };
|
|
1031
|
+
}
|
|
1032
|
+
const yamlText = match[1] ?? "";
|
|
1033
|
+
const content = raw.slice(match[0].length);
|
|
1034
|
+
if (yamlText.trim() === "") {
|
|
1035
|
+
return { data: {}, content };
|
|
1036
|
+
}
|
|
1037
|
+
const parsed = parseYaml2(yamlText);
|
|
1038
|
+
const data = parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
1039
|
+
return { data, content };
|
|
1040
|
+
}
|
|
1041
|
+
var FRONTMATTER_RE2;
|
|
1042
|
+
var init_frontmatter2 = __esm({
|
|
1043
|
+
"../mcp/src/frontmatter.ts"() {
|
|
1044
|
+
"use strict";
|
|
1045
|
+
FRONTMATTER_RE2 = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n?/;
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// ../mcp/src/lib/toolResponse.ts
|
|
1050
|
+
function textJson(data) {
|
|
1051
|
+
return {
|
|
1052
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
1053
|
+
structuredContent: data
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
function textWithStruct(markdown, data) {
|
|
1057
|
+
return {
|
|
1058
|
+
content: [{ type: "text", text: markdown }],
|
|
1059
|
+
structuredContent: data
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function textError(error) {
|
|
1063
|
+
const payload = {
|
|
1064
|
+
code: error.code,
|
|
1065
|
+
message: error.message,
|
|
1066
|
+
details: error.details ?? {}
|
|
1067
|
+
};
|
|
1068
|
+
return {
|
|
1069
|
+
isError: true,
|
|
1070
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1071
|
+
structuredContent: payload
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
var init_toolResponse = __esm({
|
|
1075
|
+
"../mcp/src/lib/toolResponse.ts"() {
|
|
1076
|
+
"use strict";
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// ../mcp/src/tools/prime.ts
|
|
1081
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
1082
|
+
import { basename as basename2, join as join6 } from "node:path";
|
|
1083
|
+
import { z as z4 } from "zod";
|
|
1084
|
+
function pathSlug(p) {
|
|
1085
|
+
return basename2(p).replace(/\.md$/, "");
|
|
1086
|
+
}
|
|
1087
|
+
async function readIndexFm(vaultRoot2, scope) {
|
|
1088
|
+
try {
|
|
1089
|
+
const raw = await readFile5(join6(vaultRoot2, "projects", scope, "index.md"), "utf8");
|
|
1090
|
+
return parseFrontmatter2(raw).data;
|
|
1091
|
+
} catch {
|
|
1092
|
+
return {};
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async function readPrimer(vaultRoot2, scope) {
|
|
1096
|
+
try {
|
|
1097
|
+
const raw = await readFile5(join6(vaultRoot2, "projects", scope, "primer.md"), "utf8");
|
|
1098
|
+
return parseFrontmatter2(raw).content.trim().replace(/^#\s+[^\n]+\n+/, "");
|
|
1099
|
+
} catch {
|
|
1100
|
+
return "";
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
function sanitizeFtsQuery(raw) {
|
|
1104
|
+
return raw.split(/\s+/).map((t) => t.replace(/["\-*():^]/g, "")).filter((t) => t.length > 0 && !FTS5_KEYWORDS.has(t)).join(" ");
|
|
1105
|
+
}
|
|
1106
|
+
async function prime(deps, input) {
|
|
1107
|
+
const { db, vaultRoot: vaultRoot2, vaultConfig } = deps;
|
|
1108
|
+
const { scope, task } = input;
|
|
1109
|
+
const [fm, primer] = await Promise.all([
|
|
1110
|
+
readIndexFm(vaultRoot2, scope),
|
|
1111
|
+
readPrimer(vaultRoot2, scope)
|
|
1112
|
+
]);
|
|
1113
|
+
const counts = db.prepare("SELECT kind, count(*) AS count FROM pages WHERE scope = ? GROUP BY kind").all(scope);
|
|
1114
|
+
const adrs = db.prepare(
|
|
1115
|
+
"SELECT path, title, summary FROM pages WHERE scope = ? AND kind = 'adr' AND status = 'active' ORDER BY updated DESC"
|
|
1116
|
+
).all(scope);
|
|
1117
|
+
const planRow = db.prepare(
|
|
1118
|
+
"SELECT path, title FROM pages WHERE scope = ? AND kind = 'plan' AND status = 'active' ORDER BY updated DESC LIMIT 1"
|
|
1119
|
+
).get(scope);
|
|
1120
|
+
const tags = db.prepare(
|
|
1121
|
+
`SELECT j.value AS tag, count(*) AS cnt
|
|
1122
|
+
FROM pages, json_each(pages.tags) AS j
|
|
1123
|
+
WHERE scope = ?
|
|
1124
|
+
GROUP BY j.value
|
|
1125
|
+
ORDER BY cnt DESC
|
|
1126
|
+
LIMIT 10`
|
|
1127
|
+
).all(scope);
|
|
1128
|
+
const hubs = db.prepare(
|
|
1129
|
+
`SELECT p.path, p.title, count(*) AS inbound
|
|
1130
|
+
FROM links l JOIN pages p ON p.path = l.target_path
|
|
1131
|
+
WHERE p.scope = ?
|
|
1132
|
+
GROUP BY p.path, p.title
|
|
1133
|
+
ORDER BY inbound DESC LIMIT 5`
|
|
1134
|
+
).all(scope);
|
|
1135
|
+
const events = db.prepare("SELECT path, operation, ts FROM events WHERE scope = ? ORDER BY ts DESC LIMIT 5").all(scope);
|
|
1136
|
+
const crossScope = db.prepare(
|
|
1137
|
+
`SELECT pf.scope AS from_scope, l.source_path AS from_path, l.target_path AS to_path
|
|
1138
|
+
FROM links l
|
|
1139
|
+
JOIN pages pt ON pt.path = l.target_path
|
|
1140
|
+
JOIN pages pf ON pf.path = l.source_path
|
|
1141
|
+
WHERE pt.scope = ? AND pf.scope IS NOT NULL AND pf.scope != ?
|
|
1142
|
+
LIMIT 10`
|
|
1143
|
+
).all(scope, scope);
|
|
1144
|
+
let relevant = [];
|
|
1145
|
+
if (task) {
|
|
1146
|
+
const ftsQuery = sanitizeFtsQuery(task);
|
|
1147
|
+
if (ftsQuery) {
|
|
1148
|
+
relevant = db.prepare(
|
|
1149
|
+
`SELECT p.path, p.title, bm25(pages_fts) AS score
|
|
1150
|
+
FROM pages_fts
|
|
1151
|
+
JOIN pages p ON p.id = pages_fts.rowid
|
|
1152
|
+
WHERE pages_fts MATCH ? AND p.scope = ?
|
|
1153
|
+
ORDER BY bm25(pages_fts) LIMIT 3`
|
|
1154
|
+
).all(ftsQuery, scope).map((row) => ({
|
|
1155
|
+
path: row.path,
|
|
1156
|
+
title: row.title,
|
|
1157
|
+
score: row.score
|
|
1158
|
+
}));
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const countsRecord = {};
|
|
1162
|
+
for (const row of counts) countsRecord[row.kind] = Number(row.count);
|
|
1163
|
+
const data = {
|
|
1164
|
+
scope,
|
|
1165
|
+
title: fm.title ?? null,
|
|
1166
|
+
methodology: fm.methodology ?? null,
|
|
1167
|
+
phase: typeof fm.phase === "number" ? fm.phase : null,
|
|
1168
|
+
summary: fm.summary ?? "",
|
|
1169
|
+
primer,
|
|
1170
|
+
counts: countsRecord,
|
|
1171
|
+
active_adrs: adrs.map((r) => ({
|
|
1172
|
+
path: r.path,
|
|
1173
|
+
slug: pathSlug(r.path),
|
|
1174
|
+
title: r.title,
|
|
1175
|
+
summary: r.summary
|
|
1176
|
+
})),
|
|
1177
|
+
current_plan: planRow ? {
|
|
1178
|
+
path: planRow.path,
|
|
1179
|
+
slug: pathSlug(planRow.path),
|
|
1180
|
+
title: planRow.title
|
|
1181
|
+
} : null,
|
|
1182
|
+
top_tags: tags.map((r) => r.tag),
|
|
1183
|
+
hub_pages: hubs.map((r) => ({
|
|
1184
|
+
path: r.path,
|
|
1185
|
+
title: r.title,
|
|
1186
|
+
inbound: Number(r.inbound)
|
|
1187
|
+
})),
|
|
1188
|
+
recent: events.map((r) => ({
|
|
1189
|
+
path: r.path,
|
|
1190
|
+
operation: r.operation,
|
|
1191
|
+
date: r.ts.slice(0, 10)
|
|
1192
|
+
})),
|
|
1193
|
+
relevant,
|
|
1194
|
+
cross_scope: crossScope.map((r) => ({
|
|
1195
|
+
from_scope: r.from_scope,
|
|
1196
|
+
from_path: r.from_path,
|
|
1197
|
+
to_path: r.to_path
|
|
1198
|
+
}))
|
|
1199
|
+
};
|
|
1200
|
+
return { markdown: renderMarkdown(data, vaultConfig, task), data };
|
|
1201
|
+
}
|
|
1202
|
+
function renderMarkdown(d, config, task) {
|
|
1203
|
+
const lines = [];
|
|
1204
|
+
const phaseLabel = d.phase !== null ? d.methodology ? `Phase ${d.phase} (${d.methodology})` : `Phase ${d.phase}` : "";
|
|
1205
|
+
const header = phaseLabel ? `${d.scope} \u2014 ${phaseLabel}` : d.scope;
|
|
1206
|
+
lines.push(`# ${header}`);
|
|
1207
|
+
if (d.summary) lines.push(d.summary);
|
|
1208
|
+
if (d.primer) {
|
|
1209
|
+
lines.push("", "## Primer", d.primer);
|
|
1210
|
+
}
|
|
1211
|
+
if (d.active_adrs.length > 0) {
|
|
1212
|
+
lines.push("", "## Active Decisions");
|
|
1213
|
+
for (const a of d.active_adrs) {
|
|
1214
|
+
lines.push(`- ${a.slug}: ${a.summary ?? a.title}`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (d.current_plan) {
|
|
1218
|
+
lines.push("", "## Current Plan");
|
|
1219
|
+
lines.push(`${d.current_plan.slug}: ${d.current_plan.title}`);
|
|
1220
|
+
}
|
|
1221
|
+
const countEntries = Object.entries(d.counts);
|
|
1222
|
+
if (countEntries.length > 0) {
|
|
1223
|
+
lines.push("", "## Pages");
|
|
1224
|
+
lines.push(countEntries.map(([k, n]) => `${k}: ${n}`).join(" | "));
|
|
1225
|
+
}
|
|
1226
|
+
lines.push("", "## Vocabulary");
|
|
1227
|
+
lines.push(`kinds: ${config.kinds.join(", ")}`);
|
|
1228
|
+
lines.push(`statuses: ${config.statuses.join(", ")}`);
|
|
1229
|
+
lines.push(`tags: ${config.tags.canonical.join(", ")}`);
|
|
1230
|
+
if (d.top_tags.length > 0) {
|
|
1231
|
+
lines.push("", "## Tags");
|
|
1232
|
+
lines.push(d.top_tags.join(", "));
|
|
1233
|
+
}
|
|
1234
|
+
if (d.hub_pages.length > 0) {
|
|
1235
|
+
lines.push("", "## Hubs");
|
|
1236
|
+
for (const h of d.hub_pages) {
|
|
1237
|
+
lines.push(`- ${pathSlug(h.path)} (${h.inbound} inbound)`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (d.recent.length > 0) {
|
|
1241
|
+
lines.push("", "## Recent");
|
|
1242
|
+
for (const r of d.recent) {
|
|
1243
|
+
lines.push(`- [${r.date}] ${pathSlug(r.path)} ${r.operation}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
if (d.relevant.length > 0) {
|
|
1247
|
+
lines.push("", `## Relevant (task: "${task}")`);
|
|
1248
|
+
for (const r of d.relevant) {
|
|
1249
|
+
lines.push(`- ${pathSlug(r.path)} (${r.score.toFixed(2)})`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
if (d.cross_scope.length > 0) {
|
|
1253
|
+
lines.push("", "## Cross-scope");
|
|
1254
|
+
for (const c of d.cross_scope) {
|
|
1255
|
+
lines.push(`- ${c.from_scope}/${pathSlug(c.from_path)} \u2192 ${pathSlug(c.to_path)}`);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return lines.join("\n");
|
|
1259
|
+
}
|
|
1260
|
+
async function handlePrime(deps, input) {
|
|
1261
|
+
if (!Object.hasOwn(deps.vaultConfig.scopes, input.scope)) {
|
|
1262
|
+
const valid = Object.keys(deps.vaultConfig.scopes).sort().join(", ");
|
|
1263
|
+
return textError({
|
|
1264
|
+
code: "UNKNOWN_SCOPE",
|
|
1265
|
+
message: `unknown scope "${input.scope}"; valid scopes: ${valid}`
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
const { markdown, data } = await prime(deps, input);
|
|
1270
|
+
return textWithStruct(markdown, data);
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
return textError({
|
|
1273
|
+
code: "PRIME_FAILED",
|
|
1274
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
var PrimeInputSchema, FTS5_KEYWORDS;
|
|
1279
|
+
var init_prime = __esm({
|
|
1280
|
+
"../mcp/src/tools/prime.ts"() {
|
|
1281
|
+
"use strict";
|
|
1282
|
+
init_frontmatter2();
|
|
1283
|
+
init_toolResponse();
|
|
1284
|
+
PrimeInputSchema = z4.object({
|
|
1285
|
+
scope: z4.string().min(1).describe("Project scope to prime (matches projects/{scope}/ in the vault)."),
|
|
1286
|
+
task: z4.string().optional().describe("Optional task description; surfaces top-3 tsvector-ranked relevant pages.")
|
|
1287
|
+
});
|
|
1288
|
+
FTS5_KEYWORDS = /* @__PURE__ */ new Set(["AND", "OR", "NOT", "NEAR"]);
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
// ../mcp/src/tools/search.ts
|
|
1293
|
+
import { z as z5 } from "zod";
|
|
1294
|
+
function sanitizeFtsQuery2(raw) {
|
|
1295
|
+
return raw.split(/\s+/).map((t) => t.replace(/["\-*():^]/g, "")).filter((t) => t.length > 0 && !FTS5_KEYWORDS2.has(t)).join(" ");
|
|
1296
|
+
}
|
|
1297
|
+
function search(deps, input) {
|
|
1298
|
+
const ftsQuery = sanitizeFtsQuery2(input.query);
|
|
1299
|
+
if (!ftsQuery) return { results: [] };
|
|
1300
|
+
let sql = `SELECT p.path, p.title, p.kind, p.summary, p.scope, bm25(pages_fts) AS score
|
|
1301
|
+
FROM pages_fts
|
|
1302
|
+
JOIN pages p ON p.id = pages_fts.rowid
|
|
1303
|
+
WHERE pages_fts MATCH ?`;
|
|
1304
|
+
const params = [ftsQuery];
|
|
1305
|
+
if (input.scope) {
|
|
1306
|
+
sql += " AND p.scope = ?";
|
|
1307
|
+
params.push(input.scope);
|
|
1308
|
+
}
|
|
1309
|
+
if (input.kind) {
|
|
1310
|
+
sql += " AND p.kind = ?";
|
|
1311
|
+
params.push(input.kind);
|
|
1312
|
+
}
|
|
1313
|
+
sql += " ORDER BY bm25(pages_fts) LIMIT ?";
|
|
1314
|
+
params.push(input.limit);
|
|
1315
|
+
const rows = deps.db.prepare(sql).all(...params);
|
|
1316
|
+
return {
|
|
1317
|
+
results: rows.map((r) => ({
|
|
1318
|
+
path: r.path,
|
|
1319
|
+
title: r.title,
|
|
1320
|
+
kind: r.kind,
|
|
1321
|
+
summary: r.summary,
|
|
1322
|
+
scope: r.scope,
|
|
1323
|
+
score: r.score
|
|
1324
|
+
}))
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
function handleSearch(deps, input) {
|
|
1328
|
+
try {
|
|
1329
|
+
return textJson(search(deps, input));
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
return textError({
|
|
1332
|
+
code: "SEARCH_FAILED",
|
|
1333
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
var SearchInputSchema, FTS5_KEYWORDS2;
|
|
1338
|
+
var init_search = __esm({
|
|
1339
|
+
"../mcp/src/tools/search.ts"() {
|
|
1340
|
+
"use strict";
|
|
1341
|
+
init_toolResponse();
|
|
1342
|
+
SearchInputSchema = z5.object({
|
|
1343
|
+
query: z5.string().min(1).describe(
|
|
1344
|
+
"Natural-language search query. Matched against title, summary, and body via SQLite FTS5."
|
|
1345
|
+
),
|
|
1346
|
+
scope: z5.string().optional().describe('Optional: restrict to a project scope (e.g. "ontology", "sotto").'),
|
|
1347
|
+
kind: z5.string().optional().describe(
|
|
1348
|
+
"Optional: restrict to a kind. Project kinds: spec, adr, plan, ops. Research kinds: article, src. Misc: note."
|
|
1349
|
+
),
|
|
1350
|
+
limit: z5.number().int().min(1).max(50).default(5).describe("Maximum number of ranked results to return. Default 5.")
|
|
1351
|
+
});
|
|
1352
|
+
FTS5_KEYWORDS2 = /* @__PURE__ */ new Set(["AND", "OR", "NOT", "NEAR"]);
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
// ../mcp/src/server.ts
|
|
1357
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1358
|
+
function buildServer(args) {
|
|
1359
|
+
const { name, version, vaultRoot: vaultRoot2, db, logger, vaultConfig } = args;
|
|
1360
|
+
const mcp = new McpServer({ name, version }, { capabilities: { tools: {}, resources: {} } });
|
|
1361
|
+
mcp.tool(
|
|
1362
|
+
"prime",
|
|
1363
|
+
"Orient on a project. Returns a markdown briefing with: identity (scope, phase, methodology, summary), the human-authored primer.md inlined, active ADRs, current plan, page counts, top tags, hub pages (most-linked-to), recent events, cross-scope references, and \u2014 when `task` is provided \u2014 the top 3 tsvector-ranked relevant pages. Call once at session start. Empty sections are omitted to keep the surface lean.",
|
|
1364
|
+
PrimeInputSchema.shape,
|
|
1365
|
+
async (input) => {
|
|
1366
|
+
logger.debug({ tool: "prime", input }, "tool call");
|
|
1367
|
+
return handlePrime({ db, vaultRoot: vaultRoot2, vaultConfig }, input);
|
|
1368
|
+
}
|
|
1369
|
+
);
|
|
1370
|
+
mcp.tool(
|
|
1371
|
+
"search",
|
|
1372
|
+
"Full-text search across wiki pages via SQLite FTS5. Returns ranked candidates {path, title, kind, summary, score} \u2014 never page bodies. The agent reads the returned paths directly from the filesystem.",
|
|
1373
|
+
SearchInputSchema.shape,
|
|
1374
|
+
async (input) => {
|
|
1375
|
+
logger.debug({ tool: "search", input }, "tool call");
|
|
1376
|
+
return handleSearch({ db }, input);
|
|
1377
|
+
}
|
|
1378
|
+
);
|
|
1379
|
+
registerTemplateResources(mcp, vaultRoot2);
|
|
1380
|
+
return mcp;
|
|
1381
|
+
}
|
|
1382
|
+
var init_server = __esm({
|
|
1383
|
+
"../mcp/src/server.ts"() {
|
|
1384
|
+
"use strict";
|
|
1385
|
+
init_templates();
|
|
1386
|
+
init_prime();
|
|
1387
|
+
init_search();
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
// ../mcp/src/vault-config.ts
|
|
1392
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
1393
|
+
import { join as join7 } from "node:path";
|
|
1394
|
+
import { parse as parse2 } from "yaml";
|
|
1395
|
+
import { z as z6 } from "zod";
|
|
1396
|
+
async function loadVaultConfig2(vaultRoot2) {
|
|
1397
|
+
const path = join7(vaultRoot2, "vault.yaml");
|
|
1398
|
+
let raw;
|
|
1399
|
+
try {
|
|
1400
|
+
raw = await readFile6(path, "utf8");
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
throw new Error(`vault.yaml not found at ${path}`, { cause: err });
|
|
1403
|
+
}
|
|
1404
|
+
const parsed = VaultConfigSchema2.safeParse(parse2(raw));
|
|
1405
|
+
if (!parsed.success) {
|
|
1406
|
+
const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
1407
|
+
throw new Error(`Invalid vault.yaml at ${path}:
|
|
1408
|
+
${issues}`);
|
|
1409
|
+
}
|
|
1410
|
+
return parsed.data;
|
|
1411
|
+
}
|
|
1412
|
+
var ScopeSchema2, VaultConfigSchema2;
|
|
1413
|
+
var init_vault_config = __esm({
|
|
1414
|
+
"../mcp/src/vault-config.ts"() {
|
|
1415
|
+
"use strict";
|
|
1416
|
+
ScopeSchema2 = z6.object({
|
|
1417
|
+
repo: z6.string().optional(),
|
|
1418
|
+
methodology: z6.enum(["sdd", "tdd", "hybrid"]).optional(),
|
|
1419
|
+
status: z6.string()
|
|
1420
|
+
});
|
|
1421
|
+
VaultConfigSchema2 = z6.object({
|
|
1422
|
+
scopes: z6.record(z6.string(), ScopeSchema2),
|
|
1423
|
+
kinds: z6.array(z6.string()),
|
|
1424
|
+
statuses: z6.array(z6.string()),
|
|
1425
|
+
methodologies: z6.array(z6.string()),
|
|
1426
|
+
tags: z6.object({
|
|
1427
|
+
canonical: z6.array(z6.string()),
|
|
1428
|
+
aliases: z6.record(z6.string(), z6.string())
|
|
1429
|
+
})
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
// ../mcp/src/start.ts
|
|
1435
|
+
var start_exports = {};
|
|
1436
|
+
__export(start_exports, {
|
|
1437
|
+
startMcpServer: () => startMcpServer
|
|
1438
|
+
});
|
|
1439
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1440
|
+
async function startMcpServer() {
|
|
1441
|
+
diag("main entered");
|
|
1442
|
+
const config = loadConfig();
|
|
1443
|
+
diag("config loaded", { vault: config.wikiVault, level: config.logLevel });
|
|
1444
|
+
const vaultConfig = await loadVaultConfig2(config.wikiVault);
|
|
1445
|
+
diag("vault config loaded", {
|
|
1446
|
+
scopes: Object.keys(vaultConfig.scopes).length,
|
|
1447
|
+
kinds: vaultConfig.kinds.length,
|
|
1448
|
+
statuses: vaultConfig.statuses.length
|
|
1449
|
+
});
|
|
1450
|
+
const logger = createLogger(config.logLevel, config.serverName);
|
|
1451
|
+
logger.info(
|
|
1452
|
+
{ vault: config.wikiVault, serverName: config.serverName, serverVersion: config.serverVersion },
|
|
1453
|
+
"starting wiki-mcp on stdio"
|
|
1454
|
+
);
|
|
1455
|
+
const db = createDatabase();
|
|
1456
|
+
diag("database opened");
|
|
1457
|
+
const mcp = buildServer({
|
|
1458
|
+
name: config.serverName,
|
|
1459
|
+
version: config.serverVersion,
|
|
1460
|
+
vaultRoot: config.wikiVault,
|
|
1461
|
+
db,
|
|
1462
|
+
logger,
|
|
1463
|
+
vaultConfig
|
|
1464
|
+
});
|
|
1465
|
+
diag("server built");
|
|
1466
|
+
const shutdown = async (signal) => {
|
|
1467
|
+
logger.info({ signal }, "shutting down");
|
|
1468
|
+
diag("shutting down", { signal });
|
|
1469
|
+
try {
|
|
1470
|
+
await mcp.close();
|
|
1471
|
+
db.close();
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
logger.error({ err }, "error during shutdown");
|
|
1474
|
+
}
|
|
1475
|
+
process.exit(0);
|
|
1476
|
+
};
|
|
1477
|
+
process.once("SIGINT", (s) => void shutdown(s));
|
|
1478
|
+
process.once("SIGTERM", (s) => void shutdown(s));
|
|
1479
|
+
const transport = new StdioServerTransport();
|
|
1480
|
+
await mcp.connect(transport);
|
|
1481
|
+
logger.info("wiki-mcp ready");
|
|
1482
|
+
diag("ready and connected to transport");
|
|
1483
|
+
}
|
|
1484
|
+
var init_start = __esm({
|
|
1485
|
+
"../mcp/src/start.ts"() {
|
|
1486
|
+
"use strict";
|
|
1487
|
+
init_config2();
|
|
1488
|
+
init_db();
|
|
1489
|
+
init_diag();
|
|
1490
|
+
init_logger();
|
|
1491
|
+
init_server();
|
|
1492
|
+
init_vault_config();
|
|
1493
|
+
}
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
// bin/kmd.ts
|
|
1497
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
1498
|
+
var USAGE = `usage: kmd <command> [options]
|
|
1499
|
+
|
|
1500
|
+
commands:
|
|
1501
|
+
sync vault \u2192 index sync (runs validate first)
|
|
1502
|
+
validate [<path>] deterministic vault checker (default: $WIKI_VAULT)
|
|
1503
|
+
mcp [<vault-root>] start the stdio MCP server (default: $WIKI_VAULT)
|
|
1504
|
+
db reset delete and recreate the index
|
|
1505
|
+
|
|
1506
|
+
options:
|
|
1507
|
+
--version print version
|
|
1508
|
+
--help show this help`;
|
|
1509
|
+
var { positionals, values } = parseArgs2({
|
|
1510
|
+
args: process.argv.slice(2),
|
|
1511
|
+
allowPositionals: true,
|
|
1512
|
+
strict: false,
|
|
1513
|
+
options: {
|
|
1514
|
+
version: { type: "boolean", short: "v" },
|
|
1515
|
+
help: { type: "boolean", short: "h" }
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
var command = values.version ? "--version" : values.help ? "--help" : positionals[0];
|
|
1519
|
+
function applyVaultRoot(positionalIndex) {
|
|
1520
|
+
const arg = positionals[positionalIndex];
|
|
1521
|
+
if (arg) {
|
|
1522
|
+
process.env.WIKI_VAULT = arg;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
async function run() {
|
|
1526
|
+
switch (command) {
|
|
1527
|
+
case "sync": {
|
|
1528
|
+
const { runSyncCommand: runSyncCommand2 } = await Promise.resolve().then(() => (init_cli(), cli_exports));
|
|
1529
|
+
await runSyncCommand2();
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1532
|
+
case "validate": {
|
|
1533
|
+
applyVaultRoot(1);
|
|
1534
|
+
const { runValidate: runValidate2 } = await Promise.resolve().then(() => (init_cli(), cli_exports));
|
|
1535
|
+
await runValidate2();
|
|
1536
|
+
break;
|
|
1537
|
+
}
|
|
1538
|
+
case "mcp": {
|
|
1539
|
+
applyVaultRoot(1);
|
|
1540
|
+
const { startMcpServer: startMcpServer2 } = await Promise.resolve().then(() => (init_start(), start_exports));
|
|
1541
|
+
await startMcpServer2();
|
|
1542
|
+
break;
|
|
1543
|
+
}
|
|
1544
|
+
case "db": {
|
|
1545
|
+
const sub = positionals[1];
|
|
1546
|
+
if (sub === "reset") {
|
|
1547
|
+
const { homedir: homedir4 } = await import("node:os");
|
|
1548
|
+
const { join: join8 } = await import("node:path");
|
|
1549
|
+
const { unlinkSync } = await import("node:fs");
|
|
1550
|
+
const dbPath = join8(homedir4(), ".kmd", "db", "index.db");
|
|
1551
|
+
let deleted = false;
|
|
1552
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
1553
|
+
try {
|
|
1554
|
+
unlinkSync(dbPath + suffix);
|
|
1555
|
+
deleted = true;
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
if (err.code !== "ENOENT") throw err;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
console.log(deleted ? `deleted ${dbPath}` : `${dbPath} does not exist \u2014 nothing to reset`);
|
|
1561
|
+
} else {
|
|
1562
|
+
console.error(sub ? `unknown db subcommand: ${sub}` : "usage: kmd db reset");
|
|
1563
|
+
process.exit(2);
|
|
1564
|
+
}
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
case "--version":
|
|
1568
|
+
case "-v": {
|
|
1569
|
+
const { readFileSync } = await import("node:fs");
|
|
1570
|
+
const { join: join8, dirname } = await import("node:path");
|
|
1571
|
+
const { fileURLToPath } = await import("node:url");
|
|
1572
|
+
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
1573
|
+
const pkg = JSON.parse(readFileSync(join8(pkgDir, "package.json"), "utf8"));
|
|
1574
|
+
console.log(pkg.version);
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
case "--help":
|
|
1578
|
+
case "-h":
|
|
1579
|
+
case void 0: {
|
|
1580
|
+
console.log(USAGE);
|
|
1581
|
+
if (command === void 0) process.exit(2);
|
|
1582
|
+
break;
|
|
1583
|
+
}
|
|
1584
|
+
default: {
|
|
1585
|
+
console.error(`unknown command: ${command}
|
|
1586
|
+
|
|
1587
|
+
${USAGE}`);
|
|
1588
|
+
process.exit(2);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
run().catch((err) => {
|
|
1593
|
+
console.error("kmd failed:", err instanceof Error ? err.stack ?? err.message : err);
|
|
1594
|
+
process.exit(1);
|
|
1595
|
+
});
|
|
1596
|
+
//# sourceMappingURL=kmd.mjs.map
|