@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.
@@ -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
+ }