total-recall-brain 3.41.3 → 3.41.5

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 (96) hide show
  1. package/bin/total-recall.mjs +12 -2
  2. package/docs/reference/cli-reference.md +18 -0
  3. package/frontend/dist/assets/{index-DFenQQEQ.js → index-WT3gs-x6.js} +75 -70
  4. package/frontend/dist/index.html +1 -1
  5. package/package.json +7 -5
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +2 -2
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
  8. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +48 -0
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +42 -0
  11. package/scaffold/.agent/skills/total-recall/plugins/auth/src/mesh-secrets.mjs +18 -0
  12. package/scaffold/.agent/skills/total-recall/plugins/auth/src/routes/secrets.mjs +5 -2
  13. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-carriers.mjs +76 -0
  14. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-prune.mjs +37 -0
  15. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +53 -17
  16. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
  17. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
  19. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3131 -28
  21. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +6 -2
  22. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3132 -29
  23. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +5 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3164 -73
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -3
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
  27. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/grants.mjs +7 -3
  28. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +61 -38
  29. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/leader-election.mjs +57 -2
  30. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +220 -36
  31. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/server/routes/mesh.mjs +7 -2
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +65 -0
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/BRICK.md +59 -0
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +534 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +139 -0
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +5937 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +26 -0
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +376 -0
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +37 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adapters/dsh.mjs +446 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +23 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +398 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +172 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +82 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +90 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +415 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +36 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/setup.mjs +35 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/tracker.mjs +133 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +233 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/DESIGN.md +32 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +845 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/BRICK.md +64 -0
  54. package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/index.mjs +250 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +619 -41
  56. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +3134 -31
  57. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +9 -3
  58. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +413 -14
  59. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +129 -0
  60. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +502 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +375 -0
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +35 -22
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +1225 -364
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
  71. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3114 -83
  72. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +5 -1
  73. package/src/cli/compact.mjs +108 -0
  74. package/src/cli/config.mjs +3 -0
  75. package/src/cli/connect.mjs +12 -116
  76. package/src/cli/context.mjs +52 -23
  77. package/src/cli/forget.mjs +2 -0
  78. package/src/cli/harness.mjs +0 -1
  79. package/src/cli/plugin/create.mjs +56 -3
  80. package/src/cli/plugin/index.mjs +15 -3
  81. package/src/cli/plugin/monetization.mjs +232 -12
  82. package/src/cli/preflight.mjs +161 -0
  83. package/src/cli/release.mjs +386 -0
  84. package/src/core/cli-commands.mjs +4 -0
  85. package/src/core/context-compactor.mjs +143 -0
  86. package/src/core/context-compiler.mjs +8 -1
  87. package/src/core/default-plugins.mjs +1 -1
  88. package/src/core/meta-harness.mjs +16 -38
  89. package/src/core/plugin-dependencies.mjs +6 -1
  90. package/src/core/preflight-gate.mjs +77 -0
  91. package/src/core/preflight-receipt.mjs +123 -0
  92. package/src/core/runtime.mjs +0 -3
  93. package/src/core/surface.mjs +39 -2
  94. package/src/server/invoke-durability.mjs +39 -0
  95. package/src/server/invoke-write-child.mjs +25 -0
  96. package/src/server/routes/invoke.mjs +7 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Targeted On-Demand Generator for OpenWiki.
