total-recall-brain 3.41.4 → 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.
- package/bin/total-recall.mjs +3 -2
- package/docs/reference/cli-reference.md +18 -0
- package/frontend/dist/assets/{index-CRzjQLu7.js → index-WT3gs-x6.js} +24 -24
- package/frontend/dist/index.html +1 -1
- package/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3121 -33
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3122 -34
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3165 -87
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +3121 -33
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +32 -12
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +294 -46
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +367 -41
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +190 -9
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +151 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +899 -662
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3087 -71
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
- package/src/cli/compact.mjs +108 -0
- package/src/cli/context.mjs +52 -23
- package/src/cli/forget.mjs +2 -0
- package/src/cli/release.mjs +386 -0
- package/src/core/cli-commands.mjs +2 -0
- package/src/core/context-compactor.mjs +143 -0
- package/src/core/preflight-gate.mjs +2 -2
- package/src/core/preflight-receipt.mjs +1 -1
- package/src/core/surface.mjs +1 -1
- package/src/server/invoke-durability.mjs +39 -0
- package/src/server/invoke-write-child.mjs +25 -0
- package/src/server/routes/invoke.mjs +7 -0
|
@@ -31,23 +31,31 @@
|
|
|
31
31
|
}
|
|
32
32
|
],
|
|
33
33
|
"card_face": {
|
|
34
|
-
"headline": "OpenWiki
|
|
34
|
+
"headline": "OpenWiki Mesh Knowledge Deck",
|
|
35
35
|
"quick_actions": [
|
|
36
|
-
{
|
|
37
|
-
"subcommand": "status",
|
|
38
|
-
"label": "STATUS"
|
|
39
|
-
},
|
|
40
36
|
{
|
|
41
37
|
"subcommand": "hubs",
|
|
42
38
|
"label": "HUBS"
|
|
43
39
|
},
|
|
44
40
|
{
|
|
45
|
-
"subcommand": "
|
|
46
|
-
"label": "
|
|
41
|
+
"subcommand": "search",
|
|
42
|
+
"label": "SEARCH"
|
|
47
43
|
},
|
|
48
44
|
{
|
|
49
|
-
"subcommand": "
|
|
50
|
-
"label": "
|
|
45
|
+
"subcommand": "graph",
|
|
46
|
+
"label": "GRAPH"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"subcommand": "generate",
|
|
50
|
+
"label": "GENERATE"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"subcommand": "token",
|
|
54
|
+
"label": "TOKEN"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"subcommand": "lint",
|
|
58
|
+
"label": "HEALTH"
|
|
51
59
|
}
|
|
52
60
|
],
|
|
53
61
|
"touch_layout": "comfortable",
|
|
@@ -324,6 +332,179 @@
|
|
|
324
332
|
"type": "boolean"
|
|
325
333
|
}
|
|
326
334
|
]
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
"name": "search",
|
|
338
|
+
"description": "Evidence passage search across knowledge hubs, all vaults, and mesh nodes",
|
|
339
|
+
"effect": "read",
|
|
340
|
+
"output": "json",
|
|
341
|
+
"args": [
|
|
342
|
+
{
|
|
343
|
+
"name": "query",
|
|
344
|
+
"label": "Search query terms",
|
|
345
|
+
"type": "string",
|
|
346
|
+
"positional": true,
|
|
347
|
+
"required": true
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
"name": "allVaults",
|
|
351
|
+
"flag": "--all-vaults",
|
|
352
|
+
"label": "Search across all registered project vaults and global vault",
|
|
353
|
+
"type": "boolean"
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
"name": "node",
|
|
357
|
+
"flag": "--node",
|
|
358
|
+
"label": "Remote mesh node to query",
|
|
359
|
+
"type": "string"
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
"name": "json",
|
|
363
|
+
"flag": "--json",
|
|
364
|
+
"label": "JSON output",
|
|
365
|
+
"type": "boolean"
|
|
366
|
+
}
|
|
367
|
+
]
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
"name": "graph",
|
|
371
|
+
"description": "Extract knowledge graph topology, inter-hub links, and concept dependencies",
|
|
372
|
+
"effect": "read",
|
|
373
|
+
"output": "json",
|
|
374
|
+
"args": [
|
|
375
|
+
{
|
|
376
|
+
"name": "allVaults",
|
|
377
|
+
"flag": "--all-vaults",
|
|
378
|
+
"label": "Extract topology across all vaults",
|
|
379
|
+
"type": "boolean"
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
"name": "node",
|
|
383
|
+
"flag": "--node",
|
|
384
|
+
"label": "Remote mesh node",
|
|
385
|
+
"type": "string"
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"name": "format",
|
|
389
|
+
"flag": "--format",
|
|
390
|
+
"label": "Output format: json, mermaid, dot",
|
|
391
|
+
"type": "string"
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
"name": "json",
|
|
395
|
+
"flag": "--json",
|
|
396
|
+
"label": "JSON output",
|
|
397
|
+
"type": "boolean"
|
|
398
|
+
}
|
|
399
|
+
]
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
"name": "token",
|
|
403
|
+
"description": "Check or set OpenWiki credentials in Total Recall auth secrets store",
|
|
404
|
+
"effect": "write",
|
|
405
|
+
"output": "json",
|
|
406
|
+
"args": [
|
|
407
|
+
{
|
|
408
|
+
"name": "action",
|
|
409
|
+
"label": "Action: status or set",
|
|
410
|
+
"type": "string",
|
|
411
|
+
"positional": true
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
"name": "provider",
|
|
415
|
+
"label": "Provider (openrouter or google)",
|
|
416
|
+
"type": "string",
|
|
417
|
+
"positional": true
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
"name": "value",
|
|
421
|
+
"label": "API Key token value",
|
|
422
|
+
"type": "string",
|
|
423
|
+
"positional": true
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
"name": "stdin",
|
|
427
|
+
"flag": "--stdin",
|
|
428
|
+
"label": "Read secret from standard input",
|
|
429
|
+
"type": "boolean"
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
"name": "json",
|
|
433
|
+
"flag": "--json",
|
|
434
|
+
"label": "JSON output",
|
|
435
|
+
"type": "boolean"
|
|
436
|
+
}
|
|
437
|
+
]
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
"name": "lint",
|
|
441
|
+
"description": "Validate link integrity, broken references, and orphaned documentation hubs",
|
|
442
|
+
"effect": "read",
|
|
443
|
+
"output": "json",
|
|
444
|
+
"args": [
|
|
445
|
+
{
|
|
446
|
+
"name": "allVaults",
|
|
447
|
+
"flag": "--all-vaults",
|
|
448
|
+
"label": "Lint across all project vaults",
|
|
449
|
+
"type": "boolean"
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
"name": "node",
|
|
453
|
+
"flag": "--node",
|
|
454
|
+
"label": "Remote mesh node",
|
|
455
|
+
"type": "string"
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
"name": "json",
|
|
459
|
+
"flag": "--json",
|
|
460
|
+
"label": "JSON output",
|
|
461
|
+
"type": "boolean"
|
|
462
|
+
}
|
|
463
|
+
]
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
"name": "generate",
|
|
467
|
+
"description": "Targeted on-demand documentation generation for any repository or workspace",
|
|
468
|
+
"effect": "write",
|
|
469
|
+
"output": "json",
|
|
470
|
+
"args": [
|
|
471
|
+
{
|
|
472
|
+
"name": "repo",
|
|
473
|
+
"flag": "--repo",
|
|
474
|
+
"label": "Registered project name or root path",
|
|
475
|
+
"type": "string"
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
"name": "path",
|
|
479
|
+
"flag": "--path",
|
|
480
|
+
"label": "Explicit directory path",
|
|
481
|
+
"type": "string"
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
"name": "force",
|
|
485
|
+
"flag": "--force",
|
|
486
|
+
"label": "Force re-generation",
|
|
487
|
+
"type": "boolean"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
"name": "dryRun",
|
|
491
|
+
"flag": "--dry-run",
|
|
492
|
+
"label": "Preview without writing",
|
|
493
|
+
"type": "boolean"
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
"name": "node",
|
|
497
|
+
"flag": "--node",
|
|
498
|
+
"label": "Remote mesh node",
|
|
499
|
+
"type": "string"
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
"name": "json",
|
|
503
|
+
"flag": "--json",
|
|
504
|
+
"label": "JSON output",
|
|
505
|
+
"type": "boolean"
|
|
506
|
+
}
|
|
507
|
+
]
|
|
327
508
|
}
|
|
328
509
|
]
|
|
329
510
|
},
|
|
@@ -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-
|
|
12
|
-
fallback_model: '
|
|
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
|
+
}
|