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.
Files changed (37) hide show
  1. package/frontend/dist/assets/index-CpsWeV4X.css +1 -0
  2. package/frontend/dist/assets/{index-D1goIPjC.js → index-poecbHSl.js} +100 -100
  3. package/frontend/dist/brand/total-recall-lockup-transparent.png +0 -0
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +1 -1
  6. package/frontend/src/App.tsx +15 -81
  7. package/frontend/src/components/DashboardOverview.tsx +164 -0
  8. package/frontend/src/components/brand/BrandMark.tsx +8 -6
  9. package/frontend/src/components/plugins/PluginWorkspace.tsx +112 -57
  10. package/frontend/src/components/workspace/PluginHome.tsx +3 -3
  11. package/frontend/src/index.css +43 -11
  12. package/package.json +1 -1
  13. package/scaffold/.agent/skills/total-recall/plugins/git/package-lock.json +6 -6
  14. package/scaffold/.agent/skills/total-recall/plugins/git/package.json +1 -1
  15. package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +8 -1
  16. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit-memory.mjs +5 -33
  17. package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-fleet-ops.md +16 -0
  18. package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-publish.md +16 -0
  19. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +119 -10
  20. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
  21. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +1 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +52 -4
  23. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +7 -7
  24. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +23 -22
  25. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +20 -33
  26. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +42 -49
  27. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/markdown-links.mjs +112 -0
  28. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +3 -2
  29. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +206 -60
  30. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +2 -1
  31. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +18 -1
  32. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/panel.js +51 -2
  33. package/src/cli/context.mjs +6 -2
  34. package/src/core/context-compactor.mjs +2 -0
  35. package/src/core/dashboard-bundle.mjs +12 -3
  36. package/src/server/routes/vault.mjs +1 -0
  37. 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: 'deepseek/deepseek-chat',
12
- fallback_model: 'deepseek/deepseek-chat',
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: String(config.model || DEFAULT_OPENWIKI_CONFIG.model).trim(),
73
- fallback_model: String(config.fallback_model || DEFAULT_OPENWIKI_CONFIG.fallback_model).trim(),
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
- const generatorArgs = [...gen.args];
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 env = {
104
- ...process.env,
105
- OPENROUTER_API_KEY: config.provider === 'openrouter' ? key : process.env.OPENROUTER_API_KEY,
106
- GOOGLE_API_KEY: config.provider === 'google' ? key : process.env.GOOGLE_API_KEY,
107
- OPENWIKI_MODEL: config.model,
108
- OPENWIKI_TIMEOUT: String(config.timeout_ms || 300000),
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, generatorArgs, {
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
- reject(new Error(`Generator exited with code ${code}: ${stderr || stdout || 'Unknown error'}`));
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 nodeMap = new Map();
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
- nodeMap.set(nodeId, nodeObj);
57
- // Also index by fileName and title for fuzzy link target resolution
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 = nodeMap.get(sourceId);
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: anchorText.length > 30 ? anchorText.slice(0, 27) + '...' : anchorText,
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
- // 2. Match Wikilinks: [[Target Page]] or [[Target Page|Anchor]]
102
- const wikiLinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
103
- while ((match = wikiLinkRegex.exec(content)) !== null) {
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 path from 'node:path';
12
- import { discoverAllHubs } from './mesh-vaults.mjs';
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
- 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);
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 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
- }
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
- // 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
- });
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
- // 2. Check wikilinks: [[Target]]
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
- 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);
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: match[1].trim(),
120
- message: `Wikilink target not found: [[${match[1].trim()}]]`,
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
  }