@geminilight/mindos 0.5.20 → 0.5.22

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 (46) hide show
  1. package/app/app/api/ask/route.ts +343 -178
  2. package/app/app/api/monitoring/route.ts +95 -0
  3. package/app/components/SettingsModal.tsx +58 -58
  4. package/app/components/settings/AgentsTab.tsx +240 -0
  5. package/app/components/settings/AiTab.tsx +4 -25
  6. package/app/components/settings/AppearanceTab.tsx +31 -13
  7. package/app/components/settings/KnowledgeTab.tsx +13 -28
  8. package/app/components/settings/McpAgentInstall.tsx +227 -0
  9. package/app/components/settings/McpServerStatus.tsx +172 -0
  10. package/app/components/settings/McpSkillsSection.tsx +583 -0
  11. package/app/components/settings/McpTab.tsx +17 -959
  12. package/app/components/settings/MonitoringTab.tsx +202 -0
  13. package/app/components/settings/PluginsTab.tsx +4 -27
  14. package/app/components/settings/Primitives.tsx +69 -0
  15. package/app/components/settings/ShortcutsTab.tsx +2 -4
  16. package/app/components/settings/SyncTab.tsx +8 -24
  17. package/app/components/settings/types.ts +116 -2
  18. package/app/instrumentation.ts +7 -2
  19. package/app/lib/agent/context.ts +151 -87
  20. package/app/lib/agent/index.ts +5 -3
  21. package/app/lib/agent/log.ts +1 -0
  22. package/app/lib/agent/model.ts +76 -10
  23. package/app/lib/agent/skill-rules.ts +70 -0
  24. package/app/lib/agent/stream-consumer.ts +73 -77
  25. package/app/lib/agent/to-agent-messages.ts +106 -0
  26. package/app/lib/agent/tools.ts +260 -266
  27. package/app/lib/api.ts +12 -3
  28. package/app/lib/core/csv.ts +2 -1
  29. package/app/lib/core/fs-ops.ts +7 -6
  30. package/app/lib/core/index.ts +1 -1
  31. package/app/lib/core/lines.ts +7 -6
  32. package/app/lib/core/search-index.ts +174 -0
  33. package/app/lib/core/search.ts +30 -1
  34. package/app/lib/core/security.ts +6 -3
  35. package/app/lib/errors.ts +108 -0
  36. package/app/lib/fs.ts +6 -3
  37. package/app/lib/i18n-en.ts +523 -0
  38. package/app/lib/i18n-zh.ts +548 -0
  39. package/app/lib/i18n.ts +4 -963
  40. package/app/lib/metrics.ts +81 -0
  41. package/app/next-env.d.ts +1 -1
  42. package/app/next.config.ts +1 -1
  43. package/app/package-lock.json +3258 -3093
  44. package/app/package.json +6 -3
  45. package/bin/cli.js +7 -4
  46. package/package.json +4 -1
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { resolveSafe } from './security';
4
+ import { MindOSError, ErrorCodes } from '@/lib/errors';
4
5
 
5
6
  /**
6
7
  * Appends a single row to a CSV file with RFC 4180 escaping.
@@ -9,7 +10,7 @@ import { resolveSafe } from './security';
9
10
  */
