@geminilight/mindos 0.6.32 → 0.6.33
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/app/app/api/ask/route.ts +69 -29
- package/app/app/api/graph/route.ts +5 -76
- package/app/app/trash/page.tsx +1 -0
- package/app/app/view/[...path]/ViewPageClient.tsx +22 -8
- package/app/components/ExportModal.tsx +2 -2
- package/app/components/FileTree.tsx +26 -5
- package/app/components/HomeContent.tsx +4 -0
- package/app/components/SystemPulse.tsx +318 -0
- package/app/components/TrashPageClient.tsx +9 -9
- package/app/components/agents/AgentsSkillsSection.tsx +173 -102
- package/app/components/ui/Toaster.tsx +11 -2
- package/app/lib/actions.ts +20 -9
- package/app/lib/agent/context.ts +22 -11
- package/app/lib/agent/loop-detection.ts +52 -0
- package/app/lib/agent/retry.ts +19 -0
- package/app/lib/core/backlinks.ts +33 -9
- package/app/lib/core/index.ts +4 -1
- package/app/lib/core/link-index.ts +224 -0
- package/app/lib/core/search-index.ts +310 -14
- package/app/lib/core/search.ts +180 -29
- package/app/lib/fs.ts +67 -10
- package/app/lib/hooks/usePinnedFiles.ts +7 -2
- package/app/lib/i18n/modules/knowledge.ts +62 -0
- package/app/lib/toast.ts +7 -1
- package/app/next-env.d.ts +1 -1
- package/app/package.json +2 -0
- package/package.json +1 -1
- package/scripts/parse-syncinclude.sh +92 -0
- package/scripts/write-build-stamp.js +40 -0
|
@@ -24,11 +24,20 @@ export default function Toaster() {
|
|
|
24
24
|
>
|
|
25
25
|
{icons[t.type]}
|
|
26
26
|
<span className="text-sm text-foreground flex-1 truncate">{t.message}</span>
|
|
27
|
+
{t.action && (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={() => { t.action!.onClick(); dismiss(t.id); }}
|
|
31
|
+
className="shrink-0 px-2.5 py-1 rounded text-xs font-medium text-[var(--amber)] hover:bg-[var(--amber-dim)] transition-colors min-h-[28px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
32
|
+
>
|
|
33
|
+
{t.action.label}
|
|
34
|
+
</button>
|
|
35
|
+
)}
|
|
27
36
|
<button
|
|
28
37
|
type="button"
|
|
29
38
|
onClick={() => dismiss(t.id)}
|
|
30
|
-
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
|
|
31
|
-
aria-label=
|
|
39
|
+
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
40
|
+
aria-label={t.action?.label ? 'Close' : 'Dismiss'}
|
|
32
41
|
>
|
|
33
42
|
<X size={13} />
|
|
34
43
|
</button>
|
package/app/lib/actions.ts
CHANGED
|
@@ -24,12 +24,12 @@ export async function createFileAction(dirPath: string, fileName: string): Promi
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export async function deleteFileAction(filePath: string): Promise<{ success: boolean; error?: string }> {
|
|
27
|
+
export async function deleteFileAction(filePath: string): Promise<{ success: boolean; trashId?: string; error?: string }> {
|
|
28
28
|
try {
|
|
29
|
-
moveToTrash(getMindRoot(), filePath);
|
|
29
|
+
const meta = moveToTrash(getMindRoot(), filePath);
|
|
30
30
|
invalidateCache();
|
|
31
31
|
revalidatePath('/', 'layout');
|
|
32
|
-
return { success: true };
|
|
32
|
+
return { success: true, trashId: meta.id };
|
|
33
33
|
} catch (err) {
|
|
34
34
|
return { success: false, error: err instanceof Error ? err.message : 'Failed to delete file' };
|
|
35
35
|
}
|
|
@@ -59,12 +59,12 @@ export async function convertToSpaceAction(
|
|
|
59
59
|
|
|
60
60
|
export async function deleteFolderAction(
|
|
61
61
|
dirPath: string,
|
|
62
|
-
): Promise<{ success: boolean; error?: string }> {
|
|
62
|
+
): Promise<{ success: boolean; trashId?: string; error?: string }> {
|
|
63
63
|
try {
|
|
64
|
-
moveToTrash(getMindRoot(), dirPath);
|
|
64
|
+
const meta = moveToTrash(getMindRoot(), dirPath);
|
|
65
65
|
invalidateCache();
|
|
66
66
|
revalidatePath('/', 'layout');
|
|
67
|
-
return { success: true };
|
|
67
|
+
return { success: true, trashId: meta.id };
|
|
68
68
|
} catch (err) {
|
|
69
69
|
return { success: false, error: err instanceof Error ? err.message : 'Failed to delete folder' };
|
|
70
70
|
}
|
|
@@ -85,17 +85,28 @@ export async function renameSpaceAction(
|
|
|
85
85
|
|
|
86
86
|
export async function deleteSpaceAction(
|
|
87
87
|
spacePath: string,
|
|
88
|
-
): Promise<{ success: boolean; error?: string }> {
|
|
88
|
+
): Promise<{ success: boolean; trashId?: string; error?: string }> {
|
|
89
89
|
try {
|
|
90
|
-
moveToTrash(getMindRoot(), spacePath);
|
|
90
|
+
const meta = moveToTrash(getMindRoot(), spacePath);
|
|
91
91
|
invalidateCache();
|
|
92
92
|
revalidatePath('/', 'layout');
|
|
93
|
-
return { success: true };
|
|
93
|
+
return { success: true, trashId: meta.id };
|
|
94
94
|
} catch (err) {
|
|
95
95
|
return { success: false, error: err instanceof Error ? err.message : 'Failed to delete space' };
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
export async function undoDeleteAction(trashId: string): Promise<{ success: boolean; error?: string }> {
|
|
100
|
+
try {
|
|
101
|
+
restoreFromTrash(getMindRoot(), trashId, false);
|
|
102
|
+
invalidateCache();
|
|
103
|
+
revalidatePath('/', 'layout');
|
|
104
|
+
return { success: true };
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return { success: false, error: err instanceof Error ? err.message : 'Undo failed' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
/**
|
|
100
111
|
* Create a new Mind Space (top-level directory) with README.md + auto-scaffolded INSTRUCTION.md.
|
|
101
112
|
* The description is written into README.md so it appears on the homepage Space card
|
package/app/lib/agent/context.ts
CHANGED
|
@@ -9,21 +9,37 @@ import type { AgentMessage } from '@mariozechner/pi-agent-core';
|
|
|
9
9
|
import type { ToolResultMessage, AssistantMessage, UserMessage } from '@mariozechner/pi-ai';
|
|
10
10
|
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
|
-
// Token estimation (1
|
|
12
|
+
// Token estimation — CJK-aware (CJK ~1.5 tokens/char, ASCII ~0.25 tokens/char)
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
+
/** CJK character ranges: Han, Hiragana, Katakana, Hangul */
|
|
16
|
+
const CJK_RANGE = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Estimate token count for a string using character-class heuristics.
|
|
20
|
+
* - CJK characters: ~1.5 tokens per character (measured against cl100k_base)
|
|
21
|
+
* - ASCII/Latin: ~0.25 tokens per character (1 token ≈ 4 chars)
|
|
22
|
+
* This is 3-4x more accurate than naive length/4 for mixed CJK/English text.
|
|
23
|
+
*/
|
|
24
|
+
export function estimateStringTokens(text: string): number {
|
|
25
|
+
const cjkMatches = text.match(CJK_RANGE);
|
|
26
|
+
const cjkCount = cjkMatches ? cjkMatches.length : 0;
|
|
27
|
+
const nonCjkCount = text.length - cjkCount;
|
|
28
|
+
return Math.ceil(cjkCount * 1.5 + nonCjkCount / 4);
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
/** Rough token count for a single AgentMessage */
|
|
16
32
|
function messageTokens(msg: AgentMessage): number {
|
|
17
33
|
if ('content' in msg) {
|
|
18
34
|
const content = (msg as any).content;
|
|
19
|
-
if (typeof content === 'string') return
|
|
35
|
+
if (typeof content === 'string') return estimateStringTokens(content);
|
|
20
36
|
if (Array.isArray(content)) {
|
|
21
|
-
let
|
|
37
|
+
let tokens = 0;
|
|
22
38
|
for (const part of content) {
|
|
23
|
-
if ('text' in part && typeof part.text === 'string')
|
|
24
|
-
if ('args' in part)
|
|
39
|
+
if ('text' in part && typeof part.text === 'string') tokens += estimateStringTokens(part.text);
|
|
40
|
+
if ('args' in part) tokens += estimateStringTokens(JSON.stringify(part.args));
|
|
25
41
|
}
|
|
26
|
-
return
|
|
42
|
+
return tokens;
|
|
27
43
|
}
|
|
28
44
|
}
|
|
29
45
|
return 0;
|
|
@@ -36,11 +52,6 @@ export function estimateTokens(messages: AgentMessage[]): number {
|
|
|
36
52
|
return total;
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
/** Estimate tokens for a plain string (e.g. system prompt) */
|
|
40
|
-
export function estimateStringTokens(text: string): number {
|
|
41
|
-
return Math.ceil(text.length / 4);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
55
|
// ---------------------------------------------------------------------------
|
|
45
56
|
// Context limits by model family
|
|
46
57
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect loops in agent tool call history.
|
|
3
|
+
*
|
|
4
|
+
* Two strategies:
|
|
5
|
+
* 1. Identical repetition: same tool+args N times in a row
|
|
6
|
+
* 2. Pattern cycle: tool sequence repeats (A→B→A→B, A→B→C→A→B→C)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface StepEntry {
|
|
10
|
+
tool: string;
|
|
11
|
+
input: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if the step history contains a repeating loop.
|
|
16
|
+
* Returns true if a loop is detected.
|
|
17
|
+
*/
|
|
18
|
+
export function detectLoop(history: StepEntry[], threshold = 3): boolean {
|
|
19
|
+
if (history.length < threshold) return false;
|
|
20
|
+
|
|
21
|
+
// Check 1: identical tool+args repeated `threshold` times
|
|
22
|
+
const lastN = history.slice(-threshold);
|
|
23
|
+
if (lastN.every(s => s.tool === lastN[0].tool && s.input === lastN[0].input)) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check 2: pattern cycle detection (e.g. A→B→A→B or A→B→C→A→B→C)
|
|
28
|
+
// Compares tool name only (not args) to catch cycles where the same
|
|
29
|
+
// tools are called in the same order but with slightly varied args.
|
|
30
|
+
// To reduce false positives, we require the cycle to repeat at least
|
|
31
|
+
// twice AND at least one pair in the cycle must share identical args.
|
|
32
|
+
if (history.length >= 4) {
|
|
33
|
+
const window = history.slice(-8);
|
|
34
|
+
for (let cycleLen = 2; cycleLen <= 4 && cycleLen * 2 <= window.length; cycleLen++) {
|
|
35
|
+
const tail = window.slice(-cycleLen * 2);
|
|
36
|
+
let toolsMatch = true;
|
|
37
|
+
let anyArgsMatch = false;
|
|
38
|
+
for (let i = 0; i < cycleLen; i++) {
|
|
39
|
+
if (tail[i].tool !== tail[i + cycleLen].tool) {
|
|
40
|
+
toolsMatch = false;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
if (tail[i].input === tail[i + cycleLen].input) {
|
|
44
|
+
anyArgsMatch = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (toolsMatch && anyArgsMatch) return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect transient errors that are safe to retry: timeouts, rate limits (429),
|
|
3
|
+
* and server errors (5xx). Auth errors (401/403), bad requests (400), and
|
|
4
|
+
* content policy violations should NOT be retried.
|
|
5
|
+
*/
|
|
6
|
+
export function isTransientError(err: Error): boolean {
|
|
7
|
+
const msg = err.message.toLowerCase();
|
|
8
|
+
// Timeout patterns
|
|
9
|
+
if (msg.includes('timeout') || msg.includes('timed out') || msg.includes('etimedout')) return true;
|
|
10
|
+
// Rate limiting
|
|
11
|
+
if (msg.includes('429') || msg.includes('rate limit') || msg.includes('too many requests')) return true;
|
|
12
|
+
// Server errors (5xx)
|
|
13
|
+
if (/\b5\d{2}\b/.test(msg) || msg.includes('internal server error') || msg.includes('service unavailable')) return true;
|
|
14
|
+
// Connection errors
|
|
15
|
+
if (msg.includes('econnreset') || msg.includes('econnrefused') || msg.includes('socket hang up')) return true;
|
|
16
|
+
// Overloaded
|
|
17
|
+
if (msg.includes('overloaded') || msg.includes('capacity')) return true;
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
@@ -6,9 +6,24 @@ import type { BacklinkEntry } from './types';
|
|
|
6
6
|
/**
|
|
7
7
|
* Finds files that reference the given targetPath via wikilinks,
|
|
8
8
|
* markdown links, or backtick references.
|
|
9
|
+
*
|
|
10
|
+
* Uses the pre-built LinkIndex for O(1) source lookup, then scans
|
|
11
|
+
* only the matching files for line-level context. This reduces the
|
|
12
|
+
* scanning cost from O(all-files * lines * patterns) to
|
|
13
|
+
* O(linking-files * lines * patterns).
|
|
14
|
+
*
|
|
15
|
+
* @param mindRoot - absolute path to MIND_ROOT
|
|
16
|
+
* @param targetPath - relative file path to find backlinks for
|
|
17
|
+
* @param linkingSources - optional pre-computed list of files that link to targetPath
|
|
18
|
+
* (from LinkIndex.getBacklinks). If omitted, falls back to full scan.
|
|
19
|
+
* @param cachedFiles - optional full file list (legacy, used when linkingSources not available)
|
|
9
20
|
*/
|
|
10
|
-
export function findBacklinks(
|
|
11
|
-
|
|
21
|
+
export function findBacklinks(
|
|
22
|
+
mindRoot: string,
|
|
23
|
+
targetPath: string,
|
|
24
|
+
linkingSources?: string[],
|
|
25
|
+
cachedFiles?: string[],
|
|
26
|
+
): BacklinkEntry[] {
|
|
12
27
|
const results: BacklinkEntry[] = [];
|
|
13
28
|
const bname = path.basename(targetPath, '.md');
|
|
14
29
|
const escapedTarget = targetPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -22,25 +37,34 @@ export function findBacklinks(mindRoot: string, targetPath: string, cachedFiles?
|
|
|
22
37
|
new RegExp('`' + escapedTarget.replace(/\//g, '\\/') + '`'),
|
|
23
38
|
];
|
|
24
39
|
|
|
25
|
-
|
|
40
|
+
// Use linkingSources from LinkIndex (O(1) lookup) when available,
|
|
41
|
+
// otherwise fall back to scanning all .md files
|
|
42
|
+
let filesToScan: string[];
|
|
43
|
+
if (linkingSources) {
|
|
44
|
+
filesToScan = linkingSources;
|
|
45
|
+
} else if (cachedFiles) {
|
|
46
|
+
filesToScan = cachedFiles.filter(f => f.endsWith('.md') && f !== targetPath);
|
|
47
|
+
} else {
|
|
48
|
+
filesToScan = collectAllFiles(mindRoot).filter(f => f.endsWith('.md') && f !== targetPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const filePath of filesToScan) {
|
|
52
|
+
if (filePath === targetPath) continue;
|
|
26
53
|
let content: string;
|
|
27
54
|
try { content = readFile(mindRoot, filePath); } catch { continue; }
|
|
28
55
|
const lines = content.split('\n');
|
|
29
56
|
for (let i = 0; i < lines.length; i++) {
|
|
30
57
|
if (patterns.some(p => p.test(lines[i]))) {
|
|
31
|
-
// Expand to a slightly larger context block for agent comprehension
|
|
32
|
-
// Attempt to find paragraph boundaries (empty lines) or cap at a reasonable size
|
|
33
58
|
let start = i;
|
|
34
59
|
while (start > 0 && start > i - 3 && lines[start].trim() !== '') start--;
|
|
35
60
|
let end = i;
|
|
36
61
|
while (end < lines.length - 1 && end < i + 3 && lines[end].trim() !== '') end++;
|
|
37
|
-
|
|
62
|
+
|
|
38
63
|
let ctx = lines.slice(start, end + 1).join('\n').trim();
|
|
39
|
-
// Collapse multiple newlines in the context to save tokens, but keep simple structure
|
|
40
64
|
ctx = ctx.replace(/\n{2,}/g, ' ↵ ');
|
|
41
|
-
|
|
65
|
+
|
|
42
66
|
results.push({ source: filePath, line: i + 1, context: ctx });
|
|
43
|
-
break;
|
|
67
|
+
break;
|
|
44
68
|
}
|
|
45
69
|
}
|
|
46
70
|
}
|
package/app/lib/core/index.ts
CHANGED
|
@@ -39,7 +39,10 @@ export {
|
|
|
39
39
|
export type { TreeOptions } from './tree';
|
|
40
40
|
|
|
41
41
|
// Search
|
|
42
|
-
export { searchFiles, invalidateSearchIndex } from './search';
|
|
42
|
+
export { searchFiles, invalidateSearchIndex, updateSearchIndexFile, addSearchIndexFile, removeSearchIndexFile } from './search';
|
|
43
|
+
|
|
44
|
+
// Link index (graph + backlinks)
|
|
45
|
+
export { LinkIndex } from './link-index';
|
|
43
46
|
|
|
44
47
|
// Line-level operations
|
|
45
48
|
export {
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { collectAllFiles } from './tree';
|
|
3
|
+
import { readFile } from './fs-ops';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory bidirectional link index for the knowledge graph.
|
|
7
|
+
*
|
|
8
|
+
* Maintains forward links (source → targets) and backward links (target → sources)
|
|
9
|
+
* in a single pass. Used by:
|
|
10
|
+
* - GET /api/graph — forward links for edge rendering
|
|
11
|
+
* - findBacklinks() — backward links for reference discovery
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle:
|
|
14
|
+
* - `rebuild(mindRoot)` — full build from disk (called lazily on first access)
|
|
15
|
+
* - `updateFile(mindRoot, filePath)` — incremental re-index after write
|
|
16
|
+
* - `addFile(mindRoot, filePath)` — incremental add after create
|
|
17
|
+
* - `removeFile(filePath)` — incremental remove after delete
|
|
18
|
+
* - `invalidate()` — mark stale (next access triggers rebuild)
|
|
19
|
+
*/
|
|
20
|
+
export class LinkIndex {
|
|
21
|
+
private forwardLinks = new Map<string, Set<string>>(); // source → targets
|
|
22
|
+
private backwardLinks = new Map<string, Set<string>>(); // target → sources
|
|
23
|
+
private builtForRoot: string | null = null;
|
|
24
|
+
/** Cached file set and basename map for incremental link resolution. */
|
|
25
|
+
private fileSet = new Set<string>();
|
|
26
|
+
private basenameMap = new Map<string, string[]>();
|
|
27
|
+
|
|
28
|
+
/** Full rebuild: read all .md files and extract links. */
|
|
29
|
+
rebuild(mindRoot: string): void {
|
|
30
|
+
const allFiles = collectAllFiles(mindRoot).filter(f => f.endsWith('.md'));
|
|
31
|
+
const fileSet = new Set(allFiles);
|
|
32
|
+
const basenameMap = buildBasenameMap(allFiles);
|
|
33
|
+
|
|
34
|
+
const forward = new Map<string, Set<string>>();
|
|
35
|
+
const backward = new Map<string, Set<string>>();
|
|
36
|
+
|
|
37
|
+
for (const filePath of allFiles) {
|
|
38
|
+
let content: string;
|
|
39
|
+
try { content = readFile(mindRoot, filePath); } catch { continue; }
|
|
40
|
+
|
|
41
|
+
const targets = extractLinks(content, filePath, fileSet, basenameMap);
|
|
42
|
+
const targetSet = new Set<string>();
|
|
43
|
+
for (const t of targets) {
|
|
44
|
+
if (t !== filePath) targetSet.add(t); // skip self-links
|
|
45
|
+
}
|
|
46
|
+
forward.set(filePath, targetSet);
|
|
47
|
+
|
|
48
|
+
for (const t of targetSet) {
|
|
49
|
+
let sources = backward.get(t);
|
|
50
|
+
if (!sources) { sources = new Set(); backward.set(t, sources); }
|
|
51
|
+
sources.add(filePath);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.forwardLinks = forward;
|
|
56
|
+
this.backwardLinks = backward;
|
|
57
|
+
this.builtForRoot = mindRoot;
|
|
58
|
+
this.fileSet = fileSet;
|
|
59
|
+
this.basenameMap = basenameMap;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Clear the index. Next access triggers lazy rebuild. */
|
|
63
|
+
invalidate(): void {
|
|
64
|
+
this.forwardLinks.clear();
|
|
65
|
+
this.backwardLinks.clear();
|
|
66
|
+
this.builtForRoot = null;
|
|
67
|
+
this.fileSet.clear();
|
|
68
|
+
this.basenameMap.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
isBuiltFor(mindRoot: string): boolean {
|
|
72
|
+
return this.builtForRoot === mindRoot;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
isBuilt(): boolean {
|
|
76
|
+
return this.builtForRoot !== null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Queries ────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/** Get all files that `sourcePath` links to. O(1). */
|
|
82
|
+
getForwardLinks(sourcePath: string): string[] {
|
|
83
|
+
return [...(this.forwardLinks.get(sourcePath) ?? [])];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Get all files that link to `targetPath`. O(1). */
|
|
87
|
+
getBacklinks(targetPath: string): string[] {
|
|
88
|
+
return [...(this.backwardLinks.get(targetPath) ?? [])];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Get all edges as [source, target] pairs. Used by Graph API. */
|
|
92
|
+
getAllEdges(): Array<{ source: string; target: string }> {
|
|
93
|
+
const edges: Array<{ source: string; target: string }> = [];
|
|
94
|
+
for (const [source, targets] of this.forwardLinks) {
|
|
95
|
+
for (const target of targets) {
|
|
96
|
+
edges.push({ source, target });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return edges;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Incremental updates ──────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** Remove a file's links from the index. */
|
|
105
|
+
removeFile(filePath: string): void {
|
|
106
|
+
// Remove forward links and corresponding backward entries
|
|
107
|
+
const oldTargets = this.forwardLinks.get(filePath);
|
|
108
|
+
if (oldTargets) {
|
|
109
|
+
for (const t of oldTargets) {
|
|
110
|
+
this.backwardLinks.get(t)?.delete(filePath);
|
|
111
|
+
}
|
|
112
|
+
this.forwardLinks.delete(filePath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Also remove as a target in backward links (file deleted)
|
|
116
|
+
const oldSources = this.backwardLinks.get(filePath);
|
|
117
|
+
if (oldSources) {
|
|
118
|
+
for (const s of oldSources) {
|
|
119
|
+
this.forwardLinks.get(s)?.delete(filePath);
|
|
120
|
+
}
|
|
121
|
+
this.backwardLinks.delete(filePath);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Remove from cached resolution sets
|
|
125
|
+
this.fileSet.delete(filePath);
|
|
126
|
+
const key = path.basename(filePath).toLowerCase();
|
|
127
|
+
const candidates = this.basenameMap.get(key);
|
|
128
|
+
if (candidates) {
|
|
129
|
+
const idx = candidates.indexOf(filePath);
|
|
130
|
+
if (idx >= 0) candidates.splice(idx, 1);
|
|
131
|
+
if (candidates.length === 0) this.basenameMap.delete(key);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Add or re-index a single file. */
|
|
136
|
+
updateFile(mindRoot: string, filePath: string): void {
|
|
137
|
+
if (!this.builtForRoot) return;
|
|
138
|
+
if (!filePath.endsWith('.md')) return;
|
|
139
|
+
|
|
140
|
+
// Remove old links
|
|
141
|
+
const oldTargets = this.forwardLinks.get(filePath);
|
|
142
|
+
if (oldTargets) {
|
|
143
|
+
for (const t of oldTargets) {
|
|
144
|
+
this.backwardLinks.get(t)?.delete(filePath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Re-extract links using cached fileSet/basenameMap
|
|
149
|
+
let content: string;
|
|
150
|
+
try { content = readFile(mindRoot, filePath); } catch { return; }
|
|
151
|
+
|
|
152
|
+
// Ensure this file is in the resolution sets
|
|
153
|
+
if (!this.fileSet.has(filePath)) {
|
|
154
|
+
this.fileSet.add(filePath);
|
|
155
|
+
const key = path.basename(filePath).toLowerCase();
|
|
156
|
+
if (!this.basenameMap.has(key)) this.basenameMap.set(key, []);
|
|
157
|
+
this.basenameMap.get(key)!.push(filePath);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const targets = extractLinks(content, filePath, this.fileSet, this.basenameMap);
|
|
161
|
+
const targetSet = new Set<string>();
|
|
162
|
+
for (const t of targets) {
|
|
163
|
+
if (t !== filePath) targetSet.add(t);
|
|
164
|
+
}
|
|
165
|
+
this.forwardLinks.set(filePath, targetSet);
|
|
166
|
+
|
|
167
|
+
for (const t of targetSet) {
|
|
168
|
+
let sources = this.backwardLinks.get(t);
|
|
169
|
+
if (!sources) { sources = new Set(); this.backwardLinks.set(t, sources); }
|
|
170
|
+
sources.add(filePath);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Link extraction (shared between graph + backlinks) ─────────────
|
|
176
|
+
|
|
177
|
+
function buildBasenameMap(allFiles: string[]): Map<string, string[]> {
|
|
178
|
+
const map = new Map<string, string[]>();
|
|
179
|
+
for (const f of allFiles) {
|
|
180
|
+
const key = path.basename(f).toLowerCase();
|
|
181
|
+
if (!map.has(key)) map.set(key, []);
|
|
182
|
+
map.get(key)!.push(f);
|
|
183
|
+
}
|
|
184
|
+
return map;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Extract wiki-links and markdown-links from file content.
|
|
189
|
+
* Returns an array of resolved target file paths.
|
|
190
|
+
*/
|
|
191
|
+
export function extractLinks(
|
|
192
|
+
content: string,
|
|
193
|
+
sourcePath: string,
|
|
194
|
+
fileSet: Set<string>,
|
|
195
|
+
basenameMap: Map<string, string[]>,
|
|
196
|
+
): string[] {
|
|
197
|
+
const targets: string[] = [];
|
|
198
|
+
const sourceDir = path.dirname(sourcePath);
|
|
199
|
+
|
|
200
|
+
// WikiLinks: [[target]] or [[target|alias]] or [[target#section]]
|
|
201
|
+
const wikiRe = /\[\[([^\]|#]+)(?:[|#][^\]]*)?/g;
|
|
202
|
+
let m: RegExpExecArray | null;
|
|
203
|
+
while ((m = wikiRe.exec(content)) !== null) {
|
|
204
|
+
const raw = m[1].trim();
|
|
205
|
+
if (!raw) continue;
|
|
206
|
+
if (fileSet.has(raw)) { targets.push(raw); continue; }
|
|
207
|
+
const withMd = raw.endsWith('.md') ? raw : raw + '.md';
|
|
208
|
+
if (fileSet.has(withMd)) { targets.push(withMd); continue; }
|
|
209
|
+
const lower = path.basename(withMd).toLowerCase();
|
|
210
|
+
const candidates = basenameMap.get(lower);
|
|
211
|
+
if (candidates && candidates.length === 1) targets.push(candidates[0]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Markdown links: [text](relative/path.md)
|
|
215
|
+
const mdLinkRe = /\[[^\]]+\]\(([^)]+\.md)(?:#[^)]*)?\)/g;
|
|
216
|
+
while ((m = mdLinkRe.exec(content)) !== null) {
|
|
217
|
+
const raw = m[1].trim();
|
|
218
|
+
if (!raw || raw.startsWith('http')) continue;
|
|
219
|
+
const resolved = path.normalize(path.join(sourceDir, raw));
|
|
220
|
+
if (fileSet.has(resolved)) targets.push(resolved);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return targets;
|
|
224
|
+
}
|