total-recall-brain 3.41.4 → 3.41.6

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 (147) hide show
  1. package/bin/total-recall.mjs +10 -6
  2. package/docs/reference/cli-reference.md +18 -0
  3. package/frontend/dist/assets/index-4tGQfTa2.js +5605 -0
  4. package/frontend/dist/assets/index-DtwsDyFM.css +1 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/package.json +11 -0
  7. package/frontend/src/App.tsx +47 -122
  8. package/frontend/src/components/plugins/plugin-frame.ts +1 -0
  9. package/frontend/src/index.css +152 -86
  10. package/frontend/src/pages/HelpPage.tsx +13 -10
  11. package/frontend/src/pages/MemoryPage.tsx +28 -30
  12. package/frontend/src/pages/NetworkPage.tsx +3 -3
  13. package/frontend/src/pages/PluginsPage.tsx +6 -5
  14. package/frontend/src/pages/RulesPage.tsx +16 -12
  15. package/package.json +4 -3
  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 +4 -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 +4 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3121 -33
  21. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +4 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3122 -34
  23. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +4 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3127 -49
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -2
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +1 -1
  27. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +136 -7
  28. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +135 -26
  29. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +3121 -33
  30. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +5 -2
  31. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +228 -9
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +24 -30
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +29 -16
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +73 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +182 -365
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +33 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +40 -14
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +4 -4
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/agy.mjs +187 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/claude.mjs +275 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cline.mjs +132 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/codex.mjs +187 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cursor.mjs +158 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/devin.mjs +157 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/dsh.mjs +183 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/grok.mjs +132 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +173 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +87 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/openclaw.mjs +173 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/pi.mjs +129 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate.mjs +12 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/json-reply.mjs +22 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +72 -70
  54. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/model-catalog.mjs +195 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/paths.mjs +11 -0
  56. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +36 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +169 -0
  58. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +136 -346
  59. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +1 -1
  60. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/ssss-store.mjs +20 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/telemetry.mjs +43 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +156 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +117 -35
  64. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +51 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +398 -57
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +304 -46
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +367 -41
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +5 -2
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +33 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +2 -2
  71. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
  74. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
  75. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +151 -4
  76. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
  77. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
  78. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +899 -662
  79. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
  80. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/mermaid-diagrams/SKILL.md +44 -0
  81. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/write-connector/SKILL.md +56 -0
  82. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3097 -81
  83. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +4 -1
  84. package/src/cli/agent.mjs +12 -1
  85. package/src/cli/compact.mjs +108 -0
  86. package/src/cli/connect.mjs +56 -1
  87. package/src/cli/context.mjs +55 -23
  88. package/src/cli/doctor.mjs +5 -2
  89. package/src/cli/forget.mjs +2 -0
  90. package/src/cli/harness.mjs +62 -50
  91. package/src/cli/memory-entry.mjs +2 -2
  92. package/src/cli/preflight.mjs +152 -16
  93. package/src/cli/release.mjs +386 -0
  94. package/src/cli/remember.mjs +19 -1
  95. package/src/cli/rules.mjs +54 -12
  96. package/src/cli/startup.mjs +1 -1
  97. package/src/cli/status.mjs +12 -2
  98. package/src/core/claude-hooks.mjs +41 -3
  99. package/src/core/cli-commands.mjs +2 -0
  100. package/src/core/cli-semantic-index.mjs +3 -2
  101. package/src/core/conflict-detector.mjs +23 -12
  102. package/src/core/context-compactor.mjs +145 -0
  103. package/src/core/context-policy.mjs +20 -0
  104. package/src/core/duplicate-guard.mjs +350 -0
  105. package/src/core/meta-harness.mjs +31 -11
  106. package/src/core/preflight-gate.mjs +2 -2
  107. package/src/core/preflight-receipt.mjs +29 -1
  108. package/src/core/scaffold-plugins.mjs +7 -0
  109. package/src/core/stdio-flush.mjs +23 -0
  110. package/src/core/surface.mjs +33 -10
  111. package/src/server/invoke-durability.mjs +39 -0
  112. package/src/server/invoke-write-child.mjs +25 -0
  113. package/src/server/routes/invoke.mjs +7 -0
  114. package/src/server/routes/plugins.mjs +2 -2
  115. package/frontend/dist/assets/index-BIQmqDKP.css +0 -1
  116. package/frontend/dist/assets/index-CRzjQLu7.js +0 -5631
  117. package/frontend/src/components/DaemonLogsTab.tsx +0 -54
  118. package/frontend/src/components/ResearchAgendaTab.tsx +0 -712
  119. package/frontend/src/components/TaskQueueTab.tsx +0 -193
  120. package/frontend/src/components/VoiceInput.tsx +0 -112
  121. package/frontend/src/pages/ChatPage.tsx +0 -1059
  122. package/frontend/src/pages/CollabPage.tsx +0 -470
  123. package/frontend/src/pages/DesignDocsPage.tsx +0 -621
  124. package/frontend/src/pages/FilesPage.tsx +0 -393
  125. package/frontend/src/pages/HealthPage.tsx +0 -359
  126. package/frontend/src/pages/InboxPage.tsx +0 -83
  127. package/frontend/src/pages/InstructionsPage.tsx +0 -583
  128. package/frontend/src/pages/IntegrationsPage.tsx +0 -761
  129. package/frontend/src/pages/MeshPage.css +0 -58
  130. package/frontend/src/pages/MeshPage.tsx +0 -1219
  131. package/frontend/src/pages/OkfPage.tsx +0 -535
  132. package/frontend/src/pages/SandboxPage.tsx +0 -113
  133. package/frontend/src/pages/SkillsPage.tsx +0 -1994
  134. package/frontend/src/pages/TasksPage.tsx +0 -427
  135. package/frontend/src/pages/UsagePage.tsx +0 -702
  136. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/README.md +0 -65
  137. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/cli.mjs +0 -76
  138. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/package.json +0 -19
  139. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -266
  140. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/adapters/dsh.mjs +0 -446
  141. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/documentation.mjs +0 -152
  142. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/executor.mjs +0 -71
  143. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/runtime.mjs +0 -36
  144. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/setup.mjs +0 -35
  145. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/worker.mjs +0 -175
  146. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +0 -32
  147. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +0 -575