10
11
  export function appendCsvRow(mindRoot: string, filePath: string, row: string[]): { newRowCount: number } {
11
12
  const resolved = resolveSafe(mindRoot, filePath);
12
- if (!filePath.endsWith('.csv')) throw new Error('Only .csv files support row append');
13
+ if (!filePath.endsWith('.csv')) throw new MindOSError(ErrorCodes.INVALID_FILE_TYPE, 'Only .csv files support row append', { filePath });
13
14
 
14
15
  const escaped = row.map((cell) => {
15
16
  if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { resolveSafe, assertWithinRoot } from './security';
4
+ import { MindOSError, ErrorCodes } from '@/lib/errors';
4
5
 
5
6
  /**
6
7
  * Reads the content of a file given a relative path from mindRoot.
@@ -35,7 +36,7 @@ export function writeFile(mindRoot: string, filePath: string, content: string):
35
36
  export function createFile(mindRoot: string, filePath: string, initialContent = ''): void {
36
37
  const resolved = resolveSafe(mindRoot, filePath);
37
38
  if (fs.existsSync(resolved)) {
38
- throw new Error(`File already exists: ${filePath}`);
39
+ throw new MindOSError(ErrorCodes.FILE_ALREADY_EXISTS, `File already exists: ${filePath}`, { filePath });
39
40
  }
40
41
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
41
42
  fs.writeFileSync(resolved, initialContent, 'utf-8');
@@ -47,7 +48,7 @@ export function createFile(mindRoot: string, filePath: string, initialContent =
47
48
  export function deleteFile(mindRoot: string, filePath: string): void {
48
49
  const resolved = resolveSafe(mindRoot, filePath);
49
50
  if (!fs.existsSync(resolved)) {
50
- throw new Error(`File not found: ${filePath}`);
51
+ throw new MindOSError(ErrorCodes.FILE_NOT_FOUND, `File not found: ${filePath}`, { filePath });
51
52
  }
52
53
  fs.unlinkSync(resolved);
53
54
  }
@@ -59,7 +60,7 @@ export function deleteFile(mindRoot: string, filePath: string): void {
59
60
  */
60
61
  export function renameFile(mindRoot: string, oldPath: string, newName: string): string {
61
62
  if (newName.includes('/') || newName.includes('\\')) {
62
- throw new Error('Invalid filename: must not contain path separators');
63
+ throw new MindOSError(ErrorCodes.INVALID_PATH, 'Invalid filename: must not contain path separators', { newName });
63
64
  }
64
65
  const root = path.resolve(mindRoot);
65
66
  const oldResolved = path.resolve(path.join(root, oldPath));
@@ -70,7 +71,7 @@ export function renameFile(mindRoot: string, oldPath: string, newName: string):
70
71
  assertWithinRoot(newResolved, root);
71
72
 
72
73
  if (fs.existsSync(newResolved)) {
73
- throw new Error('A file with that name already exists');
74
+ throw new MindOSError(ErrorCodes.FILE_ALREADY_EXISTS, 'A file with that name already exists', { newName });
74
75
  }
75
76
  fs.renameSync(oldResolved, newResolved);
76
77
  return path.relative(root, newResolved);
@@ -88,8 +89,8 @@ export function moveFile(
88
89
  ): { newPath: string; affectedFiles: string[] } {
89
90
  const fromResolved = resolveSafe(mindRoot, fromPath);
90
91
  const toResolved = resolveSafe(mindRoot, toPath);
91
- if (!fs.existsSync(fromResolved)) throw new Error(`Source not found: ${fromPath}`);
92
- if (fs.existsSync(toResolved)) throw new Error(`Destination already exists: ${toPath}`);
92
+ if (!fs.existsSync(fromResolved)) throw new MindOSError(ErrorCodes.FILE_NOT_FOUND, `Source not found: ${fromPath}`, { fromPath });
93
+ if (fs.existsSync(toResolved)) throw new MindOSError(ErrorCodes.FILE_ALREADY_EXISTS, `Destination already exists: ${toPath}`, { toPath });
93
94
  fs.mkdirSync(path.dirname(toResolved), { recursive: true });
94
95
  fs.renameSync(fromResolved, toResolved);
95
96
  const backlinks = findBacklinksFn(mindRoot, fromPath);
@@ -36,7 +36,7 @@ export {
36
36
  export type { TreeOptions } from './tree';
37
37
 
38
38
  // Search
39
- export { searchFiles } from './search';
39
+ export { searchFiles, invalidateSearchIndex } from './search';
40
40
 
41
41
  // Line-level operations
42
42
  export {
@@ -1,4 +1,5 @@
1
1
  import { readFile, writeFile } from './fs-ops';
2
+ import { MindOSError, ErrorCodes } from '@/lib/errors';
2
3
 
3
4
  /**
4
5
  * Reads a file and returns its content split into lines.
@@ -11,9 +12,9 @@ export function readLines(mindRoot: string, filePath: string): string[] {
11
12
  * Validates line indices are within bounds.
12
13
  */
13
14
  function validateLineRange(totalLines: number, start: number, end: number): void {
14
- if (start < 0 || end < 0) throw new Error('Invalid line index: indices must be >= 0');
15
- if (start > end) throw new Error(`Invalid range: start (${start}) > end (${end})`);
16
- if (start >= totalLines) throw new Error(`Invalid line index: start (${start}) >= total lines (${totalLines})`);
15
+ if (start < 0 || end < 0) throw new MindOSError(ErrorCodes.INVALID_RANGE, 'Invalid line index: indices must be >= 0', { start, end });
16
+ if (start > end) throw new MindOSError(ErrorCodes.INVALID_RANGE, `Invalid range: start (${start}) > end (${end})`, { start, end });
17
+ if (start >= totalLines) throw new MindOSError(ErrorCodes.INVALID_RANGE, `Invalid line index: start (${start}) >= total lines (${totalLines})`, { start, totalLines });
17
18
  }
18
19
 
19
20
  /**
@@ -23,7 +24,7 @@ function validateLineRange(totalLines: number, start: number, end: number): void
23
24
  export function insertLines(mindRoot: string, filePath: string, afterIndex: number, lines: string[]): void {
24
25
  const existing = readLines(mindRoot, filePath);
25
26
  if (afterIndex >= existing.length) {
26
- throw new Error(`Invalid after_index: ${afterIndex} >= total lines (${existing.length})`);
27
+ throw new MindOSError(ErrorCodes.INVALID_RANGE, `Invalid after_index: ${afterIndex} >= total lines (${existing.length})`, { afterIndex, totalLines: existing.length });
27
28
  }
28
29
  const insertAt = afterIndex < 0 ? 0 : afterIndex + 1;
29
30
  existing.splice(insertAt, 0, ...lines);
@@ -58,7 +59,7 @@ export function insertAfterHeading(mindRoot: string, filePath: string, heading:
58
59
  const trimmed = l.trim();
59
60
  return trimmed === heading || trimmed.replace(/^#+\s*/, '') === heading.replace(/^#+\s*/, '');
60
61
  });
61
- if (idx === -1) throw new Error(`Heading not found: "${heading}"`);
62
+ if (idx === -1) throw new MindOSError(ErrorCodes.HEADING_NOT_FOUND, `Heading not found: "${heading}"`, { heading });
62
63
  let insertAt = idx + 1;
63
64
  while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt++;
64
65
  insertLines(mindRoot, filePath, insertAt - 1, ['', content]);
@@ -73,7 +74,7 @@ export function updateSection(mindRoot: string, filePath: string, heading: strin
73
74
  const trimmed = l.trim();
74
75
  return trimmed === heading || trimmed.replace(/^#+\s*/, '') === heading.replace(/^#+\s*/, '');
75
76
  });
76
- if (idx === -1) throw new Error(`Heading not found: "${heading}"`);
77
+ if (idx === -1) throw new MindOSError(ErrorCodes.HEADING_NOT_FOUND, `Heading not found: "${heading}"`, { heading });
77
78
 
78
79
  const headingLevel = (lines[idx].match(/^#+/) ?? [''])[0].length;
79
80
  let sectionEnd = lines.length - 1;
@@ -0,0 +1,174 @@
1
+ import { collectAllFiles } from './tree';
2
+ import { readFile } from './fs-ops';
3
+
4
+ const MAX_CONTENT_LENGTH = 50_000;
5
+
6
+ // CJK Unicode ranges: Chinese, Japanese Hiragana/Katakana, Korean
7
+ const CJK_REGEX = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/;
8
+
9
+ /**
10
+ * Tokenize text for indexing: split on word boundaries + CJK bigrams.
11
+ *
12
+ * Latin/ASCII: split on non-alphanumeric characters, lowercased.
13
+ * CJK: generate character-level bigrams (overlapping pairs).
14
+ * Mixed text: both strategies applied, tokens merged.
15
+ */
16
+ function tokenize(text: string): Set<string> {
17
+ const tokens = new Set<string>();
18
+ const lower = text.toLowerCase();
19
+
20
+ // Latin/ASCII word tokens.
21
+ // Single Latin chars (e.g. "a") are noise and excluded; CJK unigrams
22
+ // carry meaning and are handled separately below.
23
+ const words = lower.match(/[a-z0-9_$@#]+/g);
24
+ if (words) {
25
+ for (const w of words) {
26
+ if (w.length >= 2) tokens.add(w);
27
+ }
28
+ }
29
+
30
+ // CJK bigrams + single chars (unigrams carry meaning in CJK scripts)
31
+ if (CJK_REGEX.test(lower)) {
32
+ const cjkChars: string[] = [];
33
+ for (const ch of lower) {
34
+ if (CJK_REGEX.test(ch)) {
35
+ cjkChars.push(ch);
36
+ } else {
37
+ // Emit bigrams for accumulated CJK run
38
+ if (cjkChars.length > 0) {
39
+ emitCjkTokens(cjkChars, tokens);
40
+ cjkChars.length = 0;
41
+ }
42
+ }
43
+ }
44
+ if (cjkChars.length > 0) emitCjkTokens(cjkChars, tokens);
45
+ }
46
+
47
+ return tokens;
48
+ }
49
+
50
+ function emitCjkTokens(chars: string[], tokens: Set<string>): void {
51
+ for (let i = 0; i < chars.length; i++) {
52
+ tokens.add(chars[i]); // unigram
53
+ if (i + 1 < chars.length) {
54
+ tokens.add(chars[i] + chars[i + 1]); // bigram
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * In-memory inverted index for core search acceleration.
61
+ *
62
+ * The index maps tokens → Set<filePath>. When a search query arrives,
63
+ * we tokenize the query and intersect candidate sets from the index,
64
+ * dramatically reducing the number of files that need full-text scanning.
65
+ *
66
+ * Lifecycle:
67
+ * - `rebuild(mindRoot)` — full build from disk (called lazily on first search)
68
+ * - `invalidate()` — mark stale (next search triggers rebuild)
69
+ * - `getCandidates(query)` — return candidate file set, or null if no index / no tokens
70
+ */
71
+ export class SearchIndex {
72
+ private invertedIndex: Map<string, Set<string>> | null = null;
73
+ private builtForRoot: string | null = null;
74
+ private fileCount = 0;
75
+
76
+ /** Full rebuild: read all files and build inverted index. */
77
+ rebuild(mindRoot: string): void {
78
+ const allFiles = collectAllFiles(mindRoot);
79
+ const inverted = new Map<string, Set<string>>();
80
+
81
+ for (const filePath of allFiles) {
82
+ let content: string;
83
+ try {
84
+ content = readFile(mindRoot, filePath);
85
+ } catch {
86
+ continue;
87
+ }
88
+
89
+ if (content.length > MAX_CONTENT_LENGTH) {
90
+ content = content.slice(0, MAX_CONTENT_LENGTH);
91
+ }
92
+
93
+ // Also index the file path itself
94
+ const allText = filePath + '\n' + content;
95
+ const tokens = tokenize(allText);
96
+
97
+ for (const token of tokens) {
98
+ let set = inverted.get(token);
99
+ if (!set) {
100
+ set = new Set<string>();
101
+ inverted.set(token, set);
102
+ }
103
+ set.add(filePath);
104
+ }
105
+ }
106
+
107
+ this.invertedIndex = inverted;
108
+ this.builtForRoot = mindRoot;
109
+ this.fileCount = allFiles.length;
110
+ }
111
+
112
+ /** Clear the index. Next search will trigger a lazy rebuild. */
113
+ invalidate(): void {
114
+ this.invertedIndex = null;
115
+ this.builtForRoot = null;
116
+ this.fileCount = 0;
117
+ }
118
+
119
+ /** Whether the index has been built for the given mindRoot. */
120
+ isBuiltFor(mindRoot: string): boolean {
121
+ return this.invertedIndex !== null && this.builtForRoot === mindRoot;
122
+ }
123
+
124
+ /** Whether the index has been built (for any root). */
125
+ isBuilt(): boolean {
126
+ return this.invertedIndex !== null;
127
+ }
128
+
129
+ /** Number of files in the index. */
130
+ getFileCount(): number {
131
+ return this.fileCount;
132
+ }
133
+
134
+ /**
135
+ * Get candidate file paths for a query (single or multi-word).
136
+ *
137
+ * Tokenizes the query and intersects candidate sets from the inverted index.
138
+ *
139
+ * Returns:
140
+ * - `null` if the index is not built, query is empty, or query produces no
141
+ * tokens (e.g. substring shorter than 2 chars). Callers should fall back
142
+ * to a full scan when null is returned.
143
+ * - `string[]` (possibly empty) if the index can answer definitively.
144
+ */
145
+ getCandidates(query: string): string[] | null {
146
+ if (!query.trim()) return null;
147
+ if (!this.invertedIndex) return null;
148
+
149
+ const tokens = tokenize(query.toLowerCase().trim());
150
+ // No tokens produced → query is a substring/single-char that the index
151
+ // cannot resolve. Return null so the caller falls back to full scan,
152
+ // preserving pre-index indexOf behavior for partial-word queries.
153
+ if (tokens.size === 0) return null;
154
+
155
+ let result: Set<string> | null = null;
156
+
157
+ for (const token of tokens) {
158
+ const set = this.invertedIndex.get(token);
159
+ if (!set) return []; // No files have this token → intersection is empty
160
+
161
+ if (result === null) {
162
+ result = new Set(set);
163
+ } else {
164
+ // Intersect
165
+ for (const path of result) {
166
+ if (!set.has(path)) result.delete(path);
167
+ }
168
+ if (result.size === 0) return [];
169
+ }
170
+ }
171
+
172
+ return result ? Array.from(result) : [];
173
+ }
174
+ }
@@ -1,16 +1,31 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { resolveSafe } from './security';
4
3
  import { collectAllFiles } from './tree';
5
4
  import { readFile } from './fs-ops';
5
+ import { SearchIndex } from './search-index';
6
6
  import type { SearchResult, SearchOptions } from './types';
7
7
 
8
+ /**
9
+ * Module-level search index singleton.
10
+ * Lazily built on first search, invalidated by `invalidateSearchIndex()`.
11
+ */
12
+ const searchIndex = new SearchIndex();
13
+
14
+ /** Invalidate the core search index. Called from `lib/fs.ts` on write operations. */
15
+ export function invalidateSearchIndex(): void {
16
+ searchIndex.invalidate();
17
+ }
18
+
8
19
  /**
9
20
  * Core literal search — used by MCP tools via REST API.
10
21
  *
11
22
  * This is a **case-insensitive literal string match** with occurrence-density scoring.
12
23
  * It supports scope, file_type, and modified_after filters that MCP tools expose.
13
24
  *
25
+ * Performance: uses an in-memory inverted index to narrow the candidate file set
26
+ * before doing full-text scanning. The index is built lazily on the first query
27
+ * and invalidated on any write operation.
28
+ *
14
29
  * NOTE: The App also has a separate Fuse.js fuzzy search in `lib/fs.ts` for the
15
30
  * browser `⌘K` search overlay. The two coexist intentionally:
16
31
  * - Core search (here): exact literal match, supports filters, used by MCP/API
@@ -20,6 +35,15 @@ export function searchFiles(mindRoot: string, query: string, opts: SearchOptions
20
35
  if (!query.trim()) return [];
21
36
  const { limit = 20, scope, file_type = 'all', modified_after } = opts;
22
37
 
38
+ // Ensure search index is built for this mindRoot
39
+ if (!searchIndex.isBuiltFor(mindRoot)) {
40
+ searchIndex.rebuild(mindRoot);
41
+ }
42
+
43
+ // Use index to get candidate files (or null if index unavailable → full scan)
44
+ const candidates = searchIndex.getCandidates(query);
45
+ const candidateSet = candidates ? new Set(candidates) : null;
46
+
23
47
  let allFiles = collectAllFiles(mindRoot);
24
48
 
25
49
  // Filter by scope (directory prefix)
@@ -34,6 +58,11 @@ export function searchFiles(mindRoot: string, query: string, opts: SearchOptions
34
58
  allFiles = allFiles.filter(f => f.endsWith(ext));
35
59
  }
36
60
 
61
+ // Narrow by index candidates (if available)
62
+ if (candidateSet) {
63
+ allFiles = allFiles.filter(f => candidateSet.has(f));
64
+ }
65
+
37
66
  // Filter by modification time
38
67
  let mtimeThreshold = 0;
39
68
  if (modified_after) {
@@ -1,11 +1,12 @@
1
1
  import path from 'path';
2
+ import { MindOSError, ErrorCodes } from '@/lib/errors';
2
3
 
3
4
  /**
4
5
  * Asserts that a resolved path is within the given root.
5
6
  */
6
7
  export function assertWithinRoot(resolved: string, root: string): void {
7
8
  if (!resolved.startsWith(root + path.sep) && resolved !== root) {
8
- throw new Error('Access denied: path outside MIND_ROOT');
9
+ throw new MindOSError(ErrorCodes.PATH_OUTSIDE_ROOT, 'Access denied: path outside MIND_ROOT', { resolved, root });
9
10
  }
10
11
  }
11
12
 
@@ -35,9 +36,11 @@ export function isRootProtected(filePath: string): boolean {
35
36
  */
36
37
  export function assertNotProtected(filePath: string, operation: string): void {
37
38
  if (isRootProtected(filePath)) {
38
- throw new Error(
39
+ throw new MindOSError(
40
+ ErrorCodes.PROTECTED_FILE,
39
41
  `Protected file: root "${filePath}" cannot be ${operation} via MCP. ` +
40
- `This is a system kernel file (§7 of INSTRUCTION.md). Edit it manually or use a dedicated confirmation workflow.`
42
+ `This is a system kernel file (§7 of INSTRUCTION.md). Edit it manually or use a dedicated confirmation workflow.`,
43
+ { filePath, operation },
41
44
  );
42
45
  }
43
46
  }
@@ -0,0 +1,108 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * Centralized error class for MindOS backend business logic.
5
+ *
6
+ * Every throw in `lib/core/` should use this class so that:
7
+ * 1. API routes can detect it in catch blocks and return structured JSON.
8
+ * 2. Callers can switch on `code` for programmatic handling.
9
+ * 3. `userMessage` provides a translatable, user-safe description.
10
+ */
11
+ export class MindOSError extends Error {
12
+ constructor(
13
+ public code: ErrorCode,
14
+ message: string,
15
+ public context?: Record<string, unknown>,
16
+ public userMessage?: string,
17
+ ) {
18
+ super(message);
19
+ this.name = 'MindOSError';
20
+ }
21
+ }
22
+
23
+ export const ErrorCodes = {
24
+ // File operations
25
+ FILE_NOT_FOUND: 'FILE_NOT_FOUND',
26
+ FILE_ALREADY_EXISTS: 'FILE_ALREADY_EXISTS',
27
+ PATH_OUTSIDE_ROOT: 'PATH_OUTSIDE_ROOT',
28
+ PROTECTED_FILE: 'PROTECTED_FILE',
29
+ INVALID_PATH: 'INVALID_PATH',
30
+ INVALID_RANGE: 'INVALID_RANGE',
31
+ HEADING_NOT_FOUND: 'HEADING_NOT_FOUND',
32
+ INVALID_FILE_TYPE: 'INVALID_FILE_TYPE',
33
+ // API
34
+ INVALID_REQUEST: 'INVALID_REQUEST',
35
+ MODEL_INIT_FAILED: 'MODEL_INIT_FAILED',
36
+ // Generic
37
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
38
+ PERMISSION_DENIED: 'PERMISSION_DENIED',
39
+ } as const;
40
+
41
+ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
42
+
43
+ /** Standardized API error response envelope. */
44
+ export interface ApiErrorResponse {
45
+ ok: false;
46
+ error: {
47
+ code: string;
48
+ message: string;
49
+ };
50
+ }
51
+
52
+ /** Extract a human-readable message from an unknown thrown value. */
53
+ export function toErrorMessage(err: unknown): string {
54
+ if (err instanceof Error) return err.message;
55
+ if (typeof err === 'string') return err;
56
+ return String(err);
57
+ }
58
+
59
+ /** Map an ErrorCode to an HTTP status code. */
60
+ function mapCodeToStatus(code: ErrorCode): number {
61
+ switch (code) {
62
+ case ErrorCodes.FILE_NOT_FOUND:
63
+ case ErrorCodes.HEADING_NOT_FOUND:
64
+ return 404;
65
+ case ErrorCodes.FILE_ALREADY_EXISTS:
66
+ return 409;
67
+ case ErrorCodes.PATH_OUTSIDE_ROOT:
68
+ case ErrorCodes.PROTECTED_FILE:
69
+ case ErrorCodes.PERMISSION_DENIED:
70
+ return 403;
71
+ case ErrorCodes.INVALID_PATH:
72
+ case ErrorCodes.INVALID_RANGE:
73
+ case ErrorCodes.INVALID_FILE_TYPE:
74
+ case ErrorCodes.INVALID_REQUEST:
75
+ return 400;
76
+ case ErrorCodes.MODEL_INIT_FAILED:
77
+ case ErrorCodes.INTERNAL_ERROR:
78
+ default:
79
+ return 500;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Build a NextResponse with the standard `{ ok, error }` envelope.
85
+ *
86
+ * If `status` is omitted it is derived from the error code.
87
+ */
88
+ export function apiError(code: ErrorCode, message: string, status?: number): NextResponse<ApiErrorResponse> {
89
+ const effectiveStatus = status ?? mapCodeToStatus(code);
90
+ return NextResponse.json({ ok: false as const, error: { code, message } }, { status: effectiveStatus });
91
+ }
92
+
93
+ /**
94
+ * Convenience: catch an unknown error and return a structured API response.
95
+ *
96
+ * Usage in route handlers:
97
+ * ```ts
98
+ * catch (err) {
99
+ * return handleRouteError(err);
100
+ * }
101
+ * ```
102
+ */
103
+ export function handleRouteError(err: unknown): NextResponse<ApiErrorResponse> {
104
+ if (err instanceof MindOSError) {
105
+ return apiError(err.code, err.message);
106
+ }
107
+ return apiError(ErrorCodes.INTERNAL_ERROR, 'Internal server error', 500);
108
+ }
package/app/lib/fs.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import Fuse, { FuseResultMatch } from 'fuse.js';
4
+ import { MindOSError, ErrorCodes } from '@/lib/errors';
4
5
  import {
5
6
  readFile as coreReadFile,
6
7
  writeFile as coreWriteFile,
@@ -19,6 +20,7 @@ import {
19
20
  isGitRepo as coreIsGitRepo,
20
21
  gitLog as coreGitLog,
21
22
  gitShowFile as coreGitShowFile,
23
+ invalidateSearchIndex,
22
24
  } from './core';
23
25
  import { FileNode } from './core/types';
24
26
  import { SearchMatch } from './types';
@@ -53,6 +55,7 @@ function isCacheValid(): boolean {
53
55
  export function invalidateCache(): void {
54
56
  _cache = null;
55
57
  _searchIndex = null;
58
+ invalidateSearchIndex();
56
59
  }
57
60
 
58
61
  function ensureCache(): FileTreeCache {
@@ -266,9 +269,9 @@ export function updateLines(filePath: string, startIndex: number, endIndex: numb
266
269
 
267
270
  export function deleteLines(filePath: string, startIndex: number, endIndex: number): void {
268
271
  const existing = readLines(filePath);
269
- if (startIndex < 0 || endIndex < 0) throw new Error('Invalid line index: indices must be >= 0');
270
- if (startIndex > endIndex) throw new Error(`Invalid range: start (${startIndex}) > end (${endIndex})`);
271
- if (startIndex >= existing.length) throw new Error(`Invalid line index: start (${startIndex}) >= total lines (${existing.length})`);
272
+ if (startIndex < 0 || endIndex < 0) throw new MindOSError(ErrorCodes.INVALID_RANGE, 'Invalid line index: indices must be >= 0', { startIndex, endIndex });
273
+ if (startIndex > endIndex) throw new MindOSError(ErrorCodes.INVALID_RANGE, `Invalid range: start (${startIndex}) > end (${endIndex})`, { startIndex, endIndex });
274
+ if (startIndex >= existing.length) throw new MindOSError(ErrorCodes.INVALID_RANGE, `Invalid line index: start (${startIndex}) >= total lines (${existing.length})`, { startIndex, totalLines: existing.length });
272
275
  existing.splice(startIndex, endIndex - startIndex + 1);
273
276
  saveFileContent(filePath, existing.join('\n'));
274
277
  }