oh-my-llmwikimode 1.0.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +494 -0
  3. package/bin/llmwiki.js +1493 -0
  4. package/docs/INSTALLATION.md +228 -0
  5. package/docs/SCOPE_LOCK.md +79 -0
  6. package/docs/STAGE1_GUIDE.md +265 -0
  7. package/docs/STAGE2_AGENT_TEAM_GUIDE.md +141 -0
  8. package/docs/STAGE3_CONVERSATIONAL_GROWTH_GUIDE.md +50 -0
  9. package/docs/TEST_WORKSHEET.md +120 -0
  10. package/docs/github-private-bootstrap.md +53 -0
  11. package/docs/release.md +79 -0
  12. package/docs/stage4-slice1-manual-test.md +259 -0
  13. package/docs/stage4-slice1-user-guide.md +269 -0
  14. package/docs/user-guide-ko.md +452 -0
  15. package/package.json +76 -0
  16. package/scripts/install-llmwiki.ps1 +229 -0
  17. package/src/config.js +74 -0
  18. package/src/curator/browser-data.js +134 -0
  19. package/src/curator/queue.js +324 -0
  20. package/src/curator/schema.js +237 -0
  21. package/src/curator/scoring.js +83 -0
  22. package/src/hooks.js +199 -0
  23. package/src/librarian/schema.js +218 -0
  24. package/src/librarian/weekly-digest.js +478 -0
  25. package/src/security.js +127 -0
  26. package/src/server.js +860 -0
  27. package/src/stage4/graph-reasoning/analyzer.js +255 -0
  28. package/src/stage4/graph-reasoning/browser-data.js +130 -0
  29. package/src/stage4/graph-reasoning/index.js +35 -0
  30. package/src/stage4/graph-reasoning/loader.js +122 -0
  31. package/src/stage4/graph-reasoning/queue.js +154 -0
  32. package/src/stage4/graph-reasoning/schema.js +190 -0
  33. package/src/team/browser-data.js +142 -0
  34. package/src/team/capabilities.js +79 -0
  35. package/src/team/dispatch.js +108 -0
  36. package/src/team/queue.js +290 -0
  37. package/src/team/schema.js +225 -0
  38. package/src/team/shared-memory.js +183 -0
  39. package/src/todo/browser-data.js +71 -0
  40. package/src/todo/queue.js +159 -0
  41. package/src/todo/schema.js +90 -0
  42. package/src/utils/embedding-model.js +111 -0
  43. package/src/wiki/alias-suggestions.js +180 -0
  44. package/src/wiki/browser-data.js +284 -0
  45. package/src/wiki/doctor.js +218 -0
  46. package/src/wiki/entry-normalizer.js +139 -0
  47. package/src/wiki/ingest.js +443 -0
  48. package/src/wiki/lesson-proposal-analyzer.js +463 -0
  49. package/src/wiki/lesson-proposal-manager.js +331 -0
  50. package/src/wiki/lesson-template.js +182 -0
  51. package/src/wiki/lint.js +294 -0
  52. package/src/wiki/notebooklm-adapter.js +264 -0
  53. package/src/wiki/query.js +304 -0
  54. package/src/wiki/raw-manager.js +400 -0
  55. package/src/wiki/search-feedback.js +211 -0
  56. package/src/wiki/semantic-index.js +333 -0
  57. package/src/wiki/semantic-search.js +170 -0
  58. package/src/wiki/source-ledger.js +370 -0
  59. package/src/wiki/store.js +1329 -0
  60. package/src/wiki/usage-events.js +144 -0
