@geminilight/mindos 0.6.31 → 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.
@@ -18,7 +18,6 @@ export default function WorkflowEditor({ workflow, filePath, onChange, onSaved }
18
18
  const [saving, setSaving] = useState(false);
19
19
  const [saveError, setSaveError] = useState('');
20
20
  const [saveSuccess, setSaveSuccess] = useState(false);
21
- const [showAdvanced, setShowAdvanced] = useState(!!(workflow.workDir));
22
21
 
23
22
  useEffect(() => {
24
23
  if (!saveSuccess) return;
@@ -109,19 +108,10 @@ export default function WorkflowEditor({ workflow, filePath, onChange, onSaved }
109
108
  className="w-full text-sm bg-transparent text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none border-none p-0"
110
109
  />
111
110
 
112
- {/* Advanced toggle */}
113
- <div className="flex items-center gap-3">
114
- {!showAdvanced ? (
115
- <button onClick={() => setShowAdvanced(true)}
116
- className="text-2xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
117
- + Working directory
118
- </button>
119
- ) : (
120
- <div className="flex items-center gap-2 flex-1">
121
- <FolderOpen size={12} className="text-muted-foreground/40 shrink-0" />
122
- <DirPicker value={workflow.workDir || ''} onChange={v => updateMeta({ workDir: v || undefined })} />
123
- </div>
124
- )}
111
+ {/* Working directory — always visible */}
112
+ <div className="flex items-center gap-2">
113
+ <FolderOpen size={12} className="text-muted-foreground/40 shrink-0" />
114
+ <DirPicker value={workflow.workDir || ''} onChange={v => updateMeta({ workDir: v || undefined })} />
125
115
  </div>
126
116
  </div>
127
117
 
@@ -130,16 +120,16 @@ export default function WorkflowEditor({ workflow, filePath, onChange, onSaved }
130
120
  <div className="relative">
131
121
  {/* Vertical timeline line */}
132
122
  {workflow.steps.length > 1 && (
133
- <div className="absolute left-[15px] top-6 bottom-16 w-px bg-border" />
123
+ <div className="absolute left-[19px] top-6 bottom-16 w-px bg-border" />
134
124
  )}
135
125
 
136
126
  {/* Step list */}
137
127
  <div className="flex flex-col gap-3 mb-5 relative">
138
128
  {workflow.steps.map((step, i) => (
139
- <div key={step.id} className="relative pl-9">
129
+ <div key={step.id} className="relative pl-11">
140
130
  {/* Timeline node */}
141
- <div className="absolute left-[9px] top-3 w-[13px] h-[13px] rounded-full border-2 border-border bg-background z-10 flex items-center justify-center">
142
- <span className="text-[7px] font-bold text-muted-foreground/60">{i + 1}</span>
131
+ <div className="absolute left-[7px] top-3 w-[22px] h-[22px] rounded-full border-2 border-border bg-background z-10 flex items-center justify-center">
132
+ <span className="text-[10px] font-bold text-muted-foreground/60">{i + 1}</span>
143
133
  </div>
144
134
  <StepEditor
145
135
  step={step}
@@ -154,9 +144,9 @@ export default function WorkflowEditor({ workflow, filePath, onChange, onSaved }
154
144
  </div>
155
145
 
156
146
  {/* Add step — at the end of timeline */}
157
- <div className="relative pl-9">
158
- <div className="absolute left-[9px] top-2.5 w-[13px] h-[13px] rounded-full border-2 border-dashed border-border bg-background z-10 flex items-center justify-center">
159
- <Plus size={7} className="text-muted-foreground/40" />
147
+ <div className="relative pl-11">
148
+ <div className="absolute left-[7px] top-2.5 w-[22px] h-[22px] rounded-full border-2 border-dashed border-border bg-background z-10 flex items-center justify-center">
149
+ <Plus size={9} className="text-muted-foreground/40" />
160
150
  </div>
161
151
  <button onClick={addStep}
162
152
  className="w-full text-left px-3 py-2 rounded-lg text-xs text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/40 transition-colors">
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useRef, useCallback, useEffect } from 'react';
4
- import { Play, SkipForward, RotateCcw, CheckCircle2, Circle, Loader2, AlertCircle, ChevronDown, Sparkles, XCircle, Clock, ArrowRight } from 'lucide-react';
4
+ import { Play, SkipForward, RotateCcw, CheckCircle2, Circle, Loader2, AlertCircle, ChevronDown, Sparkles, XCircle, Clock, ArrowRight, FolderOpen } from 'lucide-react';
5
5
  import { runStepWithAI, clearSkillCache } from './execution';
6
6
  import type { WorkflowYaml, WorkflowStepRuntime, StepStatus } from './types';
7
7
 
@@ -44,7 +44,7 @@ function TimelineNode({ status, index }: { status: StepStatus; index: number })
44
44
  // pending
45
45
  return (
46
46
  <div className={`${base} border-2 border-border bg-background`}>
47
- <span className="text-[9px] font-bold text-muted-foreground/40">{index + 1}</span>
47
+ <span className="text-[11px] font-bold text-muted-foreground/50">{index + 1}</span>
48
48
  </div>
49
49
  );
50
50
  }
@@ -205,6 +205,19 @@ export default function WorkflowRunner({ workflow, filePath }: { workflow: Workf
205
205
 
206
206
  return (
207
207
  <div>
208
+ {/* Working directory + description */}
209
+ {(workflow.workDir || workflow.description) && (
210
+ <div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground flex-wrap">
211
+ {workflow.workDir && (
212
+ <span className="flex items-center gap-1.5 font-mono text-2xs bg-muted/50 px-2 py-0.5 rounded">
213
+ <FolderOpen size={10} className="shrink-0" />
214
+ {workflow.workDir}
215
+ </span>
216
+ )}
217
+ {workflow.description && <span className="leading-relaxed">{workflow.description}</span>}
218
+ </div>
219
+ )}
220
+
208
221
  {/* Progress bar — full width, thin, elegant */}
209
222
  <div className="mb-6">
210
223
  <div className="flex items-center justify-between mb-2">
@@ -85,7 +85,7 @@ export function AgentSelector({ value, onChange }: { value?: string; onChange: (
85
85
  <button type="button" onClick={() => setOpen(v => !v)}
86
86
  className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
87
87
  <span className={value ? 'text-foreground truncate' : 'text-muted-foreground'}>
88
- {displayName || 'Select agent'}
88
+ {displayName || 'MindOS'}
89
89
  </span>
90
90
  <ChevronDown size={12} className="text-muted-foreground shrink-0" />
91
91
  </button>
@@ -103,8 +103,8 @@ export function AgentSelector({ value, onChange }: { value?: string; onChange: (
103
103
  </div>
104
104
 
105
105
  <button onClick={() => select(undefined)}
106
- className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${!value ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground'}`}>
107
- (none use default)
106
+ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${!value ? 'text-[var(--amber)] font-medium' : 'text-foreground'}`}>
107
+ MindOS <span className="text-muted-foreground/50 ml-1">(default)</span>
108
108
  </button>
109
109
  {filtered.slice(0, 30).map(a => (
110
110
  <button key={a.id} onClick={() => select(a.id)}
@@ -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="Dismiss"
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>
@@ -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
@@ -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 token 4 chars)
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 Math.ceil(content.length / 4);
35
+ if (typeof content === 'string') return estimateStringTokens(content);
20
36
  if (Array.isArray(content)) {
21
- let chars = 0;
37
+ let tokens = 0;
22
38
  for (const part of content) {
23
- if ('text' in part && typeof part.text === 'string') chars += part.text.length;
24
- if ('args' in part) chars += JSON.stringify(part.args).length;
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 Math.ceil(chars / 4);
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(mindRoot: string, targetPath: string, cachedFiles?: string[]): BacklinkEntry[] {
11
- const allFiles = (cachedFiles ?? collectAllFiles(mindRoot)).filter(f => f.endsWith('.md') && f !== targetPath);
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
- for (const filePath of allFiles) {
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; // currently only records the first match per file
67
+ break;
44
68
  }
45
69
  }
46
70
  }
@@ -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
+ }