llm-wiki-compiler 0.6.0 → 0.7.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/README.md +128 -8
- package/dist/cli.js +1577 -337
- package/dist/cli.js.map +1 -1
- package/dist/viewer/assets/index.html +71 -0
- package/dist/viewer/assets/llmwiki-logo-64.png +0 -0
- package/dist/viewer/assets/viewer-rail.js +181 -0
- package/dist/viewer/assets/viewer-search.js +185 -0
- package/dist/viewer/assets/viewer-sidebar.js +151 -0
- package/dist/viewer/assets/viewer.css +314 -0
- package/dist/viewer/assets/viewer.js +363 -0
- package/package.json +6 -1
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { readFile as readFile7 } from "fs/promises";
|
|
|
13
13
|
import { writeFile, rename, readFile, mkdir } from "fs/promises";
|
|
14
14
|
import path from "path";
|
|
15
15
|
import yaml from "js-yaml";
|
|
16
|
+
var CITATION_MARKER_PATTERN = /\^\[([^\]]+)\]/g;
|
|
16
17
|
var SPAN_SUFFIX_PATTERN = /^(?<file>[^:#]+)(?:(?::(?<colonStart>\d+)(?:-(?<colonEnd>\d+))?)|(?:#L(?<hashStart>\d+)(?:-L(?<hashEnd>\d+))?))?$/;
|
|
17
18
|
var MIN_LINE_NUMBER = 1;
|
|
18
19
|
var VALID_PROVENANCE_STATES = /* @__PURE__ */ new Set([
|
|
@@ -31,19 +32,27 @@ ${dumped}
|
|
|
31
32
|
---`;
|
|
32
33
|
}
|
|
33
34
|
function parseFrontmatter(content) {
|
|
35
|
+
const { meta, body } = parseFrontmatterStatus(content);
|
|
36
|
+
return { meta, body };
|
|
37
|
+
}
|
|
38
|
+
function parseFrontmatterStatus(content) {
|
|
34
39
|
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
35
40
|
if (!match) {
|
|
36
|
-
return { meta: {}, body: content };
|
|
41
|
+
return { meta: {}, body: content, hasFrontmatterBlock: false, malformedFrontmatter: false };
|
|
37
42
|
}
|
|
38
43
|
let meta = {};
|
|
44
|
+
let malformedFrontmatter = false;
|
|
39
45
|
try {
|
|
40
46
|
const parsed = yaml.load(match[1]);
|
|
41
47
|
if (parsed && typeof parsed === "object") {
|
|
42
48
|
meta = parsed;
|
|
49
|
+
} else if (parsed !== null && parsed !== void 0) {
|
|
50
|
+
malformedFrontmatter = true;
|
|
43
51
|
}
|
|
44
52
|
} catch {
|
|
53
|
+
malformedFrontmatter = true;
|
|
45
54
|
}
|
|
46
|
-
return { meta, body: match[2] };
|
|
55
|
+
return { meta, body: match[2], hasFrontmatterBlock: true, malformedFrontmatter };
|
|
47
56
|
}
|
|
48
57
|
async function atomicWrite(filePath, content) {
|
|
49
58
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
@@ -51,6 +60,41 @@ async function atomicWrite(filePath, content) {
|
|
|
51
60
|
await writeFile(tmpPath, content, "utf-8");
|
|
52
61
|
await rename(tmpPath, filePath);
|
|
53
62
|
}
|
|
63
|
+
function extractClaimCitations(body) {
|
|
64
|
+
const citations = [];
|
|
65
|
+
let match;
|
|
66
|
+
CITATION_MARKER_PATTERN.lastIndex = 0;
|
|
67
|
+
while ((match = CITATION_MARKER_PATTERN.exec(body)) !== null) {
|
|
68
|
+
const raw = match[1];
|
|
69
|
+
const spans = parseCitationEntries(raw);
|
|
70
|
+
if (spans.length > 0) citations.push({ raw, spans });
|
|
71
|
+
}
|
|
72
|
+
return citations;
|
|
73
|
+
}
|
|
74
|
+
function parseCitationEntries(inner) {
|
|
75
|
+
const spans = [];
|
|
76
|
+
for (const part of inner.split(",")) {
|
|
77
|
+
const trimmed = part.trim();
|
|
78
|
+
if (trimmed.length === 0) continue;
|
|
79
|
+
const span = parseSpanEntry(trimmed);
|
|
80
|
+
if (span !== void 0) spans.push(span);
|
|
81
|
+
}
|
|
82
|
+
return spans;
|
|
83
|
+
}
|
|
84
|
+
function parseSpanEntry(entry) {
|
|
85
|
+
const match = SPAN_SUFFIX_PATTERN.exec(entry);
|
|
86
|
+
if (!match || !match.groups) {
|
|
87
|
+
return { file: entry };
|
|
88
|
+
}
|
|
89
|
+
const { file, colonStart, colonEnd, hashStart, hashEnd } = match.groups;
|
|
90
|
+
const start = colonStart ?? hashStart;
|
|
91
|
+
const end = colonEnd ?? hashEnd;
|
|
92
|
+
if (start === void 0) return { file };
|
|
93
|
+
const startLine = Number(start);
|
|
94
|
+
const endLine = end === void 0 ? startLine : Number(end);
|
|
95
|
+
if (!isValidLineRange(startLine, endLine)) return void 0;
|
|
96
|
+
return { file, lines: { start: startLine, end: endLine } };
|
|
97
|
+
}
|
|
54
98
|
function isValidLineRange(start, end) {
|
|
55
99
|
return start >= MIN_LINE_NUMBER && end >= start;
|
|
56
100
|
}
|
|
@@ -138,9 +182,11 @@ var PROVIDER_MODELS = {
|
|
|
138
182
|
anthropic: "claude-sonnet-4-20250514",
|
|
139
183
|
openai: "gpt-4o",
|
|
140
184
|
ollama: "llama3.1",
|
|
141
|
-
minimax: "MiniMax-M2.7"
|
|
185
|
+
minimax: "MiniMax-M2.7",
|
|
186
|
+
copilot: "gpt-4o"
|
|
142
187
|
};
|
|
143
188
|
var OLLAMA_DEFAULT_HOST = "http://localhost:11434/v1";
|
|
189
|
+
var COPILOT_BASE_URL = "https://api.githubcopilot.com";
|
|
144
190
|
var OPENAI_DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
145
191
|
var OLLAMA_DEFAULT_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
146
192
|
var SOURCES_DIR = "sources";
|
|
@@ -152,6 +198,7 @@ var LOCK_FILE = ".llmwiki/lock";
|
|
|
152
198
|
var INDEX_FILE = "wiki/index.md";
|
|
153
199
|
var MOC_FILE = "wiki/MOC.md";
|
|
154
200
|
var EMBEDDINGS_FILE = ".llmwiki/embeddings.json";
|
|
201
|
+
var LAST_LINT_FILE = ".llmwiki/last-lint.json";
|
|
155
202
|
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
|
|
156
203
|
var TRANSCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".vtt", ".srt"]);
|
|
157
204
|
var IMAGE_DESCRIBE_MAX_TOKENS = 2048;
|
|
@@ -1192,90 +1239,1387 @@ async function ingestDirectory(dirPath) {
|
|
|
1192
1239
|
skipped++;
|
|
1193
1240
|
}
|
|
1194
1241
|
}
|
|
1195
|
-
if (imported === 0) {
|
|
1242
|
+
if (imported === 0) {
|
|
1243
|
+
throw new Error(
|
|
1244
|
+
`No sessions imported from ${dirPath} (${skipped} file(s) skipped). Check that at least one file is in a supported session format.`
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
status(
|
|
1248
|
+
"\u2192",
|
|
1249
|
+
dim(`Imported ${imported} session(s), skipped ${skipped}.`)
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
async function ingestSession(targetPath) {
|
|
1253
|
+
const info2 = await stat(targetPath).catch(() => {
|
|
1254
|
+
throw new Error(`Path not found: ${targetPath}`);
|
|
1255
|
+
});
|
|
1256
|
+
if (info2.isDirectory()) {
|
|
1257
|
+
await ingestDirectory(targetPath);
|
|
1258
|
+
} else {
|
|
1259
|
+
await ingestSessionFile(targetPath);
|
|
1260
|
+
}
|
|
1261
|
+
status("\u2192", dim("Next: llmwiki compile"));
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// src/commands/view.ts
|
|
1265
|
+
import { spawn } from "child_process";
|
|
1266
|
+
|
|
1267
|
+
// src/viewer/server.ts
|
|
1268
|
+
import http from "http";
|
|
1269
|
+
|
|
1270
|
+
// src/linter/cache.ts
|
|
1271
|
+
import { mkdir as mkdir3, readFile as readFile11 } from "fs/promises";
|
|
1272
|
+
import path13 from "path";
|
|
1273
|
+
var LINT_CACHE_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
1274
|
+
async function writeLintCache(root, summary) {
|
|
1275
|
+
await mkdir3(path13.join(root, LLMWIKI_DIR), { recursive: true });
|
|
1276
|
+
const entry = {
|
|
1277
|
+
warnings: summary.warnings,
|
|
1278
|
+
errors: summary.errors,
|
|
1279
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1280
|
+
};
|
|
1281
|
+
await atomicWrite(path13.join(root, LAST_LINT_FILE), `${JSON.stringify(entry, null, 2)}
|
|
1282
|
+
`);
|
|
1283
|
+
}
|
|
1284
|
+
async function readLintCache(root) {
|
|
1285
|
+
let raw;
|
|
1286
|
+
try {
|
|
1287
|
+
raw = await readFile11(path13.join(root, LAST_LINT_FILE), "utf-8");
|
|
1288
|
+
} catch {
|
|
1289
|
+
return null;
|
|
1290
|
+
}
|
|
1291
|
+
let parsed;
|
|
1292
|
+
try {
|
|
1293
|
+
parsed = JSON.parse(raw);
|
|
1294
|
+
} catch {
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
if (!isValidEntry(parsed)) return null;
|
|
1298
|
+
return { warnings: parsed.warnings, errors: parsed.errors, at: parsed.at };
|
|
1299
|
+
}
|
|
1300
|
+
function isNonNegativeInteger(value) {
|
|
1301
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
1302
|
+
}
|
|
1303
|
+
function isValidEntry(value) {
|
|
1304
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1305
|
+
const candidate = value;
|
|
1306
|
+
return isNonNegativeInteger(candidate.warnings) && isNonNegativeInteger(candidate.errors) && typeof candidate.at === "string" && LINT_CACHE_TIMESTAMP_PATTERN.test(candidate.at);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// src/viewer/health.ts
|
|
1310
|
+
async function buildHealthResponse(snapshot) {
|
|
1311
|
+
const lint2 = await readLintCache(snapshot.root);
|
|
1312
|
+
return {
|
|
1313
|
+
pendingReviews: snapshot.counts.pendingReviews,
|
|
1314
|
+
sources: snapshot.counts.compiledSources,
|
|
1315
|
+
sourceFiles: snapshot.counts.sourceFiles,
|
|
1316
|
+
concepts: snapshot.counts.concepts,
|
|
1317
|
+
queries: snapshot.counts.queries,
|
|
1318
|
+
lint: lint2
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// src/viewer/shell.ts
|
|
1323
|
+
import { readFile as readFile12 } from "fs/promises";
|
|
1324
|
+
import path14 from "path";
|
|
1325
|
+
var PAGE_INDEX_MARKER = "<!--PAGE_INDEX-->";
|
|
1326
|
+
var templateCache = /* @__PURE__ */ new Map();
|
|
1327
|
+
async function loadShellTemplate(assetsDir) {
|
|
1328
|
+
const cached = templateCache.get(assetsDir);
|
|
1329
|
+
if (cached !== void 0) return cached;
|
|
1330
|
+
let bytes;
|
|
1331
|
+
try {
|
|
1332
|
+
bytes = await readFile12(path14.join(assetsDir, "index.html"), "utf-8");
|
|
1333
|
+
} catch {
|
|
1334
|
+
bytes = null;
|
|
1335
|
+
}
|
|
1336
|
+
templateCache.set(assetsDir, bytes);
|
|
1337
|
+
return bytes;
|
|
1338
|
+
}
|
|
1339
|
+
function substitutePageIndex(template, pages) {
|
|
1340
|
+
const embedded = pages.map((page) => ({
|
|
1341
|
+
id: page.id,
|
|
1342
|
+
pageDirectory: page.pageDirectory,
|
|
1343
|
+
slug: page.slug,
|
|
1344
|
+
title: page.title,
|
|
1345
|
+
kind: typeof page.frontmatter.kind === "string" && page.frontmatter.kind.length > 0 ? page.frontmatter.kind : "concept"
|
|
1346
|
+
}));
|
|
1347
|
+
const json = JSON.stringify({ pages: embedded }).replace(/</g, "\\u003c");
|
|
1348
|
+
const block = `<script type="application/json" id="page-index">${json}</script>`;
|
|
1349
|
+
return template.replace(PAGE_INDEX_MARKER, block);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// src/viewer/static-assets.ts
|
|
1353
|
+
import { readFile as readFile13, realpath } from "fs/promises";
|
|
1354
|
+
import path16 from "path";
|
|
1355
|
+
import { fileURLToPath } from "url";
|
|
1356
|
+
|
|
1357
|
+
// src/viewer/path-safety.ts
|
|
1358
|
+
import path15 from "path";
|
|
1359
|
+
var PathSafetyError = class extends Error {
|
|
1360
|
+
constructor(message) {
|
|
1361
|
+
super(message);
|
|
1362
|
+
this.name = "PathSafetyError";
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
function assertSafeSlug(decodedSlug) {
|
|
1366
|
+
if (typeof decodedSlug !== "string") {
|
|
1367
|
+
throw new PathSafetyError("slug must be a string");
|
|
1368
|
+
}
|
|
1369
|
+
if (decodedSlug.length === 0) {
|
|
1370
|
+
throw new PathSafetyError("slug must not be empty");
|
|
1371
|
+
}
|
|
1372
|
+
if (decodedSlug === "." || decodedSlug === "..") {
|
|
1373
|
+
throw new PathSafetyError(`slug must not be "${decodedSlug}"`);
|
|
1374
|
+
}
|
|
1375
|
+
if (decodedSlug.includes("/") || decodedSlug.includes("\\")) {
|
|
1376
|
+
throw new PathSafetyError("slug must not contain path separators");
|
|
1377
|
+
}
|
|
1378
|
+
if (decodedSlug.includes("\0")) {
|
|
1379
|
+
throw new PathSafetyError("slug must not contain NUL bytes");
|
|
1380
|
+
}
|
|
1381
|
+
if (path15.sep !== "/" && decodedSlug.includes(path15.sep)) {
|
|
1382
|
+
throw new PathSafetyError(`slug must not contain platform separator "${path15.sep}"`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// src/viewer/static-assets.ts
|
|
1387
|
+
var ASSETS_DIR = path16.join(
|
|
1388
|
+
path16.dirname(fileURLToPath(import.meta.url)),
|
|
1389
|
+
"viewer/assets"
|
|
1390
|
+
);
|
|
1391
|
+
var ASSET_CONTENT_TYPES = {
|
|
1392
|
+
".html": "text/html; charset=utf-8",
|
|
1393
|
+
".css": "text/css; charset=utf-8",
|
|
1394
|
+
".js": "application/javascript; charset=utf-8",
|
|
1395
|
+
".svg": "image/svg+xml",
|
|
1396
|
+
".png": "image/png"
|
|
1397
|
+
};
|
|
1398
|
+
async function handleAsset(res, pathname) {
|
|
1399
|
+
const segments = decodeAssetSegments(pathname);
|
|
1400
|
+
if (!segments) {
|
|
1401
|
+
writeAssetError(res, 400, "bad_asset_path", "Bad asset path.");
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (segments.length === 0) {
|
|
1405
|
+
writeAssetError(res, 404, "asset_not_found", "Asset not found.");
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
const contentType = ASSET_CONTENT_TYPES[path16.extname(segments[segments.length - 1]).toLowerCase()];
|
|
1409
|
+
if (!contentType) {
|
|
1410
|
+
writeAssetError(res, 404, "asset_not_found", "Asset not found.");
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const resolved = await resolveAssetPath(segments);
|
|
1414
|
+
if (!resolved) {
|
|
1415
|
+
writeAssetError(res, 404, "asset_not_found", "Asset not found.");
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
try {
|
|
1419
|
+
const body = await readFile13(resolved);
|
|
1420
|
+
res.statusCode = 200;
|
|
1421
|
+
res.setHeader("Content-Type", contentType);
|
|
1422
|
+
res.end(body);
|
|
1423
|
+
} catch {
|
|
1424
|
+
writeAssetError(res, 404, "asset_not_found", "Asset not found.");
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
function decodeAssetSegments(pathname) {
|
|
1428
|
+
const trimmed = pathname.replace(/^\/assets\//, "");
|
|
1429
|
+
if (trimmed.length === 0) return [];
|
|
1430
|
+
const decoded = [];
|
|
1431
|
+
for (const raw of trimmed.split("/")) {
|
|
1432
|
+
let segment;
|
|
1433
|
+
try {
|
|
1434
|
+
segment = decodeURIComponent(raw);
|
|
1435
|
+
} catch {
|
|
1436
|
+
return null;
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
assertSafeSlug(segment);
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
if (err instanceof PathSafetyError) return null;
|
|
1442
|
+
throw err;
|
|
1443
|
+
}
|
|
1444
|
+
decoded.push(segment);
|
|
1445
|
+
}
|
|
1446
|
+
return decoded;
|
|
1447
|
+
}
|
|
1448
|
+
async function resolveAssetPath(segments) {
|
|
1449
|
+
const candidate = path16.join(ASSETS_DIR, ...segments);
|
|
1450
|
+
let resolved;
|
|
1451
|
+
try {
|
|
1452
|
+
resolved = await realpath(candidate);
|
|
1453
|
+
} catch {
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
1456
|
+
const baseReal = await realpath(ASSETS_DIR).catch(() => ASSETS_DIR);
|
|
1457
|
+
if (resolved === baseReal) return resolved;
|
|
1458
|
+
const prefix = baseReal.endsWith(path16.sep) ? baseReal : baseReal + path16.sep;
|
|
1459
|
+
return resolved.startsWith(prefix) ? resolved : null;
|
|
1460
|
+
}
|
|
1461
|
+
function writeAssetError(res, status2, code, message) {
|
|
1462
|
+
res.statusCode = status2;
|
|
1463
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
1464
|
+
res.end(JSON.stringify({ error: { code, message } }));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// src/viewer/render.ts
|
|
1468
|
+
import MarkdownIt from "markdown-it";
|
|
1469
|
+
import sanitizeHtml from "sanitize-html";
|
|
1470
|
+
|
|
1471
|
+
// src/wiki/collect.ts
|
|
1472
|
+
import { readdir as readdir2, readFile as readFile14, realpath as realpath2 } from "fs/promises";
|
|
1473
|
+
import path17 from "path";
|
|
1474
|
+
var WIKILINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
|
1475
|
+
function extractWikilinkSlugs(body) {
|
|
1476
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
1477
|
+
WIKILINK_RE.lastIndex = 0;
|
|
1478
|
+
let match;
|
|
1479
|
+
while ((match = WIKILINK_RE.exec(body)) !== null) {
|
|
1480
|
+
slugs.add(slugify(match[1].trim()));
|
|
1481
|
+
}
|
|
1482
|
+
return [...slugs];
|
|
1483
|
+
}
|
|
1484
|
+
async function safeRealpath(p) {
|
|
1485
|
+
try {
|
|
1486
|
+
return await realpath2(p);
|
|
1487
|
+
} catch {
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
function isInsideDir(child, dir) {
|
|
1492
|
+
if (child === dir) return true;
|
|
1493
|
+
const prefix = dir.endsWith(path17.sep) ? dir : dir + path17.sep;
|
|
1494
|
+
return child.startsWith(prefix);
|
|
1495
|
+
}
|
|
1496
|
+
async function parsePageFile(filePath, slug, pageDirectory) {
|
|
1497
|
+
let raw;
|
|
1498
|
+
try {
|
|
1499
|
+
raw = await readFile14(filePath, "utf-8");
|
|
1500
|
+
} catch {
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1503
|
+
const { meta, body, hasFrontmatterBlock, malformedFrontmatter } = parseFrontmatterStatus(raw);
|
|
1504
|
+
const title = typeof meta.title === "string" && meta.title.length > 0 ? meta.title : void 0;
|
|
1505
|
+
return {
|
|
1506
|
+
slug,
|
|
1507
|
+
pageDirectory,
|
|
1508
|
+
filePath,
|
|
1509
|
+
title,
|
|
1510
|
+
frontmatter: meta,
|
|
1511
|
+
body,
|
|
1512
|
+
parseStatus: {
|
|
1513
|
+
hasFrontmatterBlock,
|
|
1514
|
+
malformedFrontmatter,
|
|
1515
|
+
hasTitle: title !== void 0,
|
|
1516
|
+
orphaned: meta.orphaned === true
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
async function collectFromDir(canonicalRoot, pageDirectory, subdir) {
|
|
1521
|
+
const expectedDir = path17.join(canonicalRoot, subdir);
|
|
1522
|
+
const realDir = await safeRealpath(expectedDir);
|
|
1523
|
+
if (realDir !== expectedDir) return [];
|
|
1524
|
+
let files;
|
|
1525
|
+
try {
|
|
1526
|
+
files = await readdir2(realDir);
|
|
1527
|
+
} catch {
|
|
1528
|
+
return [];
|
|
1529
|
+
}
|
|
1530
|
+
const pages = [];
|
|
1531
|
+
for (const file of files.filter((f) => f.endsWith(".md"))) {
|
|
1532
|
+
const candidate = path17.join(realDir, file);
|
|
1533
|
+
const resolved = await safeRealpath(candidate);
|
|
1534
|
+
if (!resolved || !isInsideDir(resolved, realDir)) continue;
|
|
1535
|
+
const slug = file.replace(/\.md$/, "");
|
|
1536
|
+
const page = await parsePageFile(resolved, slug, pageDirectory);
|
|
1537
|
+
if (page) pages.push(page);
|
|
1538
|
+
}
|
|
1539
|
+
return pages;
|
|
1540
|
+
}
|
|
1541
|
+
async function collectRawWikiPages(root) {
|
|
1542
|
+
const canonicalRoot = await safeRealpath(root);
|
|
1543
|
+
if (!canonicalRoot) return [];
|
|
1544
|
+
const [concepts, queries] = await Promise.all([
|
|
1545
|
+
collectFromDir(canonicalRoot, "concepts", CONCEPTS_DIR),
|
|
1546
|
+
collectFromDir(canonicalRoot, "queries", QUERIES_DIR)
|
|
1547
|
+
]);
|
|
1548
|
+
return [...concepts, ...queries];
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// src/viewer/collect.ts
|
|
1552
|
+
async function collectViewerPages(root) {
|
|
1553
|
+
const raw = await collectRawWikiPages(root);
|
|
1554
|
+
return decoratePages(raw);
|
|
1555
|
+
}
|
|
1556
|
+
function resolveBareSlug(slug, pages) {
|
|
1557
|
+
if (slug.length === 0) return null;
|
|
1558
|
+
const concept = pages.find((p) => p.pageDirectory === "concepts" && p.slug === slug);
|
|
1559
|
+
if (concept) return concept.id;
|
|
1560
|
+
const query = pages.find((p) => p.pageDirectory === "queries" && p.slug === slug);
|
|
1561
|
+
if (query) return query.id;
|
|
1562
|
+
return null;
|
|
1563
|
+
}
|
|
1564
|
+
function resolveBareSlugList(targets, pages) {
|
|
1565
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1566
|
+
const ordered = [];
|
|
1567
|
+
for (const target of targets) {
|
|
1568
|
+
const resolved = resolveBareSlug(target, pages);
|
|
1569
|
+
if (resolved && !seen.has(resolved)) {
|
|
1570
|
+
seen.add(resolved);
|
|
1571
|
+
ordered.push(resolved);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return ordered;
|
|
1575
|
+
}
|
|
1576
|
+
function decoratePages(raw) {
|
|
1577
|
+
const shells = raw.map(buildPageShell);
|
|
1578
|
+
for (const page of shells) {
|
|
1579
|
+
const targets = extractWikilinkSlugs(page.body);
|
|
1580
|
+
page.outgoingLinks = resolveBareSlugList(targets, shells);
|
|
1581
|
+
}
|
|
1582
|
+
return shells;
|
|
1583
|
+
}
|
|
1584
|
+
function buildPageShell(page) {
|
|
1585
|
+
const id = `${page.pageDirectory}/${page.slug}`;
|
|
1586
|
+
return {
|
|
1587
|
+
id,
|
|
1588
|
+
slug: page.slug,
|
|
1589
|
+
pageDirectory: page.pageDirectory,
|
|
1590
|
+
title: page.title ?? page.slug,
|
|
1591
|
+
filePath: page.filePath,
|
|
1592
|
+
frontmatter: page.frontmatter,
|
|
1593
|
+
body: page.body,
|
|
1594
|
+
outgoingLinks: [],
|
|
1595
|
+
citations: extractClaimCitations(page.body),
|
|
1596
|
+
warnings: warningsFromParseStatus(page)
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
function warningsFromParseStatus(page) {
|
|
1600
|
+
const warnings = [];
|
|
1601
|
+
if (!page.parseStatus.hasFrontmatterBlock) {
|
|
1602
|
+
warnings.push({
|
|
1603
|
+
code: "missing_frontmatter",
|
|
1604
|
+
message: `Page "${page.slug}" has no frontmatter block.`
|
|
1605
|
+
});
|
|
1606
|
+
} else if (page.parseStatus.malformedFrontmatter) {
|
|
1607
|
+
warnings.push({
|
|
1608
|
+
code: "malformed_frontmatter",
|
|
1609
|
+
message: `Page "${page.slug}" has malformed YAML frontmatter.`
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
if (!page.parseStatus.hasTitle) {
|
|
1613
|
+
warnings.push({
|
|
1614
|
+
code: "missing_title",
|
|
1615
|
+
message: `Page "${page.slug}" has no frontmatter title; displaying slug.`
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
return warnings;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// src/viewer/markdown-it-helpers.ts
|
|
1622
|
+
function escapeHtml(input) {
|
|
1623
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1624
|
+
}
|
|
1625
|
+
function currentLinkLevel(state) {
|
|
1626
|
+
const lifted = state;
|
|
1627
|
+
return typeof lifted.linkLevel === "number" ? lifted.linkLevel : 0;
|
|
1628
|
+
}
|
|
1629
|
+
function shouldDeferInlineRule(state, silent) {
|
|
1630
|
+
if (currentLinkLevel(state) > 0) return true;
|
|
1631
|
+
if (silent) return true;
|
|
1632
|
+
return false;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// src/viewer/wikilink-rule.ts
|
|
1636
|
+
var OPEN = "[";
|
|
1637
|
+
var CHAR_OPEN_BRACKET = 91;
|
|
1638
|
+
function registerWikilink(md, context) {
|
|
1639
|
+
md.inline.ruler.after("link", "wikilink", buildParser(context));
|
|
1640
|
+
md.renderer.rules.wikilink = (tokens, idx) => renderWikilinkToken(tokens[idx]);
|
|
1641
|
+
}
|
|
1642
|
+
function buildParser(context) {
|
|
1643
|
+
return function parseWikilink(state, silent) {
|
|
1644
|
+
if (state.src.charCodeAt(state.pos) !== CHAR_OPEN_BRACKET) return false;
|
|
1645
|
+
if (state.src.charCodeAt(state.pos + 1) !== CHAR_OPEN_BRACKET) return false;
|
|
1646
|
+
if (shouldDeferInlineRule(state, silent)) return false;
|
|
1647
|
+
const closeAt = state.src.indexOf("]]", state.pos + 2);
|
|
1648
|
+
if (closeAt < 0) return false;
|
|
1649
|
+
const inner = state.src.slice(state.pos + 2, closeAt);
|
|
1650
|
+
if (inner.includes("\n") || inner.includes(OPEN)) return false;
|
|
1651
|
+
const { rawTarget, display } = splitTargetAndAlias(inner);
|
|
1652
|
+
const slug = slugify(rawTarget.trim());
|
|
1653
|
+
const resolved = resolveBareSlug(slug, context.pages);
|
|
1654
|
+
pushWikilinkToken(state, resolved, slug, display);
|
|
1655
|
+
state.pos = closeAt + 2;
|
|
1656
|
+
return true;
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
function splitTargetAndAlias(inner) {
|
|
1660
|
+
const pipe = inner.indexOf("|");
|
|
1661
|
+
if (pipe < 0) return { rawTarget: inner, display: inner.trim() };
|
|
1662
|
+
return {
|
|
1663
|
+
rawTarget: inner.slice(0, pipe),
|
|
1664
|
+
display: inner.slice(pipe + 1).trim() || inner.slice(0, pipe).trim()
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
function pushWikilinkToken(state, resolved, slug, display) {
|
|
1668
|
+
const token = state.push("wikilink", "", 0);
|
|
1669
|
+
token.meta = { resolved, slug, display };
|
|
1670
|
+
}
|
|
1671
|
+
function renderWikilinkToken(token) {
|
|
1672
|
+
const meta = token.meta;
|
|
1673
|
+
const display = escapeHtml(meta.display || meta.slug);
|
|
1674
|
+
if (!meta.resolved) {
|
|
1675
|
+
return `<span data-missing="true">[[${display}]]</span>`;
|
|
1676
|
+
}
|
|
1677
|
+
const href = `#/${encodeUriSegment(meta.resolved)}`;
|
|
1678
|
+
return `<a class="wikilink" data-page-id="${escapeHtml(meta.resolved)}" href="${escapeHtml(href)}">${display}</a>`;
|
|
1679
|
+
}
|
|
1680
|
+
function encodeUriSegment(id) {
|
|
1681
|
+
const [directory, slug] = id.split("/");
|
|
1682
|
+
return `${encodeURIComponent(directory)}/${encodeURIComponent(slug)}`;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// src/viewer/citation-rule.ts
|
|
1686
|
+
import path18 from "path";
|
|
1687
|
+
import { pathToFileURL } from "url";
|
|
1688
|
+
var CHAR_CARET = 94;
|
|
1689
|
+
var CHAR_OPEN_BRACKET2 = 91;
|
|
1690
|
+
function registerCitation(md, context) {
|
|
1691
|
+
md.inline.ruler.after("link", "citation", buildParser2(context));
|
|
1692
|
+
md.renderer.rules.citation = (tokens, idx) => renderCitationToken(tokens[idx]);
|
|
1693
|
+
}
|
|
1694
|
+
function buildParser2(context) {
|
|
1695
|
+
return function parseCitation(state, silent) {
|
|
1696
|
+
if (state.src.charCodeAt(state.pos) !== CHAR_CARET) return false;
|
|
1697
|
+
if (state.src.charCodeAt(state.pos + 1) !== CHAR_OPEN_BRACKET2) return false;
|
|
1698
|
+
if (shouldDeferInlineRule(state, silent)) return false;
|
|
1699
|
+
const closeAt = state.src.indexOf("]", state.pos + 2);
|
|
1700
|
+
if (closeAt < 0) return false;
|
|
1701
|
+
const inner = state.src.slice(state.pos + 2, closeAt);
|
|
1702
|
+
if (inner.includes("\n")) return false;
|
|
1703
|
+
const citations = extractClaimCitations(`^[${inner}]`);
|
|
1704
|
+
pushChipTokens(state, citations, context);
|
|
1705
|
+
state.pos = closeAt + 1;
|
|
1706
|
+
return true;
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
function pushChipTokens(state, citations, context) {
|
|
1710
|
+
for (const citation of citations) {
|
|
1711
|
+
for (const span of citation.spans) {
|
|
1712
|
+
const token = state.push("citation", "", 0);
|
|
1713
|
+
token.meta = buildChipMeta(span, context);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
function buildChipMeta(span, context) {
|
|
1718
|
+
const meta = {
|
|
1719
|
+
file: span.file,
|
|
1720
|
+
lineStart: span.lines?.start,
|
|
1721
|
+
lineEnd: span.lines?.end,
|
|
1722
|
+
resolved: context.sourceFiles.has(span.file)
|
|
1723
|
+
};
|
|
1724
|
+
if (context.isLoopback && meta.resolved && isBareFilename(span.file)) {
|
|
1725
|
+
const absolutePath = path18.join(context.root, "sources", span.file);
|
|
1726
|
+
meta.absolutePath = absolutePath;
|
|
1727
|
+
meta.editorHref = buildEditorHref(absolutePath, meta.lineStart);
|
|
1728
|
+
}
|
|
1729
|
+
return meta;
|
|
1730
|
+
}
|
|
1731
|
+
function buildEditorHref(absolutePath, lineStart) {
|
|
1732
|
+
const encodedPath = pathToFileURL(absolutePath).pathname;
|
|
1733
|
+
if (lineStart === void 0) return `vscode://file${encodedPath}`;
|
|
1734
|
+
return `vscode://file${encodedPath}:${lineStart}`;
|
|
1735
|
+
}
|
|
1736
|
+
function isBareFilename(file) {
|
|
1737
|
+
if (file.length === 0) return false;
|
|
1738
|
+
if (file.includes("/") || file.includes("\\") || file.includes("\0")) return false;
|
|
1739
|
+
if (file === "." || file === "..") return false;
|
|
1740
|
+
return true;
|
|
1741
|
+
}
|
|
1742
|
+
function renderCitationToken(token) {
|
|
1743
|
+
const meta = token.meta;
|
|
1744
|
+
const label = formatChipLabel(meta);
|
|
1745
|
+
const attrs = chipAttributes(meta);
|
|
1746
|
+
return `<span ${attrs}>${escapeHtml(label)}</span>`;
|
|
1747
|
+
}
|
|
1748
|
+
function chipAttributes(meta) {
|
|
1749
|
+
const parts = [
|
|
1750
|
+
`class="citation-chip"`,
|
|
1751
|
+
`data-file="${escapeHtml(meta.file)}"`,
|
|
1752
|
+
`data-resolved="${meta.resolved ? "true" : "false"}"`
|
|
1753
|
+
];
|
|
1754
|
+
if (meta.lineStart !== void 0) {
|
|
1755
|
+
parts.push(`data-line-start="${meta.lineStart}"`);
|
|
1756
|
+
}
|
|
1757
|
+
if (meta.lineEnd !== void 0) {
|
|
1758
|
+
parts.push(`data-line-end="${meta.lineEnd}"`);
|
|
1759
|
+
}
|
|
1760
|
+
if (meta.absolutePath !== void 0) {
|
|
1761
|
+
parts.push(`data-absolute-path="${escapeHtml(meta.absolutePath)}"`);
|
|
1762
|
+
}
|
|
1763
|
+
if (meta.editorHref !== void 0) {
|
|
1764
|
+
parts.push(`data-editor-href="${escapeHtml(meta.editorHref)}"`);
|
|
1765
|
+
}
|
|
1766
|
+
return parts.join(" ");
|
|
1767
|
+
}
|
|
1768
|
+
function formatChipLabel(meta) {
|
|
1769
|
+
if (meta.lineStart === void 0) return meta.file;
|
|
1770
|
+
if (meta.lineEnd === void 0 || meta.lineEnd === meta.lineStart) {
|
|
1771
|
+
return `${meta.file}:${meta.lineStart}`;
|
|
1772
|
+
}
|
|
1773
|
+
return `${meta.file}:${meta.lineStart}-${meta.lineEnd}`;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// src/viewer/render.ts
|
|
1777
|
+
function renderPageHtml(body, snapshot, options) {
|
|
1778
|
+
const md = buildMarkdownIt(snapshot, options);
|
|
1779
|
+
const rendered = md.render(body);
|
|
1780
|
+
const html = sanitizeHtml(rendered, buildSanitizerPolicy(options));
|
|
1781
|
+
return { html };
|
|
1782
|
+
}
|
|
1783
|
+
function buildMarkdownIt(snapshot, options) {
|
|
1784
|
+
const md = new MarkdownIt({
|
|
1785
|
+
html: false,
|
|
1786
|
+
linkify: false,
|
|
1787
|
+
breaks: false
|
|
1788
|
+
});
|
|
1789
|
+
registerWikilink(md, { pages: snapshot.pages });
|
|
1790
|
+
registerCitation(md, {
|
|
1791
|
+
root: snapshot.root,
|
|
1792
|
+
sourceFiles: new Set(snapshot.sourceFilenames),
|
|
1793
|
+
isLoopback: options.isLoopback
|
|
1794
|
+
});
|
|
1795
|
+
return md;
|
|
1796
|
+
}
|
|
1797
|
+
function buildSanitizerPolicy(options) {
|
|
1798
|
+
const allowedSchemes = ["http", "https", "mailto"];
|
|
1799
|
+
const allowedSchemesAppliedToAttributes = ["href", "src", "cite"];
|
|
1800
|
+
return {
|
|
1801
|
+
allowedTags: [
|
|
1802
|
+
"h1",
|
|
1803
|
+
"h2",
|
|
1804
|
+
"h3",
|
|
1805
|
+
"h4",
|
|
1806
|
+
"h5",
|
|
1807
|
+
"h6",
|
|
1808
|
+
"p",
|
|
1809
|
+
"br",
|
|
1810
|
+
"hr",
|
|
1811
|
+
"ul",
|
|
1812
|
+
"ol",
|
|
1813
|
+
"li",
|
|
1814
|
+
"blockquote",
|
|
1815
|
+
"strong",
|
|
1816
|
+
"em",
|
|
1817
|
+
"b",
|
|
1818
|
+
"i",
|
|
1819
|
+
"s",
|
|
1820
|
+
"u",
|
|
1821
|
+
"code",
|
|
1822
|
+
"pre",
|
|
1823
|
+
"table",
|
|
1824
|
+
"thead",
|
|
1825
|
+
"tbody",
|
|
1826
|
+
"tfoot",
|
|
1827
|
+
"tr",
|
|
1828
|
+
"th",
|
|
1829
|
+
"td",
|
|
1830
|
+
"a",
|
|
1831
|
+
"img",
|
|
1832
|
+
"span",
|
|
1833
|
+
"div"
|
|
1834
|
+
],
|
|
1835
|
+
disallowedTagsMode: "discard",
|
|
1836
|
+
allowedAttributes: {
|
|
1837
|
+
a: ["href", "title", "class", "id", "data-*", "aria-*"],
|
|
1838
|
+
img: ["src", "alt", "title", "class", "id"],
|
|
1839
|
+
span: ["class", "id", "data-*", "aria-*"],
|
|
1840
|
+
div: ["class", "id", "data-*", "aria-*"],
|
|
1841
|
+
th: ["scope", "colspan", "rowspan", "class", "id"],
|
|
1842
|
+
td: ["colspan", "rowspan", "class", "id"],
|
|
1843
|
+
table: ["class", "id"],
|
|
1844
|
+
code: ["class"],
|
|
1845
|
+
"*": ["class", "id"]
|
|
1846
|
+
},
|
|
1847
|
+
allowedSchemes,
|
|
1848
|
+
allowedSchemesByTag: {
|
|
1849
|
+
a: buildAnchorSchemes(),
|
|
1850
|
+
img: ["http", "https", "data"]
|
|
1851
|
+
},
|
|
1852
|
+
allowedSchemesAppliedToAttributes,
|
|
1853
|
+
allowProtocolRelative: false,
|
|
1854
|
+
// `allowedAttributes` above whitelists `class` everywhere via `*`,
|
|
1855
|
+
// so no further class-name allowlist is needed; leaving
|
|
1856
|
+
// `allowedClasses` unset lets every class value through.
|
|
1857
|
+
allowedStyles: {},
|
|
1858
|
+
allowedIframeHostnames: [],
|
|
1859
|
+
transformTags: {
|
|
1860
|
+
a: filterAnchorHref(),
|
|
1861
|
+
img: filterImgSrc,
|
|
1862
|
+
span: filterSpanForLanBind(options)
|
|
1863
|
+
}
|
|
1864
|
+
// sanitize-html's URL filter does not enforce hash-only links by
|
|
1865
|
+
// default; the anchor transform above whitelists `#/…` explicitly.
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
function filterSpanForLanBind(options) {
|
|
1869
|
+
return function transformSpan(tagName, attribs) {
|
|
1870
|
+
if (options.isLoopback) return { tagName, attribs };
|
|
1871
|
+
if (!("data-absolute-path" in attribs) && !("data-editor-href" in attribs)) {
|
|
1872
|
+
return { tagName, attribs };
|
|
1873
|
+
}
|
|
1874
|
+
const stripped = {};
|
|
1875
|
+
for (const [key, value] of Object.entries(attribs)) {
|
|
1876
|
+
if (key === "data-absolute-path" || key === "data-editor-href") continue;
|
|
1877
|
+
stripped[key] = value;
|
|
1878
|
+
}
|
|
1879
|
+
return { tagName, attribs: stripped };
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
function buildAnchorSchemes() {
|
|
1883
|
+
return ["http", "https", "mailto"];
|
|
1884
|
+
}
|
|
1885
|
+
function filterAnchorHref() {
|
|
1886
|
+
return function transformAnchor(tagName, attribs) {
|
|
1887
|
+
const href = attribs.href;
|
|
1888
|
+
if (typeof href !== "string" || href.length === 0) return { tagName, attribs };
|
|
1889
|
+
if (isAllowedAnchorHref(href)) return { tagName, attribs };
|
|
1890
|
+
const stripped = { ...attribs };
|
|
1891
|
+
delete stripped.href;
|
|
1892
|
+
return { tagName, attribs: stripped };
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
function filterImgSrc(tagName, attribs) {
|
|
1896
|
+
const src = attribs.src;
|
|
1897
|
+
if (typeof src !== "string" || src.length === 0) return { tagName, attribs };
|
|
1898
|
+
if (isAllowedImgSrc(src)) return { tagName, attribs };
|
|
1899
|
+
const stripped = { ...attribs };
|
|
1900
|
+
delete stripped.src;
|
|
1901
|
+
return { tagName, attribs: stripped };
|
|
1902
|
+
}
|
|
1903
|
+
function isAllowedAnchorHref(href) {
|
|
1904
|
+
if (href.startsWith("#")) return true;
|
|
1905
|
+
if (href.startsWith("http://") || href.startsWith("https://")) return true;
|
|
1906
|
+
if (href.startsWith("mailto:")) return true;
|
|
1907
|
+
return false;
|
|
1908
|
+
}
|
|
1909
|
+
function isAllowedImgSrc(src) {
|
|
1910
|
+
if (src.startsWith("http://") || src.startsWith("https://")) return true;
|
|
1911
|
+
if (src.startsWith("data:image/")) return true;
|
|
1912
|
+
return false;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// src/viewer/search.ts
|
|
1916
|
+
var MAX_QUERY_LENGTH = 200;
|
|
1917
|
+
var MAX_RESULTS = 50;
|
|
1918
|
+
var SNIPPET_RADIUS = 60;
|
|
1919
|
+
var SNIPPET_ELLIPSIS = "\u2026";
|
|
1920
|
+
function searchPages(snapshot, rawQuery) {
|
|
1921
|
+
const tokens = tokenizeQuery(rawQuery);
|
|
1922
|
+
if (tokens.length === 0) return { results: [] };
|
|
1923
|
+
const matches = collectMatches(snapshot.pages, tokens);
|
|
1924
|
+
matches.sort(compareResults);
|
|
1925
|
+
return { results: matches.slice(0, MAX_RESULTS) };
|
|
1926
|
+
}
|
|
1927
|
+
function tokenizeQuery(rawQuery) {
|
|
1928
|
+
if (typeof rawQuery !== "string") return [];
|
|
1929
|
+
const trimmed = rawQuery.trim();
|
|
1930
|
+
if (trimmed.length === 0) return [];
|
|
1931
|
+
const capped = trimmed.slice(0, MAX_QUERY_LENGTH).toLowerCase();
|
|
1932
|
+
return capped.split(/\s+/).filter((t) => t.length > 0);
|
|
1933
|
+
}
|
|
1934
|
+
function collectMatches(pages, tokens) {
|
|
1935
|
+
const matches = [];
|
|
1936
|
+
for (const page of pages) {
|
|
1937
|
+
const result = matchPage(page, tokens);
|
|
1938
|
+
if (result) matches.push(result);
|
|
1939
|
+
}
|
|
1940
|
+
return matches;
|
|
1941
|
+
}
|
|
1942
|
+
function matchPage(page, tokens) {
|
|
1943
|
+
const titleLower = page.title.toLowerCase();
|
|
1944
|
+
const bodyLower = page.body.toLowerCase();
|
|
1945
|
+
for (const token of tokens) {
|
|
1946
|
+
if (!titleLower.includes(token) && !bodyLower.includes(token)) return null;
|
|
1947
|
+
}
|
|
1948
|
+
const allInTitle = tokens.every((t) => titleLower.includes(t));
|
|
1949
|
+
if (allInTitle) return rowFromPage(page, page.title, "title");
|
|
1950
|
+
const snippet = buildBodySnippet(page.body, bodyLower, tokens);
|
|
1951
|
+
return rowFromPage(page, snippet, "body");
|
|
1952
|
+
}
|
|
1953
|
+
function rowFromPage(page, snippet, matchedIn) {
|
|
1954
|
+
return {
|
|
1955
|
+
id: page.id,
|
|
1956
|
+
pageDirectory: page.pageDirectory,
|
|
1957
|
+
title: page.title,
|
|
1958
|
+
snippet,
|
|
1959
|
+
matchedIn
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
function buildBodySnippet(body, bodyLower, tokens) {
|
|
1963
|
+
const matchPos = earliestTokenPosition(bodyLower, tokens);
|
|
1964
|
+
const start = Math.max(0, matchPos - SNIPPET_RADIUS);
|
|
1965
|
+
const end = Math.min(body.length, matchPos + SNIPPET_RADIUS);
|
|
1966
|
+
const cleaned = stripInlineMarkdownNoise(body.slice(start, end)).replace(/\s+/g, " ").trim();
|
|
1967
|
+
const prefix = start > 0 ? SNIPPET_ELLIPSIS : "";
|
|
1968
|
+
const suffix = end < body.length ? SNIPPET_ELLIPSIS : "";
|
|
1969
|
+
return `${prefix}${cleaned}${suffix}`;
|
|
1970
|
+
}
|
|
1971
|
+
function stripInlineMarkdownNoise(text) {
|
|
1972
|
+
return text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/\[\[([^\]|\n]+)\|([^\]\n]+)\]\]/g, "$2").replace(/\[\[([^\]\n]+)\]\]/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/__([^_]+)__/g, "$1").replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, "$1").replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, "$1").replace(/`([^`\n]+)`/g, "$1").replace(/~~([^~\n]+)~~/g, "$1");
|
|
1973
|
+
}
|
|
1974
|
+
function earliestTokenPosition(bodyLower, tokens) {
|
|
1975
|
+
let earliest = bodyLower.length;
|
|
1976
|
+
for (const token of tokens) {
|
|
1977
|
+
const idx = bodyLower.indexOf(token);
|
|
1978
|
+
if (idx >= 0 && idx < earliest) earliest = idx;
|
|
1979
|
+
}
|
|
1980
|
+
return earliest;
|
|
1981
|
+
}
|
|
1982
|
+
function compareResults(a, b) {
|
|
1983
|
+
if (a.matchedIn !== b.matchedIn) {
|
|
1984
|
+
return a.matchedIn === "title" ? -1 : 1;
|
|
1985
|
+
}
|
|
1986
|
+
return a.title.localeCompare(b.title);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// src/viewer/server.ts
|
|
1990
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1"]);
|
|
1991
|
+
var CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'none'";
|
|
1992
|
+
async function startViewerServer(snapshot, config) {
|
|
1993
|
+
const boundConfig = { ...config };
|
|
1994
|
+
const server = http.createServer((req, res) => {
|
|
1995
|
+
handleRequest(req, res, snapshot, boundConfig).catch((err) => {
|
|
1996
|
+
void err;
|
|
1997
|
+
if (!res.headersSent) {
|
|
1998
|
+
writeJsonError(res, 500, "internal_error", "Unexpected server error.");
|
|
1999
|
+
}
|
|
2000
|
+
});
|
|
2001
|
+
});
|
|
2002
|
+
await new Promise((resolve, reject) => {
|
|
2003
|
+
const onError = (err) => {
|
|
2004
|
+
server.off("listening", onListening);
|
|
2005
|
+
reject(err);
|
|
2006
|
+
};
|
|
2007
|
+
const onListening = () => {
|
|
2008
|
+
server.off("error", onError);
|
|
2009
|
+
resolve();
|
|
2010
|
+
};
|
|
2011
|
+
server.once("error", onError);
|
|
2012
|
+
server.once("listening", onListening);
|
|
2013
|
+
server.listen(config.port, config.host);
|
|
2014
|
+
});
|
|
2015
|
+
const address = server.address();
|
|
2016
|
+
if (!address) throw new Error("server bound but address is null");
|
|
2017
|
+
boundConfig.port = address.port;
|
|
2018
|
+
return {
|
|
2019
|
+
host: config.host,
|
|
2020
|
+
port: address.port,
|
|
2021
|
+
close: () => new Promise((resolve) => server.close(() => resolve()))
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
async function handleRequest(req, res, snapshot, config) {
|
|
2025
|
+
applySecurityHeaders(res);
|
|
2026
|
+
if (!validateOriginHeaders(req, config)) {
|
|
2027
|
+
writeJsonError(res, 403, "forbidden", "rejected by origin policy");
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const url = new URL(req.url ?? "/", buildOriginBase(config));
|
|
2031
|
+
if (!isRouteRegistered(req.method, url.pathname)) {
|
|
2032
|
+
writeJsonError(res, 404, "not_found", `${req.method ?? "?"} ${url.pathname}`);
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
await routeRegistered(req, res, url, snapshot, LOOPBACK_HOSTS.has(config.host));
|
|
2036
|
+
}
|
|
2037
|
+
async function routeRegistered(req, res, parsedUrl, snapshot, isLoopback) {
|
|
2038
|
+
if (parsedUrl.pathname === "/") return handleShell(res, snapshot);
|
|
2039
|
+
if (parsedUrl.pathname.startsWith("/assets/")) return handleAsset(res, parsedUrl.pathname);
|
|
2040
|
+
if (parsedUrl.pathname === "/api/pages") return handleApiPages(res, snapshot);
|
|
2041
|
+
if (parsedUrl.pathname === "/api/index") return handleApiIndex(res, snapshot, isLoopback);
|
|
2042
|
+
if (parsedUrl.pathname === "/api/health") return handleApiHealth(res, snapshot);
|
|
2043
|
+
if (parsedUrl.pathname === "/api/search") return handleApiSearch(res, parsedUrl, snapshot);
|
|
2044
|
+
if (parsedUrl.pathname.startsWith("/api/page/")) {
|
|
2045
|
+
return handleApiPage(res, parsedUrl.pathname, snapshot, isLoopback);
|
|
2046
|
+
}
|
|
2047
|
+
throw new Error(`route registration drift: no handler for ${parsedUrl.pathname}`);
|
|
2048
|
+
}
|
|
2049
|
+
function isRouteRegistered(method, pathname) {
|
|
2050
|
+
if (method !== "GET") return false;
|
|
2051
|
+
if (pathname === "/") return true;
|
|
2052
|
+
if (pathname.startsWith("/assets/")) return true;
|
|
2053
|
+
if (pathname === "/api/pages") return true;
|
|
2054
|
+
if (pathname === "/api/index") return true;
|
|
2055
|
+
if (pathname === "/api/health") return true;
|
|
2056
|
+
if (pathname === "/api/search") return true;
|
|
2057
|
+
if (pathname.startsWith("/api/page/")) return true;
|
|
2058
|
+
return false;
|
|
2059
|
+
}
|
|
2060
|
+
function applySecurityHeaders(res) {
|
|
2061
|
+
res.setHeader("Content-Security-Policy", CONTENT_SECURITY_POLICY);
|
|
2062
|
+
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
2063
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
2064
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
2065
|
+
}
|
|
2066
|
+
function validateOriginHeaders(req, config) {
|
|
2067
|
+
const host = req.headers.host;
|
|
2068
|
+
if (!host || !isAcceptableHost(host, config)) return false;
|
|
2069
|
+
const origin = req.headers.origin;
|
|
2070
|
+
if (typeof origin === "string" && origin.length > 0) {
|
|
2071
|
+
if (!isSameOrigin(origin, config)) return false;
|
|
2072
|
+
}
|
|
2073
|
+
const fetchSite = req.headers["sec-fetch-site"];
|
|
2074
|
+
if (fetchSite === "cross-site") return false;
|
|
2075
|
+
return true;
|
|
2076
|
+
}
|
|
2077
|
+
function isAcceptableHost(hostHeader, config) {
|
|
2078
|
+
for (const acceptable of buildAcceptableHostHeaders(config)) {
|
|
2079
|
+
if (hostHeader === acceptable) return true;
|
|
2080
|
+
}
|
|
2081
|
+
return false;
|
|
2082
|
+
}
|
|
2083
|
+
function buildAcceptableHostHeaders(config) {
|
|
2084
|
+
const formattedBind = formatHostHeader(config.host, config.port);
|
|
2085
|
+
const accepted = [formattedBind];
|
|
2086
|
+
if (config.host === "127.0.0.1" || config.host === "::1") {
|
|
2087
|
+
accepted.push(`localhost:${config.port}`);
|
|
2088
|
+
}
|
|
2089
|
+
return accepted;
|
|
2090
|
+
}
|
|
2091
|
+
function isSameOrigin(origin, config) {
|
|
2092
|
+
try {
|
|
2093
|
+
const parsed = new URL(origin);
|
|
2094
|
+
const expectedHostname = normalizeHostnameForOrigin(config.host);
|
|
2095
|
+
const originHostname = normalizeHostnameForOrigin(parsed.hostname);
|
|
2096
|
+
return originHostname === expectedHostname && Number(parsed.port) === config.port;
|
|
2097
|
+
} catch {
|
|
2098
|
+
return false;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
function formatHostHeader(host, port) {
|
|
2102
|
+
if (host.includes(":")) return `[${host}]:${port}`;
|
|
2103
|
+
return `${host}:${port}`;
|
|
2104
|
+
}
|
|
2105
|
+
function buildOriginBase(config) {
|
|
2106
|
+
if (config.host.includes(":")) return `http://[${config.host}]:${config.port}`;
|
|
2107
|
+
return `http://${config.host}:${config.port}`;
|
|
2108
|
+
}
|
|
2109
|
+
function normalizeHostnameForOrigin(host) {
|
|
2110
|
+
let h = host.toLowerCase();
|
|
2111
|
+
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
|
2112
|
+
return h;
|
|
2113
|
+
}
|
|
2114
|
+
async function handleShell(res, snapshot) {
|
|
2115
|
+
const template = await loadShellTemplate(ASSETS_DIR);
|
|
2116
|
+
if (template === null) {
|
|
2117
|
+
writeJsonError(res, 500, "shell_missing", "Viewer shell template not found on disk.");
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
const body = substitutePageIndex(template, snapshot.pages);
|
|
2121
|
+
res.statusCode = 200;
|
|
2122
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2123
|
+
res.end(body);
|
|
2124
|
+
}
|
|
2125
|
+
function handleApiPages(res, snapshot) {
|
|
2126
|
+
writeJson(res, 200, {
|
|
2127
|
+
project: snapshot.project,
|
|
2128
|
+
counts: {
|
|
2129
|
+
concepts: snapshot.counts.concepts,
|
|
2130
|
+
queries: snapshot.counts.queries,
|
|
2131
|
+
sourceFiles: snapshot.counts.sourceFiles,
|
|
2132
|
+
pendingReviews: snapshot.counts.pendingReviews
|
|
2133
|
+
},
|
|
2134
|
+
index: { available: snapshot.index.available, href: snapshot.index.href },
|
|
2135
|
+
recentPages: snapshot.recentPages,
|
|
2136
|
+
pages: snapshot.pages.map(pageListRow),
|
|
2137
|
+
updatedAt: snapshot.generatedAt
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
function pageListRow(page) {
|
|
2141
|
+
return {
|
|
2142
|
+
id: page.id,
|
|
2143
|
+
pageDirectory: page.pageDirectory,
|
|
2144
|
+
slug: page.slug,
|
|
2145
|
+
title: page.title,
|
|
2146
|
+
kind: typeof page.frontmatter.kind === "string" ? page.frontmatter.kind : "concept",
|
|
2147
|
+
summary: typeof page.frontmatter.summary === "string" ? page.frontmatter.summary : "",
|
|
2148
|
+
updatedAt: typeof page.frontmatter.updatedAt === "string" ? page.frontmatter.updatedAt : "",
|
|
2149
|
+
warnings: page.warnings
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
function handleApiIndex(res, snapshot, isLoopback) {
|
|
2153
|
+
if (!snapshot.index.available) {
|
|
2154
|
+
writeJsonError(res, 404, "index_unavailable", "wiki/index.md is not present.");
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
const rendered = tryRenderBody(snapshot.index.body, snapshot, isLoopback);
|
|
2158
|
+
if (rendered === null) {
|
|
2159
|
+
writeRenderFailed(res);
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
writeJson(res, 200, {
|
|
2163
|
+
html: rendered.html,
|
|
2164
|
+
outgoingLinks: snapshot.index.outgoingLinks,
|
|
2165
|
+
generatedAt: snapshot.generatedAt
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
async function handleApiHealth(res, snapshot) {
|
|
2169
|
+
const health = await buildHealthResponse(snapshot);
|
|
2170
|
+
writeJson(res, 200, health);
|
|
2171
|
+
}
|
|
2172
|
+
function handleApiSearch(res, parsedUrl, snapshot) {
|
|
2173
|
+
const query = parsedUrl.searchParams.get("q") ?? "";
|
|
2174
|
+
writeJson(res, 200, searchPages(snapshot, query));
|
|
2175
|
+
}
|
|
2176
|
+
function handleApiPage(res, pathname, snapshot, isLoopback) {
|
|
2177
|
+
const segments = pathname.replace(/^\/api\/page\//, "").split("/");
|
|
2178
|
+
if (segments.length !== 2) {
|
|
2179
|
+
writeJsonError(res, 400, "bad_request", "Expected /api/page/:directory/:slug");
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
const [directorySegment, encodedSlug] = segments;
|
|
2183
|
+
const decodedSlug = safeDecodeSlug(directorySegment, encodedSlug);
|
|
2184
|
+
if (!decodedSlug) {
|
|
2185
|
+
writeJsonError(res, 400, "bad_request", "Invalid directory or slug.");
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
const page = snapshot.pages.find(
|
|
2189
|
+
(p) => p.pageDirectory === decodedSlug.directory && p.slug === decodedSlug.slug
|
|
2190
|
+
);
|
|
2191
|
+
if (!page) {
|
|
2192
|
+
writeJsonError(res, 404, "page_not_found", `${decodedSlug.directory}/${decodedSlug.slug}`);
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
const rendered = tryRenderBody(page.body, snapshot, isLoopback);
|
|
2196
|
+
if (rendered === null) {
|
|
2197
|
+
writeRenderFailed(res);
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
writeJson(res, 200, pagePayload(page, snapshot, rendered.html));
|
|
2201
|
+
}
|
|
2202
|
+
function safeDecodeSlug(directorySegment, encodedSlug) {
|
|
2203
|
+
if (directorySegment !== "concepts" && directorySegment !== "queries") return null;
|
|
2204
|
+
let decoded;
|
|
2205
|
+
try {
|
|
2206
|
+
decoded = decodeURIComponent(encodedSlug);
|
|
2207
|
+
} catch {
|
|
2208
|
+
return null;
|
|
2209
|
+
}
|
|
2210
|
+
try {
|
|
2211
|
+
assertSafeSlug(decoded);
|
|
2212
|
+
} catch (err) {
|
|
2213
|
+
if (err instanceof PathSafetyError) return null;
|
|
2214
|
+
throw err;
|
|
2215
|
+
}
|
|
2216
|
+
return { directory: directorySegment, slug: decoded };
|
|
2217
|
+
}
|
|
2218
|
+
function pagePayload(page, snapshot, renderedHtml) {
|
|
2219
|
+
return {
|
|
2220
|
+
id: page.id,
|
|
2221
|
+
title: page.title,
|
|
2222
|
+
pageDirectory: page.pageDirectory,
|
|
2223
|
+
slug: page.slug,
|
|
2224
|
+
html: renderedHtml,
|
|
2225
|
+
citations: page.citations,
|
|
2226
|
+
outgoingLinks: page.outgoingLinks,
|
|
2227
|
+
frontmatter: page.frontmatter,
|
|
2228
|
+
warnings: page.warnings,
|
|
2229
|
+
updatedAt: typeof page.frontmatter.updatedAt === "string" ? page.frontmatter.updatedAt : "",
|
|
2230
|
+
createdAt: typeof page.frontmatter.createdAt === "string" ? page.frontmatter.createdAt : "",
|
|
2231
|
+
generatedAt: snapshot.generatedAt
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
function tryRenderBody(body, snapshot, isLoopback) {
|
|
2235
|
+
try {
|
|
2236
|
+
return renderPageHtml(body, snapshot, { isLoopback });
|
|
2237
|
+
} catch {
|
|
2238
|
+
return null;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
function writeRenderFailed(res) {
|
|
2242
|
+
writeJsonError(res, 500, "render_failed", "Could not render page.");
|
|
2243
|
+
}
|
|
2244
|
+
function writeJson(res, status2, body) {
|
|
2245
|
+
res.statusCode = status2;
|
|
2246
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
2247
|
+
res.end(JSON.stringify(body));
|
|
2248
|
+
}
|
|
2249
|
+
function writeJsonError(res, status2, code, message) {
|
|
2250
|
+
writeJson(res, status2, { error: { code, message } });
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// src/viewer/snapshot.ts
|
|
2254
|
+
import { readdir as readdir4, readFile as readFile16, realpath as realpath3 } from "fs/promises";
|
|
2255
|
+
import path21 from "path";
|
|
2256
|
+
|
|
2257
|
+
// src/compiler/candidates.ts
|
|
2258
|
+
import { readdir as readdir3, rename as rename2, unlink, writeFile as writeFile3, mkdir as mkdir4 } from "fs/promises";
|
|
2259
|
+
import { existsSync } from "fs";
|
|
2260
|
+
import path19 from "path";
|
|
2261
|
+
import { randomBytes } from "crypto";
|
|
2262
|
+
var ID_SUFFIX_BYTES = 4;
|
|
2263
|
+
var CANDIDATE_EXT = ".json";
|
|
2264
|
+
function buildCandidateId(slug) {
|
|
2265
|
+
const suffix = randomBytes(ID_SUFFIX_BYTES).toString("hex");
|
|
2266
|
+
return `${slug}-${suffix}`;
|
|
2267
|
+
}
|
|
2268
|
+
function candidatePath(root, id) {
|
|
2269
|
+
return path19.join(root, CANDIDATES_DIR, `${id}${CANDIDATE_EXT}`);
|
|
2270
|
+
}
|
|
2271
|
+
function archivePath(root, id) {
|
|
2272
|
+
return path19.join(root, CANDIDATES_ARCHIVE_DIR, `${id}${CANDIDATE_EXT}`);
|
|
2273
|
+
}
|
|
2274
|
+
async function writeCandidate(root, draft) {
|
|
2275
|
+
const candidate = {
|
|
2276
|
+
id: buildCandidateId(draft.slug),
|
|
2277
|
+
title: draft.title,
|
|
2278
|
+
slug: draft.slug,
|
|
2279
|
+
summary: draft.summary,
|
|
2280
|
+
sources: draft.sources,
|
|
2281
|
+
body: draft.body,
|
|
2282
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2283
|
+
...draft.sourceStates ? { sourceStates: draft.sourceStates } : {},
|
|
2284
|
+
...draft.schemaViolations ? { schemaViolations: draft.schemaViolations } : {},
|
|
2285
|
+
...draft.provenanceViolations ? { provenanceViolations: draft.provenanceViolations } : {}
|
|
2286
|
+
};
|
|
2287
|
+
await atomicWrite(candidatePath(root, candidate.id), JSON.stringify(candidate, null, 2));
|
|
2288
|
+
return candidate;
|
|
2289
|
+
}
|
|
2290
|
+
function failWithError(message) {
|
|
2291
|
+
status("!", error(message));
|
|
2292
|
+
process.exitCode = 1;
|
|
2293
|
+
return null;
|
|
2294
|
+
}
|
|
2295
|
+
async function loadCandidateOrFail(root, id) {
|
|
2296
|
+
const candidate = await readCandidate(root, id);
|
|
2297
|
+
if (!candidate) return failWithError(`Candidate not found: ${id}`);
|
|
2298
|
+
return candidate;
|
|
2299
|
+
}
|
|
2300
|
+
async function loadCandidateUnderLockOrFail(root, id) {
|
|
2301
|
+
const candidate = await readCandidate(root, id);
|
|
2302
|
+
if (!candidate) {
|
|
2303
|
+
return failWithError(`Candidate ${id} was removed by another process during review.`);
|
|
2304
|
+
}
|
|
2305
|
+
return candidate;
|
|
2306
|
+
}
|
|
2307
|
+
async function readCandidate(root, id) {
|
|
2308
|
+
const raw = await safeReadFile(candidatePath(root, id));
|
|
2309
|
+
if (!raw) return null;
|
|
2310
|
+
try {
|
|
2311
|
+
const parsed = JSON.parse(raw);
|
|
2312
|
+
if (!isValidCandidate(parsed)) return null;
|
|
2313
|
+
return parsed;
|
|
2314
|
+
} catch {
|
|
2315
|
+
return null;
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
function isValidCandidate(value) {
|
|
2319
|
+
if (!value || typeof value !== "object") return false;
|
|
2320
|
+
const candidate = value;
|
|
2321
|
+
return typeof candidate.id === "string" && typeof candidate.title === "string" && typeof candidate.slug === "string" && typeof candidate.body === "string" && Array.isArray(candidate.sources);
|
|
2322
|
+
}
|
|
2323
|
+
async function listCandidates(root) {
|
|
2324
|
+
const dir = path19.join(root, CANDIDATES_DIR);
|
|
2325
|
+
if (!existsSync(dir)) return [];
|
|
2326
|
+
const entries = await readdir3(dir, { withFileTypes: true });
|
|
2327
|
+
const candidates = [];
|
|
2328
|
+
for (const entry of entries) {
|
|
2329
|
+
if (!entry.isFile() || !entry.name.endsWith(CANDIDATE_EXT)) continue;
|
|
2330
|
+
const id = entry.name.slice(0, -CANDIDATE_EXT.length);
|
|
2331
|
+
const candidate = await readCandidate(root, id);
|
|
2332
|
+
if (candidate) candidates.push(candidate);
|
|
2333
|
+
}
|
|
2334
|
+
candidates.sort((a, b) => a.generatedAt.localeCompare(b.generatedAt));
|
|
2335
|
+
return candidates;
|
|
2336
|
+
}
|
|
2337
|
+
async function countCandidates(root) {
|
|
2338
|
+
const candidates = await listCandidates(root);
|
|
2339
|
+
return candidates.length;
|
|
2340
|
+
}
|
|
2341
|
+
async function deleteCandidate(root, id) {
|
|
2342
|
+
const filePath = candidatePath(root, id);
|
|
2343
|
+
if (!existsSync(filePath)) return false;
|
|
2344
|
+
await unlink(filePath);
|
|
2345
|
+
return true;
|
|
2346
|
+
}
|
|
2347
|
+
async function archiveCandidate(root, id) {
|
|
2348
|
+
const sourcePath = candidatePath(root, id);
|
|
2349
|
+
if (!existsSync(sourcePath)) return false;
|
|
2350
|
+
const target = archivePath(root, id);
|
|
2351
|
+
await mkdir4(path19.dirname(target), { recursive: true });
|
|
2352
|
+
try {
|
|
2353
|
+
await rename2(sourcePath, target);
|
|
2354
|
+
} catch {
|
|
2355
|
+
const raw = await safeReadFile(sourcePath);
|
|
2356
|
+
await writeFile3(target, raw, "utf-8");
|
|
2357
|
+
await unlink(sourcePath);
|
|
2358
|
+
}
|
|
2359
|
+
return true;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// src/utils/state.ts
|
|
2363
|
+
import { readFile as readFile15, writeFile as writeFile4, rename as rename3, mkdir as mkdir5, copyFile } from "fs/promises";
|
|
2364
|
+
import { existsSync as existsSync2 } from "fs";
|
|
2365
|
+
import path20 from "path";
|
|
2366
|
+
function emptyState() {
|
|
2367
|
+
return { version: 1, indexHash: "", sources: {} };
|
|
2368
|
+
}
|
|
2369
|
+
async function readState(root) {
|
|
2370
|
+
const filePath = path20.join(root, STATE_FILE);
|
|
2371
|
+
if (!existsSync2(filePath)) {
|
|
2372
|
+
return emptyState();
|
|
2373
|
+
}
|
|
2374
|
+
try {
|
|
2375
|
+
const raw = await readFile15(filePath, "utf-8");
|
|
2376
|
+
return JSON.parse(raw);
|
|
2377
|
+
} catch {
|
|
2378
|
+
const bakPath = filePath + ".bak";
|
|
2379
|
+
console.warn(`\u26A0 Corrupt state.json \u2014 backed up to ${bakPath}, starting fresh.`);
|
|
2380
|
+
await copyFile(filePath, bakPath);
|
|
2381
|
+
return emptyState();
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
async function writeState(root, state) {
|
|
2385
|
+
const dir = path20.join(root, LLMWIKI_DIR);
|
|
2386
|
+
await mkdir5(dir, { recursive: true });
|
|
2387
|
+
const filePath = path20.join(root, STATE_FILE);
|
|
2388
|
+
const tmpPath = filePath + ".tmp";
|
|
2389
|
+
await writeFile4(tmpPath, JSON.stringify(state, null, 2), "utf-8");
|
|
2390
|
+
await rename3(tmpPath, filePath);
|
|
2391
|
+
}
|
|
2392
|
+
async function updateSourceState(root, sourceFile, entry) {
|
|
2393
|
+
const state = await readState(root);
|
|
2394
|
+
state.sources[sourceFile] = entry;
|
|
2395
|
+
await writeState(root, state);
|
|
2396
|
+
}
|
|
2397
|
+
async function removeSourceState(root, sourceFile) {
|
|
2398
|
+
const state = await readState(root);
|
|
2399
|
+
delete state.sources[sourceFile];
|
|
2400
|
+
await writeState(root, state);
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// src/viewer/snapshot.ts
|
|
2404
|
+
var RECENT_PAGES_LIMIT = 8;
|
|
2405
|
+
var INDEX_HREF = "/#/index";
|
|
2406
|
+
async function buildViewerSnapshot(root) {
|
|
2407
|
+
const [pages, state, pendingReviews, sourceFilenames, index] = await Promise.all([
|
|
2408
|
+
collectViewerPages(root),
|
|
2409
|
+
readState(root),
|
|
2410
|
+
countCandidates(root),
|
|
2411
|
+
listSourceFiles(root),
|
|
2412
|
+
readIndexFile(root)
|
|
2413
|
+
]);
|
|
2414
|
+
const project = buildProject(root);
|
|
2415
|
+
const counts = {
|
|
2416
|
+
concepts: pages.filter((p) => p.pageDirectory === "concepts").length,
|
|
2417
|
+
queries: pages.filter((p) => p.pageDirectory === "queries").length,
|
|
2418
|
+
sourceFiles: sourceFilenames.length,
|
|
2419
|
+
pendingReviews,
|
|
2420
|
+
compiledSources: Object.keys(state.sources).length
|
|
2421
|
+
};
|
|
2422
|
+
const fullIndex = {
|
|
2423
|
+
available: index.available,
|
|
2424
|
+
href: INDEX_HREF,
|
|
2425
|
+
body: index.body,
|
|
2426
|
+
outgoingLinks: resolveBareSlugList(extractWikilinkSlugs(index.body), pages)
|
|
2427
|
+
};
|
|
2428
|
+
const sourceFileSet = new Set(sourceFilenames);
|
|
2429
|
+
const annotatedPages = pages.map((page) => annotateCitationWarnings(page, sourceFileSet));
|
|
2430
|
+
return {
|
|
2431
|
+
root,
|
|
2432
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2433
|
+
project,
|
|
2434
|
+
counts,
|
|
2435
|
+
index: fullIndex,
|
|
2436
|
+
recentPages: buildRecentPages(annotatedPages),
|
|
2437
|
+
pages: annotatedPages,
|
|
2438
|
+
sourceFilenames
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
function annotateCitationWarnings(page, sourceFiles) {
|
|
2442
|
+
const extra = [];
|
|
2443
|
+
const markerPattern = /\^\[([^\]\n]+)\]/g;
|
|
2444
|
+
let match;
|
|
2445
|
+
while ((match = markerPattern.exec(page.body)) !== null) {
|
|
2446
|
+
appendCitationWarningsForMarker(match[1], sourceFiles, extra);
|
|
2447
|
+
}
|
|
2448
|
+
if (extra.length === 0) return page;
|
|
2449
|
+
return { ...page, warnings: [...page.warnings, ...extra] };
|
|
2450
|
+
}
|
|
2451
|
+
function appendCitationWarningsForMarker(raw, sourceFiles, into) {
|
|
2452
|
+
for (const entry of raw.split(",")) {
|
|
2453
|
+
const trimmed = entry.trim();
|
|
2454
|
+
if (trimmed.length === 0) continue;
|
|
2455
|
+
if (isMalformedCitationEntry(trimmed)) {
|
|
2456
|
+
into.push({
|
|
2457
|
+
code: "malformed_citation",
|
|
2458
|
+
message: `Malformed citation entry: ${trimmed}`
|
|
2459
|
+
});
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
const file = trimmed.split(/[:#]/)[0];
|
|
2463
|
+
if (file.length > 0 && !sourceFiles.has(file)) {
|
|
2464
|
+
into.push({
|
|
2465
|
+
code: "unresolved_citation",
|
|
2466
|
+
message: `Source not found: ${file}`
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
function buildProject(root) {
|
|
2472
|
+
const rootName = path21.basename(root);
|
|
2473
|
+
return { title: rootName, rootName };
|
|
2474
|
+
}
|
|
2475
|
+
async function listSourceFiles(root) {
|
|
2476
|
+
let canonicalRoot;
|
|
2477
|
+
try {
|
|
2478
|
+
canonicalRoot = await realpath3(root);
|
|
2479
|
+
} catch {
|
|
2480
|
+
return [];
|
|
2481
|
+
}
|
|
2482
|
+
const expectedDir = path21.join(canonicalRoot, SOURCES_DIR);
|
|
2483
|
+
let realDir;
|
|
2484
|
+
try {
|
|
2485
|
+
realDir = await realpath3(expectedDir);
|
|
2486
|
+
} catch {
|
|
2487
|
+
return [];
|
|
2488
|
+
}
|
|
2489
|
+
if (realDir !== expectedDir) return [];
|
|
2490
|
+
try {
|
|
2491
|
+
const entries = await readdir4(realDir, { withFileTypes: true });
|
|
2492
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
2493
|
+
} catch {
|
|
2494
|
+
return [];
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
async function readIndexFile(root) {
|
|
2498
|
+
let canonicalRoot;
|
|
2499
|
+
try {
|
|
2500
|
+
canonicalRoot = await realpath3(root);
|
|
2501
|
+
} catch {
|
|
2502
|
+
return { available: false, body: "" };
|
|
2503
|
+
}
|
|
2504
|
+
const expectedIndex = path21.join(canonicalRoot, "wiki", "index.md");
|
|
2505
|
+
let resolved;
|
|
2506
|
+
try {
|
|
2507
|
+
resolved = await realpath3(expectedIndex);
|
|
2508
|
+
} catch {
|
|
2509
|
+
return { available: false, body: "" };
|
|
2510
|
+
}
|
|
2511
|
+
if (resolved !== expectedIndex) {
|
|
2512
|
+
return { available: false, body: "" };
|
|
2513
|
+
}
|
|
2514
|
+
try {
|
|
2515
|
+
const body = await readFile16(resolved, "utf-8");
|
|
2516
|
+
return { available: true, body };
|
|
2517
|
+
} catch {
|
|
2518
|
+
return { available: false, body: "" };
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
function buildRecentPages(pages) {
|
|
2522
|
+
const rows = pages.map((page) => ({
|
|
2523
|
+
id: page.id,
|
|
2524
|
+
pageDirectory: page.pageDirectory,
|
|
2525
|
+
slug: page.slug,
|
|
2526
|
+
title: page.title,
|
|
2527
|
+
updatedAt: typeof page.frontmatter.updatedAt === "string" ? page.frontmatter.updatedAt : ""
|
|
2528
|
+
}));
|
|
2529
|
+
rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
2530
|
+
return rows.slice(0, RECENT_PAGES_LIMIT);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// src/commands/view.ts
|
|
2534
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
2535
|
+
var WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
2536
|
+
"0.0.0.0",
|
|
2537
|
+
"::",
|
|
2538
|
+
"0:0:0:0:0:0:0:0",
|
|
2539
|
+
"0000:0000:0000:0000:0000:0000:0000:0000",
|
|
2540
|
+
"*"
|
|
2541
|
+
]);
|
|
2542
|
+
async function viewCommand(options) {
|
|
2543
|
+
const { host, port } = resolveBindConfig(options);
|
|
2544
|
+
const root = process.cwd();
|
|
2545
|
+
const snapshot = await buildViewerSnapshot(root);
|
|
2546
|
+
const handle = await startViewerServer(snapshot, { host, port });
|
|
2547
|
+
const url = buildReadyUrl(handle.host, handle.port);
|
|
2548
|
+
process.stdout.write(`Viewer ready at ${url}
|
|
2549
|
+
`);
|
|
2550
|
+
if (options.open) openInBrowser(url);
|
|
2551
|
+
registerShutdown(handle.close);
|
|
2552
|
+
}
|
|
2553
|
+
function openInBrowser(url) {
|
|
2554
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
2555
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
2556
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
2557
|
+
child.on("error", () => void 0);
|
|
2558
|
+
child.unref();
|
|
2559
|
+
}
|
|
2560
|
+
function resolveBindConfig(options) {
|
|
2561
|
+
const hostFlag = typeof options.host === "string" && options.host.length > 0;
|
|
2562
|
+
const allowLan = options.allowLan === true;
|
|
2563
|
+
if (hostFlag !== allowLan) {
|
|
1196
2564
|
throw new Error(
|
|
1197
|
-
|
|
2565
|
+
"Privacy gate: --host and --allow-lan must be supplied together. Use both to bind beyond loopback, or neither to keep the viewer on 127.0.0.1."
|
|
1198
2566
|
);
|
|
1199
2567
|
}
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
2568
|
+
const host = hostFlag ? options.host : LOOPBACK_HOST;
|
|
2569
|
+
if (WILDCARD_HOSTS.has(host)) {
|
|
2570
|
+
throw new Error(
|
|
2571
|
+
`--host ${host} is not supported: wildcard binds defeat the viewer's DNS-rebind protection. Use a specific interface IP (e.g. 192.168.1.10) instead.`
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
const port = parsePort(options.port);
|
|
2575
|
+
return { host, port };
|
|
1204
2576
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
2577
|
+
function buildReadyUrl(host, port) {
|
|
2578
|
+
if (host.includes(":")) return `http://[${host}]:${port}`;
|
|
2579
|
+
return `http://${host}:${port}`;
|
|
2580
|
+
}
|
|
2581
|
+
function parsePort(raw) {
|
|
2582
|
+
if (raw === void 0) return 0;
|
|
2583
|
+
const value = typeof raw === "number" ? raw : Number(raw);
|
|
2584
|
+
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
|
2585
|
+
throw new Error(`Invalid --port value: ${raw}`);
|
|
1213
2586
|
}
|
|
1214
|
-
|
|
2587
|
+
return value;
|
|
2588
|
+
}
|
|
2589
|
+
function registerShutdown(close) {
|
|
2590
|
+
const shutdown = async () => {
|
|
2591
|
+
try {
|
|
2592
|
+
await close();
|
|
2593
|
+
process.exit(0);
|
|
2594
|
+
} catch {
|
|
2595
|
+
process.exit(1);
|
|
2596
|
+
}
|
|
2597
|
+
};
|
|
2598
|
+
process.once("SIGINT", () => void shutdown());
|
|
2599
|
+
process.once("SIGTERM", () => void shutdown());
|
|
1215
2600
|
}
|
|
1216
2601
|
|
|
1217
2602
|
// src/commands/compile.ts
|
|
1218
2603
|
import { existsSync as existsSync7 } from "fs";
|
|
1219
2604
|
|
|
1220
2605
|
// src/compiler/index.ts
|
|
1221
|
-
import { readFile as
|
|
1222
|
-
import
|
|
1223
|
-
|
|
1224
|
-
// src/utils/state.ts
|
|
1225
|
-
import { readFile as readFile11, writeFile as writeFile3, rename as rename2, mkdir as mkdir3, copyFile } from "fs/promises";
|
|
1226
|
-
import { existsSync } from "fs";
|
|
1227
|
-
import path13 from "path";
|
|
1228
|
-
function emptyState() {
|
|
1229
|
-
return { version: 1, indexHash: "", sources: {} };
|
|
1230
|
-
}
|
|
1231
|
-
async function readState(root) {
|
|
1232
|
-
const filePath = path13.join(root, STATE_FILE);
|
|
1233
|
-
if (!existsSync(filePath)) {
|
|
1234
|
-
return emptyState();
|
|
1235
|
-
}
|
|
1236
|
-
try {
|
|
1237
|
-
const raw = await readFile11(filePath, "utf-8");
|
|
1238
|
-
return JSON.parse(raw);
|
|
1239
|
-
} catch {
|
|
1240
|
-
const bakPath = filePath + ".bak";
|
|
1241
|
-
console.warn(`\u26A0 Corrupt state.json \u2014 backed up to ${bakPath}, starting fresh.`);
|
|
1242
|
-
await copyFile(filePath, bakPath);
|
|
1243
|
-
return emptyState();
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
async function writeState(root, state) {
|
|
1247
|
-
const dir = path13.join(root, LLMWIKI_DIR);
|
|
1248
|
-
await mkdir3(dir, { recursive: true });
|
|
1249
|
-
const filePath = path13.join(root, STATE_FILE);
|
|
1250
|
-
const tmpPath = filePath + ".tmp";
|
|
1251
|
-
await writeFile3(tmpPath, JSON.stringify(state, null, 2), "utf-8");
|
|
1252
|
-
await rename2(tmpPath, filePath);
|
|
1253
|
-
}
|
|
1254
|
-
async function updateSourceState(root, sourceFile, entry) {
|
|
1255
|
-
const state = await readState(root);
|
|
1256
|
-
state.sources[sourceFile] = entry;
|
|
1257
|
-
await writeState(root, state);
|
|
1258
|
-
}
|
|
1259
|
-
async function removeSourceState(root, sourceFile) {
|
|
1260
|
-
const state = await readState(root);
|
|
1261
|
-
delete state.sources[sourceFile];
|
|
1262
|
-
await writeState(root, state);
|
|
1263
|
-
}
|
|
2606
|
+
import { readFile as readFile23 } from "fs/promises";
|
|
2607
|
+
import path33 from "path";
|
|
1264
2608
|
|
|
1265
2609
|
// src/compiler/source-state.ts
|
|
1266
|
-
import
|
|
2610
|
+
import path23 from "path";
|
|
1267
2611
|
|
|
1268
2612
|
// src/compiler/hasher.ts
|
|
1269
2613
|
import { createHash as createHash2 } from "crypto";
|
|
1270
|
-
import { readFile as
|
|
1271
|
-
import
|
|
2614
|
+
import { readFile as readFile17, readdir as readdir5 } from "fs/promises";
|
|
2615
|
+
import path22 from "path";
|
|
1272
2616
|
async function hashFile(filePath) {
|
|
1273
|
-
const content = await
|
|
2617
|
+
const content = await readFile17(filePath, "utf-8");
|
|
1274
2618
|
return createHash2("sha256").update(content).digest("hex");
|
|
1275
2619
|
}
|
|
1276
2620
|
async function detectChanges(root, prevState) {
|
|
1277
|
-
const sourcesPath =
|
|
1278
|
-
const currentFiles = await
|
|
2621
|
+
const sourcesPath = path22.join(root, SOURCES_DIR);
|
|
2622
|
+
const currentFiles = await listSourceFiles2(sourcesPath);
|
|
1279
2623
|
const changes = [];
|
|
1280
2624
|
for (const file of currentFiles) {
|
|
1281
2625
|
const status2 = await classifyFile(root, file, prevState);
|
|
@@ -1285,16 +2629,16 @@ async function detectChanges(root, prevState) {
|
|
|
1285
2629
|
changes.push(...deletedChanges);
|
|
1286
2630
|
return changes;
|
|
1287
2631
|
}
|
|
1288
|
-
async function
|
|
2632
|
+
async function listSourceFiles2(sourcesPath) {
|
|
1289
2633
|
try {
|
|
1290
|
-
const entries = await
|
|
2634
|
+
const entries = await readdir5(sourcesPath);
|
|
1291
2635
|
return entries.filter((f) => f.endsWith(".md"));
|
|
1292
2636
|
} catch {
|
|
1293
2637
|
return [];
|
|
1294
2638
|
}
|
|
1295
2639
|
}
|
|
1296
2640
|
async function classifyFile(root, file, prevState) {
|
|
1297
|
-
const filePath =
|
|
2641
|
+
const filePath = path22.join(root, SOURCES_DIR, file);
|
|
1298
2642
|
const hash = await hashFile(filePath);
|
|
1299
2643
|
const prev = prevState.sources[file];
|
|
1300
2644
|
if (!prev) return "new";
|
|
@@ -1317,7 +2661,7 @@ async function buildExtractionSourceStates(root, extractions) {
|
|
|
1317
2661
|
return snapshot;
|
|
1318
2662
|
}
|
|
1319
2663
|
async function buildEntry(root, result, compiledAt) {
|
|
1320
|
-
const filePath =
|
|
2664
|
+
const filePath = path23.join(root, SOURCES_DIR, result.sourceFile);
|
|
1321
2665
|
const hash = await hashFile(filePath);
|
|
1322
2666
|
return {
|
|
1323
2667
|
hash,
|
|
@@ -1406,7 +2750,8 @@ var OpenAIProvider = class {
|
|
|
1406
2750
|
model: this.model,
|
|
1407
2751
|
max_tokens: maxTokens,
|
|
1408
2752
|
messages: [{ role: "system", content: system }, ...messages],
|
|
1409
|
-
tools: openaiTools
|
|
2753
|
+
tools: openaiTools,
|
|
2754
|
+
tool_choice: "required"
|
|
1410
2755
|
});
|
|
1411
2756
|
const toolCalls = response.choices[0]?.message?.tool_calls;
|
|
1412
2757
|
if (toolCalls && toolCalls.length > 0) {
|
|
@@ -1463,8 +2808,24 @@ var MiniMaxProvider = class extends OpenAIProvider {
|
|
|
1463
2808
|
}
|
|
1464
2809
|
};
|
|
1465
2810
|
|
|
2811
|
+
// src/providers/copilot.ts
|
|
2812
|
+
var CopilotProvider = class extends OpenAIProvider {
|
|
2813
|
+
constructor(model, apiKey) {
|
|
2814
|
+
super(model, { baseURL: COPILOT_BASE_URL, apiKey });
|
|
2815
|
+
}
|
|
2816
|
+
/**
|
|
2817
|
+
* GitHub Copilot has no native embeddings API.
|
|
2818
|
+
* Throws an informative error directing the user to an alternative.
|
|
2819
|
+
*/
|
|
2820
|
+
async embed(_text) {
|
|
2821
|
+
throw new Error(
|
|
2822
|
+
"GitHub Copilot does not support embeddings.\n For semantic search (llmwiki query), switch to the OpenAI provider:\n export LLMWIKI_PROVIDER=openai\n export OPENAI_API_KEY=sk-..."
|
|
2823
|
+
);
|
|
2824
|
+
}
|
|
2825
|
+
};
|
|
2826
|
+
|
|
1466
2827
|
// src/utils/provider.ts
|
|
1467
|
-
var SUPPORTED_PROVIDERS = /* @__PURE__ */ new Set(["anthropic", "openai", "ollama", "minimax"]);
|
|
2828
|
+
var SUPPORTED_PROVIDERS = /* @__PURE__ */ new Set(["anthropic", "openai", "ollama", "minimax", "copilot"]);
|
|
1468
2829
|
function getProvider() {
|
|
1469
2830
|
const providerName = getProviderName();
|
|
1470
2831
|
switch (providerName) {
|
|
@@ -1484,6 +2845,8 @@ function getProvider() {
|
|
|
1484
2845
|
});
|
|
1485
2846
|
case "minimax":
|
|
1486
2847
|
return getMiniMaxProvider();
|
|
2848
|
+
case "copilot":
|
|
2849
|
+
return getCopilotProvider();
|
|
1487
2850
|
default:
|
|
1488
2851
|
throw new Error(`Unhandled provider: ${providerName}`);
|
|
1489
2852
|
}
|
|
@@ -1504,6 +2867,15 @@ function getMiniMaxProvider() {
|
|
|
1504
2867
|
}
|
|
1505
2868
|
return new MiniMaxProvider(getModelForProvider("minimax"), apiKey);
|
|
1506
2869
|
}
|
|
2870
|
+
function getCopilotProvider() {
|
|
2871
|
+
const apiKey = process.env.GITHUB_TOKEN;
|
|
2872
|
+
if (!apiKey) {
|
|
2873
|
+
throw new Error(
|
|
2874
|
+
"GitHub Copilot provider requires GITHUB_TOKEN environment variable.\n Run: gh auth refresh --scopes copilot\n Then set it with: export GITHUB_TOKEN=$(gh auth token)\n The token must belong to a GitHub account with an active Copilot subscription."
|
|
2875
|
+
);
|
|
2876
|
+
}
|
|
2877
|
+
return new CopilotProvider(getModelForProvider("copilot"), apiKey);
|
|
2878
|
+
}
|
|
1507
2879
|
function getAnthropicProvider() {
|
|
1508
2880
|
const model = resolveAnthropicModelFromEnv() ?? PROVIDER_MODELS.anthropic;
|
|
1509
2881
|
const baseURL = resolveAnthropicBaseURLFromEnv();
|
|
@@ -1555,8 +2927,8 @@ async function callClaude(options) {
|
|
|
1555
2927
|
}
|
|
1556
2928
|
|
|
1557
2929
|
// src/utils/lock.ts
|
|
1558
|
-
import { open, readFile as
|
|
1559
|
-
import
|
|
2930
|
+
import { open, readFile as readFile18, unlink as unlink2, mkdir as mkdir6 } from "fs/promises";
|
|
2931
|
+
import path24 from "path";
|
|
1560
2932
|
var RECLAIM_SUFFIX = ".reclaim";
|
|
1561
2933
|
var MAX_ACQUIRE_ATTEMPTS = 2;
|
|
1562
2934
|
function isProcessAlive(pid) {
|
|
@@ -1568,8 +2940,8 @@ function isProcessAlive(pid) {
|
|
|
1568
2940
|
}
|
|
1569
2941
|
}
|
|
1570
2942
|
async function acquireLock(root) {
|
|
1571
|
-
const lockPath =
|
|
1572
|
-
await
|
|
2943
|
+
const lockPath = path24.join(root, LOCK_FILE);
|
|
2944
|
+
await mkdir6(path24.join(root, LLMWIKI_DIR), { recursive: true });
|
|
1573
2945
|
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
1574
2946
|
const created = await tryCreateLock(lockPath);
|
|
1575
2947
|
if (created) return true;
|
|
@@ -1593,7 +2965,7 @@ async function reclaimStaleLock(root, lockPath) {
|
|
|
1593
2965
|
return false;
|
|
1594
2966
|
}
|
|
1595
2967
|
try {
|
|
1596
|
-
await
|
|
2968
|
+
await unlink2(lockPath);
|
|
1597
2969
|
} catch {
|
|
1598
2970
|
}
|
|
1599
2971
|
const acquired = await tryCreateLock(lockPath);
|
|
@@ -1603,7 +2975,7 @@ async function reclaimStaleLock(root, lockPath) {
|
|
|
1603
2975
|
return acquired;
|
|
1604
2976
|
} finally {
|
|
1605
2977
|
try {
|
|
1606
|
-
await
|
|
2978
|
+
await unlink2(reclaimPath);
|
|
1607
2979
|
} catch {
|
|
1608
2980
|
}
|
|
1609
2981
|
}
|
|
@@ -1612,7 +2984,7 @@ async function acquireReclaimLock(reclaimPath) {
|
|
|
1612
2984
|
if (await tryCreateLock(reclaimPath)) return true;
|
|
1613
2985
|
if (!await isLockStale(reclaimPath)) return false;
|
|
1614
2986
|
try {
|
|
1615
|
-
await
|
|
2987
|
+
await unlink2(reclaimPath);
|
|
1616
2988
|
} catch {
|
|
1617
2989
|
}
|
|
1618
2990
|
return false;
|
|
@@ -1632,7 +3004,7 @@ async function tryCreateLock(lockPath) {
|
|
|
1632
3004
|
}
|
|
1633
3005
|
async function isLockStale(lockPath) {
|
|
1634
3006
|
try {
|
|
1635
|
-
const content = await
|
|
3007
|
+
const content = await readFile18(lockPath, "utf-8");
|
|
1636
3008
|
const pid = parseInt(content.trim(), 10);
|
|
1637
3009
|
if (isNaN(pid)) return true;
|
|
1638
3010
|
return !isProcessAlive(pid);
|
|
@@ -1641,9 +3013,9 @@ async function isLockStale(lockPath) {
|
|
|
1641
3013
|
}
|
|
1642
3014
|
}
|
|
1643
3015
|
async function releaseLock(root) {
|
|
1644
|
-
const lockPath =
|
|
3016
|
+
const lockPath = path24.join(root, LOCK_FILE);
|
|
1645
3017
|
try {
|
|
1646
|
-
await
|
|
3018
|
+
await unlink2(lockPath);
|
|
1647
3019
|
} catch {
|
|
1648
3020
|
}
|
|
1649
3021
|
}
|
|
@@ -1900,9 +3272,9 @@ function buildDefaultSchema() {
|
|
|
1900
3272
|
}
|
|
1901
3273
|
|
|
1902
3274
|
// src/schema/loader.ts
|
|
1903
|
-
import { existsSync as
|
|
1904
|
-
import { readFile as
|
|
1905
|
-
import
|
|
3275
|
+
import { existsSync as existsSync3 } from "fs";
|
|
3276
|
+
import { readFile as readFile19 } from "fs/promises";
|
|
3277
|
+
import path25 from "path";
|
|
1906
3278
|
import yaml2 from "js-yaml";
|
|
1907
3279
|
var SCHEMA_CANDIDATE_PATHS = [
|
|
1908
3280
|
".llmwiki/schema.json",
|
|
@@ -1913,8 +3285,8 @@ var SCHEMA_CANDIDATE_PATHS = [
|
|
|
1913
3285
|
];
|
|
1914
3286
|
function findSchemaPath(root) {
|
|
1915
3287
|
for (const candidate of SCHEMA_CANDIDATE_PATHS) {
|
|
1916
|
-
const absolute =
|
|
1917
|
-
if (
|
|
3288
|
+
const absolute = path25.join(root, candidate);
|
|
3289
|
+
if (existsSync3(absolute)) return absolute;
|
|
1918
3290
|
}
|
|
1919
3291
|
return null;
|
|
1920
3292
|
}
|
|
@@ -1966,12 +3338,12 @@ async function loadSchema(root) {
|
|
|
1966
3338
|
const defaults = buildDefaultSchema();
|
|
1967
3339
|
const schemaPath = findSchemaPath(root);
|
|
1968
3340
|
if (!schemaPath) return defaults;
|
|
1969
|
-
const raw = await
|
|
3341
|
+
const raw = await readFile19(schemaPath, "utf-8");
|
|
1970
3342
|
const parsed = parseSchemaFile(schemaPath, raw);
|
|
1971
3343
|
return applyOverrides(defaults, parsed, schemaPath);
|
|
1972
3344
|
}
|
|
1973
3345
|
function defaultSchemaInitPath(root) {
|
|
1974
|
-
return
|
|
3346
|
+
return path25.join(root, SCHEMA_CANDIDATE_PATHS[0]);
|
|
1975
3347
|
}
|
|
1976
3348
|
|
|
1977
3349
|
// src/schema/helpers.ts
|
|
@@ -2143,7 +3515,7 @@ async function freezeFailedExtractions(root, results, frozenSlugs) {
|
|
|
2143
3515
|
}
|
|
2144
3516
|
|
|
2145
3517
|
// src/compiler/orphan.ts
|
|
2146
|
-
import
|
|
3518
|
+
import path26 from "path";
|
|
2147
3519
|
async function markOrphaned(root, sourceFile, state) {
|
|
2148
3520
|
const sourceEntry = state.sources[sourceFile];
|
|
2149
3521
|
if (!sourceEntry) return;
|
|
@@ -2169,7 +3541,7 @@ async function orphanUnownedFrozenPages(root, frozenSlugs) {
|
|
|
2169
3541
|
}
|
|
2170
3542
|
}
|
|
2171
3543
|
async function orphanPage(root, slug, reason) {
|
|
2172
|
-
const pagePath =
|
|
3544
|
+
const pagePath = path26.join(root, CONCEPTS_DIR, `${slug}.md`);
|
|
2173
3545
|
const content = await safeReadFile(pagePath);
|
|
2174
3546
|
if (!content) return;
|
|
2175
3547
|
const { meta } = parseFrontmatter(content);
|
|
@@ -2180,18 +3552,18 @@ async function orphanPage(root, slug, reason) {
|
|
|
2180
3552
|
}
|
|
2181
3553
|
|
|
2182
3554
|
// src/compiler/resolver.ts
|
|
2183
|
-
import { readdir as
|
|
2184
|
-
import
|
|
2185
|
-
import { existsSync as
|
|
3555
|
+
import { readdir as readdir6, readFile as readFile20 } from "fs/promises";
|
|
3556
|
+
import path27 from "path";
|
|
3557
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2186
3558
|
async function buildTitleIndex(root) {
|
|
2187
|
-
const conceptsDir =
|
|
2188
|
-
if (!
|
|
2189
|
-
const files = await
|
|
3559
|
+
const conceptsDir = path27.join(root, CONCEPTS_DIR);
|
|
3560
|
+
if (!existsSync4(conceptsDir)) return [];
|
|
3561
|
+
const files = await readdir6(conceptsDir);
|
|
2190
3562
|
const pages = [];
|
|
2191
3563
|
for (const file of files) {
|
|
2192
3564
|
if (!file.endsWith(".md")) continue;
|
|
2193
|
-
const filePath =
|
|
2194
|
-
const content = await
|
|
3565
|
+
const filePath = path27.join(conceptsDir, file);
|
|
3566
|
+
const content = await readFile20(filePath, "utf-8");
|
|
2195
3567
|
const { meta } = parseFrontmatter(content);
|
|
2196
3568
|
if (meta.title && typeof meta.title === "string" && !meta.orphaned) {
|
|
2197
3569
|
pages.push({
|
|
@@ -2277,7 +3649,7 @@ async function resolveInboundLinks(titleIndex, newSlugs) {
|
|
|
2277
3649
|
let count = 0;
|
|
2278
3650
|
for (const page of titleIndex) {
|
|
2279
3651
|
if (newSlugs.includes(page.slug)) continue;
|
|
2280
|
-
const content = await
|
|
3652
|
+
const content = await readFile20(page.filePath, "utf-8");
|
|
2281
3653
|
const { body } = parseFrontmatter(content);
|
|
2282
3654
|
const linked = addWikilinks(body, newTitles, page.title);
|
|
2283
3655
|
if (linked !== body) {
|
|
@@ -2289,7 +3661,7 @@ async function resolveInboundLinks(titleIndex, newSlugs) {
|
|
|
2289
3661
|
return count;
|
|
2290
3662
|
}
|
|
2291
3663
|
async function linkPage(page, titleIndex) {
|
|
2292
|
-
const content = await
|
|
3664
|
+
const content = await readFile20(page.filePath, "utf-8");
|
|
2293
3665
|
const { body } = parseFrontmatter(content);
|
|
2294
3666
|
const linked = addWikilinks(body, titleIndex, page.title);
|
|
2295
3667
|
if (linked === body) return false;
|
|
@@ -2299,18 +3671,18 @@ async function linkPage(page, titleIndex) {
|
|
|
2299
3671
|
}
|
|
2300
3672
|
|
|
2301
3673
|
// src/compiler/indexgen.ts
|
|
2302
|
-
import { readdir as
|
|
2303
|
-
import
|
|
3674
|
+
import { readdir as readdir7 } from "fs/promises";
|
|
3675
|
+
import path28 from "path";
|
|
2304
3676
|
async function generateIndex(root) {
|
|
2305
3677
|
status("*", info("Generating index..."));
|
|
2306
|
-
const conceptsPath =
|
|
2307
|
-
const queriesPath =
|
|
3678
|
+
const conceptsPath = path28.join(root, CONCEPTS_DIR);
|
|
3679
|
+
const queriesPath = path28.join(root, QUERIES_DIR);
|
|
2308
3680
|
const concepts = await collectPageSummaries(conceptsPath);
|
|
2309
3681
|
const queries = await collectPageSummaries(queriesPath);
|
|
2310
3682
|
concepts.sort((a, b) => a.title.localeCompare(b.title));
|
|
2311
3683
|
queries.sort((a, b) => a.title.localeCompare(b.title));
|
|
2312
3684
|
const indexContent = buildIndexContent(concepts, queries);
|
|
2313
|
-
const indexPath =
|
|
3685
|
+
const indexPath = path28.join(root, INDEX_FILE);
|
|
2314
3686
|
await atomicWrite(indexPath, indexContent);
|
|
2315
3687
|
const total = concepts.length + queries.length;
|
|
2316
3688
|
status("+", success(`Index updated with ${total} pages.`));
|
|
@@ -2318,13 +3690,13 @@ async function generateIndex(root) {
|
|
|
2318
3690
|
async function scanWikiPages(dirPath) {
|
|
2319
3691
|
let files;
|
|
2320
3692
|
try {
|
|
2321
|
-
files = await
|
|
3693
|
+
files = await readdir7(dirPath);
|
|
2322
3694
|
} catch {
|
|
2323
3695
|
return [];
|
|
2324
3696
|
}
|
|
2325
3697
|
const scanned = [];
|
|
2326
3698
|
for (const file of files.filter((f) => f.endsWith(".md"))) {
|
|
2327
|
-
const content = await safeReadFile(
|
|
3699
|
+
const content = await safeReadFile(path28.join(dirPath, file));
|
|
2328
3700
|
const { meta } = parseFrontmatter(content);
|
|
2329
3701
|
scanned.push({ slug: file.replace(/\.md$/, ""), meta });
|
|
2330
3702
|
}
|
|
@@ -2396,8 +3768,8 @@ function warnTruncation(concept, totalRaw, sourceCount, perSource, budget) {
|
|
|
2396
3768
|
}
|
|
2397
3769
|
|
|
2398
3770
|
// src/compiler/obsidian.ts
|
|
2399
|
-
import { readdir as
|
|
2400
|
-
import
|
|
3771
|
+
import { readdir as readdir8 } from "fs/promises";
|
|
3772
|
+
import path29 from "path";
|
|
2401
3773
|
var ABBREVIATION_MIN_WORDS = 3;
|
|
2402
3774
|
var SWAP_CONJUNCTIONS = [" and ", " or "];
|
|
2403
3775
|
function addObsidianMeta(frontmatter, conceptTitle, tags) {
|
|
@@ -2439,23 +3811,23 @@ function generateAbbreviation(title) {
|
|
|
2439
3811
|
return abbreviation;
|
|
2440
3812
|
}
|
|
2441
3813
|
async function generateMOC(root) {
|
|
2442
|
-
const conceptsPath =
|
|
3814
|
+
const conceptsPath = path29.join(root, CONCEPTS_DIR);
|
|
2443
3815
|
const pages = await loadConceptPages(conceptsPath);
|
|
2444
3816
|
const tagGroups = groupPagesByTag(pages);
|
|
2445
3817
|
const content = buildMOCContent(tagGroups);
|
|
2446
|
-
await atomicWrite(
|
|
3818
|
+
await atomicWrite(path29.join(root, MOC_FILE), content);
|
|
2447
3819
|
}
|
|
2448
3820
|
async function loadConceptPages(conceptsPath) {
|
|
2449
3821
|
let files;
|
|
2450
3822
|
try {
|
|
2451
|
-
files = await
|
|
3823
|
+
files = await readdir8(conceptsPath);
|
|
2452
3824
|
} catch {
|
|
2453
3825
|
return [];
|
|
2454
3826
|
}
|
|
2455
3827
|
const pages = [];
|
|
2456
3828
|
for (const file of files) {
|
|
2457
3829
|
if (!file.endsWith(".md")) continue;
|
|
2458
|
-
const content = await safeReadFile(
|
|
3830
|
+
const content = await safeReadFile(path29.join(conceptsPath, file));
|
|
2459
3831
|
if (!content) continue;
|
|
2460
3832
|
const { meta } = parseFrontmatter(content);
|
|
2461
3833
|
if (meta.orphaned) continue;
|
|
@@ -2506,9 +3878,9 @@ function buildMOCContent(tagGroups) {
|
|
|
2506
3878
|
}
|
|
2507
3879
|
|
|
2508
3880
|
// src/utils/embeddings.ts
|
|
2509
|
-
import { readFile as
|
|
2510
|
-
import { existsSync as
|
|
2511
|
-
import
|
|
3881
|
+
import { readFile as readFile21, readdir as readdir9 } from "fs/promises";
|
|
3882
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3883
|
+
import path30 from "path";
|
|
2512
3884
|
|
|
2513
3885
|
// src/utils/retrieval.ts
|
|
2514
3886
|
import { createHash as createHash3 } from "crypto";
|
|
@@ -2673,13 +4045,13 @@ function findTopKChunks(queryVec, chunks, k) {
|
|
|
2673
4045
|
return scored.slice(0, k);
|
|
2674
4046
|
}
|
|
2675
4047
|
async function readEmbeddingStore(root) {
|
|
2676
|
-
const filePath =
|
|
2677
|
-
if (!
|
|
2678
|
-
const raw = await
|
|
4048
|
+
const filePath = path30.join(root, EMBEDDINGS_FILE);
|
|
4049
|
+
if (!existsSync5(filePath)) return null;
|
|
4050
|
+
const raw = await readFile21(filePath, "utf-8");
|
|
2679
4051
|
return JSON.parse(raw);
|
|
2680
4052
|
}
|
|
2681
4053
|
async function writeEmbeddingStore(root, store) {
|
|
2682
|
-
const filePath =
|
|
4054
|
+
const filePath = path30.join(root, EMBEDDINGS_FILE);
|
|
2683
4055
|
await atomicWrite(filePath, JSON.stringify(store, null, 2));
|
|
2684
4056
|
}
|
|
2685
4057
|
async function findRelevantPages(root, question) {
|
|
@@ -2711,10 +4083,10 @@ async function loadActiveStore(root, hasContent) {
|
|
|
2711
4083
|
async function collectPageRecords(root) {
|
|
2712
4084
|
const records = [];
|
|
2713
4085
|
for (const dir of [CONCEPTS_DIR, QUERIES_DIR]) {
|
|
2714
|
-
const absDir =
|
|
4086
|
+
const absDir = path30.join(root, dir);
|
|
2715
4087
|
let files;
|
|
2716
4088
|
try {
|
|
2717
|
-
files = await
|
|
4089
|
+
files = await readdir9(absDir);
|
|
2718
4090
|
} catch {
|
|
2719
4091
|
continue;
|
|
2720
4092
|
}
|
|
@@ -2726,7 +4098,7 @@ async function collectPageRecords(root) {
|
|
|
2726
4098
|
return records;
|
|
2727
4099
|
}
|
|
2728
4100
|
async function readPageRecord(absDir, file) {
|
|
2729
|
-
const content = await safeReadFile(
|
|
4101
|
+
const content = await safeReadFile(path30.join(absDir, file));
|
|
2730
4102
|
const { meta, body } = parseFrontmatter(content);
|
|
2731
4103
|
if (meta.orphaned || typeof meta.title !== "string") return null;
|
|
2732
4104
|
return {
|
|
@@ -2887,115 +4259,10 @@ function shouldRunEmbedding(modelChanged, toEmbed, previousEntries, previousChun
|
|
|
2887
4259
|
return false;
|
|
2888
4260
|
}
|
|
2889
4261
|
|
|
2890
|
-
// src/compiler/candidates.ts
|
|
2891
|
-
import { readdir as readdir7, rename as rename3, unlink as unlink2, writeFile as writeFile4, mkdir as mkdir5 } from "fs/promises";
|
|
2892
|
-
import { existsSync as existsSync5 } from "fs";
|
|
2893
|
-
import path23 from "path";
|
|
2894
|
-
import { randomBytes } from "crypto";
|
|
2895
|
-
var ID_SUFFIX_BYTES = 4;
|
|
2896
|
-
var CANDIDATE_EXT = ".json";
|
|
2897
|
-
function buildCandidateId(slug) {
|
|
2898
|
-
const suffix = randomBytes(ID_SUFFIX_BYTES).toString("hex");
|
|
2899
|
-
return `${slug}-${suffix}`;
|
|
2900
|
-
}
|
|
2901
|
-
function candidatePath(root, id) {
|
|
2902
|
-
return path23.join(root, CANDIDATES_DIR, `${id}${CANDIDATE_EXT}`);
|
|
2903
|
-
}
|
|
2904
|
-
function archivePath(root, id) {
|
|
2905
|
-
return path23.join(root, CANDIDATES_ARCHIVE_DIR, `${id}${CANDIDATE_EXT}`);
|
|
2906
|
-
}
|
|
2907
|
-
async function writeCandidate(root, draft) {
|
|
2908
|
-
const candidate = {
|
|
2909
|
-
id: buildCandidateId(draft.slug),
|
|
2910
|
-
title: draft.title,
|
|
2911
|
-
slug: draft.slug,
|
|
2912
|
-
summary: draft.summary,
|
|
2913
|
-
sources: draft.sources,
|
|
2914
|
-
body: draft.body,
|
|
2915
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2916
|
-
...draft.sourceStates ? { sourceStates: draft.sourceStates } : {},
|
|
2917
|
-
...draft.schemaViolations ? { schemaViolations: draft.schemaViolations } : {},
|
|
2918
|
-
...draft.provenanceViolations ? { provenanceViolations: draft.provenanceViolations } : {}
|
|
2919
|
-
};
|
|
2920
|
-
await atomicWrite(candidatePath(root, candidate.id), JSON.stringify(candidate, null, 2));
|
|
2921
|
-
return candidate;
|
|
2922
|
-
}
|
|
2923
|
-
function failWithError(message) {
|
|
2924
|
-
status("!", error(message));
|
|
2925
|
-
process.exitCode = 1;
|
|
2926
|
-
return null;
|
|
2927
|
-
}
|
|
2928
|
-
async function loadCandidateOrFail(root, id) {
|
|
2929
|
-
const candidate = await readCandidate(root, id);
|
|
2930
|
-
if (!candidate) return failWithError(`Candidate not found: ${id}`);
|
|
2931
|
-
return candidate;
|
|
2932
|
-
}
|
|
2933
|
-
async function loadCandidateUnderLockOrFail(root, id) {
|
|
2934
|
-
const candidate = await readCandidate(root, id);
|
|
2935
|
-
if (!candidate) {
|
|
2936
|
-
return failWithError(`Candidate ${id} was removed by another process during review.`);
|
|
2937
|
-
}
|
|
2938
|
-
return candidate;
|
|
2939
|
-
}
|
|
2940
|
-
async function readCandidate(root, id) {
|
|
2941
|
-
const raw = await safeReadFile(candidatePath(root, id));
|
|
2942
|
-
if (!raw) return null;
|
|
2943
|
-
try {
|
|
2944
|
-
const parsed = JSON.parse(raw);
|
|
2945
|
-
if (!isValidCandidate(parsed)) return null;
|
|
2946
|
-
return parsed;
|
|
2947
|
-
} catch {
|
|
2948
|
-
return null;
|
|
2949
|
-
}
|
|
2950
|
-
}
|
|
2951
|
-
function isValidCandidate(value) {
|
|
2952
|
-
if (!value || typeof value !== "object") return false;
|
|
2953
|
-
const candidate = value;
|
|
2954
|
-
return typeof candidate.id === "string" && typeof candidate.title === "string" && typeof candidate.slug === "string" && typeof candidate.body === "string" && Array.isArray(candidate.sources);
|
|
2955
|
-
}
|
|
2956
|
-
async function listCandidates(root) {
|
|
2957
|
-
const dir = path23.join(root, CANDIDATES_DIR);
|
|
2958
|
-
if (!existsSync5(dir)) return [];
|
|
2959
|
-
const entries = await readdir7(dir, { withFileTypes: true });
|
|
2960
|
-
const candidates = [];
|
|
2961
|
-
for (const entry of entries) {
|
|
2962
|
-
if (!entry.isFile() || !entry.name.endsWith(CANDIDATE_EXT)) continue;
|
|
2963
|
-
const id = entry.name.slice(0, -CANDIDATE_EXT.length);
|
|
2964
|
-
const candidate = await readCandidate(root, id);
|
|
2965
|
-
if (candidate) candidates.push(candidate);
|
|
2966
|
-
}
|
|
2967
|
-
candidates.sort((a, b) => a.generatedAt.localeCompare(b.generatedAt));
|
|
2968
|
-
return candidates;
|
|
2969
|
-
}
|
|
2970
|
-
async function countCandidates(root) {
|
|
2971
|
-
const candidates = await listCandidates(root);
|
|
2972
|
-
return candidates.length;
|
|
2973
|
-
}
|
|
2974
|
-
async function deleteCandidate(root, id) {
|
|
2975
|
-
const filePath = candidatePath(root, id);
|
|
2976
|
-
if (!existsSync5(filePath)) return false;
|
|
2977
|
-
await unlink2(filePath);
|
|
2978
|
-
return true;
|
|
2979
|
-
}
|
|
2980
|
-
async function archiveCandidate(root, id) {
|
|
2981
|
-
const sourcePath = candidatePath(root, id);
|
|
2982
|
-
if (!existsSync5(sourcePath)) return false;
|
|
2983
|
-
const target = archivePath(root, id);
|
|
2984
|
-
await mkdir5(path23.dirname(target), { recursive: true });
|
|
2985
|
-
try {
|
|
2986
|
-
await rename3(sourcePath, target);
|
|
2987
|
-
} catch {
|
|
2988
|
-
const raw = await safeReadFile(sourcePath);
|
|
2989
|
-
await writeFile4(target, raw, "utf-8");
|
|
2990
|
-
await unlink2(sourcePath);
|
|
2991
|
-
}
|
|
2992
|
-
return true;
|
|
2993
|
-
}
|
|
2994
|
-
|
|
2995
4262
|
// src/linter/rules.ts
|
|
2996
|
-
import { readdir as
|
|
4263
|
+
import { readdir as readdir10, readFile as readFile22 } from "fs/promises";
|
|
2997
4264
|
import { existsSync as existsSync6 } from "fs";
|
|
2998
|
-
import
|
|
4265
|
+
import path31 from "path";
|
|
2999
4266
|
var MIN_BODY_LENGTH = 50;
|
|
3000
4267
|
var WIKILINK_PATTERN2 = /\[\[([^\]]+)\]\]/g;
|
|
3001
4268
|
var CITATION_PATTERN = /\^\[([^\]]+)\]/g;
|
|
@@ -3012,26 +4279,26 @@ function findMatchesInContent(content, pattern) {
|
|
|
3012
4279
|
}
|
|
3013
4280
|
async function readMarkdownFiles(dirPath) {
|
|
3014
4281
|
if (!existsSync6(dirPath)) return [];
|
|
3015
|
-
const entries = await
|
|
4282
|
+
const entries = await readdir10(dirPath);
|
|
3016
4283
|
const mdFiles = entries.filter((f) => f.endsWith(".md"));
|
|
3017
4284
|
const results = await Promise.all(
|
|
3018
4285
|
mdFiles.map(async (fileName) => {
|
|
3019
|
-
const filePath =
|
|
3020
|
-
const content = await
|
|
4286
|
+
const filePath = path31.join(dirPath, fileName);
|
|
4287
|
+
const content = await readFile22(filePath, "utf-8");
|
|
3021
4288
|
return { filePath, content };
|
|
3022
4289
|
})
|
|
3023
4290
|
);
|
|
3024
4291
|
return results;
|
|
3025
4292
|
}
|
|
3026
4293
|
async function collectAllPages(root) {
|
|
3027
|
-
const conceptPages = await readMarkdownFiles(
|
|
3028
|
-
const queryPages = await readMarkdownFiles(
|
|
4294
|
+
const conceptPages = await readMarkdownFiles(path31.join(root, CONCEPTS_DIR));
|
|
4295
|
+
const queryPages = await readMarkdownFiles(path31.join(root, QUERIES_DIR));
|
|
3029
4296
|
return [...conceptPages, ...queryPages];
|
|
3030
4297
|
}
|
|
3031
4298
|
function buildPageSlugSet(pages) {
|
|
3032
4299
|
const slugs = /* @__PURE__ */ new Set();
|
|
3033
4300
|
for (const page of pages) {
|
|
3034
|
-
const baseName =
|
|
4301
|
+
const baseName = path31.basename(page.filePath, ".md");
|
|
3035
4302
|
slugs.add(baseName.toLowerCase());
|
|
3036
4303
|
}
|
|
3037
4304
|
return slugs;
|
|
@@ -3254,7 +4521,7 @@ function countLines(content) {
|
|
|
3254
4521
|
}
|
|
3255
4522
|
async function checkBrokenCitations(root) {
|
|
3256
4523
|
const pages = await collectAllPages(root);
|
|
3257
|
-
const sourcesDir =
|
|
4524
|
+
const sourcesDir = path31.join(root, SOURCES_DIR);
|
|
3258
4525
|
const results = [];
|
|
3259
4526
|
const lineCountCache = /* @__PURE__ */ new Map();
|
|
3260
4527
|
for (const page of pages) {
|
|
@@ -3280,7 +4547,7 @@ async function collectBrokenForMarker(captured, line, pageFile, sourcesDir, line
|
|
|
3280
4547
|
const trimmed = part.trim();
|
|
3281
4548
|
if (trimmed.length === 0) continue;
|
|
3282
4549
|
const filename = stripSpanSuffix(trimmed);
|
|
3283
|
-
const citedPath =
|
|
4550
|
+
const citedPath = path31.join(sourcesDir, filename);
|
|
3284
4551
|
if (!existsSync6(citedPath)) {
|
|
3285
4552
|
out.push({
|
|
3286
4553
|
rule: "broken-citation",
|
|
@@ -3338,8 +4605,8 @@ function checkPageMalformedCitations(content, filePath) {
|
|
|
3338
4605
|
}
|
|
3339
4606
|
|
|
3340
4607
|
// src/compiler/page-renderer.ts
|
|
3341
|
-
import { readdir as
|
|
3342
|
-
import
|
|
4608
|
+
import { readdir as readdir11 } from "fs/promises";
|
|
4609
|
+
import path32 from "path";
|
|
3343
4610
|
|
|
3344
4611
|
// src/compiler/provenance.ts
|
|
3345
4612
|
function addProvenanceMeta(fields, concept) {
|
|
@@ -3366,7 +4633,7 @@ function reportContradictionWarnings(conceptTitle, concept) {
|
|
|
3366
4633
|
// src/compiler/page-renderer.ts
|
|
3367
4634
|
var RELATED_PAGE_CONTEXT_LIMIT = 5;
|
|
3368
4635
|
async function renderMergedPageContent(root, entry, schema) {
|
|
3369
|
-
const pagePath =
|
|
4636
|
+
const pagePath = path32.join(root, CONCEPTS_DIR, `${entry.slug}.md`);
|
|
3370
4637
|
const existingPage = await safeReadFile(pagePath);
|
|
3371
4638
|
const relatedPages = await loadRelatedPages(root, entry.slug);
|
|
3372
4639
|
const system = buildPagePrompt(
|
|
@@ -3405,17 +4672,17 @@ function buildMergedFrontmatter(entry, existingPage, schema) {
|
|
|
3405
4672
|
return buildFrontmatter(frontmatterFields);
|
|
3406
4673
|
}
|
|
3407
4674
|
async function loadRelatedPages(root, excludeSlug) {
|
|
3408
|
-
const conceptsPath =
|
|
4675
|
+
const conceptsPath = path32.join(root, CONCEPTS_DIR);
|
|
3409
4676
|
let files;
|
|
3410
4677
|
try {
|
|
3411
|
-
files = await
|
|
4678
|
+
files = await readdir11(conceptsPath);
|
|
3412
4679
|
} catch {
|
|
3413
4680
|
return "";
|
|
3414
4681
|
}
|
|
3415
4682
|
const related = files.filter((f) => f.endsWith(".md") && f !== `${excludeSlug}.md`).slice(0, RELATED_PAGE_CONTEXT_LIMIT);
|
|
3416
4683
|
const contents = [];
|
|
3417
4684
|
for (const f of related) {
|
|
3418
|
-
const content = await safeReadFile(
|
|
4685
|
+
const content = await safeReadFile(path32.join(conceptsPath, f));
|
|
3419
4686
|
if (!content) continue;
|
|
3420
4687
|
const { meta } = parseFrontmatter(content);
|
|
3421
4688
|
if (meta.orphaned) continue;
|
|
@@ -3628,9 +4895,9 @@ function printChangesSummary(changes) {
|
|
|
3628
4895
|
}
|
|
3629
4896
|
async function extractForSource(root, sourceFile) {
|
|
3630
4897
|
status("*", info(`Extracting: ${sourceFile}`));
|
|
3631
|
-
const sourcePath =
|
|
3632
|
-
const sourceContent = await
|
|
3633
|
-
const existingIndex = await safeReadFile(
|
|
4898
|
+
const sourcePath = path33.join(root, SOURCES_DIR, sourceFile);
|
|
4899
|
+
const sourceContent = await readFile23(sourcePath, "utf-8");
|
|
4900
|
+
const existingIndex = await safeReadFile(path33.join(root, INDEX_FILE));
|
|
3634
4901
|
const concepts = await extractConcepts(sourceContent, existingIndex);
|
|
3635
4902
|
if (concepts.length > 0) {
|
|
3636
4903
|
const names = concepts.map((c) => c.concept).join(", ");
|
|
@@ -3696,7 +4963,7 @@ async function generateMergedPage(root, entry, schema, options, sourceStates) {
|
|
|
3696
4963
|
if (options.review) {
|
|
3697
4964
|
return await persistReviewCandidate(root, entry, fullPage, sourceStates, schema);
|
|
3698
4965
|
}
|
|
3699
|
-
const pagePath =
|
|
4966
|
+
const pagePath = path33.join(root, CONCEPTS_DIR, `${entry.slug}.md`);
|
|
3700
4967
|
const error2 = await writePageIfValid(pagePath, fullPage, entry.concept.concept);
|
|
3701
4968
|
return { error: error2 ?? void 0 };
|
|
3702
4969
|
}
|
|
@@ -3726,7 +4993,7 @@ async function collectCandidateProvenanceViolations(root, fullPage, virtualPath)
|
|
|
3726
4993
|
const broken = await checkPageBrokenCitations(
|
|
3727
4994
|
fullPage,
|
|
3728
4995
|
virtualPath,
|
|
3729
|
-
|
|
4996
|
+
path33.join(root, SOURCES_DIR)
|
|
3730
4997
|
);
|
|
3731
4998
|
return [...malformed, ...broken];
|
|
3732
4999
|
}
|
|
@@ -3743,7 +5010,7 @@ async function generateSeedPages(root, schema, generation) {
|
|
|
3743
5010
|
}
|
|
3744
5011
|
async function generateSingleSeedPage(root, schema, seed) {
|
|
3745
5012
|
const slug = slugify(seed.title);
|
|
3746
|
-
const pagePath =
|
|
5013
|
+
const pagePath = path33.join(root, CONCEPTS_DIR, `${slug}.md`);
|
|
3747
5014
|
const relatedContent = await loadSeedRelatedPages(root, seed.relatedSlugs ?? []);
|
|
3748
5015
|
const rule = schema.kinds[seed.kind];
|
|
3749
5016
|
const system = buildSeedPagePrompt(seed, rule, relatedContent);
|
|
@@ -3776,7 +5043,7 @@ async function loadSeedRelatedPages(root, slugs) {
|
|
|
3776
5043
|
if (slugs.length === 0) return "";
|
|
3777
5044
|
const contents = [];
|
|
3778
5045
|
for (const slug of slugs) {
|
|
3779
|
-
const pagePath =
|
|
5046
|
+
const pagePath = path33.join(root, CONCEPTS_DIR, `${slug}.md`);
|
|
3780
5047
|
const content = await safeReadFile(pagePath);
|
|
3781
5048
|
if (content) contents.push(content);
|
|
3782
5049
|
}
|
|
@@ -3831,7 +5098,7 @@ async function compileCommand(options = {}) {
|
|
|
3831
5098
|
|
|
3832
5099
|
// src/commands/query.ts
|
|
3833
5100
|
import { existsSync as existsSync8 } from "fs";
|
|
3834
|
-
import
|
|
5101
|
+
import path34 from "path";
|
|
3835
5102
|
var PAGE_DIRS = [CONCEPTS_DIR, QUERIES_DIR];
|
|
3836
5103
|
var PAGE_SELECTION_TOOL = {
|
|
3837
5104
|
name: "select_pages",
|
|
@@ -3888,7 +5155,7 @@ async function selectRelevantPages(root, question, debug) {
|
|
|
3888
5155
|
const { pages: rawPages2, reasoning: reasoning2 } = await selectPages(question, filteredIndex);
|
|
3889
5156
|
return { pages: rawPages2, rawPages: rawPages2, reasoning: reasoning2, chunks: [] };
|
|
3890
5157
|
}
|
|
3891
|
-
const indexContent = await safeReadFile(
|
|
5158
|
+
const indexContent = await safeReadFile(path34.join(root, INDEX_FILE));
|
|
3892
5159
|
const { pages: rawPages, reasoning } = await selectPages(question, indexContent);
|
|
3893
5160
|
return { pages: rawPages.map((p) => slugify(p)), rawPages, reasoning, chunks: [] };
|
|
3894
5161
|
}
|
|
@@ -3980,7 +5247,7 @@ async function loadSelectedPages(root, slugs) {
|
|
|
3980
5247
|
for (const slug of slugs) {
|
|
3981
5248
|
let content = "";
|
|
3982
5249
|
for (const dir of PAGE_DIRS) {
|
|
3983
|
-
const candidate = await safeReadFile(
|
|
5250
|
+
const candidate = await safeReadFile(path34.join(root, dir, `${slug}.md`));
|
|
3984
5251
|
if (!candidate) continue;
|
|
3985
5252
|
const { meta } = parseFrontmatter(candidate);
|
|
3986
5253
|
if (meta.orphaned) continue;
|
|
@@ -4031,7 +5298,7 @@ function summarizeAnswer(answer) {
|
|
|
4031
5298
|
}
|
|
4032
5299
|
async function saveQueryPage(root, question, answer) {
|
|
4033
5300
|
const slug = slugify(question);
|
|
4034
|
-
const filePath =
|
|
5301
|
+
const filePath = path34.join(root, QUERIES_DIR, `${slug}.md`);
|
|
4035
5302
|
const frontmatter = buildFrontmatter({
|
|
4036
5303
|
title: question,
|
|
4037
5304
|
summary: summarizeAnswer(answer),
|
|
@@ -4057,7 +5324,7 @@ ${answer}
|
|
|
4057
5324
|
return slug;
|
|
4058
5325
|
}
|
|
4059
5326
|
async function generateAnswer(root, question, options = {}) {
|
|
4060
|
-
if (!existsSync8(
|
|
5327
|
+
if (!existsSync8(path34.join(root, INDEX_FILE))) {
|
|
4061
5328
|
throw new Error("Wiki index not found. Run `llmwiki compile` first.");
|
|
4062
5329
|
}
|
|
4063
5330
|
const selection = await selectRelevantPages(root, question, Boolean(options.debug));
|
|
@@ -4085,7 +5352,7 @@ function buildEmptyResult(selection) {
|
|
|
4085
5352
|
};
|
|
4086
5353
|
}
|
|
4087
5354
|
async function queryCommand(root, question, options) {
|
|
4088
|
-
if (!existsSync8(
|
|
5355
|
+
if (!existsSync8(path34.join(root, INDEX_FILE))) {
|
|
4089
5356
|
status("!", error("Wiki index not found. Run `llmwiki compile` first."));
|
|
4090
5357
|
return;
|
|
4091
5358
|
}
|
|
@@ -4136,10 +5403,10 @@ var DEBUG_CHUNK_PREVIEW_CHARS = 120;
|
|
|
4136
5403
|
// src/commands/watch.ts
|
|
4137
5404
|
import { watch as chokidarWatch } from "chokidar";
|
|
4138
5405
|
import { existsSync as existsSync9 } from "fs";
|
|
4139
|
-
import
|
|
5406
|
+
import path35 from "path";
|
|
4140
5407
|
var DEBOUNCE_MS = 500;
|
|
4141
5408
|
async function watchCommand() {
|
|
4142
|
-
const sourcesPath =
|
|
5409
|
+
const sourcesPath = path35.resolve(SOURCES_DIR);
|
|
4143
5410
|
if (!existsSync9(sourcesPath)) {
|
|
4144
5411
|
status(
|
|
4145
5412
|
"!",
|
|
@@ -4174,7 +5441,7 @@ async function watchCommand() {
|
|
|
4174
5441
|
const scheduleCompile = (eventPath, event) => {
|
|
4175
5442
|
status(
|
|
4176
5443
|
"~",
|
|
4177
|
-
dim(`${event}: ${
|
|
5444
|
+
dim(`${event}: ${path35.basename(eventPath)}`)
|
|
4178
5445
|
);
|
|
4179
5446
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
4180
5447
|
debounceTimer = setTimeout(triggerCompile, DEBOUNCE_MS);
|
|
@@ -4253,75 +5520,38 @@ async function lintCommand() {
|
|
|
4253
5520
|
info(`${summary.info} info`)
|
|
4254
5521
|
].join(", ");
|
|
4255
5522
|
status("*", summaryLine);
|
|
5523
|
+
await writeLintCache(process.cwd(), summary);
|
|
4256
5524
|
if (summary.errors > 0) {
|
|
4257
5525
|
process.exit(1);
|
|
4258
5526
|
}
|
|
4259
5527
|
}
|
|
4260
5528
|
|
|
4261
5529
|
// src/commands/export.ts
|
|
4262
|
-
import
|
|
5530
|
+
import path36 from "path";
|
|
4263
5531
|
import { createRequire } from "module";
|
|
4264
5532
|
|
|
4265
5533
|
// src/export/collect.ts
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
var WIKILINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
|
4269
|
-
function extractWikilinkSlugs(body) {
|
|
4270
|
-
const slugs = /* @__PURE__ */ new Set();
|
|
4271
|
-
let match;
|
|
4272
|
-
while ((match = WIKILINK_RE.exec(body)) !== null) {
|
|
4273
|
-
slugs.add(slugify(match[1].trim()));
|
|
4274
|
-
}
|
|
4275
|
-
return [...slugs];
|
|
4276
|
-
}
|
|
4277
|
-
async function parsePageFile(filePath, slug, pageDirectory) {
|
|
4278
|
-
let raw;
|
|
4279
|
-
try {
|
|
4280
|
-
raw = await readFile19(filePath, "utf-8");
|
|
4281
|
-
} catch {
|
|
4282
|
-
return null;
|
|
4283
|
-
}
|
|
4284
|
-
const { meta, body } = parseFrontmatter(raw);
|
|
4285
|
-
if (!meta.title || typeof meta.title !== "string") return null;
|
|
4286
|
-
if (meta.orphaned === true) return null;
|
|
5534
|
+
function toExportPage(raw) {
|
|
5535
|
+
const meta = raw.frontmatter;
|
|
4287
5536
|
return {
|
|
4288
|
-
title:
|
|
4289
|
-
slug,
|
|
4290
|
-
pageDirectory,
|
|
5537
|
+
title: raw.title,
|
|
5538
|
+
slug: raw.slug,
|
|
5539
|
+
pageDirectory: raw.pageDirectory,
|
|
4291
5540
|
summary: typeof meta.summary === "string" ? meta.summary : "",
|
|
4292
5541
|
sources: Array.isArray(meta.sources) ? meta.sources.filter((s) => typeof s === "string") : [],
|
|
4293
5542
|
tags: Array.isArray(meta.tags) ? meta.tags.filter((t) => typeof t === "string") : [],
|
|
4294
5543
|
createdAt: typeof meta.createdAt === "string" ? meta.createdAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
4295
5544
|
updatedAt: typeof meta.updatedAt === "string" ? meta.updatedAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
4296
|
-
links: extractWikilinkSlugs(body),
|
|
4297
|
-
body
|
|
5545
|
+
links: extractWikilinkSlugs(raw.body),
|
|
5546
|
+
body: raw.body
|
|
4298
5547
|
};
|
|
4299
5548
|
}
|
|
4300
|
-
async function collectFromDir(dirPath, pageDirectory) {
|
|
4301
|
-
let files;
|
|
4302
|
-
try {
|
|
4303
|
-
files = await readdir10(dirPath);
|
|
4304
|
-
} catch {
|
|
4305
|
-
return [];
|
|
4306
|
-
}
|
|
4307
|
-
const pages = [];
|
|
4308
|
-
for (const file of files.filter((f) => f.endsWith(".md"))) {
|
|
4309
|
-
const slug = file.replace(/\.md$/, "");
|
|
4310
|
-
const page = await parsePageFile(path29.join(dirPath, file), slug, pageDirectory);
|
|
4311
|
-
if (page) pages.push(page);
|
|
4312
|
-
}
|
|
4313
|
-
return pages;
|
|
4314
|
-
}
|
|
4315
5549
|
async function collectExportPages(root) {
|
|
4316
|
-
const
|
|
4317
|
-
const
|
|
4318
|
-
const
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
]);
|
|
4322
|
-
const all = [...concepts, ...queries];
|
|
4323
|
-
all.sort((a, b) => a.title.localeCompare(b.title));
|
|
4324
|
-
return all;
|
|
5550
|
+
const raw = await collectRawWikiPages(root);
|
|
5551
|
+
const kept = raw.filter((page) => page.parseStatus.hasTitle && !page.parseStatus.orphaned);
|
|
5552
|
+
const pages = kept.map(toExportPage);
|
|
5553
|
+
pages.sort((a, b) => a.title.localeCompare(b.title));
|
|
5554
|
+
return pages;
|
|
4325
5555
|
}
|
|
4326
5556
|
|
|
4327
5557
|
// src/export/llms-txt.ts
|
|
@@ -4559,7 +5789,7 @@ var TARGET_FILENAMES = {
|
|
|
4559
5789
|
};
|
|
4560
5790
|
function resolveProjectTitle(root) {
|
|
4561
5791
|
try {
|
|
4562
|
-
const pkg = require2(
|
|
5792
|
+
const pkg = require2(path36.join(root, "package.json"));
|
|
4563
5793
|
return typeof pkg.name === "string" ? pkg.name : "Knowledge Wiki";
|
|
4564
5794
|
} catch {
|
|
4565
5795
|
return "Knowledge Wiki";
|
|
@@ -4611,7 +5841,7 @@ async function runExport(root, options = {}) {
|
|
|
4611
5841
|
const written = [];
|
|
4612
5842
|
for (const target of targets) {
|
|
4613
5843
|
const content = buildContent(target, pages, projectTitle, marpSource);
|
|
4614
|
-
const outPath =
|
|
5844
|
+
const outPath = path36.join(root, EXPORT_DIR, TARGET_FILENAMES[target]);
|
|
4615
5845
|
await atomicWrite(outPath, content);
|
|
4616
5846
|
written.push(outPath);
|
|
4617
5847
|
status("+", success(`Exported ${target} \u2192 ${source(outPath)}`));
|
|
@@ -4638,8 +5868,8 @@ async function exportCommand(root, options) {
|
|
|
4638
5868
|
|
|
4639
5869
|
// src/commands/schema.ts
|
|
4640
5870
|
import { existsSync as existsSync10 } from "fs";
|
|
4641
|
-
import { mkdir as
|
|
4642
|
-
import
|
|
5871
|
+
import { mkdir as mkdir7, writeFile as writeFile5 } from "fs/promises";
|
|
5872
|
+
import path37 from "path";
|
|
4643
5873
|
async function schemaInitCommand() {
|
|
4644
5874
|
const root = process.cwd();
|
|
4645
5875
|
const defaults = buildDefaultSchema();
|
|
@@ -4648,7 +5878,7 @@ async function schemaInitCommand() {
|
|
|
4648
5878
|
status("!", warn(`Schema file already exists at ${targetPath}`));
|
|
4649
5879
|
return;
|
|
4650
5880
|
}
|
|
4651
|
-
await
|
|
5881
|
+
await mkdir7(path37.dirname(targetPath), { recursive: true });
|
|
4652
5882
|
const serializable = {
|
|
4653
5883
|
version: defaults.version,
|
|
4654
5884
|
defaultKind: defaults.defaultKind,
|
|
@@ -4714,7 +5944,7 @@ async function reviewShowCommand(id) {
|
|
|
4714
5944
|
}
|
|
4715
5945
|
|
|
4716
5946
|
// src/commands/review-approve.ts
|
|
4717
|
-
import
|
|
5947
|
+
import path38 from "path";
|
|
4718
5948
|
|
|
4719
5949
|
// src/commands/review-helpers.ts
|
|
4720
5950
|
async function runReviewUnderLock(id, underLock) {
|
|
@@ -4746,7 +5976,7 @@ async function approveUnderLock(root, id) {
|
|
|
4746
5976
|
process.exitCode = 1;
|
|
4747
5977
|
return;
|
|
4748
5978
|
}
|
|
4749
|
-
const pagePath =
|
|
5979
|
+
const pagePath = path38.join(root, CONCEPTS_DIR, `${candidate.slug}.md`);
|
|
4750
5980
|
await atomicWrite(pagePath, candidate.body);
|
|
4751
5981
|
status("+", success(`Approved \u2192 ${source(pagePath)}`));
|
|
4752
5982
|
await persistCandidateSourceStates(root, candidate);
|
|
@@ -4806,7 +6036,7 @@ import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js
|
|
|
4806
6036
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4807
6037
|
|
|
4808
6038
|
// src/mcp/tools.ts
|
|
4809
|
-
import
|
|
6039
|
+
import path39 from "path";
|
|
4810
6040
|
import { z } from "zod";
|
|
4811
6041
|
|
|
4812
6042
|
// src/mcp/provider-check.ts
|
|
@@ -4814,7 +6044,8 @@ var PROVIDER_KEY_VARS = {
|
|
|
4814
6044
|
anthropic: "ANTHROPIC_API_KEY",
|
|
4815
6045
|
openai: "OPENAI_API_KEY",
|
|
4816
6046
|
ollama: null,
|
|
4817
|
-
minimax: "MINIMAX_API_KEY"
|
|
6047
|
+
minimax: "MINIMAX_API_KEY",
|
|
6048
|
+
copilot: "GITHUB_TOKEN"
|
|
4818
6049
|
};
|
|
4819
6050
|
function ensureProviderAvailable() {
|
|
4820
6051
|
const provider = process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER;
|
|
@@ -4942,7 +6173,7 @@ async function pickSearchSlugs(root, question) {
|
|
|
4942
6173
|
if (candidates.length > 0) return candidates.map((c) => c.slug);
|
|
4943
6174
|
} catch {
|
|
4944
6175
|
}
|
|
4945
|
-
const indexContent = await safeReadFile(
|
|
6176
|
+
const indexContent = await safeReadFile(path39.join(root, INDEX_FILE));
|
|
4946
6177
|
const { pages } = await selectPages(question, indexContent);
|
|
4947
6178
|
return pages;
|
|
4948
6179
|
}
|
|
@@ -5001,8 +6232,8 @@ function registerStatusTool(server, root) {
|
|
|
5001
6232
|
);
|
|
5002
6233
|
}
|
|
5003
6234
|
async function collectStatus(root) {
|
|
5004
|
-
const concepts = await collectPageSummaries(
|
|
5005
|
-
const queries = await collectPageSummaries(
|
|
6235
|
+
const concepts = await collectPageSummaries(path39.join(root, CONCEPTS_DIR));
|
|
6236
|
+
const queries = await collectPageSummaries(path39.join(root, QUERIES_DIR));
|
|
5006
6237
|
const state = await readState(root);
|
|
5007
6238
|
const changes = await detectChanges(root, state);
|
|
5008
6239
|
const orphans = await findOrphanedSlugs(root);
|
|
@@ -5019,7 +6250,7 @@ async function collectStatus(root) {
|
|
|
5019
6250
|
};
|
|
5020
6251
|
}
|
|
5021
6252
|
async function findOrphanedSlugs(root) {
|
|
5022
|
-
const scanned = await scanWikiPages(
|
|
6253
|
+
const scanned = await scanWikiPages(path39.join(root, CONCEPTS_DIR));
|
|
5023
6254
|
return scanned.filter(({ meta }) => meta.orphaned).map(({ slug }) => slug);
|
|
5024
6255
|
}
|
|
5025
6256
|
async function loadPageRecords(root, slugs) {
|
|
@@ -5032,7 +6263,7 @@ async function loadPageRecords(root, slugs) {
|
|
|
5032
6263
|
}
|
|
5033
6264
|
async function readPage(root, slug) {
|
|
5034
6265
|
for (const dir of PAGE_DIRS2) {
|
|
5035
|
-
const content = await safeReadFile(
|
|
6266
|
+
const content = await safeReadFile(path39.join(root, dir, `${slug}.md`));
|
|
5036
6267
|
if (!content) continue;
|
|
5037
6268
|
const { meta, body } = parseFrontmatter(content);
|
|
5038
6269
|
if (meta.orphaned) continue;
|
|
@@ -5047,8 +6278,8 @@ async function readPage(root, slug) {
|
|
|
5047
6278
|
}
|
|
5048
6279
|
|
|
5049
6280
|
// src/mcp/resources.ts
|
|
5050
|
-
import
|
|
5051
|
-
import { readdir as
|
|
6281
|
+
import path40 from "path";
|
|
6282
|
+
import { readdir as readdir12 } from "fs/promises";
|
|
5052
6283
|
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5053
6284
|
function jsonContent(uri, payload) {
|
|
5054
6285
|
return {
|
|
@@ -5081,7 +6312,7 @@ function registerIndexResource(server, root) {
|
|
|
5081
6312
|
mimeType: "text/markdown"
|
|
5082
6313
|
},
|
|
5083
6314
|
async (uri) => {
|
|
5084
|
-
const content = await safeReadFile(
|
|
6315
|
+
const content = await safeReadFile(path40.join(root, INDEX_FILE));
|
|
5085
6316
|
return { contents: [markdownContent(uri, content)] };
|
|
5086
6317
|
}
|
|
5087
6318
|
);
|
|
@@ -5148,23 +6379,23 @@ function registerQueryResource(server, root) {
|
|
|
5148
6379
|
);
|
|
5149
6380
|
}
|
|
5150
6381
|
async function listSources(root) {
|
|
5151
|
-
const sourcesPath =
|
|
6382
|
+
const sourcesPath = path40.join(root, SOURCES_DIR);
|
|
5152
6383
|
let files;
|
|
5153
6384
|
try {
|
|
5154
|
-
files = await
|
|
6385
|
+
files = await readdir12(sourcesPath);
|
|
5155
6386
|
} catch {
|
|
5156
6387
|
return [];
|
|
5157
6388
|
}
|
|
5158
6389
|
const records = [];
|
|
5159
6390
|
for (const file of files.filter((f) => f.endsWith(".md"))) {
|
|
5160
|
-
const content = await safeReadFile(
|
|
6391
|
+
const content = await safeReadFile(path40.join(sourcesPath, file));
|
|
5161
6392
|
const { meta } = parseFrontmatter(content);
|
|
5162
6393
|
records.push({ filename: file, ...meta });
|
|
5163
6394
|
}
|
|
5164
6395
|
return records;
|
|
5165
6396
|
}
|
|
5166
6397
|
async function loadPageWithMeta(root, dir, slug) {
|
|
5167
|
-
const filePath =
|
|
6398
|
+
const filePath = path40.join(root, dir, `${slug}.md`);
|
|
5168
6399
|
const content = await safeReadFile(filePath);
|
|
5169
6400
|
if (!content) {
|
|
5170
6401
|
throw new Error(`Page not found: ${dir}/${slug}.md`);
|
|
@@ -5173,10 +6404,10 @@ async function loadPageWithMeta(root, dir, slug) {
|
|
|
5173
6404
|
return { slug, meta, body: body.trim() };
|
|
5174
6405
|
}
|
|
5175
6406
|
async function listPagesUnder(root, dir, scheme) {
|
|
5176
|
-
const pagesPath =
|
|
6407
|
+
const pagesPath = path40.join(root, dir);
|
|
5177
6408
|
let files;
|
|
5178
6409
|
try {
|
|
5179
|
-
files = await
|
|
6410
|
+
files = await readdir12(pagesPath);
|
|
5180
6411
|
} catch {
|
|
5181
6412
|
return { resources: [] };
|
|
5182
6413
|
}
|
|
@@ -5220,6 +6451,14 @@ program.command("ingest-session <path>").description("Ingest a coding-agent sess
|
|
|
5220
6451
|
process.exit(1);
|
|
5221
6452
|
}
|
|
5222
6453
|
});
|
|
6454
|
+
program.command("view").description("Start a local read-only web viewer for the current wiki project").option("--port <port>", "Port to bind (default 0 \u2014 OS-assigned)").option("--host <host>", "Host to bind (requires --allow-lan; default 127.0.0.1)").option("--allow-lan", "Bind beyond loopback (requires --host); off by default for privacy").option("--open", "Open the viewer in the default browser after startup").action(async (options) => {
|
|
6455
|
+
try {
|
|
6456
|
+
await viewCommand(options);
|
|
6457
|
+
} catch (err) {
|
|
6458
|
+
console.error(`\x1B[31mError:\x1B[0m ${err instanceof Error ? err.message : err}`);
|
|
6459
|
+
process.exit(1);
|
|
6460
|
+
}
|
|
6461
|
+
});
|
|
5223
6462
|
program.command("compile").description("Compile sources/ into an interlinked wiki").option(
|
|
5224
6463
|
"--review",
|
|
5225
6464
|
"Write generated pages as review candidates under .llmwiki/candidates/ instead of mutating wiki/. Orphan-marking for deleted sources is deferred until the next non-review compile."
|
|
@@ -5346,7 +6585,8 @@ var PROVIDER_KEY_VARS2 = {
|
|
|
5346
6585
|
anthropic: "ANTHROPIC_API_KEY",
|
|
5347
6586
|
openai: "OPENAI_API_KEY",
|
|
5348
6587
|
ollama: null,
|
|
5349
|
-
minimax: "MINIMAX_API_KEY"
|
|
6588
|
+
minimax: "MINIMAX_API_KEY",
|
|
6589
|
+
copilot: "GITHUB_TOKEN"
|
|
5350
6590
|
};
|
|
5351
6591
|
function requireProvider() {
|
|
5352
6592
|
const provider = process.env.LLMWIKI_PROVIDER ?? DEFAULT_PROVIDER;
|