3
+ *
4
+ * Generates or regenerates documentation for a specific repository or workspace
5
+ * using the hermetic bundled generator `vendor/openwiki.mjs`.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { spawn } from 'node:child_process';
10
+ import { loadOpenWikiConfig } from './config.mjs';
11
+ import { readProjects, resolveGenerator, resolveKey } from './refresh.mjs';
12
+ import { execOnMeshNode, getMeshNodes } from './mesh-vaults.mjs';
13
+
14
+ export async function generateWiki({
15
+ repo = null,
16
+ targetPath = null,
17
+ outputDir = null,
18
+ focus = 'all',
19
+ force = false,
20
+ dryRun = false,
21
+ node = null,
22
+ onProgress = null,
23
+ } = {}) {
24
+ // If remote mesh node specified, dispatch via mesh
25
+ if (node && node !== 'local' && node !== 'self') {
26
+ const nodes = getMeshNodes();
27
+ const selfNode = nodes.find((n) => n.self);
28
+ const isSelf = selfNode && (selfNode.ip === node || selfNode.hostname.toLowerCase() === node.toLowerCase());
29
+
30
+ if (!isSelf) {
31
+ const escapedRepo = repo ? `--repo="${repo}"` : '';
32
+ const cmd = `total-recall openwiki generate ${escapedRepo} ${force ? '--force' : ''} --json`;
33
+ const meshRes = execOnMeshNode(node, cmd);
34
+ if (meshRes.ok) {
35
+ try {
36
+ return JSON.parse(meshRes.stdout);
37
+ } catch {
38
+ return { ok: true, node, output: meshRes.stdout };
39
+ }
40
+ } else {
41
+ throw new Error(`Mesh generation failed on node ${node}: ${meshRes.stderr || meshRes.error}`);
42
+ }
43
+ }
44
+ }
45
+
46
+ // Resolve target directory
47
+ let resolvedTarget = process.cwd();
48
+ let repoName = path.basename(resolvedTarget);
49
+
50
+ if (repo) {
51
+ let projects = [];
52
+ try {
53
+ projects = readProjects();
54
+ } catch {
55
+ projects = [];
56
+ }
57
+ const found = projects.find((p) => p.name === repo || p.root === repo);
58
+ if (!found) {
59
+ throw new Error(`Project '${repo}' not found in registered projects.`);
60
+ }
61
+ resolvedTarget = found.root;
62
+ repoName = found.name;
63
+ } else if (targetPath) {
64
+ resolvedTarget = path.resolve(process.cwd(), targetPath);
65
+ repoName = path.basename(resolvedTarget);
66
+ }
67
+
68
+ if (!fs.existsSync(resolvedTarget)) {
69
+ throw new Error(`Target path does not exist: ${resolvedTarget}`);
70
+ }
71
+
72
+ const outPath = outputDir ? path.resolve(resolvedTarget, outputDir) : path.join(resolvedTarget, 'openwiki');
73
+
74
+ if (dryRun) {
75
+ return {
76
+ ok: true,
77
+ dryRun: true,
78
+ repoName,
79
+ targetPath: resolvedTarget,
80
+ outputDir: outPath,
81
+ focus,
82
+ message: `[Dry Run] Would generate documentation for ${repoName} at ${outPath}`,
83
+ };
84
+ }
85
+
86
+ const config = loadOpenWikiConfig();
87
+ const { key, from } = resolveKey({ provider: config.provider });
88
+ if (!key) {
89
+ const keyName = config.provider === 'google' ? 'GOOGLE_API_KEY' : 'OPENROUTER_API_KEY';
90
+ throw new Error(`No API credential found for provider '${config.provider}'. Save via: total-recall auth secret set ${keyName} --stdin`);
91
+ }
92
+
93
+ const gen = resolveGenerator();
94
+ const generatorArgs = [...gen.args];
95
+
96
+ // Pass --init or update mode
97
+ const isInit = !fs.existsSync(outPath) || force;
98
+ if (isInit) {
99
+ generatorArgs.push('--init');
100
+ }
101
+
102
+ const env = {
103
+ ...process.env,
104
+ OPENROUTER_API_KEY: config.provider === 'openrouter' ? key : process.env.OPENROUTER_API_KEY,
105
+ GOOGLE_API_KEY: config.provider === 'google' ? key : process.env.GOOGLE_API_KEY,
106
+ OPENWIKI_MODEL: config.model,
107
+ OPENWIKI_TIMEOUT: String(config.timeout_ms || 300000),
108
+ };
109
+
110
+ const startedAt = Date.now();
111
+ if (typeof onProgress === 'function') {
112
+ onProgress(`Starting generator for ${repoName} (${gen.via})...`);
113
+ }
114
+
115
+ return new Promise((resolve, reject) => {
116
+ const child = spawn(gen.command, generatorArgs, {
117
+ cwd: resolvedTarget,
118
+ env,
119
+ stdio: ['ignore', 'pipe', 'pipe'],
120
+ });
121
+
122
+ let stdout = '';
123
+ let stderr = '';
124
+
125
+ child.stdout.on('data', (chunk) => {
126
+ const str = chunk.toString();
127
+ stdout += str;
128
+ if (typeof onProgress === 'function') {
129
+ onProgress(str.trim());
130
+ }
131
+ });
132
+
133
+ child.stderr.on('data', (chunk) => {
134
+ stderr += chunk.toString();
135
+ });
136
+
137
+ child.on('close', (code) => {
138
+ const durationMs = Date.now() - startedAt;
139
+ if (code === 0) {
140
+ resolve({
141
+ ok: true,
142
+ repoName,
143
+ targetPath: resolvedTarget,
144
+ outputDir: outPath,
145
+ generatorVia: gen.via,
146
+ durationMs,
147
+ credential: from,
148
+ stdout: stdout.trim(),
149
+ });
150
+ } else {
151
+ reject(new Error(`Generator exited with code ${code}: ${stderr || stdout || 'Unknown error'}`));
152
+ }
153
+ });
154
+
155
+ child.on('error', (err) => {
156
+ reject(new Error(`Failed to spawn generator: ${err.message}`));
157
+ });
158
+ });
159
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * OpenWiki Knowledge Topology & Graph Extractor.
3
+ *
4
+ * Extracts nodes, edges, cross-document links, and architectural relationships
5
+ * across local workspace, all vaults, or remote mesh nodes. Formats to JSON, Mermaid, or DOT.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { discoverAllHubs, execOnMeshNode } from './mesh-vaults.mjs';
10
+
11
+ /**
12
+ * Builds knowledge graph from markdown hubs.
13
+ */
14
+ export async function buildKnowledgeGraph({
15
+ allVaults = false,
16
+ node = null,
17
+ format = 'json',
18
+ cwd = process.cwd(),
19
+ } = {}) {
20
+ // If remote mesh node specified, dispatch via mesh
21
+ if (node && node !== 'local' && node !== 'self') {
22
+ const meshRes = execOnMeshNode(
23
+ node,
24
+ `total-recall openwiki graph ${allVaults ? '--all-vaults' : ''} --format=${format} --json`
25
+ );
26
+ if (meshRes.ok) {
27
+ try {
28
+ return JSON.parse(meshRes.stdout);
29
+ } catch {
30
+ throw new Error(`Invalid JSON from mesh node ${node}: ${meshRes.stdout}`);
31
+ }
32
+ } else {
33
+ throw new Error(`Mesh graph failed on node ${node}: ${meshRes.stderr || meshRes.error}`);
34
+ }
35
+ }
36
+
37
+ const hubsResult = await discoverAllHubs({ allVaults, cwd });
38
+ const nodes = [];
39
+ const edges = [];
40
+ const nodeMap = new Map();
41
+
42
+ // Create nodes
43
+ for (const hub of hubsResult.hubs) {
44
+ const nodeId = `${hub.repoName}:${hub.fileName.replace(/\.md$/, '')}`;
45
+ const nodeObj = {
46
+ id: nodeId,
47
+ label: hub.title || hub.fileName,
48
+ fileName: hub.fileName,
49
+ repoName: hub.repoName,
50
+ path: hub.path,
51
+ lines: hub.lines,
52
+ size_bytes: hub.size_bytes,
53
+ inDegree: 0,
54
+ outDegree: 0,
55
+ };
56
+ nodes.push(nodeObj);
57
+ nodeMap.set(nodeId, nodeObj);
58
+ // Also index by fileName and title for fuzzy link target resolution
59
+ nodeMap.set(hub.fileName.toLowerCase(), nodeObj);
60
+ nodeMap.set(hub.fileName.replace(/\.md$/, '').toLowerCase(), nodeObj);
61
+ nodeMap.set(hub.title.toLowerCase(), nodeObj);
62
+ }
63
+
64
+ // Extract edges
65
+ for (const hub of hubsResult.hubs) {
66
+ let content;
67
+ try {
68
+ content = fs.readFileSync(hub.path, 'utf8');
69
+ } catch {
70
+ continue;
71
+ }
72
+
73
+ const sourceId = `${hub.repoName}:${hub.fileName.replace(/\.md$/, '')}`;
74
+ const sourceNode = nodeMap.get(sourceId);
75
+
76
+ // 1. Match Markdown links: [Text](target.md) or [Text](./target.md)
77
+ const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
78
+ let match;
79
+ while ((match = mdLinkRegex.exec(content)) !== null) {
80
+ const anchorText = match[1].trim();
81
+ const targetUrl = match[2].trim().split('#')[0]; // strip hash anchor
82
+ if (!targetUrl || targetUrl.startsWith('http://') || targetUrl.startsWith('https://')) continue;
83
+
84
+ const targetFileName = path.basename(targetUrl);
85
+ if (!targetFileName.endsWith('.md')) continue;
86
+
87
+ const targetNode = nodeMap.get(targetFileName.toLowerCase()) ||
88
+ nodeMap.get(targetFileName.replace(/\.md$/, '').toLowerCase());
89
+
90
+ if (targetNode && targetNode.id !== sourceId) {
91
+ edges.push({
92
+ from: sourceId,
93
+ to: targetNode.id,
94
+ label: anchorText.length > 30 ? anchorText.slice(0, 27) + '...' : anchorText,
95
+ type: 'markdown-link',
96
+ });
97
+ if (sourceNode) sourceNode.outDegree++;
98
+ targetNode.inDegree++;
99
+ }
100
+ }
101
+
102
+ // 2. Match Wikilinks: [[Target Page]] or [[Target Page|Anchor]]
103
+ const wikiLinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
104
+ while ((match = wikiLinkRegex.exec(content)) !== null) {
105
+ const targetName = match[1].trim();
106
+ const anchor = (match[2] || targetName).trim();
107
+ const targetNode = nodeMap.get(targetName.toLowerCase()) ||
108
+ nodeMap.get(`${targetName}.md`.toLowerCase());
109
+
110
+ if (targetNode && targetNode.id !== sourceId) {
111
+ edges.push({
112
+ from: sourceId,
113
+ to: targetNode.id,
114
+ label: anchor.length > 30 ? anchor.slice(0, 27) + '...' : anchor,
115
+ type: 'wikilink',
116
+ });
117
+ if (sourceNode) sourceNode.outDegree++;
118
+ targetNode.inDegree++;
119
+ }
120
+ }
121
+ }
122
+
123
+ // Deduplicate edges
124
+ const edgeKeySet = new Set();
125
+ const dedupedEdges = [];
126
+ for (const edge of edges) {
127
+ const key = `${edge.from}->${edge.to}`;
128
+ if (!edgeKeySet.has(key)) {
129
+ edgeKeySet.add(key);
130
+ dedupedEdges.push(edge);
131
+ }
132
+ }
133
+
134
+ // Identify orphans (disconnected nodes)
135
+ const orphans = nodes.filter((n) => n.inDegree === 0 && n.outDegree === 0).map((n) => n.id);
136
+
137
+ const payload = {
138
+ ok: true,
139
+ scope: hubsResult.scope,
140
+ counts: {
141
+ nodes: nodes.length,
142
+ edges: dedupedEdges.length,
143
+ orphans: orphans.length,
144
+ },
145
+ nodes,
146
+ edges: dedupedEdges,
147
+ orphans,
148
+ };
149
+
150
+ if (format === 'mermaid') {
151
+ payload.mermaid = generateMermaidDiagram(nodes, dedupedEdges);
152
+ } else if (format === 'dot') {
153
+ payload.dot = generateDotDiagram(nodes, dedupedEdges);
154
+ }
155
+
156
+ return payload;
157
+ }
158
+
159
+ function sanitizeId(id) {
160
+ return id.replace(/[^a-zA-Z0-9_]/g, '_');
161
+ }
162
+
163
+ export function generateMermaidDiagram(nodes, edges) {
164
+ const lines = ['flowchart TD'];
165
+ for (const node of nodes) {
166
+ const cleanId = sanitizeId(node.id);
167
+ const cleanLabel = (node.label || node.fileName).replace(/["[\]()]/g, '');
168
+ lines.push(` ${cleanId}["${cleanLabel}"]`);
169
+ }
170
+ for (const edge of edges) {
171
+ const fromId = sanitizeId(edge.from);
172
+ const toId = sanitizeId(edge.to);
173
+ if (edge.label && edge.label !== edge.to) {
174
+ const cleanEdgeLabel = edge.label.replace(/["[\]()]/g, '');
175
+ lines.push(` ${fromId} -->|"${cleanEdgeLabel}"| ${toId}`);
176
+ } else {
177
+ lines.push(` ${fromId} --> ${toId}`);
178
+ }
179
+ }
180
+ return lines.join('\n');
181
+ }
182
+
183
+ export function generateDotDiagram(nodes, edges) {
184
+ const lines = ['digraph OpenWikiGraph {', ' rankdir=LR;', ' node [shape=box, style=rounded, fontname="Helvetica"];'];
185
+ for (const node of nodes) {
186
+ const cleanId = sanitizeId(node.id);
187
+ const cleanLabel = (node.label || node.fileName).replace(/"/g, '\\"');
188
+ lines.push(` ${cleanId} [label="${cleanLabel}"];`);
189
+ }
190
+ for (const edge of edges) {
191
+ const fromId = sanitizeId(edge.from);
192
+ const toId = sanitizeId(edge.to);
193
+ lines.push(` ${fromId} -> ${toId};`);
194
+ }
195
+ lines.push('}');
196
+ return lines.join('\n');
197
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * OpenWiki Documentation & Link Hygiene Linter.
3
+ *
4
+ * Scans markdown hubs across workspace or all vaults, detecting:
5
+ * - Broken relative links and dead file targets
6
+ * - Missing or broken wikilinks
7
+ * - Missing title headers or empty pages
8
+ * - Orphaned hubs with zero inbound or outbound links
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { discoverAllHubs } from './mesh-vaults.mjs';
13
+
14
+ export async function lintWikiHubs({
15
+ allVaults = false,
16
+ node = null,
17
+ cwd = process.cwd(),
18
+ } = {}) {
19
+ const hubsResult = await discoverAllHubs({ allVaults, node, cwd });
20
+ const allHubs = hubsResult.hubs;
21
+
22
+ const existingFileSet = new Set(allHubs.map((h) => path.resolve(h.path)));
23
+ const baseNameMap = new Map();
24
+ for (const h of allHubs) {
25
+ baseNameMap.set(h.fileName.toLowerCase(), h.path);
26
+ baseNameMap.set(h.fileName.replace(/\.md$/, '').toLowerCase(), h.path);
27
+ }
28
+
29
+ const issues = [];
30
+ let totalLinks = 0;
31
+ let brokenLinksCount = 0;
32
+ const inboundCount = new Map(allHubs.map((h) => [h.path, 0]));
33
+ const outboundCount = new Map(allHubs.map((h) => [h.path, 0]));
34
+
35
+ for (const hub of allHubs) {
36
+ let content;
37
+ try {
38
+ content = fs.readFileSync(hub.path, 'utf8');
39
+ } catch {
40
+ issues.push({
41
+ type: 'unreadable_file',
42
+ file: hub.path,
43
+ message: 'Could not read markdown file',
44
+ });
45
+ continue;
46
+ }
47
+
48
+ if (!content.trim()) {
49
+ issues.push({
50
+ type: 'empty_hub',
51
+ file: hub.path,
52
+ message: 'Hub markdown file is completely empty',
53
+ });
54
+ continue;
55
+ }
56
+
57
+ // Check title header
58
+ if (!content.match(/^#\s+.+/m)) {
59
+ issues.push({
60
+ type: 'missing_title',
61
+ file: hub.path,
62
+ message: 'Missing level 1 header (# Title)',
63
+ });
64
+ }
65
+
66
+ const hubDir = path.dirname(hub.path);
67
+
68
+ // 1. Check relative markdown links: [Text](target.md)
69
+ const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
70
+ let match;
71
+ while ((match = mdLinkRegex.exec(content)) !== null) {
72
+ const linkText = match[1];
73
+ const rawUrl = match[2].trim().split('#')[0]; // exclude anchor
74
+ if (!rawUrl || rawUrl.startsWith('http://') || rawUrl.startsWith('https://') || rawUrl.startsWith('mailto:')) {
75
+ continue;
76
+ }
77
+
78
+ totalLinks++;
79
+ outboundCount.set(hub.path, (outboundCount.get(hub.path) || 0) + 1);
80
+
81
+ // Resolve relative to hub file directory
82
+ const resolvedTarget = path.resolve(hubDir, rawUrl);
83
+ if (fs.existsSync(resolvedTarget)) {
84
+ inboundCount.set(resolvedTarget, (inboundCount.get(resolvedTarget) || 0) + 1);
85
+ } else {
86
+ // Fallback: check if matches another hub by basename
87
+ const targetBase = path.basename(rawUrl).toLowerCase();
88
+ if (baseNameMap.has(targetBase)) {
89
+ const alternate = baseNameMap.get(targetBase);
90
+ inboundCount.set(alternate, (inboundCount.get(alternate) || 0) + 1);
91
+ } else {
92
+ brokenLinksCount++;
93
+ issues.push({
94
+ type: 'broken_link',
95
+ file: hub.path,
96
+ linkText,
97
+ target: rawUrl,
98
+ message: `Target file not found: ${rawUrl}`,
99
+ });
100
+ }
101
+ }
102
+ }
103
+
104
+ // 2. Check wikilinks: [[Target]]
105
+ const wikiRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
106
+ while ((match = wikiRegex.exec(content)) !== null) {
107
+ const targetName = match[1].trim().toLowerCase();
108
+ totalLinks++;
109
+ outboundCount.set(hub.path, (outboundCount.get(hub.path) || 0) + 1);
110
+
111
+ if (baseNameMap.has(targetName) || baseNameMap.has(`${targetName}.md`)) {
112
+ const targetPath = baseNameMap.get(targetName) || baseNameMap.get(`${targetName}.md`);
113
+ inboundCount.set(targetPath, (inboundCount.get(targetPath) || 0) + 1);
114
+ } else {
115
+ brokenLinksCount++;
116
+ issues.push({
117
+ type: 'broken_wikilink',
118
+ file: hub.path,
119
+ target: match[1].trim(),
120
+ message: `Wikilink target not found: [[${match[1].trim()}]]`,
121
+ });
122
+ }
123
+ }
124
+ }
125
+
126
+ // Detect orphaned pages
127
+ const orphans = [];
128
+ for (const hub of allHubs) {
129
+ const inbound = inboundCount.get(hub.path) || 0;
130
+ const outbound = outboundCount.get(hub.path) || 0;
131
+ if (inbound === 0 && outbound === 0 && allHubs.length > 1) {
132
+ orphans.push({
133
+ file: hub.path,
134
+ title: hub.title,
135
+ });
136
+ }
137
+ }
138
+
139
+ const score = totalLinks === 0
140
+ ? (issues.length === 0 ? 100 : Math.max(50, 100 - issues.length * 10))
141
+ : Math.max(0, Math.round(((totalLinks - brokenLinksCount) / totalLinks) * 100));
142
+
143
+ return {
144
+ ok: issues.length === 0,
145
+ healthScore: score,
146
+ totalHubs: allHubs.length,
147
+ totalLinks,
148
+ brokenLinksCount,
149
+ orphanCount: orphans.length,
150
+ issues,
151
+ orphans,
152
+ };
153
+ }