@@ -15,11 +15,14 @@
15
15
  "LICENSE",
16
16
  "ui/",
17
17
  "src/",
18
+ "vendor/",
18
19
  "capability/",
19
20
  "templates/"
20
21
  ],
21
22
  "dependencies": {
22
- "@gregiteen/ssss-cli": "0.14.3",
23
- "openwiki": "^0.7.3"
23
+ "@gregiteen/ssss-cli": "0.14.4"
24
+ },
25
+ "overrides": {
26
+ "@ssss/cli": "npm:@gregiteen/ssss-cli@0.14.4"
24
27
  }
25
28
  }
@@ -10,6 +10,12 @@
10
10
  "intent": "Watch upstream OpenWiki repository and announce/propagate updates",
11
11
  "schedule": "0 */4 * * *",
12
12
  "command": "watch"
13
+ },
14
+ {
15
+ "intent": "Auto-refresh OpenWiki documentation for repositories with new commits",
16
+ "schedule": "*/30 * * * *",
17
+ "command": "auto-refresh",
18
+ "timeout_ms": 600000
13
19
  }
14
20
  ],
15
21
  "branding": {
@@ -228,6 +234,33 @@
228
234
  }
229
235
  ]
230
236
  },
237
+ {
238
+ "name": "auto-refresh",
239
+ "description": "Daemon automated check and regeneration for stale OpenWiki documentation across registered repositories",
240
+ "timeout_ms": 600000,
241
+ "effect": "write",
242
+ "output": "json",
243
+ "args": [
244
+ {
245
+ "name": "repo",
246
+ "flag": "--repo",
247
+ "label": "Target repository",
248
+ "type": "string"
249
+ },
250
+ {
251
+ "name": "force",
252
+ "flag": "--force",
253
+ "label": "Force regeneration",
254
+ "type": "boolean"
255
+ },
256
+ {
257
+ "name": "json",
258
+ "flag": "--json",
259
+ "label": "JSON output",
260
+ "type": "boolean"
261
+ }
262
+ ]
263
+ },
231
264
  {
232
265
  "name": "watch",
233
266
  "description": "Watch upstream OpenWiki repository, announce new releases, and trigger propagation",
@@ -8,8 +8,8 @@ import path from 'node:path';
8
8
 
9
9
  export const DEFAULT_OPENWIKI_CONFIG = Object.freeze({
10
10
  provider: 'openrouter',
11
- model: 'deepseek/deepseek-v4.1-flash',
12
- fallback_model: 'qwen/qwen-2.5-coder-32b-instruct',
11
+ model: 'deepseek/deepseek-chat',
12
+ fallback_model: 'deepseek/deepseek-chat',
13
13
  max_tokens: 4096,
14
14
  timeout_ms: 300000
15
15
  });
@@ -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
+ }