@@ -0,0 +1,1329 @@
1
+ /**
2
+ * Wiki storage module for oh-my-llmwikimode
3
+ *
4
+ * Markdown + YAML frontmatter persistence with JSON-derived index.
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import crypto from "node:crypto";
10
+ import {
11
+ VALID_CONFIDENCES as NORMALIZER_VALID_CONFIDENCES,
12
+ VALID_SOURCES as NORMALIZER_VALID_SOURCES,
13
+ VALID_STATUSES as NORMALIZER_VALID_STATUSES,
14
+ normalizeEntryConfidence,
15
+ normalizeEntrySource,
16
+ normalizeEntryStatus,
17
+ normalizeEntryTags,
18
+ normalizeEntryText as normalizeEntryTextValue,
19
+ normalizeScalar as normalizeScalarValue,
20
+ normalizeStringList as normalizeStringListValue,
21
+ normalizeWikiIndex,
22
+ } from "./entry-normalizer.js";
23
+ import { appendSearchFailure, countSearchFailures } from "./search-feedback.js";
24
+ import { getSemanticIndexPath, getSemanticIndexSize, maybeAutoBuildSemanticIndex } from "./semantic-index.js";
25
+ import { semanticSearch } from "./semantic-search.js";
26
+
27
+ // ─── Frontmatter Parser ──────────────────────────────────────────────────────
28
+
29
+ export function parseFrontmatter(content) {
30
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
31
+ if (!match) return { frontmatter: {}, body: content };
32
+
33
+ const lines = match[1].split("\n");
34
+ const frontmatter = {};
35
+ let currentKey = null;
36
+ let currentArray = null;
37
+
38
+ for (const line of lines) {
39
+ const trimmedLine = line.trim();
40
+
41
+ // Array item (with potential leading whitespace)
42
+ if (trimmedLine.startsWith("- ") && currentArray) {
43
+ currentArray.push(trimmedLine.slice(2));
44
+ continue;
45
+ }
46
+
47
+ const keyValueMatch = line.match(/^(\w+):\s*(.*)$/);
48
+ if (keyValueMatch) {
49
+ const [, key, value] = keyValueMatch;
50
+ const trimmedValue = value.trim();
51
+ if (trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) {
52
+ try {
53
+ frontmatter[key] = JSON.parse(trimmedValue);
54
+ } catch {
55
+ frontmatter[key] = trimmedValue.slice(1, -1);
56
+ }
57
+ currentKey = key;
58
+ currentArray = null;
59
+ } else if (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) {
60
+ frontmatter[key] = trimmedValue.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
61
+ currentKey = key;
62
+ currentArray = null;
63
+ } else if (/^-?\d+\.?\d*$/.test(trimmedValue)) {
64
+ // Parse as number
65
+ frontmatter[key] = parseFloat(trimmedValue);
66
+ currentKey = key;
67
+ currentArray = null;
68
+ } else if (trimmedValue) {
69
+ frontmatter[key] = trimmedValue;
70
+ currentKey = key;
71
+ currentArray = null;
72
+ } else {
73
+ // Empty value - likely start of array
74
+ currentKey = key;
75
+ currentArray = null;
76
+ }
77
+ } else if (trimmedLine.startsWith("- ")) {
78
+ currentArray = [];
79
+ if (currentKey) {
80
+ frontmatter[currentKey] = currentArray;
81
+ }
82
+ currentArray.push(trimmedLine.slice(2));
83
+ }
84
+ }
85
+
86
+ return { frontmatter, body: match[2].trim() };
87
+ }
88
+
89
+ function normalizeScalar(value) {
90
+ return normalizeScalarValue(value);
91
+ }
92
+
93
+ function stringifyScalar(value) {
94
+ if (typeof value === "number") {
95
+ return String(value);
96
+ }
97
+ const scalar = normalizeScalar(value);
98
+ if (!scalar || scalar.startsWith("[") || scalar.startsWith("{") || scalar.includes(":")) {
99
+ return JSON.stringify(scalar);
100
+ }
101
+ return scalar;
102
+ }
103
+
104
+ function normalizeEntryText(value) {
105
+ return normalizeEntryTextValue(value);
106
+ }
107
+
108
+ function normalizeTags(value) {
109
+ return normalizeEntryTags(value);
110
+ }
111
+
112
+ function compareStrings(a, b) {
113
+ const left = String(a ?? "");
114
+ const right = String(b ?? "");
115
+ if (left < right) return -1;
116
+ if (left > right) return 1;
117
+ return 0;
118
+ }
119
+
120
+ function normalizeStringList(value) {
121
+ return normalizeStringListValue(value);
122
+ }
123
+
124
+ function normalizeLookupLabel(value) {
125
+ return normalizeScalar(value)
126
+ .toLowerCase()
127
+ .replace(/[^\p{L}\p{N}]/gu, "");
128
+ }
129
+
130
+ export function stringifyFrontmatter(frontmatter) {
131
+ const lines = ["---"];
132
+ for (const [key, value] of Object.entries(frontmatter)) {
133
+ if (Array.isArray(value)) {
134
+ lines.push(`${key}:`);
135
+ for (const item of value) {
136
+ lines.push(` - ${normalizeScalar(item)}`);
137
+ }
138
+ } else {
139
+ lines.push(`${key}: ${stringifyScalar(value)}`);
140
+ }
141
+ }
142
+ lines.push("---", "");
143
+ return lines.join("\n");
144
+ }
145
+
146
+ // ─── Wiki Paths ──────────────────────────────────────────────────────────────
147
+
148
+ export function getWikiPaths(wikiRoot) {
149
+ return {
150
+ root: wikiRoot,
151
+ inbox: path.join(wikiRoot, "inbox"),
152
+ problems: path.join(wikiRoot, "problems"),
153
+ lessons: path.join(wikiRoot, "editorial", "lessons"),
154
+ projects: path.join(wikiRoot, "projects"),
155
+ system: path.join(wikiRoot, ".system"),
156
+ autoMemoryAuditFile: path.join(wikiRoot, ".system", "auto-memory-audit.jsonl"),
157
+ indexFile: path.join(wikiRoot, ".system", "index.json"),
158
+ rawRoot: path.join(wikiRoot, "raw"),
159
+ rawSources: path.join(wikiRoot, "raw", "sources"),
160
+ rawNotebooklm: path.join(wikiRoot, "raw", "notebooklm"),
161
+ rawPacks: path.join(wikiRoot, "raw", "packs"),
162
+ };
163
+ }
164
+
165
+ export function ensureWikiStructure(paths) {
166
+ const dirs = [paths.root, paths.inbox, paths.problems, paths.lessons, paths.projects, paths.system, paths.rawRoot, paths.rawSources, paths.rawNotebooklm, paths.rawPacks];
167
+ for (const dir of dirs) {
168
+ if (!fs.existsSync(dir)) {
169
+ fs.mkdirSync(dir, { recursive: true });
170
+ }
171
+ }
172
+ }
173
+
174
+ function ensureAuditStructure(paths) {
175
+ for (const dir of [paths.root, paths.system]) {
176
+ if (!fs.existsSync(dir)) {
177
+ fs.mkdirSync(dir, { recursive: true });
178
+ }
179
+ }
180
+ }
181
+
182
+ function isInsideDirectory(parentDirectory, candidatePath) {
183
+ const relativePath = path.relative(parentDirectory, candidatePath);
184
+ return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
185
+ }
186
+
187
+ function resolvePromotableEntryPath(wikiRoot, entryPath) {
188
+ if (!entryPath || typeof entryPath !== "string") {
189
+ return { success: false, error: "Entry path is required" };
190
+ }
191
+
192
+ if (path.isAbsolute(entryPath)) {
193
+ return { success: false, error: "Entry path must be relative" };
194
+ }
195
+
196
+ if (!entryPath.endsWith(".md")) {
197
+ return { success: false, error: "Only Markdown entries can be promoted" };
198
+ }
199
+
200
+ const normalizedEntryPath = entryPath.replace(/\\/g, "/");
201
+ if (normalizedEntryPath.split("/").includes("..")) {
202
+ return { success: false, error: "Path traversal is not allowed" };
203
+ }
204
+
205
+ const isAllowedSource = normalizedEntryPath.startsWith("inbox/") || normalizedEntryPath.startsWith("problems/") || normalizedEntryPath.startsWith("projects/");
206
+ if (!isAllowedSource) {
207
+ return { success: false, error: "Only inbox/, problems/, or projects/ entries can be promoted" };
208
+ }
209
+
210
+ const rootPath = path.resolve(wikiRoot);
211
+ const fullPath = path.resolve(rootPath, entryPath);
212
+ if (!isInsideDirectory(rootPath, fullPath)) {
213
+ return { success: false, error: "Entry path escapes wiki root" };
214
+ }
215
+
216
+ const allowedSourceRoots = [
217
+ path.join(rootPath, "inbox"),
218
+ path.join(rootPath, "problems"),
219
+ path.join(rootPath, "projects"),
220
+ ];
221
+ if (!allowedSourceRoots.some((sourceRoot) => isInsideDirectory(sourceRoot, fullPath))) {
222
+ return { success: false, error: "Entry must resolve inside inbox/, problems/, or projects/" };
223
+ }
224
+
225
+ if (!fs.existsSync(fullPath)) {
226
+ return { success: false, error: "File not found" };
227
+ }
228
+
229
+ const realRoot = fs.realpathSync(rootPath);
230
+ const realFullPath = fs.realpathSync(fullPath);
231
+ if (!isInsideDirectory(realRoot, realFullPath)) {
232
+ return { success: false, error: "Entry path resolves outside wiki root" };
233
+ }
234
+
235
+ if (fs.lstatSync(fullPath).isSymbolicLink()) {
236
+ return { success: false, error: "Symbolic link entries cannot be promoted" };
237
+ }
238
+
239
+ return { success: true, fullPath, rootPath };
240
+ }
241
+
242
+ function resolveEditableEntryPath(wikiRoot, entryPath) {
243
+ if (!entryPath || typeof entryPath !== "string") {
244
+ return { success: false, error: "Entry path is required" };
245
+ }
246
+
247
+ if (path.isAbsolute(entryPath)) {
248
+ return { success: false, error: "Entry path must be relative" };
249
+ }
250
+
251
+ if (!entryPath.endsWith(".md")) {
252
+ return { success: false, error: "Only Markdown entries can be edited" };
253
+ }
254
+
255
+ const normalizedEntryPath = entryPath.replace(/\\/g, "/");
256
+ if (normalizedEntryPath.split("/").includes("..")) {
257
+ return { success: false, error: "Path traversal is not allowed" };
258
+ }
259
+
260
+ const allowedSourcePrefixes = ["inbox/", "problems/", "editorial/lessons/", "projects/"];
261
+ if (!allowedSourcePrefixes.some((prefix) => normalizedEntryPath.startsWith(prefix))) {
262
+ return { success: false, error: "Only inbox/, problems/, editorial/lessons/, or projects/ entries can be edited" };
263
+ }
264
+
265
+ const rootPath = path.resolve(wikiRoot);
266
+ const fullPath = path.resolve(rootPath, normalizedEntryPath);
267
+ if (!isInsideDirectory(rootPath, fullPath)) {
268
+ return { success: false, error: "Entry path escapes wiki root" };
269
+ }
270
+
271
+ const allowedSourceRoots = [
272
+ path.join(rootPath, "inbox"),
273
+ path.join(rootPath, "problems"),
274
+ path.join(rootPath, "editorial", "lessons"),
275
+ path.join(rootPath, "projects"),
276
+ ];
277
+ if (!allowedSourceRoots.some((sourceRoot) => isInsideDirectory(sourceRoot, fullPath))) {
278
+ return { success: false, error: "Entry must resolve inside an editable wiki directory" };
279
+ }
280
+
281
+ if (!fs.existsSync(fullPath)) {
282
+ return { success: false, error: "File not found" };
283
+ }
284
+
285
+ if (fs.lstatSync(fullPath).isSymbolicLink()) {
286
+ return { success: false, error: "Symbolic link entries cannot be edited" };
287
+ }
288
+
289
+ const realRoot = fs.realpathSync(rootPath);
290
+ const realFullPath = fs.realpathSync(fullPath);
291
+ if (!isInsideDirectory(realRoot, realFullPath)) {
292
+ return { success: false, error: "Entry path resolves outside wiki root" };
293
+ }
294
+
295
+ return { success: true, fullPath, rootPath };
296
+ }
297
+
298
+ // ─── Store ───────────────────────────────────────────────────────────────────
299
+
300
+ export const VALID_STATUSES = NORMALIZER_VALID_STATUSES;
301
+ export const VALID_SOURCES = NORMALIZER_VALID_SOURCES;
302
+ export const VALID_CONFIDENCES = NORMALIZER_VALID_CONFIDENCES;
303
+
304
+ function normalizeStatus(value) {
305
+ return normalizeEntryStatus(value);
306
+ }
307
+
308
+ function normalizeSource(value) {
309
+ return normalizeEntrySource(value);
310
+ }
311
+
312
+ function normalizeConfidence(value) {
313
+ return normalizeEntryConfidence(value);
314
+ }
315
+
316
+ // Statuses that should be excluded from the public graph
317
+ const EXCLUDED_GRAPH_STATUSES = new Set(["rejected", "superseded", "private", "needs-clarification"]);
318
+
319
+ function isGraphVisibleEntry(entry) {
320
+ return !EXCLUDED_GRAPH_STATUSES.has(normalizeStatus(entry?.status));
321
+ }
322
+
323
+ function buildEntryResolver(entries) {
324
+ const resolver = new Map();
325
+ const sortedEntries = [...entries].sort((a, b) => compareStrings(a.path, b.path));
326
+
327
+ for (const entry of sortedEntries) {
328
+ const labels = normalizeStringList([entry.title, ...(Array.isArray(entry.aliases) ? entry.aliases : [])]);
329
+ for (const label of labels) {
330
+ const normalizedLabel = normalizeLookupLabel(label);
331
+ if (!normalizedLabel) continue;
332
+ if (!resolver.has(normalizedLabel)) resolver.set(normalizedLabel, []);
333
+ resolver.get(normalizedLabel).push(entry.path);
334
+ }
335
+ }
336
+
337
+ for (const [label, entryPaths] of resolver.entries()) {
338
+ resolver.set(label, [...new Set(entryPaths)].sort(compareStrings));
339
+ }
340
+
341
+ return resolver;
342
+ }
343
+
344
+ function resolveEntryPath(resolver, entryPaths, target) {
345
+ const normalizedTarget = normalizeScalar(target).replace(/\\/g, "/");
346
+ if (!normalizedTarget) return null;
347
+ if (entryPaths.has(normalizedTarget)) return normalizedTarget;
348
+
349
+ const normalizedLabel = normalizeLookupLabel(normalizedTarget);
350
+ const resolvedPaths = resolver.get(normalizedLabel);
351
+ return resolvedPaths?.[0] || null;
352
+ }
353
+
354
+ function deterministicBuiltAt(entries) {
355
+ return [...entries]
356
+ .map((entry) => normalizeScalar(entry.created_at))
357
+ .filter(Boolean)
358
+ .sort(compareStrings)
359
+ .pop() || "1970-01-01T00:00:00.000Z";
360
+ }
361
+
362
+ function generateGraphId() {
363
+ return `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
364
+ }
365
+
366
+ function computeContentHash(content) {
367
+ return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
368
+ }
369
+
370
+ const AUTO_MEMORY_AUDIT_ACTIONS = new Set([
371
+ "stored",
372
+ "skipped_privacy",
373
+ "skipped_oversized",
374
+ "skipped_injection",
375
+ "skipped_explicit_request",
376
+ "skipped_internal",
377
+ "error",
378
+ ]);
379
+
380
+ export function auditAutoMemory(wikiRoot, action, details = {}) {
381
+ const paths = getWikiPaths(wikiRoot);
382
+ ensureAuditStructure(paths);
383
+
384
+ const auditAction = AUTO_MEMORY_AUDIT_ACTIONS.has(action) ? action : "error";
385
+ const entry = {
386
+ timestamp: new Date().toISOString(),
387
+ action: auditAction,
388
+ reason: normalizeScalar(details.reason),
389
+ entry_id: normalizeScalar(details.entry_id),
390
+ source: normalizeSource(details.source || "chat.message"),
391
+ };
392
+
393
+ fs.appendFileSync(paths.autoMemoryAuditFile, `${JSON.stringify(entry)}\n`);
394
+
395
+ return {
396
+ success: true,
397
+ path: paths.autoMemoryAuditFile,
398
+ entry,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Store knowledge in the wiki.
404
+ * @param {string} wikiRoot - Path to the wiki root directory
405
+ * @param {Object} knowledge - Knowledge entry to store
406
+ * @param {string} knowledge.summary - Summary/title of the entry
407
+ * @param {string[]} [knowledge.tags=[]] - Tags for the entry
408
+ * @param {string} [knowledge.source="auto-memory"] - Source of the entry
409
+ * @param {string} [knowledge.status="candidate"] - Lifecycle status
410
+ * @param {string} [knowledge.project] - Project name (optional). When provided, stores under projects/{project}/inbox/
411
+ * @returns {Object|null} Stored entry info or null if summary is empty
412
+ */
413
+ export function storeKnowledge(wikiRoot, knowledge) {
414
+ const paths = getWikiPaths(wikiRoot);
415
+ ensureWikiStructure(paths);
416
+
417
+ const { summary, tags = [], source = "auto-memory", status = "candidate", project } = knowledge;
418
+ if (!summary) return null;
419
+
420
+ // Determine target directory based on project
421
+ let targetDir = paths.inbox;
422
+ let category = "inbox";
423
+ if (project) {
424
+ const safeProject = String(project).replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-");
425
+ if (safeProject) {
426
+ targetDir = path.join(paths.projects, safeProject, "inbox");
427
+ category = "project";
428
+ if (!fs.existsSync(targetDir)) {
429
+ fs.mkdirSync(targetDir, { recursive: true });
430
+ }
431
+ }
432
+ }
433
+
434
+ // Generate safe filename
435
+ const safeTitle = summary
436
+ .slice(0, 50)
437
+ .replace(/[^\w\s-]/g, "")
438
+ .trim()
439
+ .replace(/\s+/g, "-");
440
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
441
+ const filename = `${timestamp}-${safeTitle}.md`;
442
+ let filePath = path.join(targetDir, filename);
443
+ let collisionIndex = 2;
444
+ while (fs.existsSync(filePath)) {
445
+ filePath = path.join(targetDir, `${timestamp}-${safeTitle}-${collisionIndex}.md`);
446
+ collisionIndex += 1;
447
+ }
448
+
449
+ const frontmatter = {
450
+ title: summary.slice(0, 100),
451
+ aliases: Array.isArray(knowledge.aliases) ? knowledge.aliases : [],
452
+ tags: Array.isArray(tags) ? tags : [tags],
453
+ status: normalizeStatus(status),
454
+ source: normalizeSource(source),
455
+ summary: (knowledge.summary || summary).slice(0, 200),
456
+ confidence: normalizeConfidence(knowledge.confidence),
457
+ confidence_score: typeof knowledge.confidence_score === "number" ? knowledge.confidence_score : 1.0,
458
+ related: Array.isArray(knowledge.related) ? knowledge.related : [],
459
+ references: Array.isArray(knowledge.references) ? knowledge.references : [],
460
+ graph_id: knowledge.graph_id || generateGraphId(),
461
+ community: knowledge.community || "",
462
+ project: project || "",
463
+ created_at: new Date().toISOString(),
464
+ updated_at: new Date().toISOString(),
465
+ seen_count: 1,
466
+ };
467
+
468
+ const bodyContent = knowledge.details || summary;
469
+ const content = stringifyFrontmatter(frontmatter) + "\n" + bodyContent;
470
+ fs.writeFileSync(filePath, content);
471
+
472
+ return filePath;
473
+ }
474
+
475
+ // ─── Tokenize ────────────────────────────────────────────────────────────────
476
+
477
+ export function tokenize(text) {
478
+ const normalizedText = normalizeEntryText(text);
479
+ const tokens = [];
480
+ const words = normalizedText
481
+ .toLowerCase()
482
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
483
+ .split(/\s+/)
484
+ .filter((w) => w.length >= 2);
485
+
486
+ tokens.push(...words);
487
+
488
+ // Korean bigram
489
+ const koreanMatches = normalizedText.match(/[\uac00-\ud7af]+/g) || [];
490
+ for (const word of koreanMatches) {
491
+ for (let i = 0; i < word.length - 1; i++) {
492
+ tokens.push(word.slice(i, i + 2));
493
+ }
494
+ }
495
+
496
+ return tokens;
497
+ }
498
+
499
+ // ─── Index Builder ───────────────────────────────────────────────────────────
500
+
501
+ export function buildIndex(wikiRoot) {
502
+ const paths = getWikiPaths(wikiRoot);
503
+ ensureWikiStructure(paths);
504
+
505
+ const records = [];
506
+
507
+ function scanDir(dir, category) {
508
+ if (!fs.existsSync(dir)) return;
509
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
510
+
511
+ for (const entry of entries) {
512
+ const entryPath = path.join(dir, entry.name);
513
+ if (entry.isDirectory()) {
514
+ scanDir(entryPath, category);
515
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
516
+ try {
517
+ const content = fs.readFileSync(entryPath, "utf-8");
518
+ const { frontmatter, body } = parseFrontmatter(content);
519
+
520
+ if (!frontmatter.title) continue;
521
+
522
+ const relPath = path.relative(wikiRoot, entryPath).replace(/\\/g, "/");
523
+ const item = {
524
+ path: relPath,
525
+ category,
526
+ title: normalizeEntryText(frontmatter.title),
527
+ aliases: normalizeStringList(frontmatter.aliases),
528
+ tags: normalizeStringList(frontmatter.tags),
529
+ status: normalizeStatus(frontmatter.status),
530
+ source: normalizeSource(frontmatter.source),
531
+ summary: normalizeEntryText(frontmatter.summary || frontmatter.title),
532
+ confidence: normalizeConfidence(frontmatter.confidence),
533
+ confidence_score: typeof frontmatter.confidence_score === "number" ? frontmatter.confidence_score : 1.0,
534
+ related: normalizeStringList(frontmatter.related),
535
+ community: normalizeScalar(frontmatter.community),
536
+ project: normalizeScalar(frontmatter.project),
537
+ created_at: normalizeScalar(frontmatter.created_at),
538
+ updated_at: normalizeScalar(frontmatter.updated_at),
539
+ };
540
+ records.push({ entry: item, body });
541
+ } catch (err) {
542
+ // Skip corrupt files
543
+ console.warn(`[oh-my-llmwikimode] Skipping corrupt file: ${entryPath} - ${err.message}`);
544
+ }
545
+ }
546
+ }
547
+ }
548
+
549
+ scanDir(paths.inbox, "inbox");
550
+ scanDir(paths.problems, "problems");
551
+ scanDir(paths.lessons, "lessons");
552
+ scanDir(paths.projects, "project");
553
+
554
+ records.sort((a, b) => compareStrings(a.entry.path, b.entry.path));
555
+ const entries = records.map((record, index) => ({ ...record.entry, entry_id: index }));
556
+ const recordsByPath = new Map(records.map((record, index) => [entries[index].path, { entry: entries[index], body: record.body }]));
557
+
558
+ // Tokenize and build inverted index after deterministic entry ordering.
559
+ const invertedIndex = {};
560
+ const termFrequency = {};
561
+ for (const entry of entries) {
562
+ const record = recordsByPath.get(entry.path);
563
+ const text = [
564
+ entry.title,
565
+ entry.summary,
566
+ ...entry.aliases,
567
+ ...entry.tags,
568
+ record?.body || "",
569
+ ].join(" ").toLowerCase();
570
+ const allTokens = tokenize(text);
571
+ const uniqueTokens = [...new Set(allTokens)].sort(compareStrings);
572
+
573
+ // Legacy inverted index (unique posting list)
574
+ for (const token of uniqueTokens) {
575
+ if (!invertedIndex[token]) {
576
+ invertedIndex[token] = [];
577
+ }
578
+ invertedIndex[token].push(entry.entry_id);
579
+ }
580
+
581
+ // Term frequency for BM25 (count all occurrences)
582
+ for (const token of allTokens) {
583
+ if (!termFrequency[token]) {
584
+ termFrequency[token] = {};
585
+ }
586
+ termFrequency[token][entry.entry_id] = (termFrequency[token][entry.entry_id] || 0) + 1;
587
+ }
588
+
589
+ // Document length (total tokens including duplicates)
590
+ entry.doc_length = allTokens.length;
591
+
592
+ // Snippet from body (first 200 chars, cleaned)
593
+ if (record?.body) {
594
+ entry.snippet = record.body.replace(/\s+/g, " ").trim().slice(0, 200);
595
+ }
596
+ }
597
+
598
+ const sortedInvertedIndex = Object.fromEntries(
599
+ Object.entries(invertedIndex)
600
+ .sort(([left], [right]) => compareStrings(left, right))
601
+ .map(([token, postingList]) => [token, [...new Set(postingList)].sort((a, b) => a - b)])
602
+ );
603
+
604
+ const allEntryPaths = new Set(entries.map((entry) => entry.path));
605
+ const visibleEntries = entries.filter(isGraphVisibleEntry);
606
+ const visibleEntryPaths = new Set(visibleEntries.map((entry) => entry.path));
607
+ const allResolver = buildEntryResolver(entries);
608
+ const visibleResolver = buildEntryResolver(visibleEntries);
609
+
610
+ // Extract wikilinks and build backlinks
611
+ const backlinks = new Map(entries.map((entry) => [entry.path, new Set()]));
612
+ const edges = [];
613
+ const warnings = [];
614
+
615
+ for (const entry of entries) {
616
+ const record = recordsByPath.get(entry.path);
617
+ if (!record) continue;
618
+
619
+ const wikilinks = extractWikilinks(record.body).sort(compareStrings);
620
+
621
+ for (const link of wikilinks) {
622
+ const resolvedBacklinkTarget = resolveEntryPath(allResolver, allEntryPaths, link);
623
+ if (resolvedBacklinkTarget && backlinks.has(resolvedBacklinkTarget)) {
624
+ backlinks.get(resolvedBacklinkTarget).add(entry.path);
625
+ }
626
+
627
+ if (!isGraphVisibleEntry(entry)) continue;
628
+
629
+ const resolvedGraphTarget = resolveEntryPath(visibleResolver, visibleEntryPaths, link);
630
+ if (resolvedGraphTarget) {
631
+ edges.push({
632
+ source: entry.path,
633
+ target: resolvedGraphTarget,
634
+ relation: "references",
635
+ confidence: "EXTRACTED",
636
+ confidence_score: 1.0,
637
+ });
638
+ } else {
639
+ warnings.push({
640
+ type: "unresolved_wikilink",
641
+ source: entry.path,
642
+ target: link,
643
+ relation: "references",
644
+ });
645
+ }
646
+ }
647
+
648
+ if (!isGraphVisibleEntry(entry)) continue;
649
+
650
+ for (const relatedTarget of entry.related) {
651
+ const resolvedRelatedTarget = resolveEntryPath(visibleResolver, visibleEntryPaths, relatedTarget);
652
+ if (!resolvedRelatedTarget) continue;
653
+ edges.push({
654
+ source: entry.path,
655
+ target: resolvedRelatedTarget,
656
+ relation: "related",
657
+ confidence: "EXTRACTED",
658
+ confidence_score: 1.0,
659
+ });
660
+ }
661
+ }
662
+
663
+ // Attach backlinks to entries
664
+ for (const entry of entries) {
665
+ entry.backlinks_from = [...(backlinks.get(entry.path) || [])].sort(compareStrings);
666
+ entry.backlink_count = entry.backlinks_from.length;
667
+ }
668
+
669
+ // Detect duplicates before writing so both index and graph metadata are stable.
670
+ const duplicates = detectDuplicates(entries, wikiRoot);
671
+
672
+ // Build and write graph
673
+ const graph = buildGraph(entries, edges, wikiRoot, { warnings });
674
+ writeGraph(paths.system, graph);
675
+
676
+ const index = {
677
+ entries,
678
+ inverted_index: sortedInvertedIndex,
679
+ term_frequency: termFrequency,
680
+ duplicates: duplicates.length > 0 ? duplicates : undefined,
681
+ built_at: deterministicBuiltAt(entries),
682
+ };
683
+
684
+ // Atomic write
685
+ const tmpFile = `${paths.indexFile}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
686
+ fs.writeFileSync(tmpFile, JSON.stringify(index, null, 2));
687
+ fs.renameSync(tmpFile, paths.indexFile);
688
+
689
+ return index;
690
+ }
691
+
692
+ // ─── Search ──────────────────────────────────────────────────────────────────
693
+
694
+ // BM25-lite constants
695
+ const BM25_K1 = 1.2;
696
+ const BM25_B = 0.75;
697
+ const SEMANTIC_FALLBACK_MIN_ENTRIES = 10;
698
+
699
+ export function search(query, index, maxResults = 3) {
700
+ const normalizedIndex = normalizeWikiIndex(index);
701
+ if (!normalizedIndex.inverted_index) return [];
702
+
703
+ const queryText = normalizeEntryText(query).toLowerCase();
704
+ const queryTokens = tokenize(queryText);
705
+ if (queryTokens.length === 0) return [];
706
+
707
+ const scores = new Map();
708
+ const N = normalizedIndex.entries.length;
709
+
710
+ // Compute average document length for BM25
711
+ const avgDocLength = N > 0
712
+ ? normalizedIndex.entries.reduce((sum, entry) => sum + (entry.doc_length || 0), 0) / N
713
+ : 0;
714
+
715
+ for (const token of queryTokens) {
716
+ const postings = normalizedIndex.inverted_index[token] || [];
717
+ const df = postings.length;
718
+ if (df === 0) continue;
719
+
720
+ // IDF with smoothing
721
+ const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
722
+
723
+ for (const entryId of postings) {
724
+ const entry = normalizedIndex.entries[entryId];
725
+ if (!entry) continue;
726
+
727
+ // Term frequency for this entry
728
+ const tf = (normalizedIndex.term_frequency?.[token]?.[entryId]) || 1;
729
+ const docLength = entry.doc_length || 0;
730
+
731
+ // BM25 term saturation and length normalization
732
+ const lengthNorm = avgDocLength > 0 ? (1 - BM25_B) + BM25_B * (docLength / avgDocLength) : 1;
733
+ const bm25TermScore = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * lengthNorm);
734
+
735
+ const currentScore = scores.get(entryId) || 0;
736
+ scores.set(entryId, currentScore + idf * bm25TermScore);
737
+ }
738
+ }
739
+
740
+ const results = [];
741
+ for (const [entryId, score] of scores) {
742
+ const entry = normalizedIndex.entries[entryId];
743
+ if (!entry) continue;
744
+ if (entry.status === "rejected" || entry.status === "superseded" || entry.status === "needs-clarification" || entry.status === "private") {
745
+ continue;
746
+ }
747
+
748
+ // Field boost
749
+ let boostedScore = score;
750
+ const queryLower = queryText;
751
+ const entryTitle = String(normalizeEntryText(entry.title) ?? "");
752
+ const entryTags = normalizeTags(entry.tags).map((tag) => String(tag));
753
+ if (entryTitle && entryTitle.toLowerCase().includes(queryLower)) boostedScore *= 2.0;
754
+ if (entryTags.some((tag) => tag.toLowerCase().includes(queryLower))) boostedScore *= 1.5;
755
+
756
+ // Citation metadata
757
+ const citation = {
758
+ source_id: entry.source || "manual",
759
+ path: entry.path,
760
+ chunk_id: "entry",
761
+ snippet: entry.snippet || entry.summary || entryTitle,
762
+ };
763
+
764
+ results.push({ ...entry, title: entryTitle, tags: entryTags, score: boostedScore, citation });
765
+ }
766
+
767
+ // Deterministic sorting: score desc, title asc, path asc, entry_id asc
768
+ results.sort((a, b) => {
769
+ if (b.score !== a.score) return b.score - a.score;
770
+ const titleCompare = compareStrings(a.title, b.title);
771
+ if (titleCompare !== 0) return titleCompare;
772
+ const pathCompare = compareStrings(a.path, b.path);
773
+ if (pathCompare !== 0) return pathCompare;
774
+ return (a.entry_id || 0) - (b.entry_id || 0);
775
+ });
776
+
777
+ return results.slice(0, maxResults);
778
+ }
779
+
780
+ // Uses data/semantic-index/embeddings.jsonl as the local vector store.
781
+ export async function searchWithSemanticFallback(wikiRoot, query, index, maxResults = 3, options = {}) {
782
+ const keywordResults = search(query, index, maxResults);
783
+ if (keywordResults.length > 0) return keywordResults;
784
+
785
+ if (process.env.LLMWIKI_DISABLE_SEMANTIC === "1") return [];
786
+
787
+ try {
788
+ await maybeAutoBuildSemanticIndex(wikiRoot, { now: options.now, model: options.model });
789
+
790
+ const indexPath = getSemanticIndexPath(wikiRoot);
791
+ if (!fs.existsSync(indexPath)) return [];
792
+ if (getSemanticIndexSize(wikiRoot) < SEMANTIC_FALLBACK_MIN_ENTRIES) return [];
793
+ if (countSearchFailures(wikiRoot) < SEMANTIC_FALLBACK_MIN_ENTRIES) return [];
794
+
795
+ const semanticResult = await semanticSearch(wikiRoot, query, {
796
+ topK: maxResults,
797
+ threshold: typeof options.threshold === "number" ? options.threshold : 0.6,
798
+ model: options.model,
799
+ fallbackFromKeyword: true,
800
+ });
801
+ const semanticResults = semanticResult.results;
802
+ if (semanticResults.length === 0) return [];
803
+
804
+ try {
805
+ appendSearchFailure(
806
+ wikiRoot,
807
+ {
808
+ query,
809
+ result_count: 0,
810
+ max_results: maxResults,
811
+ top_paths: semanticResults.map((result) => result.path),
812
+ source: "semantic_fallback",
813
+ },
814
+ options.now ? { now: options.now } : {}
815
+ );
816
+ } catch {
817
+ // Search feedback logging is best-effort and must not block retrieval.
818
+ }
819
+
820
+ return semanticResults.map((result) => ({
821
+ path: result.path,
822
+ title: result.title,
823
+ similarity: result.similarity,
824
+ score: result.similarity,
825
+ fallback_from_keyword: true,
826
+ source: "semantic",
827
+ citation: {
828
+ source_id: "semantic-index",
829
+ path: result.path,
830
+ chunk_id: "entry",
831
+ snippet: result.title,
832
+ },
833
+ }));
834
+ } catch {
835
+ return [];
836
+ }
837
+ }
838
+
839
+ // ─── Promote ─────────────────────────────────────────────────────────────────
840
+
841
+ export function promoteEntry(wikiRoot, entryPath) {
842
+ const paths = getWikiPaths(wikiRoot);
843
+ ensureWikiStructure(paths);
844
+
845
+ const resolved = resolvePromotableEntryPath(wikiRoot, entryPath);
846
+ if (!resolved.success) {
847
+ return resolved;
848
+ }
849
+
850
+ const { fullPath, rootPath } = resolved;
851
+
852
+ const content = fs.readFileSync(fullPath, "utf-8");
853
+ const { frontmatter, body } = parseFrontmatter(content);
854
+
855
+ if (frontmatter.status === "lesson") {
856
+ return { success: false, error: "Already a lesson" };
857
+ }
858
+
859
+ // Move to editorial/lessons/
860
+ const filename = path.basename(entryPath);
861
+ const newPath = path.join(paths.lessons, filename);
862
+ if (fs.existsSync(newPath)) {
863
+ return { success: false, error: "Lesson already exists" };
864
+ }
865
+
866
+ frontmatter.status = "lesson";
867
+ frontmatter.promoted_at = new Date().toISOString();
868
+
869
+ const newContent = stringifyFrontmatter(frontmatter) + "\n" + body;
870
+ fs.writeFileSync(newPath, newContent);
871
+ fs.unlinkSync(fullPath);
872
+
873
+ return { success: true, path: path.relative(rootPath, newPath).replace(/\\/g, "/") };
874
+ }
875
+
876
+ // ─── Reject ──────────────────────────────────────────────────────────────────
877
+
878
+ export function rejectEntry(wikiRoot, entryPath) {
879
+ const paths = getWikiPaths(wikiRoot);
880
+ ensureWikiStructure(paths);
881
+
882
+ const resolved = resolveEditableEntryPath(wikiRoot, entryPath);
883
+ if (!resolved.success) {
884
+ return resolved;
885
+ }
886
+
887
+ const { fullPath, rootPath } = resolved;
888
+ const content = fs.readFileSync(fullPath, "utf-8");
889
+ const { frontmatter, body } = parseFrontmatter(content);
890
+ const now = new Date().toISOString();
891
+
892
+ frontmatter.status = "rejected";
893
+ frontmatter.rejected_at = frontmatter.rejected_at || now;
894
+ frontmatter.updated_at = now;
895
+
896
+ const newContent = stringifyFrontmatter(frontmatter) + "\n" + body;
897
+ const tmpFile = `${fullPath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
898
+ fs.writeFileSync(tmpFile, newContent);
899
+ fs.renameSync(tmpFile, fullPath);
900
+
901
+ return {
902
+ success: true,
903
+ path: path.relative(rootPath, fullPath).replace(/\\/g, "/"),
904
+ status: "rejected",
905
+ };
906
+ }
907
+
908
+ // ─── Format Results ──────────────────────────────────────────────────────────
909
+
910
+ export function formatResultsForContext(results, wikiRoot, budget = 2500) {
911
+ if (!results || results.length === 0) return "";
912
+
913
+ const lines = ["--- Related Wiki Knowledge ---"];
914
+ let totalLength = lines[0].length;
915
+
916
+ for (const result of results) {
917
+ const fullPath = path.join(wikiRoot, result.path);
918
+ let content = "";
919
+
920
+ try {
921
+ content = fs.readFileSync(fullPath, "utf-8");
922
+ } catch (error) {
923
+ console.warn(`[oh-my-llmwikimode] Skipping unreadable result: ${fullPath} - ${error.message}`);
924
+ continue;
925
+ }
926
+
927
+ const { frontmatter, body } = parseFrontmatter(content);
928
+ const entryTitle = String(normalizeEntryText(frontmatter.title || result.title) ?? "");
929
+ const entryTags = normalizeTags(frontmatter.tags || result.tags).map((tag) => String(tag));
930
+ const entry = [
931
+ `\n# ${entryTitle}`,
932
+ `Tags: ${entryTags.join(", ")}`,
933
+ `Status: ${frontmatter.status || result.status || "candidate"}`,
934
+ "",
935
+ body.slice(0, 800),
936
+ "---",
937
+ ].join("\n");
938
+
939
+ if (totalLength + entry.length > budget - 50) break;
940
+ lines.push(entry);
941
+ totalLength += entry.length;
942
+ }
943
+
944
+ lines.push("--- End Wiki Knowledge ---");
945
+ return lines.join("\n");
946
+ }
947
+
948
+ // ─── Wikilink Extraction ─────────────────────────────────────────────────────
949
+
950
+ export function extractWikilinks(text) {
951
+ if (!text || typeof text !== "string") return [];
952
+ const matches = text.matchAll(/\[\[([^\]|#]+)(?:\|[^\]]*)?(?:#[^\]]*)?\]\]/g);
953
+ const links = [];
954
+ for (const match of matches) {
955
+ const link = match[1].trim();
956
+ if (link) links.push(link);
957
+ }
958
+ return [...new Set(links)];
959
+ }
960
+
961
+ // ─── Graph Builder ───────────────────────────────────────────────────────────
962
+
963
+ function normalizeGraphWarnings(warnings, visibleNodeIds) {
964
+ return (Array.isArray(warnings) ? warnings : [])
965
+ .filter((warning) => warning && typeof warning === "object")
966
+ .map((warning) => ({
967
+ type: normalizeScalar(warning.type),
968
+ source: normalizeScalar(warning.source),
969
+ target: normalizeScalar(warning.target),
970
+ relation: normalizeScalar(warning.relation || "references"),
971
+ }))
972
+ .filter((warning) => warning.type && warning.source && warning.target && visibleNodeIds.has(warning.source));
973
+ }
974
+
975
+ function compareWarnings(a, b) {
976
+ return compareStrings(
977
+ `${a.type}\0${a.normalized_label || ""}\0${a.source || ""}\0${a.target || ""}\0${a.relation || ""}`,
978
+ `${b.type}\0${b.normalized_label || ""}\0${b.source || ""}\0${b.target || ""}\0${b.relation || ""}`
979
+ );
980
+ }
981
+
982
+ function buildDuplicateLabelWarnings(entries) {
983
+ const labelMap = new Map();
984
+
985
+ for (const entry of [...entries].sort((a, b) => compareStrings(a.path, b.path))) {
986
+ const normalizedLabel = normalizeLookupLabel(entry.title);
987
+ if (!normalizedLabel) continue;
988
+ if (!labelMap.has(normalizedLabel)) labelMap.set(normalizedLabel, []);
989
+ labelMap.get(normalizedLabel).push(entry.path);
990
+ }
991
+
992
+ return Array.from(labelMap.entries())
993
+ .filter(([, entryPaths]) => entryPaths.length > 1)
994
+ .map(([normalizedLabel, entryPaths]) => ({
995
+ type: "duplicate_label",
996
+ normalized_label: normalizedLabel,
997
+ entries: [...new Set(entryPaths)].sort(compareStrings),
998
+ }))
999
+ .sort(compareWarnings);
1000
+ }
1001
+
1002
+ function dedupeEdges(edges, visibleNodeIds) {
1003
+ const edgeMap = new Map();
1004
+
1005
+ for (const edge of Array.isArray(edges) ? edges : []) {
1006
+ const source = normalizeScalar(edge.source);
1007
+ const target = normalizeScalar(edge.target);
1008
+ if (!visibleNodeIds.has(source) || !visibleNodeIds.has(target)) continue;
1009
+
1010
+ const relation = normalizeScalar(edge.relation || "references") || "references";
1011
+ const key = `${source}\0${target}\0${relation}`;
1012
+ if (edgeMap.has(key)) continue;
1013
+
1014
+ edgeMap.set(key, {
1015
+ source,
1016
+ target,
1017
+ relation,
1018
+ confidence: normalizeConfidence(edge.confidence),
1019
+ confidence_score: typeof edge.confidence_score === "number" ? edge.confidence_score : 1.0,
1020
+ });
1021
+ }
1022
+
1023
+ return Array.from(edgeMap.values()).sort((a, b) => {
1024
+ if (a.source !== b.source) return compareStrings(a.source, b.source);
1025
+ if (a.target !== b.target) return compareStrings(a.target, b.target);
1026
+ return compareStrings(a.relation, b.relation);
1027
+ });
1028
+ }
1029
+
1030
+ function buildGapHints(nodes) {
1031
+ return nodes
1032
+ .filter((node) => node.degree < 2)
1033
+ .map((node) => ({
1034
+ type: node.degree === 0 ? "isolated_node" : "low_degree_node",
1035
+ node: node.id,
1036
+ label: node.label,
1037
+ degree: node.degree,
1038
+ hint: "Add wikilinks or related frontmatter to connect this entry.",
1039
+ }))
1040
+ .sort((a, b) => {
1041
+ if (a.degree !== b.degree) return a.degree - b.degree;
1042
+ return compareStrings(a.node, b.node);
1043
+ });
1044
+ }
1045
+
1046
+ export function buildGraph(entries, edges, wikiRoot, options = {}) {
1047
+ // Filter out excluded statuses from graph nodes
1048
+ const visibleEntries = entries
1049
+ .filter(isGraphVisibleEntry)
1050
+ .sort((a, b) => compareStrings(a.path, b.path));
1051
+
1052
+ const nodes = visibleEntries
1053
+ .map((entry) => ({
1054
+ id: entry.path,
1055
+ label: entry.title,
1056
+ path: entry.path,
1057
+ category: entry.category,
1058
+ community: entry.community || "",
1059
+ tags: normalizeStringList(entry.tags),
1060
+ aliases: normalizeStringList(entry.aliases),
1061
+ status: entry.status,
1062
+ source: entry.source,
1063
+ confidence: entry.confidence || "EXTRACTED",
1064
+ confidence_score: typeof entry.confidence_score === "number" ? entry.confidence_score : 1.0,
1065
+ summary: entry.summary || entry.title,
1066
+ created_at: entry.created_at,
1067
+ backlink_count: 0,
1068
+ degree: 0,
1069
+ }))
1070
+ .sort((a, b) => compareStrings(a.id, b.id));
1071
+
1072
+ // Build visible node id set for edge filtering
1073
+ const visibleNodeIds = new Set(nodes.map((n) => n.id));
1074
+
1075
+ // Filter edges to only connect visible nodes
1076
+ const sortedEdges = dedupeEdges(edges, visibleNodeIds);
1077
+
1078
+ // Compute degree and backlink count from visible edges only
1079
+ for (const node of nodes) {
1080
+ node.degree = sortedEdges.filter((e) => e.source === node.id || e.target === node.id).length;
1081
+ node.backlink_count = sortedEdges.filter((e) => e.target === node.id).length;
1082
+ }
1083
+
1084
+ // Build communities from tags
1085
+ const communityMap = new Map();
1086
+ for (const node of nodes) {
1087
+ for (const tag of node.tags) {
1088
+ if (!communityMap.has(tag)) communityMap.set(tag, []);
1089
+ communityMap.get(tag).push(node.id);
1090
+ }
1091
+ }
1092
+
1093
+ const communities = Array.from(communityMap.entries())
1094
+ .sort(([left], [right]) => compareStrings(left, right))
1095
+ .map(([id, members], index) => ({
1096
+ id,
1097
+ label: id,
1098
+ members: [...new Set(members)].sort(compareStrings),
1099
+ color: ["#0F766E", "#2563EB", "#9333EA", "#B45309", "#0E7490", "#0891B2"][index % 6],
1100
+ }));
1101
+
1102
+ const warnings = [
1103
+ ...normalizeGraphWarnings(options.warnings, visibleNodeIds),
1104
+ ...buildDuplicateLabelWarnings(visibleEntries),
1105
+ ].sort(compareWarnings);
1106
+ const gapHints = buildGapHints(nodes);
1107
+
1108
+ return {
1109
+ version: 1,
1110
+ nodes,
1111
+ edges: sortedEdges,
1112
+ communities,
1113
+ meta: {
1114
+ built_at: deterministicBuiltAt(visibleEntries),
1115
+ file_count: visibleEntries.length,
1116
+ warning_count: warnings.length,
1117
+ warnings,
1118
+ gap_hints: gapHints,
1119
+ },
1120
+ };
1121
+ }
1122
+
1123
+ export function writeGraph(systemDir, graph) {
1124
+ const graphPath = path.join(systemDir, "graph.json");
1125
+ const tmpFile = `${graphPath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
1126
+ fs.writeFileSync(tmpFile, JSON.stringify(graph, null, 2));
1127
+ fs.renameSync(tmpFile, graphPath);
1128
+ }
1129
+
1130
+ // ─── Dedupe Detection ────────────────────────────────────────────────────────
1131
+
1132
+ export function detectDuplicates(entries, wikiRoot) {
1133
+ const titleMap = new Map();
1134
+ const hashMap = new Map();
1135
+
1136
+ for (const entry of [...entries].sort((a, b) => compareStrings(a.path, b.path))) {
1137
+ const normalizedTitle = normalizeLookupLabel(entry.title);
1138
+ if (normalizedTitle) {
1139
+ if (!titleMap.has(normalizedTitle)) titleMap.set(normalizedTitle, []);
1140
+ titleMap.get(normalizedTitle).push(entry.path);
1141
+ }
1142
+
1143
+ // Content hash
1144
+ try {
1145
+ const fullPath = path.join(wikiRoot, entry.path);
1146
+ const content = fs.readFileSync(fullPath, "utf-8");
1147
+ const hash = computeContentHash(content);
1148
+ if (!hashMap.has(hash)) hashMap.set(hash, []);
1149
+ hashMap.get(hash).push(entry.path);
1150
+ } catch {
1151
+ // Skip unreadable
1152
+ }
1153
+ }
1154
+
1155
+ const titleDuplicates = Array.from(titleMap.entries())
1156
+ .filter(([, entryPaths]) => entryPaths.length > 1)
1157
+ .map(([normalizedTitle, entryPaths]) => ({
1158
+ type: "title",
1159
+ normalized_title: normalizedTitle,
1160
+ entries: [...new Set(entryPaths)].sort(compareStrings),
1161
+ }));
1162
+
1163
+ const contentDuplicates = Array.from(hashMap.entries())
1164
+ .filter(([, entryPaths]) => entryPaths.length > 1)
1165
+ .map(([hash, entryPaths]) => ({
1166
+ type: "content",
1167
+ hash,
1168
+ entries: [...new Set(entryPaths)].sort(compareStrings),
1169
+ }));
1170
+
1171
+ return [...titleDuplicates, ...contentDuplicates].sort((a, b) => {
1172
+ if (a.type !== b.type) return compareStrings(a.type, b.type);
1173
+ return compareStrings(a.normalized_title || a.hash, b.normalized_title || b.hash);
1174
+ });
1175
+ }
1176
+
1177
+ // ─── Wiki Stats ──────────────────────────────────────────────────────────────
1178
+
1179
+ export function getWikiStats(wikiRoot) {
1180
+ const index = buildIndex(wikiRoot);
1181
+ const entries = index.entries || [];
1182
+
1183
+ const stats = {
1184
+ total_entries: entries.length,
1185
+ by_category: {},
1186
+ by_status: {},
1187
+ by_source: {},
1188
+ top_tags: [],
1189
+ recent_entries: [],
1190
+ internal_prompt_count: 0,
1191
+ };
1192
+
1193
+ const tagCounts = new Map();
1194
+ const now = Date.now();
1195
+ const oneDayMs = 24 * 60 * 60 * 1000;
1196
+
1197
+ for (const entry of entries) {
1198
+ // Category count
1199
+ const category = entry.category || "unknown";
1200
+ stats.by_category[category] = (stats.by_category[category] || 0) + 1;
1201
+
1202
+ // Status count
1203
+ const status = entry.status || "unknown";
1204
+ stats.by_status[status] = (stats.by_status[status] || 0) + 1;
1205
+
1206
+ // Source count
1207
+ const source = entry.source || "unknown";
1208
+ stats.by_source[source] = (stats.by_source[source] || 0) + 1;
1209
+
1210
+ // Tag counts
1211
+ const tags = Array.isArray(entry.tags) ? entry.tags : [];
1212
+ for (const tag of tags) {
1213
+ const tagStr = String(tag);
1214
+ tagCounts.set(tagStr, (tagCounts.get(tagStr) || 0) + 1);
1215
+ }
1216
+
1217
+ // Recent entries (last 7 days)
1218
+ try {
1219
+ const createdAt = entry.created_at ? new Date(entry.created_at).getTime() : 0;
1220
+ if (createdAt > 0 && (now - createdAt) < 7 * oneDayMs) {
1221
+ stats.recent_entries.push({
1222
+ title: String(entry.title || "").slice(0, 60),
1223
+ path: entry.path,
1224
+ created_at: entry.created_at,
1225
+ });
1226
+ }
1227
+ } catch {
1228
+ // Ignore invalid dates
1229
+ }
1230
+
1231
+ // Internal prompt detection
1232
+ const title = String(entry.title || "").toLowerCase();
1233
+ const summary = String(entry.summary || "").toLowerCase();
1234
+ if (
1235
+ title.includes("[search-mode]") ||
1236
+ title.includes("[analyze-mode]") ||
1237
+ title.includes("maximize search effort") ||
1238
+ title.includes("analysis mode. gather context") ||
1239
+ summary.includes("[search-mode]") ||
1240
+ summary.includes("[analyze-mode]") ||
1241
+ summary.includes("maximize search effort") ||
1242
+ summary.includes("analysis mode. gather context")
1243
+ ) {
1244
+ stats.internal_prompt_count += 1;
1245
+ }
1246
+ }
1247
+
1248
+ // Top 20 tags
1249
+ stats.top_tags = Array.from(tagCounts.entries())
1250
+ .sort((a, b) => b[1] - a[1])
1251
+ .slice(0, 20)
1252
+ .map(([tag, count]) => ({ tag, count }));
1253
+
1254
+ // Sort recent entries by date desc
1255
+ stats.recent_entries.sort((a, b) => {
1256
+ try {
1257
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
1258
+ } catch {
1259
+ return 0;
1260
+ }
1261
+ });
1262
+
1263
+ return stats;
1264
+ }
1265
+
1266
+ // ─── Wiki Explore ────────────────────────────────────────────────────────────
1267
+
1268
+ export function exploreWiki(wikiRoot, query, options = {}) {
1269
+ const { maxResults = 10, excerptLength = 300, sample = false } = options;
1270
+ const index = buildIndex(wikiRoot);
1271
+
1272
+ if (!index || !index.entries || index.entries.length === 0) {
1273
+ return { success: true, entries: [], message: "No entries in wiki." };
1274
+ }
1275
+
1276
+ // Search for matching entries
1277
+ let candidates;
1278
+ if (query && query.trim()) {
1279
+ candidates = search(query, index, Math.min(maxResults * 2, 50));
1280
+ } else {
1281
+ // No query: return recent entries
1282
+ candidates = [...index.entries]
1283
+ .filter((e) => e.status !== "rejected" && e.status !== "superseded" && e.status !== "needs-clarification" && e.status !== "private")
1284
+ .sort((a, b) => {
1285
+ try {
1286
+ return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime();
1287
+ } catch {
1288
+ return 0;
1289
+ }
1290
+ });
1291
+ }
1292
+
1293
+ // Sample mode: shuffle for diversity
1294
+ if (sample) {
1295
+ candidates = candidates.sort(() => Math.random() - 0.5);
1296
+ }
1297
+
1298
+ const results = [];
1299
+ for (const entry of candidates.slice(0, maxResults)) {
1300
+ const fullPath = path.join(wikiRoot, entry.path);
1301
+ let excerpt = "";
1302
+
1303
+ try {
1304
+ const content = fs.readFileSync(fullPath, "utf-8");
1305
+ const { body } = parseFrontmatter(content);
1306
+ excerpt = body.slice(0, excerptLength);
1307
+ if (body.length > excerptLength) excerpt += "...";
1308
+ } catch {
1309
+ excerpt = "(unable to read content)";
1310
+ }
1311
+
1312
+ results.push({
1313
+ title: String(entry.title || "").slice(0, 100),
1314
+ path: entry.path,
1315
+ category: entry.category,
1316
+ status: entry.status,
1317
+ tags: Array.isArray(entry.tags) ? entry.tags.map((t) => String(t)) : [],
1318
+ created_at: entry.created_at,
1319
+ excerpt,
1320
+ });
1321
+ }
1322
+
1323
+ return {
1324
+ success: true,
1325
+ query: query || "(recent entries)",
1326
+ count: results.length,
1327
+ entries: results,
1328
+ };
1329
+ }