@agent-wiki/mcp-server 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/wiki.js ADDED
@@ -0,0 +1,1378 @@
1
+ /**
2
+ * Core Wiki engine — pure data layer, zero LLM dependency.
3
+ *
4
+ * Architecture (Karpathy LLM Wiki pattern):
5
+ *
6
+ * raw/ — Immutable source documents. Write-once, never modified.
7
+ * Each file has a .meta.yaml sidecar with provenance.
8
+ *
9
+ * wiki/ — Mutable Markdown layer. Three kinds of files:
10
+ * 1. System: index.md, log.md, timeline.md (auto-maintained)
11
+ * 2. Entity pages: concept-*, person-*, artifact-*, etc.
12
+ * 3. Synthesis pages: synthesis-* (distilled from multiple pages)
13
+ *
14
+ * schemas/ — Entity templates (person, concept, event, etc.)
15
+ *
16
+ * Key principles:
17
+ * - Raw files are IMMUTABLE — the source of truth
18
+ * - Wiki pages are MUTABLE — compiled knowledge, continuously refined
19
+ * - Self-checking: lint detects contradictions, broken links, stale claims
20
+ * - Knowledge compounds: every write improves the whole
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, statSync, copyFileSync, createWriteStream } from "node:fs";
23
+ import { pipeline } from "node:stream/promises";
24
+ import { Readable } from "node:stream";
25
+ import { join, relative, resolve, basename, extname, dirname } from "node:path";
26
+ import { createHash } from "node:crypto";
27
+ import matter from "gray-matter";
28
+ import yaml from "js-yaml";
29
+ // System pages that lint should treat specially
30
+ const SYSTEM_PAGES = new Set(["index.md", "log.md", "timeline.md"]);
31
+ // ── Wiki Class ────────────────────────────────────────────────────
32
+ export class Wiki {
33
+ config;
34
+ /**
35
+ * @param root — path to config root (where .agent-wiki.yaml lives)
36
+ * @param workspace — override workspace directory (all data: wiki/, raw/, schemas/).
37
+ * If not set, falls back to: AGENT_WIKI_WORKSPACE env → config file → root.
38
+ */
39
+ constructor(root, workspace) {
40
+ const resolvedRoot = resolve(root ?? ".");
41
+ this.config = Wiki.loadConfig(resolvedRoot, workspace);
42
+ }
43
+ // ── Init ──────────────────────────────────────────────────────
44
+ /**
45
+ * Initialize a new knowledge base.
46
+ * @param path — config root (where .agent-wiki.yaml is created)
47
+ * @param workspace — optional separate workspace directory for all data.
48
+ * If set, wiki/, raw/, schemas/ go there instead of path.
49
+ */
50
+ static init(path, workspace) {
51
+ const configRoot = resolve(path);
52
+ const wsRoot = workspace ? resolve(workspace) : configRoot;
53
+ const wikiDir = join(wsRoot, "wiki");
54
+ const rawDir = join(wsRoot, "raw");
55
+ const schemasDir = join(wsRoot, "schemas");
56
+ for (const dir of [configRoot, wsRoot, wikiDir, rawDir, schemasDir]) {
57
+ mkdirSync(dir, { recursive: true });
58
+ }
59
+ const now = new Date().toISOString();
60
+ const nowShort = now.replace("T", " ").slice(0, 16) + " UTC";
61
+ // index.md
62
+ writeFileSync(join(wikiDir, "index.md"), `---
63
+ title: Knowledge Base Index
64
+ type: index
65
+ created: "${now}"
66
+ updated: "${now}"
67
+ ---
68
+
69
+ # Knowledge Base Index
70
+
71
+ ## Categories
72
+
73
+ _No pages yet. Use your agent to add knowledge._
74
+
75
+ ## Recent Updates
76
+
77
+ _No updates yet._
78
+ `);
79
+ // log.md
80
+ writeFileSync(join(wikiDir, "log.md"), `---
81
+ title: Operation Log
82
+ type: log
83
+ created: "${now}"
84
+ ---
85
+
86
+ # Operation Log
87
+
88
+ | Time | Operation | Page | Summary |
89
+ |------|-----------|------|---------|
90
+ | ${nowShort} | init | — | Knowledge base initialized |
91
+ `);
92
+ // timeline.md
93
+ writeFileSync(join(wikiDir, "timeline.md"), `---
94
+ title: Knowledge Timeline
95
+ type: timeline
96
+ created: "${now}"
97
+ updated: "${now}"
98
+ ---
99
+
100
+ # Knowledge Timeline
101
+
102
+ _Chronological view of all knowledge in this wiki._
103
+
104
+ ## ${now.slice(0, 10)}
105
+
106
+ - **init** — Knowledge base created
107
+ `);
108
+ // default config — include workspace if separate from config root
109
+ const configData = {
110
+ version: "2",
111
+ wiki: {
112
+ ...(workspace ? { workspace: wsRoot } : {}),
113
+ path: "wiki/",
114
+ raw_path: "raw/",
115
+ schemas_path: "schemas/",
116
+ },
117
+ lint: {
118
+ check_orphans: true,
119
+ check_stale_days: 30,
120
+ check_missing_sources: true,
121
+ check_contradictions: true,
122
+ check_integrity: true,
123
+ },
124
+ };
125
+ writeFileSync(join(configRoot, ".agent-wiki.yaml"), yaml.dump(configData, { lineWidth: 100 }));
126
+ // default schemas
127
+ writeDefaultSchemas(schemasDir);
128
+ // .gitignore in workspace (if separate, also add one there)
129
+ writeFileSync(join(configRoot, ".gitignore"), "node_modules/\ndist/\n.env\n");
130
+ if (workspace && wsRoot !== configRoot) {
131
+ writeFileSync(join(wsRoot, ".gitignore"), "# Agent Wiki workspace data\n");
132
+ }
133
+ return new Wiki(configRoot, workspace);
134
+ }
135
+ // ── Config ────────────────────────────────────────────────────
136
+ /**
137
+ * Load config from .agent-wiki.yaml.
138
+ *
139
+ * Workspace resolution priority:
140
+ * 1. Explicit `workspaceOverride` parameter (from CLI --workspace)
141
+ * 2. `AGENT_WIKI_WORKSPACE` environment variable
142
+ * 3. `workspace` field in .agent-wiki.yaml (absolute, or relative to config file)
143
+ * 4. Fall back to config root itself
144
+ *
145
+ * All data dirs (wiki/, raw/, schemas/) resolve relative to workspace.
146
+ */
147
+ static loadConfig(root, workspaceOverride) {
148
+ let raw = {};
149
+ const configPath = join(root, ".agent-wiki.yaml");
150
+ const homeConfigPath = join(process.env.HOME ?? "~", ".agent-wiki.yaml");
151
+ if (existsSync(configPath)) {
152
+ raw = yaml.load(readFileSync(configPath, "utf-8")) ?? {};
153
+ }
154
+ else if (existsSync(homeConfigPath)) {
155
+ raw = yaml.load(readFileSync(homeConfigPath, "utf-8")) ?? {};
156
+ }
157
+ const wikiData = (raw.wiki ?? {});
158
+ const lintData = (raw.lint ?? {});
159
+ // Resolve workspace directory (priority: override > env > config > root)
160
+ let workspace;
161
+ if (workspaceOverride) {
162
+ workspace = resolve(workspaceOverride);
163
+ }
164
+ else if (process.env.AGENT_WIKI_WORKSPACE) {
165
+ workspace = resolve(process.env.AGENT_WIKI_WORKSPACE);
166
+ }
167
+ else if (wikiData.workspace) {
168
+ // Relative paths in config resolve against the config file's directory
169
+ workspace = resolve(root, wikiData.workspace);
170
+ }
171
+ else {
172
+ workspace = root;
173
+ }
174
+ // Ensure workspace exists
175
+ mkdirSync(workspace, { recursive: true });
176
+ return {
177
+ configRoot: root,
178
+ workspace,
179
+ wikiDir: join(workspace, wikiData.path ?? "wiki"),
180
+ rawDir: join(workspace, wikiData.raw_path ?? "raw"),
181
+ schemasDir: join(workspace, wikiData.schemas_path ?? "schemas"),
182
+ lint: {
183
+ checkOrphans: lintData.check_orphans ?? true,
184
+ checkStaleDays: lintData.check_stale_days ?? 30,
185
+ checkMissingSources: lintData.check_missing_sources ?? true,
186
+ checkContradictions: lintData.check_contradictions ?? true,
187
+ checkIntegrity: lintData.check_integrity ?? true,
188
+ },
189
+ };
190
+ }
191
+ // ═══════════════════════════════════════════════════════════════
192
+ // RAW LAYER — Immutable source documents
193
+ // ═══════════════════════════════════════════════════════════════
194
+ /** Register a raw document. Copies file to raw/ with metadata sidecar.
195
+ * If content is provided as string, writes it directly.
196
+ * If sourcePath is an existing file, copies it.
197
+ * Raw files are IMMUTABLE — re-adding the same path is an error. */
198
+ rawAdd(filename, opts) {
199
+ const rawPath = join(this.config.rawDir, filename);
200
+ const metaPath = rawPath + ".meta.yaml";
201
+ // Immutability guard — never overwrite existing raw files
202
+ if (existsSync(rawPath)) {
203
+ throw new Error(`Raw file already exists: ${filename}. Raw files are immutable.`);
204
+ }
205
+ mkdirSync(dirname(rawPath), { recursive: true });
206
+ // Write content
207
+ if (opts.content !== undefined) {
208
+ writeFileSync(rawPath, opts.content);
209
+ }
210
+ else if (opts.sourcePath && existsSync(opts.sourcePath)) {
211
+ copyFileSync(opts.sourcePath, rawPath);
212
+ }
213
+ else {
214
+ throw new Error("Either content or a valid sourcePath is required");
215
+ }
216
+ // Compute hash and size
217
+ const buf = readFileSync(rawPath);
218
+ const sha256 = createHash("sha256").update(buf).digest("hex");
219
+ const size = buf.length;
220
+ const now = new Date().toISOString();
221
+ const doc = {
222
+ path: filename,
223
+ sourceUrl: opts.sourceUrl,
224
+ downloadedAt: now,
225
+ sha256,
226
+ size,
227
+ mimeType: opts.mimeType ?? guessMime(filename),
228
+ description: opts.description,
229
+ tags: opts.tags,
230
+ };
231
+ // Write metadata sidecar
232
+ writeFileSync(metaPath, yaml.dump(doc, { lineWidth: 100 }));
233
+ this.log("raw-add", filename, `Added raw: ${filename} (${formatBytes(size)}, sha256:${sha256.slice(0, 12)}...)`);
234
+ return doc;
235
+ }
236
+ /** List all raw documents with metadata. */
237
+ rawList() {
238
+ if (!existsSync(this.config.rawDir))
239
+ return [];
240
+ const docs = [];
241
+ for (const file of listAllFiles(this.config.rawDir, this.config.rawDir)) {
242
+ if (file.endsWith(".meta.yaml"))
243
+ continue; // skip sidecars
244
+ const metaPath = join(this.config.rawDir, file) + ".meta.yaml";
245
+ if (existsSync(metaPath)) {
246
+ const meta = yaml.load(readFileSync(metaPath, "utf-8"));
247
+ docs.push(meta);
248
+ }
249
+ else {
250
+ // raw file without metadata — create minimal entry
251
+ const fullPath = join(this.config.rawDir, file);
252
+ const buf = readFileSync(fullPath);
253
+ docs.push({
254
+ path: file,
255
+ downloadedAt: statSync(fullPath).mtime.toISOString(),
256
+ sha256: createHash("sha256").update(buf).digest("hex"),
257
+ size: buf.length,
258
+ mimeType: guessMime(file),
259
+ });
260
+ }
261
+ }
262
+ return docs;
263
+ }
264
+ /** Read a raw document's content. */
265
+ rawRead(filename) {
266
+ const fullPath = join(this.config.rawDir, filename);
267
+ if (!existsSync(fullPath))
268
+ return null;
269
+ const content = readFileSync(fullPath, "utf-8");
270
+ const metaPath = fullPath + ".meta.yaml";
271
+ const meta = existsSync(metaPath)
272
+ ? yaml.load(readFileSync(metaPath, "utf-8"))
273
+ : null;
274
+ return { content, meta };
275
+ }
276
+ /** Verify integrity of all raw files against their stored hashes. */
277
+ rawVerify() {
278
+ const results = [];
279
+ if (!existsSync(this.config.rawDir))
280
+ return results;
281
+ for (const file of listAllFiles(this.config.rawDir, this.config.rawDir)) {
282
+ if (file.endsWith(".meta.yaml"))
283
+ continue;
284
+ const fullPath = join(this.config.rawDir, file);
285
+ const metaPath = fullPath + ".meta.yaml";
286
+ if (!existsSync(metaPath)) {
287
+ results.push({ path: file, status: "missing-meta" });
288
+ continue;
289
+ }
290
+ const meta = yaml.load(readFileSync(metaPath, "utf-8"));
291
+ const buf = readFileSync(fullPath);
292
+ const actualHash = createHash("sha256").update(buf).digest("hex");
293
+ results.push({
294
+ path: file,
295
+ status: actualHash === meta.sha256 ? "ok" : "corrupted",
296
+ });
297
+ }
298
+ return results;
299
+ }
300
+ /**
301
+ * Fetch a file from a URL and save it to raw/.
302
+ * Supports arXiv smart resolution: arxiv.org/abs/XXXX → arxiv.org/pdf/XXXX.pdf
303
+ * Returns the RawDocument metadata.
304
+ */
305
+ async rawFetch(url, opts = {}) {
306
+ // ── arXiv smart URL resolution ──
307
+ let resolvedUrl = url;
308
+ let inferredFilename = opts.filename;
309
+ const arxivAbsMatch = url.match(/arxiv\.org\/abs\/(\d+\.\d+)(v\d+)?/);
310
+ const arxivPdfMatch = url.match(/arxiv\.org\/pdf\/(\d+\.\d+)(v\d+)?/);
311
+ if (arxivAbsMatch) {
312
+ const id = arxivAbsMatch[1] + (arxivAbsMatch[2] ?? "");
313
+ resolvedUrl = `https://arxiv.org/pdf/${id}.pdf`;
314
+ if (!inferredFilename)
315
+ inferredFilename = `arxiv-${id.replace(/\./g, "-")}.pdf`;
316
+ }
317
+ else if (arxivPdfMatch && !inferredFilename) {
318
+ const id = arxivPdfMatch[1] + (arxivPdfMatch[2] ?? "");
319
+ inferredFilename = `arxiv-${id.replace(/\./g, "-")}.pdf`;
320
+ }
321
+ // ── Infer filename from URL if not provided ──
322
+ if (!inferredFilename) {
323
+ const urlObj = new URL(resolvedUrl);
324
+ const pathParts = urlObj.pathname.split("/").filter(Boolean);
325
+ const lastPart = pathParts[pathParts.length - 1] ?? "download";
326
+ // Clean up query params and fragments
327
+ inferredFilename = lastPart.split("?")[0].split("#")[0];
328
+ // If no extension, try to add one based on content-type later
329
+ if (!inferredFilename.includes(".")) {
330
+ inferredFilename += ".bin";
331
+ }
332
+ }
333
+ // ── Immutability guard ──
334
+ const rawPath = join(this.config.rawDir, inferredFilename);
335
+ if (existsSync(rawPath)) {
336
+ throw new Error(`Raw file already exists: ${inferredFilename}. Raw files are immutable.`);
337
+ }
338
+ mkdirSync(dirname(rawPath), { recursive: true });
339
+ // ── Download ──
340
+ const response = await fetch(resolvedUrl, {
341
+ headers: {
342
+ "User-Agent": "agent-wiki/0.3.0",
343
+ },
344
+ redirect: "follow",
345
+ });
346
+ if (!response.ok) {
347
+ throw new Error(`Download failed: HTTP ${response.status} ${response.statusText} — ${resolvedUrl}`);
348
+ }
349
+ // Update filename extension based on content-type if it was generic
350
+ const contentType = response.headers.get("content-type") ?? "";
351
+ if (inferredFilename.endsWith(".bin")) {
352
+ const extMap = {
353
+ "application/pdf": ".pdf",
354
+ "text/html": ".html",
355
+ "text/plain": ".txt",
356
+ "application/json": ".json",
357
+ "text/markdown": ".md",
358
+ "image/png": ".png",
359
+ "image/jpeg": ".jpg",
360
+ "application/xml": ".xml",
361
+ "text/xml": ".xml",
362
+ };
363
+ for (const [mime, ext] of Object.entries(extMap)) {
364
+ if (contentType.includes(mime)) {
365
+ inferredFilename = inferredFilename.replace(/\.bin$/, ext);
366
+ break;
367
+ }
368
+ }
369
+ }
370
+ // Re-check with potentially updated filename
371
+ const finalPath = join(this.config.rawDir, inferredFilename);
372
+ if (finalPath !== rawPath && existsSync(finalPath)) {
373
+ throw new Error(`Raw file already exists: ${inferredFilename}. Raw files are immutable.`);
374
+ }
375
+ // Stream to file
376
+ const body = response.body;
377
+ if (!body)
378
+ throw new Error("Empty response body");
379
+ const nodeStream = Readable.fromWeb(body);
380
+ const fileStream = createWriteStream(finalPath);
381
+ await pipeline(nodeStream, fileStream);
382
+ // ── Compute hash and create metadata ──
383
+ const buf = readFileSync(finalPath);
384
+ const sha256 = createHash("sha256").update(buf).digest("hex");
385
+ const now = new Date().toISOString();
386
+ const mime = contentType.split(";")[0]?.trim() || guessMime(inferredFilename);
387
+ const doc = {
388
+ path: inferredFilename,
389
+ sourceUrl: url, // original URL, not resolved
390
+ downloadedAt: now,
391
+ sha256,
392
+ size: buf.length,
393
+ mimeType: mime,
394
+ description: opts.description,
395
+ tags: opts.tags,
396
+ };
397
+ // Write metadata sidecar
398
+ writeFileSync(finalPath + ".meta.yaml", yaml.dump(doc, { lineWidth: 100 }));
399
+ this.log("raw-fetch", inferredFilename, `Downloaded from ${url} (${formatBytes(buf.length)}, ${mime})`);
400
+ return doc;
401
+ }
402
+ // ═══════════════════════════════════════════════════════════════
403
+ // WIKI LAYER — Mutable compiled knowledge
404
+ // ═══════════════════════════════════════════════════════════════
405
+ // ── CRUD ──────────────────────────────────────────────────────
406
+ /** Read a wiki page. Returns null if not found. */
407
+ read(pagePath) {
408
+ const fullPath = join(this.config.wikiDir, pagePath);
409
+ if (!existsSync(fullPath)) {
410
+ const withMd = fullPath.endsWith(".md") ? fullPath : fullPath + ".md";
411
+ if (!existsSync(withMd))
412
+ return null;
413
+ return this.parsePage(relative(this.config.wikiDir, withMd), readFileSync(withMd, "utf-8"));
414
+ }
415
+ return this.parsePage(pagePath, readFileSync(fullPath, "utf-8"));
416
+ }
417
+ /** Write (create or update) a wiki page. Content must include frontmatter.
418
+ * Automatically injects/updates created and updated timestamps. */
419
+ write(pagePath, content, source) {
420
+ const fullPath = join(this.config.wikiDir, pagePath);
421
+ const dir = dirname(fullPath);
422
+ mkdirSync(dir, { recursive: true });
423
+ const now = new Date().toISOString();
424
+ // Parse incoming content to inject timestamps
425
+ const parsed = matter(content);
426
+ if (!parsed.data.created) {
427
+ // Check if page already exists — preserve original created time
428
+ if (existsSync(fullPath)) {
429
+ const existing = matter(readFileSync(fullPath, "utf-8"));
430
+ parsed.data.created = existing.data.created ?? now;
431
+ }
432
+ else {
433
+ parsed.data.created = now;
434
+ }
435
+ }
436
+ parsed.data.updated = now;
437
+ // Reconstruct content with updated frontmatter
438
+ const finalContent = matter.stringify(parsed.content, parsed.data);
439
+ writeFileSync(fullPath, finalContent.trimEnd() + "\n");
440
+ this.log("write", pagePath, `Wrote ${pagePath}${source ? ` (${source})` : ""}`);
441
+ }
442
+ /** Delete a wiki page. Returns true if it existed. */
443
+ delete(pagePath) {
444
+ // Guard: never delete system pages
445
+ if (SYSTEM_PAGES.has(pagePath)) {
446
+ throw new Error(`Cannot delete system page: ${pagePath}`);
447
+ }
448
+ const fullPath = join(this.config.wikiDir, pagePath);
449
+ if (!existsSync(fullPath))
450
+ return false;
451
+ unlinkSync(fullPath);
452
+ this.log("delete", pagePath, `Deleted ${pagePath}`);
453
+ return true;
454
+ }
455
+ /** List all wiki pages, optionally filtered by type or tag. */
456
+ list(filterType, filterTag) {
457
+ const pages = this.listAllPages();
458
+ if (!filterType && !filterTag)
459
+ return pages;
460
+ return pages.filter((p) => {
461
+ const page = this.read(p);
462
+ if (!page)
463
+ return false;
464
+ if (filterType && page.type !== filterType)
465
+ return false;
466
+ if (filterTag && !page.tags.includes(filterTag))
467
+ return false;
468
+ return true;
469
+ });
470
+ }
471
+ // ── Search ────────────────────────────────────────────────────
472
+ /** Keyword search across all wiki pages. Returns paths sorted by relevance. */
473
+ search(query, limit = 10) {
474
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
475
+ if (terms.length === 0)
476
+ return [];
477
+ const pages = this.listAllPages();
478
+ const results = [];
479
+ for (const pagePath of pages) {
480
+ const page = this.read(pagePath);
481
+ if (!page)
482
+ continue;
483
+ const text = (page.title + " " + page.tags.join(" ") + " " + page.content).toLowerCase();
484
+ let score = 0;
485
+ for (const term of terms) {
486
+ let idx = 0;
487
+ while ((idx = text.indexOf(term, idx)) !== -1) {
488
+ score++;
489
+ idx += term.length;
490
+ }
491
+ if (page.title.toLowerCase().includes(term))
492
+ score += 5;
493
+ if (page.tags.some((t) => String(t).toLowerCase().includes(term)))
494
+ score += 3;
495
+ // Boost synthesis pages slightly — they represent distilled knowledge
496
+ if (page.type === "synthesis")
497
+ score += 1;
498
+ }
499
+ if (score > 0) {
500
+ const firstIdx = text.indexOf(terms[0]);
501
+ const start = Math.max(0, firstIdx - 50);
502
+ const end = Math.min(text.length, firstIdx + 100);
503
+ const snippet = (start > 0 ? "..." : "") + text.slice(start, end).trim() + (end < text.length ? "..." : "");
504
+ results.push({ path: pagePath, score, snippet });
505
+ }
506
+ }
507
+ results.sort((a, b) => b.score - a.score);
508
+ return results.slice(0, limit);
509
+ }
510
+ // ── Lint — Self-checking & error detection ────────────────────
511
+ /** Run comprehensive health checks. Pure rules, no LLM.
512
+ * Detects: contradictions, orphans, broken links, missing sources,
513
+ * stale content, structural issues, integrity problems. */
514
+ lint() {
515
+ const pages = this.listAllPages();
516
+ const report = {
517
+ pagesChecked: pages.length,
518
+ rawChecked: 0,
519
+ issues: [],
520
+ contradictions: [],
521
+ };
522
+ // Build a map of all pages for cross-referencing
523
+ const pageMap = new Map();
524
+ for (const pagePath of pages) {
525
+ const page = this.read(pagePath);
526
+ if (page)
527
+ pageMap.set(pagePath, page);
528
+ }
529
+ for (const [pagePath, page] of pageMap) {
530
+ // ── Missing frontmatter ──
531
+ if (Object.keys(page.frontmatter).length === 0) {
532
+ report.issues.push({
533
+ severity: "warning",
534
+ page: pagePath,
535
+ message: "Missing YAML frontmatter",
536
+ suggestion: "Add frontmatter with title, type, tags, and sources",
537
+ autoFixable: true,
538
+ category: "structure",
539
+ });
540
+ }
541
+ // ── Missing title ──
542
+ if (!page.title || page.title === basename(pagePath, extname(pagePath))) {
543
+ report.issues.push({
544
+ severity: "warning",
545
+ page: pagePath,
546
+ message: "Missing or auto-generated title",
547
+ suggestion: "Add a meaningful title in frontmatter",
548
+ autoFixable: false,
549
+ category: "structure",
550
+ });
551
+ }
552
+ // ── Orphan pages ──
553
+ if (this.config.lint.checkOrphans && !SYSTEM_PAGES.has(pagePath)) {
554
+ const slug = basename(pagePath, extname(pagePath));
555
+ const hasIncoming = [...pageMap].some(([other, otherPage]) => {
556
+ if (other === pagePath)
557
+ return false;
558
+ return otherPage.links.includes(slug);
559
+ });
560
+ if (!hasIncoming) {
561
+ report.issues.push({
562
+ severity: "warning",
563
+ page: pagePath,
564
+ message: "Orphan page — no other pages link here",
565
+ suggestion: `Add [[${slug}]] to related pages or index.md`,
566
+ autoFixable: true,
567
+ category: "orphan",
568
+ });
569
+ }
570
+ }
571
+ // ── Broken links ──
572
+ for (const link of page.links) {
573
+ const linkPath = link.endsWith(".md") ? link : link + ".md";
574
+ if (!pages.includes(linkPath) && !pages.includes(link)) {
575
+ report.issues.push({
576
+ severity: "error",
577
+ page: pagePath,
578
+ message: `Broken link: [[${link}]]`,
579
+ suggestion: `Create ${link}.md or fix the link`,
580
+ autoFixable: false,
581
+ category: "broken-link",
582
+ });
583
+ }
584
+ }
585
+ // ── Missing sources (non-system pages) ──
586
+ if (this.config.lint.checkMissingSources && !SYSTEM_PAGES.has(pagePath)) {
587
+ if (page.sources.length === 0 && page.type && page.type !== "index" && page.type !== "log" && page.type !== "timeline") {
588
+ report.issues.push({
589
+ severity: "info",
590
+ page: pagePath,
591
+ message: "No sources listed — claims are not traceable to raw documents",
592
+ suggestion: "Add sources to frontmatter linking to raw/ files or URLs",
593
+ autoFixable: false,
594
+ category: "missing-source",
595
+ });
596
+ }
597
+ }
598
+ // ── Synthesis page integrity ──
599
+ if (page.type === "synthesis" && page.derivedFrom) {
600
+ for (const src of page.derivedFrom) {
601
+ const srcPath = src.endsWith(".md") ? src : src + ".md";
602
+ if (!pages.includes(srcPath) && !pages.includes(src)) {
603
+ report.issues.push({
604
+ severity: "error",
605
+ page: pagePath,
606
+ message: `Synthesis source missing: ${src}`,
607
+ suggestion: `The page this synthesis derives from no longer exists. Review and update.`,
608
+ autoFixable: false,
609
+ category: "integrity",
610
+ });
611
+ }
612
+ }
613
+ }
614
+ // ── Stale content ──
615
+ if (this.config.lint.checkStaleDays > 0) {
616
+ try {
617
+ const fullPath = join(this.config.wikiDir, pagePath);
618
+ const stat = statSync(fullPath);
619
+ const ageDays = Math.floor((Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24));
620
+ if (ageDays > this.config.lint.checkStaleDays) {
621
+ report.issues.push({
622
+ severity: "info",
623
+ page: pagePath,
624
+ message: `Stale content — last modified ${ageDays} days ago`,
625
+ suggestion: "Review and update if needed",
626
+ autoFixable: false,
627
+ category: "stale",
628
+ });
629
+ }
630
+ }
631
+ catch {
632
+ // stat failed, skip
633
+ }
634
+ }
635
+ }
636
+ // ── Cross-page contradiction detection ──
637
+ if (this.config.lint.checkContradictions) {
638
+ const contradictions = this.detectContradictions(pageMap);
639
+ report.contradictions = contradictions;
640
+ for (const c of contradictions) {
641
+ report.issues.push({
642
+ severity: c.severity,
643
+ page: c.pageA,
644
+ message: `Contradiction with [[${basename(c.pageB, ".md")}]]: ${c.claim}`,
645
+ suggestion: `"${c.excerptA}" vs "${c.excerptB}" — review and resolve`,
646
+ autoFixable: false,
647
+ category: "contradiction",
648
+ });
649
+ }
650
+ }
651
+ // ── Raw file integrity ──
652
+ if (this.config.lint.checkIntegrity) {
653
+ const rawResults = this.rawVerify();
654
+ report.rawChecked = rawResults.length;
655
+ for (const r of rawResults) {
656
+ if (r.status === "corrupted") {
657
+ report.issues.push({
658
+ severity: "error",
659
+ page: `raw/${r.path}`,
660
+ message: "Raw file corrupted — SHA-256 mismatch",
661
+ suggestion: "Re-download the original source",
662
+ autoFixable: false,
663
+ category: "integrity",
664
+ });
665
+ }
666
+ else if (r.status === "missing-meta") {
667
+ report.issues.push({
668
+ severity: "warning",
669
+ page: `raw/${r.path}`,
670
+ message: "Raw file has no metadata sidecar (.meta.yaml)",
671
+ suggestion: "Use raw_add to properly register this file",
672
+ autoFixable: true,
673
+ category: "integrity",
674
+ });
675
+ }
676
+ }
677
+ }
678
+ this.log("lint", "—", `Checked ${report.pagesChecked} pages + ${report.rawChecked} raw files, found ${report.issues.length} issues (${report.contradictions.length} contradictions)`);
679
+ return report;
680
+ }
681
+ // ── Contradiction detection ───────────────────────────────────
682
+ /** Detect contradictions between pages.
683
+ * Looks for numeric claims, date claims, and factual statements
684
+ * that conflict across pages about the same entity/topic. */
685
+ detectContradictions(pageMap) {
686
+ const contradictions = [];
687
+ // Extract claims from pages — look for patterns like "X is Y", dates, numbers
688
+ const claims = new Map();
689
+ for (const [pagePath, page] of pageMap) {
690
+ if (SYSTEM_PAGES.has(pagePath))
691
+ continue;
692
+ // Extract date claims: "published in YYYY", "released YYYY", "founded YYYY"
693
+ const datePatterns = page.content.matchAll(/(?:published|released|founded|created|introduced|launched|announced|born|died)\s+(?:in\s+)?(\d{4})/gi);
694
+ for (const m of datePatterns) {
695
+ const key = m[0].replace(/\d{4}/, "YEAR").toLowerCase().trim();
696
+ const entry = { page: pagePath, excerpt: m[0], value: m[1] };
697
+ if (!claims.has(key))
698
+ claims.set(key, []);
699
+ claims.get(key).push(entry);
700
+ }
701
+ // Extract numeric claims: "achieved XX% mAP", "XX FPS", "XX parameters"
702
+ const numericPatterns = page.content.matchAll(/(\d+\.?\d*)\s*(%|fps|ms|map|ap|parameters|params|layers|million|billion|m\b|b\b|k\b)/gi);
703
+ for (const m of numericPatterns) {
704
+ // Context: 30 chars before and after
705
+ const idx = page.content.indexOf(m[0]);
706
+ const ctxStart = Math.max(0, idx - 30);
707
+ const ctxEnd = Math.min(page.content.length, idx + m[0].length + 30);
708
+ const context = page.content.slice(ctxStart, ctxEnd).replace(/\n/g, " ").trim();
709
+ // Key = normalized context without the number
710
+ const key = context.replace(/\d+\.?\d*/g, "N").toLowerCase().slice(0, 60);
711
+ const entry = { page: pagePath, excerpt: context, value: m[1] };
712
+ if (!claims.has(key))
713
+ claims.set(key, []);
714
+ claims.get(key).push(entry);
715
+ }
716
+ }
717
+ // Compare claims from different pages
718
+ for (const [claimKey, entries] of claims) {
719
+ if (entries.length < 2)
720
+ continue;
721
+ // Group by page
722
+ const byPage = new Map();
723
+ for (const e of entries) {
724
+ if (!byPage.has(e.page))
725
+ byPage.set(e.page, e);
726
+ }
727
+ const uniquePages = [...byPage.values()];
728
+ if (uniquePages.length < 2)
729
+ continue;
730
+ // Check if values differ
731
+ for (let i = 0; i < uniquePages.length; i++) {
732
+ for (let j = i + 1; j < uniquePages.length; j++) {
733
+ const a = uniquePages[i];
734
+ const b = uniquePages[j];
735
+ if (a.value !== b.value) {
736
+ contradictions.push({
737
+ claim: claimKey.replace(/\bn\b/gi, "?"),
738
+ pageA: a.page,
739
+ excerptA: a.excerpt,
740
+ pageB: b.page,
741
+ excerptB: b.excerpt,
742
+ severity: Math.abs(parseFloat(a.value) - parseFloat(b.value)) > 10 ? "error" : "warning",
743
+ });
744
+ }
745
+ }
746
+ }
747
+ }
748
+ return contradictions;
749
+ }
750
+ // ── Synthesis — Knowledge distillation ────────────────────────
751
+ /** Get context for synthesis: reads multiple pages and returns
752
+ * their content for the agent to distill into a new page. */
753
+ synthesizeContext(pagePaths) {
754
+ const pages = [];
755
+ const allTags = new Set();
756
+ const allLinks = new Set();
757
+ for (const p of pagePaths) {
758
+ const page = this.read(p) ?? this.read(p + ".md");
759
+ if (page) {
760
+ pages.push({ path: page.path, title: page.title, content: page.content });
761
+ page.tags.forEach((t) => allTags.add(t));
762
+ page.links.forEach((l) => allLinks.add(l));
763
+ }
764
+ }
765
+ // Generate suggestions for the synthesis
766
+ const suggestions = [];
767
+ if (pages.length >= 2) {
768
+ suggestions.push(`Combine insights from ${pages.map((p) => p.title).join(", ")}`);
769
+ suggestions.push("Look for common themes, contradictions, and gaps");
770
+ suggestions.push("Create cross-references using [[page-name]] syntax");
771
+ }
772
+ if (allTags.size > 0) {
773
+ suggestions.push(`Suggested tags: ${[...allTags].join(", ")}`);
774
+ }
775
+ return { pages, suggestions };
776
+ }
777
+ // ── Auto-Classification ───────────────────────────────────────
778
+ /** Auto-classify content into entity type and suggested tags.
779
+ * Pure heuristic — zero LLM dependency. Analyzes title, body,
780
+ * and structure to determine the best type and relevant tags.
781
+ * If frontmatter already has a type, respects it. */
782
+ classify(content) {
783
+ const parsed = matter(content);
784
+ const body = parsed.content.toLowerCase();
785
+ const title = (parsed.data.title ?? "").toLowerCase();
786
+ const combined = title + " " + body;
787
+ // If frontmatter already specifies type, respect it but still suggest tags
788
+ if (parsed.data.type && typeof parsed.data.type === "string" && parsed.data.type !== "note") {
789
+ const existingTags = Array.isArray(parsed.data.tags) ? parsed.data.tags.map(String) : [];
790
+ const suggestedTags = existingTags.length > 0 ? existingTags : this.extractTags(combined);
791
+ return { type: parsed.data.type, tags: suggestedTags, confidence: 1.0 };
792
+ }
793
+ // Score each entity type based on keyword signals
794
+ const scores = {
795
+ person: 0, concept: 0, event: 0, artifact: 0,
796
+ comparison: 0, summary: 0, "how-to": 0, synthesis: 0, note: 0,
797
+ };
798
+ // Person signals
799
+ for (const w of ["born", "career", "biography", "researcher", "professor", "author",
800
+ "founder", "role:", "affiliat", "人物", "创始人", "研究员"]) {
801
+ if (combined.includes(w))
802
+ scores.person += 2;
803
+ }
804
+ // Concept signals
805
+ for (const w of ["definition", "theory", "concept", "principle", "paradigm",
806
+ "what is", "核心思想", "定义", "概念", "理论", "原理"]) {
807
+ if (combined.includes(w))
808
+ scores.concept += 2;
809
+ }
810
+ // Event signals
811
+ for (const w of ["happened", "occurred", "conference", "launched", "announced",
812
+ "event", "发布会", "事件", "会议"]) {
813
+ if (combined.includes(w))
814
+ scores.event += 2;
815
+ }
816
+ // Artifact signals (papers, tools, models)
817
+ for (const w of ["paper", "论文", "tool", "library", "framework", "model",
818
+ "version", "release", "arxiv", "github", "引用", "doi"]) {
819
+ if (combined.includes(w))
820
+ scores.artifact += 2;
821
+ }
822
+ // Comparison signals
823
+ for (const w of ["vs", "versus", "compared", "comparison", "benchmark",
824
+ "对比", "比较", "横评"]) {
825
+ if (combined.includes(w))
826
+ scores.comparison += 2;
827
+ }
828
+ // Many table rows strongly suggest comparison
829
+ if ((body.match(/\|/g) ?? []).length > 15)
830
+ scores.comparison += 3;
831
+ // Summary signals
832
+ for (const w of ["summary", "overview", "timeline", "history", "evolution",
833
+ "演进", "总结", "概述", "版本", "时间线", "回顾"]) {
834
+ if (combined.includes(w))
835
+ scores.summary += 2;
836
+ }
837
+ // How-to signals
838
+ for (const w of ["step", "guide", "tutorial", "how to", "procedure",
839
+ "install", "setup", "步骤", "指南", "教程", "安装"]) {
840
+ if (combined.includes(w))
841
+ scores["how-to"] += 2;
842
+ }
843
+ // Synthesis signals
844
+ for (const w of ["synthesis", "derived from", "combining", "integrat",
845
+ "综合", "提炼", "整合"]) {
846
+ if (combined.includes(w))
847
+ scores.synthesis += 2;
848
+ }
849
+ // Pick the highest-scoring type
850
+ let bestType = "note";
851
+ let bestScore = 0;
852
+ for (const [type, score] of Object.entries(scores)) {
853
+ if (score > bestScore) {
854
+ bestScore = score;
855
+ bestType = type;
856
+ }
857
+ }
858
+ const confidence = bestScore > 0 ? Math.min(bestScore / 10, 1.0) : 0.3;
859
+ const tags = this.extractTags(combined);
860
+ return { type: bestType, tags, confidence };
861
+ }
862
+ /** Auto-classify and inject type/tags into content if missing.
863
+ * Returns the enriched content string. */
864
+ autoClassifyContent(content) {
865
+ const parsed = matter(content);
866
+ // Only auto-classify if type is missing or is generic "note"
867
+ if (parsed.data.type && parsed.data.type !== "note")
868
+ return content;
869
+ const classification = this.classify(content);
870
+ if (!parsed.data.type || parsed.data.type === "note") {
871
+ parsed.data.type = classification.type;
872
+ }
873
+ // Merge tags: keep existing + add new suggestions (deduplicated)
874
+ const existingTags = Array.isArray(parsed.data.tags) ? parsed.data.tags.map(String) : [];
875
+ const merged = [...new Set([...existingTags, ...classification.tags])];
876
+ if (merged.length > 0)
877
+ parsed.data.tags = merged;
878
+ return matter.stringify(parsed.content, parsed.data);
879
+ }
880
+ /** Extract relevant tags from text using keyword matching. */
881
+ extractTags(text) {
882
+ const knownTags = {
883
+ "yolo": "yolo", "object detection": "object-detection", "目标检测": "object-detection",
884
+ "computer vision": "computer-vision", "计算机视觉": "computer-vision",
885
+ "deep learning": "deep-learning", "深度学习": "deep-learning",
886
+ "machine learning": "machine-learning", "机器学习": "machine-learning",
887
+ "transformer": "transformer", "attention": "attention-mechanism",
888
+ "cnn": "cnn", "卷积": "cnn", "real-time": "real-time", "实时": "real-time",
889
+ "python": "python", "pytorch": "pytorch", "tensorflow": "tensorflow",
890
+ "arxiv": "academic", "论文": "academic", "paper": "academic",
891
+ "benchmark": "benchmark", "基准": "benchmark",
892
+ "neural network": "neural-network", "神经网络": "neural-network",
893
+ "nlp": "nlp", "自然语言": "nlp", "language model": "llm", "大模型": "llm",
894
+ "gan": "gan", "diffusion": "diffusion", "stable diffusion": "stable-diffusion",
895
+ "reinforcement learning": "reinforcement-learning", "强化学习": "reinforcement-learning",
896
+ "autonomous driving": "autonomous-driving", "自动驾驶": "autonomous-driving",
897
+ "segmentation": "segmentation", "分割": "segmentation",
898
+ "detection": "detection", "检测": "detection",
899
+ "classification": "classification", "分类": "classification",
900
+ "training": "training", "inference": "inference", "推理": "inference",
901
+ "edge deploy": "edge-deployment", "边缘部署": "edge-deployment",
902
+ "anchor": "anchor", "backbone": "backbone", "fpn": "feature-pyramid",
903
+ "docker": "docker", "kubernetes": "kubernetes",
904
+ "api": "api", "rest": "rest-api", "mcp": "mcp",
905
+ };
906
+ const tags = new Set();
907
+ const lower = text.toLowerCase();
908
+ for (const [keyword, tag] of Object.entries(knownTags)) {
909
+ if (lower.includes(keyword))
910
+ tags.add(tag);
911
+ }
912
+ return [...tags];
913
+ }
914
+ // ── Schemas ───────────────────────────────────────────────────
915
+ /** List available entity type schemas. */
916
+ schemas() {
917
+ const dir = this.config.schemasDir;
918
+ if (!existsSync(dir))
919
+ return [];
920
+ const result = [];
921
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
922
+ const content = readFileSync(join(dir, file), "utf-8");
923
+ const parsed = matter(content);
924
+ result.push({
925
+ name: basename(file, ".md"),
926
+ description: parsed.data.description ?? "",
927
+ template: content,
928
+ });
929
+ }
930
+ return result;
931
+ }
932
+ // ── Log ───────────────────────────────────────────────────────
933
+ /** Get operation log entries. */
934
+ getLog(limit = 20) {
935
+ const logPath = join(this.config.wikiDir, "log.md");
936
+ if (!existsSync(logPath))
937
+ return [];
938
+ const content = readFileSync(logPath, "utf-8");
939
+ const lines = content.split("\n").filter((l) => l.startsWith("|") && !l.startsWith("| Time") && !l.startsWith("|---"));
940
+ const entries = [];
941
+ for (const line of lines) {
942
+ const cols = line.split("|").map((c) => c.trim()).filter(Boolean);
943
+ if (cols.length >= 4) {
944
+ entries.push({ time: cols[0], operation: cols[1], page: cols[2], summary: cols[3] });
945
+ }
946
+ else if (cols.length >= 3) {
947
+ entries.push({ time: cols[0], operation: cols[1], page: "—", summary: cols[2] });
948
+ }
949
+ }
950
+ return entries.slice(-limit);
951
+ }
952
+ // ── Index rebuild ─────────────────────────────────────────────
953
+ /** Rebuild index.md from all pages. Groups by type with page counts. */
954
+ rebuildIndex() {
955
+ const pages = this.listAllPages().filter((p) => !SYSTEM_PAGES.has(p));
956
+ const categories = {};
957
+ let rawCount = 0;
958
+ try {
959
+ rawCount = this.rawList().length;
960
+ }
961
+ catch { /* no raw dir */ }
962
+ for (const pagePath of pages) {
963
+ const page = this.read(pagePath);
964
+ if (!page)
965
+ continue;
966
+ const type = page.type ?? "uncategorized";
967
+ if (!categories[type])
968
+ categories[type] = [];
969
+ const slug = basename(pagePath, extname(pagePath));
970
+ const updated = page.updated ? ` _(${page.updated.slice(0, 10)})_` : "";
971
+ categories[type].push(`- [[${slug}]] — ${page.title}${updated}`);
972
+ }
973
+ const now = new Date().toISOString();
974
+ let lines = [
975
+ "---",
976
+ "title: Knowledge Base Index",
977
+ "type: index",
978
+ `created: "${this.read("index.md")?.created ?? now}"`,
979
+ `updated: "${now}"`,
980
+ "---",
981
+ "",
982
+ "# Knowledge Base Index",
983
+ "",
984
+ `**${pages.length} pages** across **${Object.keys(categories).length} categories** | **${rawCount} raw sources**`,
985
+ "",
986
+ ];
987
+ const sortedTypes = Object.keys(categories).sort();
988
+ if (sortedTypes.length > 0) {
989
+ for (const type of sortedTypes) {
990
+ const label = type.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
991
+ lines.push(`## ${label} (${categories[type].length})`);
992
+ lines.push("");
993
+ lines.push(...categories[type]);
994
+ lines.push("");
995
+ }
996
+ }
997
+ else {
998
+ lines.push("_No pages yet._");
999
+ lines.push("");
1000
+ }
1001
+ lines.push("---", "", `_Last rebuilt: ${now.replace("T", " ").slice(0, 16)} UTC_`, "");
1002
+ writeFileSync(join(this.config.wikiDir, "index.md"), lines.join("\n"));
1003
+ this.log("rebuild-index", "index.md", `Rebuilt index with ${pages.length} pages`);
1004
+ }
1005
+ // ── Timeline ──────────────────────────────────────────────────
1006
+ /** Rebuild timeline.md — chronological view of all knowledge. */
1007
+ rebuildTimeline() {
1008
+ const pages = this.listAllPages().filter((p) => !SYSTEM_PAGES.has(p));
1009
+ const entries = [];
1010
+ for (const pagePath of pages) {
1011
+ const page = this.read(pagePath);
1012
+ if (!page)
1013
+ continue;
1014
+ const date = page.created ?? page.updated ?? "unknown";
1015
+ entries.push({
1016
+ date: date.slice(0, 10),
1017
+ page: basename(pagePath, extname(pagePath)),
1018
+ title: page.title,
1019
+ type: page.type ?? "note",
1020
+ });
1021
+ }
1022
+ entries.sort((a, b) => b.date.localeCompare(a.date));
1023
+ const now = new Date().toISOString();
1024
+ let lines = [
1025
+ "---",
1026
+ "title: Knowledge Timeline",
1027
+ "type: timeline",
1028
+ `updated: "${now}"`,
1029
+ "---",
1030
+ "",
1031
+ "# Knowledge Timeline",
1032
+ "",
1033
+ `_${entries.length} entries — last rebuilt: ${now.replace("T", " ").slice(0, 16)} UTC_`,
1034
+ "",
1035
+ ];
1036
+ // Group by date
1037
+ let currentDate = "";
1038
+ for (const e of entries) {
1039
+ if (e.date !== currentDate) {
1040
+ currentDate = e.date;
1041
+ lines.push(`## ${currentDate}`, "");
1042
+ }
1043
+ lines.push(`- **[${e.type}]** [[${e.page}]] — ${e.title}`);
1044
+ }
1045
+ lines.push("");
1046
+ writeFileSync(join(this.config.wikiDir, "timeline.md"), lines.join("\n"));
1047
+ this.log("rebuild-timeline", "timeline.md", `Rebuilt timeline with ${entries.length} entries`);
1048
+ }
1049
+ // ── Internal helpers ──────────────────────────────────────────
1050
+ listAllPages() {
1051
+ const dir = this.config.wikiDir;
1052
+ if (!existsSync(dir))
1053
+ return [];
1054
+ return listMdFiles(dir, dir);
1055
+ }
1056
+ parsePage(pagePath, raw) {
1057
+ const parsed = matter(raw);
1058
+ const fm = parsed.data;
1059
+ const body = parsed.content.trim();
1060
+ const linkMatches = body.matchAll(/\[\[([^\]]+)\]\]/g);
1061
+ const links = [...linkMatches].map((m) => m[1]);
1062
+ return {
1063
+ path: pagePath,
1064
+ title: fm.title ?? basename(pagePath, extname(pagePath)),
1065
+ type: fm.type,
1066
+ tags: Array.isArray(fm.tags) ? fm.tags.map(String) : [],
1067
+ sources: Array.isArray(fm.sources) ? fm.sources.map(String) : [],
1068
+ content: body,
1069
+ frontmatter: fm,
1070
+ links,
1071
+ created: fm.created,
1072
+ updated: fm.updated,
1073
+ derivedFrom: Array.isArray(fm.derived_from) ? fm.derived_from.map(String) : undefined,
1074
+ };
1075
+ }
1076
+ log(operation, page, summary) {
1077
+ const logPath = join(this.config.wikiDir, "log.md");
1078
+ const now = new Date().toISOString().replace("T", " ").slice(0, 16) + " UTC";
1079
+ const entry = `| ${now} | ${operation} | ${page} | ${summary} |\n`;
1080
+ if (existsSync(logPath)) {
1081
+ const content = readFileSync(logPath, "utf-8");
1082
+ writeFileSync(logPath, content + entry);
1083
+ }
1084
+ else {
1085
+ const header = "---\ntitle: Operation Log\ntype: log\n---\n\n" +
1086
+ "# Operation Log\n\n" +
1087
+ "| Time | Operation | Page | Summary |\n" +
1088
+ "|------|-----------|------|--------|\n" +
1089
+ entry;
1090
+ writeFileSync(logPath, header);
1091
+ }
1092
+ }
1093
+ }
1094
+ // ── File helpers ──────────────────────────────────────────────────
1095
+ function listMdFiles(dir, root) {
1096
+ const result = [];
1097
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1098
+ if (entry.name.startsWith("."))
1099
+ continue;
1100
+ const full = join(dir, entry.name);
1101
+ if (entry.isDirectory()) {
1102
+ result.push(...listMdFiles(full, root));
1103
+ }
1104
+ else if (entry.name.endsWith(".md")) {
1105
+ result.push(relative(root, full));
1106
+ }
1107
+ }
1108
+ return result.sort();
1109
+ }
1110
+ function listAllFiles(dir, root) {
1111
+ const result = [];
1112
+ if (!existsSync(dir))
1113
+ return result;
1114
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1115
+ if (entry.name.startsWith("."))
1116
+ continue;
1117
+ const full = join(dir, entry.name);
1118
+ if (entry.isDirectory()) {
1119
+ result.push(...listAllFiles(full, root));
1120
+ }
1121
+ else {
1122
+ result.push(relative(root, full));
1123
+ }
1124
+ }
1125
+ return result.sort();
1126
+ }
1127
+ function guessMime(filename) {
1128
+ const ext = extname(filename).toLowerCase();
1129
+ const map = {
1130
+ ".md": "text/markdown",
1131
+ ".txt": "text/plain",
1132
+ ".pdf": "application/pdf",
1133
+ ".html": "text/html",
1134
+ ".json": "application/json",
1135
+ ".yaml": "text/yaml",
1136
+ ".yml": "text/yaml",
1137
+ ".csv": "text/csv",
1138
+ ".xml": "text/xml",
1139
+ ".png": "image/png",
1140
+ ".jpg": "image/jpeg",
1141
+ ".jpeg": "image/jpeg",
1142
+ ".gif": "image/gif",
1143
+ ".svg": "image/svg+xml",
1144
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1145
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1146
+ };
1147
+ return map[ext] ?? "application/octet-stream";
1148
+ }
1149
+ function formatBytes(bytes) {
1150
+ if (bytes < 1024)
1151
+ return `${bytes} B`;
1152
+ if (bytes < 1024 * 1024)
1153
+ return `${(bytes / 1024).toFixed(1)} KB`;
1154
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1155
+ }
1156
+ function writeDefaultSchemas(dir) {
1157
+ const schemas = {
1158
+ "person.md": `---
1159
+ template: person
1160
+ description: Profile of a person
1161
+ ---
1162
+
1163
+ # {{title}}
1164
+
1165
+ **Role:** [TODO]
1166
+ **Affiliations:** [TODO]
1167
+
1168
+ ## Key Contributions
1169
+
1170
+ - [TODO]
1171
+
1172
+ ## Relationships
1173
+
1174
+ - [TODO]
1175
+
1176
+ ## Sources
1177
+
1178
+ - [TODO]
1179
+ `,
1180
+ "concept.md": `---
1181
+ template: concept
1182
+ description: An idea, theory, or abstract concept
1183
+ ---
1184
+
1185
+ # {{title}}
1186
+
1187
+ ## Definition
1188
+
1189
+ [TODO]
1190
+
1191
+ ## Properties
1192
+
1193
+ - [TODO]
1194
+
1195
+ ## Relationships
1196
+
1197
+ - [TODO]
1198
+
1199
+ ## Examples
1200
+
1201
+ - [TODO]
1202
+
1203
+ ## Sources
1204
+
1205
+ - [TODO]
1206
+ `,
1207
+ "event.md": `---
1208
+ template: event
1209
+ description: Something that happened at a specific time
1210
+ ---
1211
+
1212
+ # {{title}}
1213
+
1214
+ **Date:** [TODO]
1215
+ **Location:** [TODO]
1216
+
1217
+ ## Participants
1218
+
1219
+ - [TODO]
1220
+
1221
+ ## What Happened
1222
+
1223
+ [TODO]
1224
+
1225
+ ## Outcomes & Impact
1226
+
1227
+ [TODO]
1228
+
1229
+ ## Sources
1230
+
1231
+ - [TODO]
1232
+ `,
1233
+ "artifact.md": `---
1234
+ template: artifact
1235
+ description: A tool, paper, product, or created thing
1236
+ ---
1237
+
1238
+ # {{title}}
1239
+
1240
+ **Type:** [TODO]
1241
+ **Creator:** [TODO]
1242
+ **Date:** [TODO]
1243
+
1244
+ ## Purpose
1245
+
1246
+ [TODO]
1247
+
1248
+ ## Key Features
1249
+
1250
+ - [TODO]
1251
+
1252
+ ## Sources
1253
+
1254
+ - [TODO]
1255
+ `,
1256
+ "comparison.md": `---
1257
+ template: comparison
1258
+ description: Side-by-side analysis of two or more items
1259
+ ---
1260
+
1261
+ # {{title}}
1262
+
1263
+ ## Items Compared
1264
+
1265
+ | Dimension | Item A | Item B |
1266
+ |-----------|--------|--------|
1267
+ | [TODO] | [TODO] | [TODO] |
1268
+
1269
+ ## Analysis
1270
+
1271
+ [TODO]
1272
+
1273
+ ## Verdict
1274
+
1275
+ [TODO]
1276
+
1277
+ ## Sources
1278
+
1279
+ - [TODO]
1280
+ `,
1281
+ "summary.md": `---
1282
+ template: summary
1283
+ description: Summary of a source document
1284
+ ---
1285
+
1286
+ # {{title}}
1287
+
1288
+ **Source:** [TODO]
1289
+ **Date:** [TODO]
1290
+
1291
+ ## Key Points
1292
+
1293
+ 1. [TODO]
1294
+
1295
+ ## Detailed Summary
1296
+
1297
+ [TODO]
1298
+
1299
+ ## Sources
1300
+
1301
+ - [TODO]
1302
+ `,
1303
+ "how-to.md": `---
1304
+ template: how-to
1305
+ description: A procedure or guide
1306
+ ---
1307
+
1308
+ # {{title}}
1309
+
1310
+ ## Goal
1311
+
1312
+ [TODO]
1313
+
1314
+ ## Prerequisites
1315
+
1316
+ - [TODO]
1317
+
1318
+ ## Steps
1319
+
1320
+ 1. [TODO]
1321
+
1322
+ ## Pitfalls
1323
+
1324
+ - [TODO]
1325
+
1326
+ ## Sources
1327
+
1328
+ - [TODO]
1329
+ `,
1330
+ "note.md": `---
1331
+ template: note
1332
+ description: Freeform knowledge — anything that does not fit other templates
1333
+ ---
1334
+
1335
+ # {{title}}
1336
+
1337
+ {{content}}
1338
+
1339
+ ## Sources
1340
+
1341
+ - [TODO]
1342
+ `,
1343
+ "synthesis.md": `---
1344
+ template: synthesis
1345
+ description: Distilled knowledge combining insights from multiple pages
1346
+ ---
1347
+
1348
+ # {{title}}
1349
+
1350
+ **Derived from:** [TODO: list source pages with [[links]]]
1351
+ **Date:** [TODO]
1352
+
1353
+ ## Key Insights
1354
+
1355
+ [TODO: What emerges from combining these sources?]
1356
+
1357
+ ## Connections
1358
+
1359
+ [TODO: How do these sources relate to each other?]
1360
+
1361
+ ## Contradictions & Open Questions
1362
+
1363
+ [TODO: Where do sources disagree? What remains unclear?]
1364
+
1365
+ ## Synthesis
1366
+
1367
+ [TODO: The integrated understanding]
1368
+
1369
+ ## Sources
1370
+
1371
+ - [TODO]
1372
+ `,
1373
+ };
1374
+ for (const [filename, content] of Object.entries(schemas)) {
1375
+ writeFileSync(join(dir, filename), content);
1376
+ }
1377
+ }
1378
+ //# sourceMappingURL=wiki.js.map