total-recall-brain 3.46.1 → 3.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontend/dist/assets/index-CpsWeV4X.css +1 -0
- package/frontend/dist/assets/{index-D1goIPjC.js → index-poecbHSl.js} +100 -100
- package/frontend/dist/brand/total-recall-lockup-transparent.png +0 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +1 -1
- package/frontend/src/App.tsx +15 -81
- package/frontend/src/components/DashboardOverview.tsx +164 -0
- package/frontend/src/components/brand/BrandMark.tsx +8 -6
- package/frontend/src/components/plugins/PluginWorkspace.tsx +112 -57
- package/frontend/src/components/workspace/PluginHome.tsx +3 -3
- package/frontend/src/index.css +43 -11
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/git/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +8 -1
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit-memory.mjs +5 -33
- package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-fleet-ops.md +16 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-publish.md +16 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +119 -10
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +52 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +7 -7
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +23 -22
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +20 -33
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +42 -49
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/markdown-links.mjs +112 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +3 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +206 -60
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +2 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +18 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/panel.js +51 -2
- package/src/cli/context.mjs +6 -2
- package/src/core/context-compactor.mjs +2 -0
- package/src/core/dashboard-bundle.mjs +12 -3
- package/src/server/routes/vault.mjs +1 -0
- package/frontend/dist/assets/index-BRYE1jmv.css +0 -1
|
@@ -6,10 +6,13 @@ import fs from 'node:fs';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
9
|
+
// No model default: models and prices change too often to fix one in code, and a stale
|
|
10
|
+
// default (deepseek/deepseek-chat) cost about 5x a current model of the same family.
|
|
11
|
+
// A refresh with no configured model stops before it calls a provider.
|
|
9
12
|
export const DEFAULT_OPENWIKI_CONFIG = Object.freeze({
|
|
10
13
|
provider: 'openrouter',
|
|
11
|
-
model:
|
|
12
|
-
fallback_model:
|
|
14
|
+
model: null,
|
|
15
|
+
fallback_model: null,
|
|
13
16
|
max_tokens: 4096,
|
|
14
17
|
timeout_ms: 300000
|
|
15
18
|
});
|
|
@@ -69,8 +72,8 @@ export function saveOpenWikiConfig(config, options = {}) {
|
|
|
69
72
|
const filePath = resolveConfigPath(options);
|
|
70
73
|
const validated = {
|
|
71
74
|
provider: String(config.provider || DEFAULT_OPENWIKI_CONFIG.provider).toLowerCase(),
|
|
72
|
-
model:
|
|
73
|
-
fallback_model:
|
|
75
|
+
model: config.model ? String(config.model).trim() || null : null,
|
|
76
|
+
fallback_model: config.fallback_model ? String(config.fallback_model).trim() || null : null,
|
|
74
77
|
max_tokens: Number.isInteger(Number(config.max_tokens)) ? Math.max(512, Math.min(131072, Number(config.max_tokens))) : DEFAULT_OPENWIKI_CONFIG.max_tokens,
|
|
75
78
|
timeout_ms: Number.isInteger(Number(config.timeout_ms)) ? Math.max(30000, Math.min(1800000, Number(config.timeout_ms))) : DEFAULT_OPENWIKI_CONFIG.timeout_ms
|
|
76
79
|
};
|
|
@@ -78,9 +81,6 @@ export function saveOpenWikiConfig(config, options = {}) {
|
|
|
78
81
|
if (!ALLOWED_PROVIDERS.includes(validated.provider)) {
|
|
79
82
|
throw new Error(`Invalid provider: ${validated.provider}. Allowed: ${ALLOWED_PROVIDERS.join(', ')}`);
|
|
80
83
|
}
|
|
81
|
-
if (!validated.model) {
|
|
82
|
-
throw new Error('Model identifier cannot be empty');
|
|
83
|
-
}
|
|
84
84
|
|
|
85
85
|
const dir = path.dirname(filePath);
|
|
86
86
|
if (!fs.existsSync(dir)) {
|
|
@@ -8,7 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { loadOpenWikiConfig } from './config.mjs';
|
|
11
|
-
import { readProjects, resolveGenerator, resolveKey } from './refresh.mjs';
|
|
11
|
+
import { buildGeneratorInvocation, ensureDefaultIgnores, readProjects, redact, resolveGenerator, resolveKey } from './refresh.mjs';
|
|
12
12
|
import { execOnMeshNode, getMeshNodes } from './mesh-vaults.mjs';
|
|
13
13
|
|
|
14
14
|
export async function generateWiki({
|
|
@@ -92,31 +92,30 @@ export async function generateWiki({
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
const gen = resolveGenerator();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// Pass --init or update mode
|
|
98
|
-
const isInit = !fs.existsSync(outPath) || force;
|
|
99
|
-
if (isInit) {
|
|
100
|
-
generatorArgs.push('--init');
|
|
95
|
+
if (!gen.command) {
|
|
96
|
+
throw new Error('No openwiki generator available (missing bundled generator, local .bin, PATH entry, or npx)');
|
|
101
97
|
}
|
|
102
98
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
99
|
+
const mode = (!fs.existsSync(outPath) || force) ? 'init' : 'update';
|
|
100
|
+
const timeoutMs = config.timeout_ms || 300000;
|
|
101
|
+
const { cmdArgs, childEnv } = buildGeneratorInvocation({
|
|
102
|
+
baseArgs: gen.args,
|
|
103
|
+
mode,
|
|
104
|
+
config,
|
|
105
|
+
key,
|
|
106
|
+
});
|
|
107
|
+
ensureDefaultIgnores(resolvedTarget);
|
|
110
108
|
|
|
111
109
|
const startedAt = Date.now();
|
|
112
110
|
if (typeof onProgress === 'function') {
|
|
113
|
-
onProgress(`Starting generator for ${repoName} (${gen.via})...`);
|
|
111
|
+
onProgress(`Starting generator for ${repoName} (${gen.via}, ${mode})...`);
|
|
114
112
|
}
|
|
115
113
|
|
|
116
114
|
return new Promise((resolve, reject) => {
|
|
117
|
-
const child = spawn(gen.command,
|
|
115
|
+
const child = spawn(gen.command, cmdArgs, {
|
|
118
116
|
cwd: resolvedTarget,
|
|
119
|
-
env,
|
|
117
|
+
env: childEnv,
|
|
118
|
+
timeout: timeoutMs,
|
|
120
119
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
121
120
|
});
|
|
122
121
|
|
|
@@ -127,7 +126,7 @@ export async function generateWiki({
|
|
|
127
126
|
const str = chunk.toString();
|
|
128
127
|
stdout += str;
|
|
129
128
|
if (typeof onProgress === 'function') {
|
|
130
|
-
onProgress(str.trim());
|
|
129
|
+
onProgress(redact(str.trim(), key));
|
|
131
130
|
}
|
|
132
131
|
});
|
|
133
132
|
|
|
@@ -135,7 +134,7 @@ export async function generateWiki({
|
|
|
135
134
|
stderr += chunk.toString();
|
|
136
135
|
});
|
|
137
136
|
|
|
138
|
-
child.on('close', (code) => {
|
|
137
|
+
child.on('close', (code, signal) => {
|
|
139
138
|
const durationMs = Date.now() - startedAt;
|
|
140
139
|
if (code === 0) {
|
|
141
140
|
resolve({
|
|
@@ -144,17 +143,19 @@ export async function generateWiki({
|
|
|
144
143
|
targetPath: resolvedTarget,
|
|
145
144
|
outputDir: outPath,
|
|
146
145
|
generatorVia: gen.via,
|
|
146
|
+
mode,
|
|
147
147
|
durationMs,
|
|
148
148
|
credential: from,
|
|
149
|
-
stdout: stdout.trim(),
|
|
149
|
+
stdout: redact(stdout.trim(), key),
|
|
150
150
|
});
|
|
151
151
|
} else {
|
|
152
|
-
|
|
152
|
+
const detail = redact(stderr || stdout || (signal ? `terminated by ${signal}` : 'Unknown error'), key);
|
|
153
|
+
reject(new Error(`Generator exited with code ${code ?? signal ?? 1}: ${detail}`));
|
|
153
154
|
}
|
|
154
155
|
});
|
|
155
156
|
|
|
156
157
|
child.on('error', (err) => {
|
|
157
|
-
reject(new Error(`Failed to spawn generator: ${err.message}`));
|
|
158
|
+
reject(new Error(`Failed to spawn generator: ${redact(err.message, key)}`));
|
|
158
159
|
});
|
|
159
160
|
});
|
|
160
161
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* across local workspace, all vaults, or remote mesh nodes. Formats to JSON, Mermaid, or DOT.
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
-
import path from 'node:path';
|
|
9
8
|
import { discoverAllHubs, execOnMeshNode } from './mesh-vaults.mjs';
|
|
9
|
+
import { extractMarkdownLinks, indexHubsForResolution, resolveLinkTarget } from './markdown-links.mjs';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Builds knowledge graph from markdown hubs.
|
|
@@ -16,6 +16,7 @@ export async function buildKnowledgeGraph({
|
|
|
16
16
|
node = null,
|
|
17
17
|
format = 'json',
|
|
18
18
|
cwd = process.cwd(),
|
|
19
|
+
nested = true,
|
|
19
20
|
} = {}) {
|
|
20
21
|
// If remote mesh node specified, dispatch via mesh
|
|
21
22
|
if (node && node !== 'local' && node !== 'self') {
|
|
@@ -33,10 +34,11 @@ export async function buildKnowledgeGraph({
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
const hubsResult = await discoverAllHubs({ allVaults, cwd });
|
|
37
|
+
const hubsResult = await discoverAllHubs({ allVaults, cwd, nested });
|
|
37
38
|
const nodes = [];
|
|
38
39
|
const edges = [];
|
|
39
|
-
const
|
|
40
|
+
const nodeById = new Map();
|
|
41
|
+
const nodeByPath = new Map();
|
|
40
42
|
|
|
41
43
|
// Create nodes
|
|
42
44
|
for (const hub of hubsResult.hubs) {
|
|
@@ -53,13 +55,12 @@ export async function buildKnowledgeGraph({
|
|
|
53
55
|
outDegree: 0,
|
|
54
56
|
};
|
|
55
57
|
nodes.push(nodeObj);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
nodeMap.set(hub.fileName.toLowerCase(), nodeObj);
|
|
59
|
-
nodeMap.set(hub.fileName.replace(/\.md$/, '').toLowerCase(), nodeObj);
|
|
60
|
-
nodeMap.set(hub.title.toLowerCase(), nodeObj);
|
|
58
|
+
nodeById.set(nodeId, nodeObj);
|
|
59
|
+
nodeByPath.set(hub.path, nodeObj);
|
|
61
60
|
}
|
|
62
61
|
|
|
62
|
+
const index = indexHubsForResolution(hubsResult.hubs);
|
|
63
|
+
|
|
63
64
|
// Extract edges
|
|
64
65
|
for (const hub of hubsResult.hubs) {
|
|
65
66
|
let content;
|
|
@@ -70,27 +71,18 @@ export async function buildKnowledgeGraph({
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
const sourceId = `${hub.repoName}:${hub.fileName.replace(/\.md$/, '')}`;
|
|
73
|
-
const sourceNode =
|
|
74
|
-
|
|
75
|
-
// 1. Match Markdown links: [Text](target.md) or [Text](./target.md)
|
|
76
|
-
const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
77
|
-
let match;
|
|
78
|
-
while ((match = mdLinkRegex.exec(content)) !== null) {
|
|
79
|
-
const anchorText = match[1].trim();
|
|
80
|
-
const targetUrl = match[2].trim().split('#')[0]; // strip hash anchor
|
|
81
|
-
if (!targetUrl || targetUrl.startsWith('http://') || targetUrl.startsWith('https://')) continue;
|
|
82
|
-
|
|
83
|
-
const targetFileName = path.basename(targetUrl);
|
|
84
|
-
if (!targetFileName.endsWith('.md')) continue;
|
|
85
|
-
|
|
86
|
-
const targetNode = nodeMap.get(targetFileName.toLowerCase()) ||
|
|
87
|
-
nodeMap.get(targetFileName.replace(/\.md$/, '').toLowerCase());
|
|
74
|
+
const sourceNode = nodeById.get(sourceId);
|
|
75
|
+
const { markdownLinks, wikilinks } = extractMarkdownLinks(content);
|
|
88
76
|
|
|
77
|
+
for (const link of markdownLinks) {
|
|
78
|
+
if (!link.target.endsWith('.md')) continue;
|
|
79
|
+
const resolved = resolveLinkTarget(hub, link.target, index, { isWikilink: false });
|
|
80
|
+
const targetNode = resolved.hub ? nodeByPath.get(resolved.hub.path) : null;
|
|
89
81
|
if (targetNode && targetNode.id !== sourceId) {
|
|
90
82
|
edges.push({
|
|
91
83
|
from: sourceId,
|
|
92
84
|
to: targetNode.id,
|
|
93
|
-
label:
|
|
85
|
+
label: link.text.length > 30 ? link.text.slice(0, 27) + '...' : link.text,
|
|
94
86
|
type: 'markdown-link',
|
|
95
87
|
});
|
|
96
88
|
if (sourceNode) sourceNode.outDegree++;
|
|
@@ -98,19 +90,14 @@ export async function buildKnowledgeGraph({
|
|
|
98
90
|
}
|
|
99
91
|
}
|
|
100
92
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const targetName = match[1].trim();
|
|
105
|
-
const anchor = (match[2] || targetName).trim();
|
|
106
|
-
const targetNode = nodeMap.get(targetName.toLowerCase()) ||
|
|
107
|
-
nodeMap.get(`${targetName}.md`.toLowerCase());
|
|
108
|
-
|
|
93
|
+
for (const link of wikilinks) {
|
|
94
|
+
const resolved = resolveLinkTarget(hub, link.target, index, { isWikilink: true });
|
|
95
|
+
const targetNode = resolved.hub ? nodeByPath.get(resolved.hub.path) : null;
|
|
109
96
|
if (targetNode && targetNode.id !== sourceId) {
|
|
110
97
|
edges.push({
|
|
111
98
|
from: sourceId,
|
|
112
99
|
to: targetNode.id,
|
|
113
|
-
label: anchor.length > 30 ? anchor.slice(0, 27) + '...' : anchor,
|
|
100
|
+
label: link.anchor.length > 30 ? link.anchor.slice(0, 27) + '...' : link.anchor,
|
|
114
101
|
type: 'wikilink',
|
|
115
102
|
});
|
|
116
103
|
if (sourceNode) sourceNode.outDegree++;
|
|
@@ -8,24 +8,34 @@
|
|
|
8
8
|
* - Orphaned hubs with zero inbound or outbound links
|
|
9
9
|
*/
|
|
10
10
|
import fs from 'node:fs';
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
11
|
+
import { discoverAllHubs, execOnMeshNode } from './mesh-vaults.mjs';
|
|
12
|
+
import { extractMarkdownLinks, indexHubsForResolution, resolveLinkTarget } from './markdown-links.mjs';
|
|
13
13
|
|
|
14
14
|
export async function lintWikiHubs({
|
|
15
15
|
allVaults = false,
|
|
16
16
|
node = null,
|
|
17
17
|
cwd = process.cwd(),
|
|
18
|
+
nested = true,
|
|
18
19
|
} = {}) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
// Delegate to remote mesh node when requested, matching search and graph.
|
|
21
|
+
if (node && node !== 'local' && node !== 'self') {
|
|
22
|
+
const meshRes = execOnMeshNode(node, [
|
|
23
|
+
'lint', ...(allVaults ? ['--all-vaults'] : []), '--json',
|
|
24
|
+
]);
|
|
25
|
+
if (meshRes.ok) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(meshRes.stdout);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`Invalid JSON from mesh node ${node}: ${meshRes.stdout}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Mesh lint failed on node ${node}: ${meshRes.stderr || meshRes.error}`);
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
const hubsResult = await discoverAllHubs({ allVaults, cwd, nested });
|
|
36
|
+
const allHubs = hubsResult.hubs;
|
|
37
|
+
const index = indexHubsForResolution(allHubs);
|
|
38
|
+
|
|
29
39
|
const issues = [];
|
|
30
40
|
let totalLinks = 0;
|
|
31
41
|
let brokenLinksCount = 0;
|
|
@@ -63,61 +73,44 @@ export async function lintWikiHubs({
|
|
|
63
73
|
});
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
const
|
|
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
|
-
}
|
|
76
|
+
const { markdownLinks, wikilinks } = extractMarkdownLinks(content);
|
|
77
77
|
|
|
78
|
+
for (const link of markdownLinks) {
|
|
78
79
|
totalLinks++;
|
|
79
80
|
outboundCount.set(hub.path, (outboundCount.get(hub.path) || 0) + 1);
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
});
|
|
82
|
+
const resolved = resolveLinkTarget(hub, link.target, index, { isWikilink: false });
|
|
83
|
+
if (resolved.existsOnDisk) {
|
|
84
|
+
const targetPath = resolved.hub?.path || resolved.resolvedPath;
|
|
85
|
+
if (targetPath) {
|
|
86
|
+
inboundCount.set(targetPath, (inboundCount.get(targetPath) || 0) + 1);
|
|
100
87
|
}
|
|
88
|
+
} else {
|
|
89
|
+
brokenLinksCount++;
|
|
90
|
+
issues.push({
|
|
91
|
+
type: 'broken_link',
|
|
92
|
+
file: hub.path,
|
|
93
|
+
linkText: link.text,
|
|
94
|
+
target: link.target,
|
|
95
|
+
message: `Target file not found: ${link.target}`,
|
|
96
|
+
});
|
|
101
97
|
}
|
|
102
98
|
}
|
|
103
99
|
|
|
104
|
-
|
|
105
|
-
const wikiRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
|
106
|
-
while ((match = wikiRegex.exec(content)) !== null) {
|
|
107
|
-
const targetName = match[1].trim().toLowerCase();
|
|
100
|
+
for (const link of wikilinks) {
|
|
108
101
|
totalLinks++;
|
|
109
102
|
outboundCount.set(hub.path, (outboundCount.get(hub.path) || 0) + 1);
|
|
110
103
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
inboundCount.set(
|
|
104
|
+
const resolved = resolveLinkTarget(hub, link.target, index, { isWikilink: true });
|
|
105
|
+
if (resolved.existsOnDisk && resolved.hub) {
|
|
106
|
+
inboundCount.set(resolved.hub.path, (inboundCount.get(resolved.hub.path) || 0) + 1);
|
|
114
107
|
} else {
|
|
115
108
|
brokenLinksCount++;
|
|
116
109
|
issues.push({
|
|
117
110
|
type: 'broken_wikilink',
|
|
118
111
|
file: hub.path,
|
|
119
|
-
target:
|
|
120
|
-
message: `Wikilink target not found: [[${
|
|
112
|
+
target: link.target,
|
|
113
|
+
message: `Wikilink target not found: [[${link.target}]]`,
|
|
121
114
|
});
|
|
122
115
|
}
|
|
123
116
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Markdown link extraction and hub target resolution for OpenWiki.
|
|
3
|
+
*
|
|
4
|
+
* Strips fenced code blocks and inline code spans so code examples never create
|
|
5
|
+
* false-positive broken links or graph edges, and resolves targets with
|
|
6
|
+
* same-file-directory and same-repository priority before global fallback.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Remove fenced code blocks (``` / ~~~) and inline code spans (`...`).
|
|
13
|
+
*/
|
|
14
|
+
export function stripMarkdownCode(content) {
|
|
15
|
+
const withoutFences = String(content || '')
|
|
16
|
+
.replace(/^([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1\2[ \t]*$/gm, '');
|
|
17
|
+
return withoutFences.replace(/`[^`\n]+`/g, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract Markdown links and Wikilinks from prose (excluding code blocks/spans).
|
|
22
|
+
*/
|
|
23
|
+
export function extractMarkdownLinks(content) {
|
|
24
|
+
const clean = stripMarkdownCode(content);
|
|
25
|
+
const markdownLinks = [];
|
|
26
|
+
const wikilinks = [];
|
|
27
|
+
|
|
28
|
+
const mdLinkRegex = /(?<!!)\[([^\]]+)\]\(([^)]+)\)/g;
|
|
29
|
+
let match;
|
|
30
|
+
while ((match = mdLinkRegex.exec(clean)) !== null) {
|
|
31
|
+
const text = match[1].trim();
|
|
32
|
+
const rawUrl = match[2].trim().split('#')[0].trim();
|
|
33
|
+
if (!rawUrl || /^(https?:|mailto:|tel:|data:|#)/i.test(rawUrl)) continue;
|
|
34
|
+
markdownLinks.push({ text, target: rawUrl });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const wikiRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
38
|
+
while ((match = wikiRegex.exec(clean)) !== null) {
|
|
39
|
+
const target = match[1].trim();
|
|
40
|
+
const anchor = (match[2] || target).trim();
|
|
41
|
+
if (target) wikilinks.push({ target, anchor });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { markdownLinks, wikilinks };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a multi-level lookup index for hubs so same-repo targets beat
|
|
49
|
+
* cross-repo basename collisions.
|
|
50
|
+
*/
|
|
51
|
+
export function indexHubsForResolution(hubs) {
|
|
52
|
+
const byPath = new Map();
|
|
53
|
+
const byRepoKey = new Map();
|
|
54
|
+
const byGlobalKey = new Map();
|
|
55
|
+
|
|
56
|
+
for (const hub of hubs) {
|
|
57
|
+
const resolved = path.resolve(hub.path);
|
|
58
|
+
byPath.set(resolved, hub);
|
|
59
|
+
|
|
60
|
+
const relLower = hub.fileName.toLowerCase();
|
|
61
|
+
const relNoExt = relLower.replace(/\.md$/i, '');
|
|
62
|
+
const baseLower = path.basename(hub.fileName).toLowerCase();
|
|
63
|
+
const baseNoExt = baseLower.replace(/\.md$/i, '');
|
|
64
|
+
const titleLower = (hub.title || '').toLowerCase();
|
|
65
|
+
|
|
66
|
+
const keys = [relLower, relNoExt, baseLower, baseNoExt, titleLower].filter(Boolean);
|
|
67
|
+
for (const k of keys) {
|
|
68
|
+
const repoKey = `${hub.repoName}:${k}`;
|
|
69
|
+
if (!byRepoKey.has(repoKey)) byRepoKey.set(repoKey, hub);
|
|
70
|
+
if (!byGlobalKey.has(k)) byGlobalKey.set(k, hub);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { byPath, byRepoKey, byGlobalKey };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a Markdown link or wikilink target from `sourceHub` using `index`.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveLinkTarget(sourceHub, rawTarget, index, { isWikilink = false } = {}) {
|
|
81
|
+
const sourceDir = path.dirname(sourceHub.path);
|
|
82
|
+
|
|
83
|
+
if (!isWikilink) {
|
|
84
|
+
const resolved = path.resolve(sourceDir, rawTarget);
|
|
85
|
+
if (index.byPath.has(resolved)) {
|
|
86
|
+
return { hub: index.byPath.get(resolved), existsOnDisk: true };
|
|
87
|
+
}
|
|
88
|
+
if (fs.existsSync(resolved)) {
|
|
89
|
+
return { hub: null, existsOnDisk: true, resolvedPath: resolved };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const normalized = rawTarget.replace(/^\.\//, '').toLowerCase();
|
|
94
|
+
const noExt = normalized.replace(/\.md$/i, '');
|
|
95
|
+
const base = path.basename(normalized);
|
|
96
|
+
const baseNoExt = base.replace(/\.md$/i, '');
|
|
97
|
+
const candidates = isWikilink
|
|
98
|
+
? [normalized, `${normalized}.md`, base, `${base}.md`]
|
|
99
|
+
: [normalized, noExt, base, baseNoExt];
|
|
100
|
+
|
|
101
|
+
for (const key of candidates) {
|
|
102
|
+
const repoMatch = index.byRepoKey.get(`${sourceHub.repoName}:${key}`);
|
|
103
|
+
if (repoMatch) return { hub: repoMatch, existsOnDisk: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const key of candidates) {
|
|
107
|
+
const globalMatch = index.byGlobalKey.get(key);
|
|
108
|
+
if (globalMatch) return { hub: globalMatch, existsOnDisk: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { hub: null, existsOnDisk: false };
|
|
112
|
+
}
|
|
@@ -257,16 +257,17 @@ export async function discoverAllHubs({
|
|
|
257
257
|
const projHubs = scanDirectoryHubs(vault.openwikiPath, {
|
|
258
258
|
label: 'project-openwiki',
|
|
259
259
|
repoName: vault.name,
|
|
260
|
+
nested,
|
|
260
261
|
});
|
|
261
262
|
allHubs.push(...projHubs);
|
|
262
263
|
} else if (vault.type === 'global') {
|
|
263
264
|
const gHubsDir = path.join(vault.root, 'openwiki-hubs');
|
|
264
265
|
const gArticlesDir = path.join(vault.root, 'openwiki-articles');
|
|
265
266
|
if (fs.existsSync(gHubsDir)) {
|
|
266
|
-
allHubs.push(...scanDirectoryHubs(gHubsDir, { label: 'global-vault-hubs', repoName: 'global-vault' }));
|
|
267
|
+
allHubs.push(...scanDirectoryHubs(gHubsDir, { label: 'global-vault-hubs', repoName: 'global-vault', nested }));
|
|
267
268
|
}
|
|
268
269
|
if (fs.existsSync(gArticlesDir)) {
|
|
269
|
-
allHubs.push(...scanDirectoryHubs(gArticlesDir, { label: 'global-vault-articles', repoName: 'global-vault' }));
|
|
270
|
+
allHubs.push(...scanDirectoryHubs(gArticlesDir, { label: 'global-vault-articles', repoName: 'global-vault', nested }));
|
|
270
271
|
}
|
|
271
272
|
}
|
|
272
273
|
}
|