@geminilight/mindos 0.5.68 → 0.5.70

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 (33) hide show
  1. package/app/app/api/ask/route.ts +12 -4
  2. package/app/app/api/file/import/route.ts +197 -0
  3. package/app/app/api/mcp/install/route.ts +99 -12
  4. package/app/app/api/mcp/status/route.ts +1 -1
  5. package/app/components/ActivityBar.tsx +3 -4
  6. package/app/components/FileTree.tsx +35 -9
  7. package/app/components/ImportModal.tsx +415 -0
  8. package/app/components/OnboardingView.tsx +9 -0
  9. package/app/components/Panel.tsx +4 -2
  10. package/app/components/SidebarLayout.tsx +83 -8
  11. package/app/components/TableOfContents.tsx +1 -0
  12. package/app/components/agents/AgentDetailContent.tsx +37 -28
  13. package/app/components/agents/AgentsMcpSection.tsx +16 -12
  14. package/app/components/agents/AgentsOverviewSection.tsx +48 -34
  15. package/app/components/agents/AgentsPrimitives.tsx +41 -20
  16. package/app/components/agents/AgentsSkillsSection.tsx +16 -7
  17. package/app/components/agents/SkillDetailPopover.tsx +13 -11
  18. package/app/components/ask/AskContent.tsx +11 -0
  19. package/app/components/panels/AgentsPanelAgentGroups.tsx +8 -6
  20. package/app/components/panels/AgentsPanelHubNav.tsx +3 -3
  21. package/app/components/panels/DiscoverPanel.tsx +88 -2
  22. package/app/hooks/useFileImport.ts +191 -0
  23. package/app/hooks/useFileUpload.ts +11 -0
  24. package/app/lib/agent/context.ts +7 -2
  25. package/app/lib/agent/tools.ts +245 -6
  26. package/app/lib/core/backlinks.ts +12 -4
  27. package/app/lib/core/file-convert.ts +97 -0
  28. package/app/lib/core/organize.ts +105 -0
  29. package/app/lib/core/search.ts +17 -3
  30. package/app/lib/fs.ts +5 -3
  31. package/app/lib/i18n-en.ts +51 -0
  32. package/app/lib/i18n-zh.ts +51 -0
  33. package/package.json +1 -1
@@ -12,7 +12,14 @@ const MAX_FILE_CHARS = 20_000;
12
12
 
13
13
  export function truncate(content: string): string {
14
14
  if (content.length <= MAX_FILE_CHARS) return content;
15
- return content.slice(0, MAX_FILE_CHARS) + `\n\n[...truncated — file is ${content.length} chars, showing first ${MAX_FILE_CHARS}]`;
15
+
16
+ // Smart truncation: try to truncate at a natural boundary (newline)
17
+ let cutoff = content.lastIndexOf('\n', MAX_FILE_CHARS);
18
+ if (cutoff === -1) cutoff = MAX_FILE_CHARS;
19
+
20
+ const totalLines = content.split('\n').length;
21
+
22
+ return content.slice(0, cutoff) + `\n\n[...truncated — file is ${content.length} chars (${totalLines} lines), showing first ~${cutoff} chars]\n[Use the read_file_chunk tool to read the rest of the file by specifying start_line and end_line]`;
16
23
  }
17
24
 
18
25
  // ─── Helper: format tool error consistently ────────────────────────────────
@@ -51,6 +58,12 @@ const PathParam = Type.Object({
51
58
  path: Type.String({ description: 'Relative file path' }),
52
59
  });
53
60
 
61
+ const ReadFileChunkParams = Type.Object({
62
+ path: Type.String({ description: 'Relative file path' }),
63
+ start_line: Type.Number({ description: 'Line number to start reading from (1-indexed)' }),
64
+ end_line: Type.Number({ description: 'Line number to stop reading at (1-indexed)' }),
65
+ });
66
+
54
67
  const QueryParam = Type.Object({
55
68
  query: Type.String({ description: 'Search query (case-insensitive)' }),
56
69
  });
@@ -69,11 +82,26 @@ const CreateFileParams = Type.Object({
69
82
  content: Type.Optional(Type.String({ description: 'Initial file content' })),
70
83
  });
71
84
 
85
+ const BatchCreateFileParams = Type.Object({
86
+ files: Type.Array(Type.Object({
87
+ path: Type.String({ description: 'Relative file path (must end in .md or .csv)' }),
88
+ content: Type.String({ description: 'Initial file content' }),
89
+ }), { description: 'List of files to create' }),
90
+ });
91
+
72
92
  const AppendParams = Type.Object({
73
93
  path: Type.String({ description: 'Relative file path' }),
74
94
  content: Type.String({ description: 'Content to append' }),
75
95
  });
76
96
 
97
+ const FetchUrlParams = Type.Object({
98
+ url: Type.String({ description: 'The HTTP/HTTPS URL to fetch' }),
99
+ });
100
+
101
+ const WebSearchParams = Type.Object({
102
+ query: Type.String({ description: 'The search query or keywords to look up on the internet' }),
103
+ });
104
+
77
105
  const InsertHeadingParams = Type.Object({
78
106
  path: Type.String({ description: 'Relative file path' }),
79
107
  heading: Type.String({ description: 'Heading text to find (e.g. "## Tasks" or just "Tasks")' }),
@@ -86,6 +114,13 @@ const UpdateSectionParams = Type.Object({
86
114
  content: Type.String({ description: 'New content for the section' }),
87
115
  });
88
116
 
117
+ const EditLinesParams = Type.Object({
118
+ path: Type.String({ description: 'Relative file path' }),
119
+ start_line: Type.Number({ description: '1-indexed line number to start replacing' }),
120
+ end_line: Type.Number({ description: '1-indexed line number to stop replacing (inclusive)' }),
121
+ content: Type.String({ description: 'New content to insert in place of those lines' }),
122
+ });
123
+
89
124
  const RenameParams = Type.Object({
90
125
  path: Type.String({ description: 'Current relative file path' }),
91
126
  new_name: Type.String({ description: 'New filename (no path separators, e.g. "new-name.md")' }),
@@ -115,8 +150,8 @@ const CsvAppendParams = Type.Object({
115
150
 
116
151
  // Write-operation tool names — used by beforeToolCall for write-protection
117
152
  export const WRITE_TOOLS = new Set([
118
- 'write_file', 'create_file', 'append_to_file', 'insert_after_heading',
119
- 'update_section', 'delete_file', 'rename_file', 'move_file', 'append_csv',
153
+ 'write_file', 'create_file', 'batch_create_files', 'append_to_file', 'insert_after_heading',
154
+ 'update_section', 'edit_lines', 'delete_file', 'rename_file', 'move_file', 'append_csv',
120
155
  ]);
121
156
 
122
157
  export const knowledgeBaseTools: AgentTool<any>[] = [
@@ -171,13 +206,39 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
171
206
  {
172
207
  name: 'read_file',
173
208
  label: 'Read File',
174
- description: 'Read the content of a file by its relative path. Always read a file before modifying it.',
209
+ description: 'Read the content of a file by its relative path. Always read a file before modifying it. If the file is too large, it will be truncated. Use read_file_chunk to read specific parts of large files.',
175
210
  parameters: PathParam,
176
211
  execute: safeExecute(async (_id, params: Static<typeof PathParam>) => {
177
212
  return textResult(truncate(getFileContent(params.path)));
178
213
  }),
179
214
  },
180
215
 
216
+ {
217
+ name: 'read_file_chunk',
218
+ label: 'Read File Chunk',
219
+ description: 'Read a specific range of lines from a file. Highly recommended for reading large files that were truncated by read_file.',
220
+ parameters: ReadFileChunkParams,
221
+ execute: safeExecute(async (_id, params: Static<typeof ReadFileChunkParams>) => {
222
+ const content = getFileContent(params.path);
223
+ const lines = content.split('\n');
224
+ const start = Math.max(1, params.start_line);
225
+ const end = Math.min(lines.length, params.end_line);
226
+
227
+ if (start > end) {
228
+ return textResult(`Error: start_line (${start}) is greater than end_line (${end}) or file has fewer lines.`);
229
+ }
230
+
231
+ // Prefix each line with its line number (1-indexed)
232
+ const pad = String(lines.length).length;
233
+ const chunk = lines
234
+ .slice(start - 1, end)
235
+ .map((l, i) => `${String(start + i).padStart(pad, ' ')} | ${l}`)
236
+ .join('\n');
237
+
238
+ return textResult(`Showing lines ${start} to ${end} of ${lines.length}:\n\n${chunk}`);
239
+ }),
240
+ },
241
+
181
242
  {
182
243
  name: 'search',
183
244
  label: 'Search',
@@ -190,6 +251,144 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
190
251
  }),
191
252
  },
192
253
 
254
+ {
255
+ name: 'web_search',
256
+ label: 'Web Search',
257
+ description: 'Search the internet for up-to-date information. Uses DuckDuckGo HTML search. Returns top search results with titles, snippets, and URLs.',
258
+ parameters: WebSearchParams,
259
+ execute: safeExecute(async (_id, params: Static<typeof WebSearchParams>) => {
260
+ try {
261
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(params.query)}`;
262
+ const res = await fetch(url, {
263
+ headers: {
264
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
265
+ 'Accept-Language': 'en-US,en;q=0.9',
266
+ },
267
+ signal: AbortSignal.timeout(10000)
268
+ });
269
+
270
+ if (!res.ok) {
271
+ return textResult(`Failed to search: HTTP ${res.status}`);
272
+ }
273
+
274
+ const html = await res.text();
275
+ const results: string[] = [];
276
+
277
+ // Simple regex parsing for DuckDuckGo HTML results
278
+ const resultBlocks = html.split('class="result__body"').slice(1);
279
+
280
+ for (let i = 0; i < Math.min(resultBlocks.length, 5); i++) {
281
+ const block = resultBlocks[i];
282
+
283
+ const titleMatch = block.match(/class="result__title"[^>]*>[\s\S]*?<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
284
+ const snippetMatch = block.match(/class="result__snippet[^>]*>([\s\S]*?)(?:<\/a>|<\/div>)/i);
285
+
286
+ if (titleMatch) {
287
+ let link = titleMatch[1];
288
+ // Decode DuckDuckGo redirect URL if necessary
289
+ if (link.startsWith('//duckduckgo.com/l/?uddg=')) {
290
+ const urlParam = new URL('https:' + link).searchParams.get('uddg');
291
+ if (urlParam) link = decodeURIComponent(urlParam);
292
+ }
293
+
294
+ // Clean up tags
295
+ const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
296
+ const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
297
+
298
+ results.push(`### ${i+1}. ${title}\n**URL:** ${link}\n**Snippet:** ${snippet}\n`);
299
+ }
300
+ }
301
+
302
+ if (results.length === 0) {
303
+ return textResult(`No web search results found for: ${params.query}`);
304
+ }
305
+
306
+ return textResult(`## Web Search Results for: "${params.query}"\n\n${results.join('\n')}\n\n*Note: Use web_fetch tool with any of the URLs above to read the full page content.*`);
307
+ } catch (err) {
308
+ return textResult(`Web search failed: ${formatToolError(err)}`);
309
+ }
310
+ }),
311
+ },
312
+
313
+ {
314
+ name: 'web_fetch',
315
+ label: 'Web Fetch',
316
+ description: 'Fetch the text content of any public URL. Extracts main text from HTML and converts it to Markdown. Use this to read external docs, repos, or articles.',
317
+ parameters: FetchUrlParams,
318
+ execute: safeExecute(async (_id, params: Static<typeof FetchUrlParams>) => {
319
+ let url = params.url;
320
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
321
+ url = 'https://' + url;
322
+ }
323
+
324
+ try {
325
+ const res = await fetch(url, {
326
+ headers: {
327
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
328
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
329
+ },
330
+ // Don't wait forever
331
+ signal: AbortSignal.timeout(10000)
332
+ });
333
+
334
+ if (!res.ok) {
335
+ return textResult(`Failed to fetch URL: HTTP ${res.status} ${res.statusText}`);
336
+ }
337
+
338
+ const contentType = res.headers.get('content-type') || '';
339
+
340
+ // If it's a raw file (like raw.githubusercontent.com or a raw text file)
341
+ if (contentType.includes('text/plain') || contentType.includes('application/json') || url.includes('raw.githubusercontent.com')) {
342
+ const text = await res.text();
343
+ return textResult(truncate(text));
344
+ }
345
+
346
+ // For HTML, we do a basic extraction (in a real app you might use JSDOM/Readability, but we'll do a robust regex cleanup here to avoid new dependencies)
347
+ let html = await res.text();
348
+
349
+ // Extract title if possible
350
+ const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
351
+ const title = titleMatch ? titleMatch[1].trim() : url;
352
+
353
+ // Strip out scripts, styles, svg, and headers/footers roughly
354
+ html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
355
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
356
+ .replace(/<svg\b[^<]*(?:(?!<\/svg>)<[^<]*)*<\/svg>/gi, ' ')
357
+ .replace(/<header\b[^<]*(?:(?!<\/header>)<[^<]*)*<\/header>/gi, ' ')
358
+ .replace(/<footer\b[^<]*(?:(?!<\/footer>)<[^<]*)*<\/footer>/gi, ' ')
359
+ .replace(/<nav\b[^<]*(?:(?!<\/nav>)<[^<]*)*<\/nav>/gi, ' ');
360
+
361
+ // Convert some basic tags to markdown equivalents roughly before stripping all HTML
362
+ html = html.replace(/<h[1-2][^>]*>(.*?)<\/h[1-2]>/gi, '\n\n# $1\n\n')
363
+ .replace(/<h[3-6][^>]*>(.*?)<\/h[3-6]>/gi, '\n\n## $1\n\n')
364
+ .replace(/<p[^>]*>(.*?)<\/p>/gi, '\n\n$1\n\n')
365
+ .replace(/<li[^>]*>(.*?)<\/li>/gi, '\n- $1')
366
+ .replace(/<br\s*\/?>/gi, '\n');
367
+
368
+ // Strip remaining HTML tags
369
+ let text = html.replace(/<[^>]+>/g, ' ');
370
+
371
+ // Decode common HTML entities
372
+ text = text.replace(/&nbsp;/g, ' ')
373
+ .replace(/&amp;/g, ' ')
374
+ .replace(/&lt;/g, '<')
375
+ .replace(/&gt;/g, '>')
376
+ .replace(/&quot;/g, '"')
377
+ .replace(/&#39;/g, "'");
378
+
379
+ // Clean up whitespace: remove empty lines and extra spaces
380
+ text = text.replace(/[ \t]+/g, ' ')
381
+ .replace(/\n\s*\n\s*\n/g, '\n\n')
382
+ .trim();
383
+
384
+ const result = `# ${title}\nSource: ${url}\n\n${text}`;
385
+ return textResult(truncate(result));
386
+ } catch (err) {
387
+ return textResult(`Failed to fetch URL: ${formatToolError(err)}`);
388
+ }
389
+ }),
390
+ },
391
+
193
392
  {
194
393
  name: 'get_recent',
195
394
  label: 'Recent Files',
@@ -223,6 +422,28 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
223
422
  }),
224
423
  },
225
424
 
425
+ {
426
+ name: 'batch_create_files',
427
+ label: 'Batch Create Files',
428
+ description: 'Create multiple new files in a single operation. Highly recommended when scaffolding new features or projects.',
429
+ parameters: BatchCreateFileParams,
430
+ execute: safeExecute(async (_id, params: Static<typeof BatchCreateFileParams>) => {
431
+ const created: string[] = [];
432
+ const errors: string[] = [];
433
+ for (const file of params.files) {
434
+ try {
435
+ createFile(file.path, file.content);
436
+ created.push(file.path);
437
+ } catch (e) {
438
+ errors.push(`${file.path}: ${formatToolError(e)}`);
439
+ }
440
+ }
441
+ let msg = `Batch creation complete.\nCreated ${created.length} files: ${created.join(', ')}`;
442
+ if (errors.length > 0) msg += `\n\nFailed to create ${errors.length} files:\n${errors.join('\n')}`;
443
+ return textResult(msg);
444
+ }),
445
+ },
446
+
226
447
  {
227
448
  name: 'append_to_file',
228
449
  label: 'Append to File',
@@ -237,7 +458,7 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
237
458
  {
238
459
  name: 'insert_after_heading',
239
460
  label: 'Insert After Heading',
240
- description: 'Insert content right after a Markdown heading. Useful for adding items under a specific section.',
461
+ description: 'Insert content right after a Markdown heading. Useful for adding items under a specific section. If heading matches fail, use edit_lines instead.',
241
462
  parameters: InsertHeadingParams,
242
463
  execute: safeExecute(async (_id, params: Static<typeof InsertHeadingParams>) => {
243
464
  insertAfterHeading(params.path, params.heading, params.content);
@@ -248,7 +469,7 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
248
469
  {
249
470
  name: 'update_section',
250
471
  label: 'Update Section',
251
- description: 'Replace the content of a Markdown section identified by its heading. The section spans from the heading to the next heading of equal or higher level.',
472
+ description: 'Replace the content of a Markdown section identified by its heading. The section spans from the heading to the next heading of equal or higher level. If heading matches fail, use edit_lines instead.',
252
473
  parameters: UpdateSectionParams,
253
474
  execute: safeExecute(async (_id, params: Static<typeof UpdateSectionParams>) => {
254
475
  updateSection(params.path, params.heading, params.content);
@@ -256,6 +477,24 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
256
477
  }),
257
478
  },
258
479
 
480
+ {
481
+ name: 'edit_lines',
482
+ label: 'Edit Lines',
483
+ description: 'Replace a specific range of lines with new content. Extremely reliable for precise edits. You must know the exact line numbers (use read_file_chunk to get them).',
484
+ parameters: EditLinesParams,
485
+ execute: safeExecute(async (_id, params: Static<typeof EditLinesParams>) => {
486
+ const { path: fp, start_line, end_line, content } = params;
487
+ const start = Math.max(0, start_line - 1);
488
+ const end = Math.max(0, end_line - 1);
489
+
490
+ const mindRoot = getMindRoot();
491
+ // Import the core function dynamically or it should be added to lib/fs.ts
492
+ const { updateLines } = await import('@/lib/core');
493
+ updateLines(mindRoot, fp, start, end, content.split('\n'));
494
+ return textResult(`Lines ${start_line}-${end_line} replaced in ${fp}`);
495
+ }),
496
+ },
497
+
259
498
  {
260
499
  name: 'delete_file',
261
500
  label: 'Delete File',
@@ -28,11 +28,19 @@ export function findBacklinks(mindRoot: string, targetPath: string): BacklinkEnt
28
28
  const lines = content.split('\n');
29
29
  for (let i = 0; i < lines.length; i++) {
30
30
  if (patterns.some(p => p.test(lines[i]))) {
31
- const start = Math.max(0, i - 1);
32
- const end = Math.min(lines.length - 1, i + 1);
33
- const ctx = lines.slice(start, end + 1).join('\n').trim();
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
+ let start = i;
34
+ while (start > 0 && start > i - 3 && lines[start].trim() !== '') start--;
35
+ let end = i;
36
+ while (end < lines.length - 1 && end < i + 3 && lines[end].trim() !== '') end++;
37
+
38
+ 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
+ ctx = ctx.replace(/\n{2,}/g, ' ↵ ');
41
+
34
42
  results.push({ source: filePath, line: i + 1, context: ctx });
35
- break;
43
+ break; // currently only records the first match per file
36
44
  }
37
45
  }
38
46
  }
@@ -0,0 +1,97 @@
1
+ import path from 'path';
2
+
3
+ export const ALLOWED_IMPORT_EXTENSIONS = new Set([
4
+ '.txt', '.md', '.markdown', '.csv', '.json', '.yaml', '.yml', '.xml', '.html', '.htm', '.pdf',
5
+ ]);
6
+
7
+ export interface ConvertResult {
8
+ content: string;
9
+ originalName: string;
10
+ targetName: string;
11
+ metadata?: Record<string, string>;
12
+ }
13
+
14
+ export function sanitizeFileName(name: string): string {
15
+ let base = name.replace(/\\/g, '/').split('/').pop() ?? '';
16
+ base = base.replace(/\.\./g, '').replace(/^\/+/, '');
17
+ base = base.replace(/[\\:*?"<>|]/g, '-');
18
+ base = base.replace(/-{2,}/g, '-');
19
+ base = base.replace(/^[-\s]+|[-\s]+$/g, '');
20
+ return base || 'imported-file';
21
+ }
22
+
23
+ export function titleFromFileName(name: string): string {
24
+ const ext = path.extname(name);
25
+ const stem = (ext ? name.slice(0, -ext.length) : name).replace(/^\.+/, '');
26
+ const words = stem.replace(/[-_]+/g, ' ').trim().split(/\s+/);
27
+ if (words.length === 0 || (words.length === 1 && !words[0])) return 'Untitled';
28
+ return words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
29
+ }
30
+
31
+ function stripHtmlTags(html: string): string {
32
+ return html
33
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
34
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
35
+ .replace(/<[^>]+>/g, '')
36
+ .replace(/&nbsp;/g, ' ')
37
+ .replace(/&amp;/g, '&')
38
+ .replace(/&lt;/g, '<')
39
+ .replace(/&gt;/g, '>')
40
+ .replace(/&quot;/g, '"')
41
+ .replace(/\n{3,}/g, '\n\n')
42
+ .trim();
43
+ }
44
+
45
+ export function convertToMarkdown(fileName: string, rawContent: string): ConvertResult {
46
+ const originalName = fileName;
47
+ const ext = path.extname(fileName).toLowerCase();
48
+ const stem = path.basename(fileName, ext) || 'note';
49
+ const title = titleFromFileName(fileName);
50
+
51
+ if (ext === '.md' || ext === '.markdown') {
52
+ return { content: rawContent, originalName, targetName: sanitizeFileName(fileName) };
53
+ }
54
+
55
+ if (ext === '.csv' || ext === '.json') {
56
+ return { content: rawContent, originalName, targetName: sanitizeFileName(fileName) };
57
+ }
58
+
59
+ if (ext === '.txt') {
60
+ return {
61
+ content: `# ${title}\n\n${rawContent}`,
62
+ originalName,
63
+ targetName: sanitizeFileName(`${stem}.md`),
64
+ };
65
+ }
66
+
67
+ if (ext === '.yaml' || ext === '.yml') {
68
+ return {
69
+ content: `# ${title}\n\n\`\`\`yaml\n${rawContent}\n\`\`\`\n`,
70
+ originalName,
71
+ targetName: sanitizeFileName(`${stem}.md`),
72
+ };
73
+ }
74
+
75
+ if (ext === '.html' || ext === '.htm') {
76
+ const text = stripHtmlTags(rawContent);
77
+ return {
78
+ content: `# ${title}\n\n${text}\n`,
79
+ originalName,
80
+ targetName: sanitizeFileName(`${stem}.md`),
81
+ };
82
+ }
83
+
84
+ if (ext === '.xml') {
85
+ return {
86
+ content: `# ${title}\n\n\`\`\`xml\n${rawContent}\n\`\`\`\n`,
87
+ originalName,
88
+ targetName: sanitizeFileName(`${stem}.md`),
89
+ };
90
+ }
91
+
92
+ return {
93
+ content: `# ${title}\n\n${rawContent}`,
94
+ originalName,
95
+ targetName: sanitizeFileName(`${stem}.md`),
96
+ };
97
+ }
@@ -0,0 +1,105 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { resolveSafe } from './security';
4
+ import { appendContentChange } from './content-changes';
5
+
6
+ export interface OrganizeResult {
7
+ readmeUpdated: boolean;
8
+ relatedFiles: Array<{ path: string; matchType: 'backlink' | 'keyword' }>;
9
+ }
10
+
11
+ const STOP_WORDS = new Set([
12
+ 'the', 'a', 'an', 'is', 'of', 'and', 'for', 'to', 'in', 'on', 'at', 'by', 'or',
13
+ 'not', 'but', 'with', 'from', 'this', 'that', 'was', 'are', 'be', 'has', 'had',
14
+ 'readme', 'instruction', 'md',
15
+ ]);
16
+
17
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.next', '.DS_Store']);
18
+
19
+ function extractKeywords(filePath: string): string[] {
20
+ const stem = path.basename(filePath, path.extname(filePath));
21
+ return stem
22
+ .split(/[-_\s]+/)
23
+ .map(w => w.toLowerCase())
24
+ .filter(w => w.length >= 3 && !STOP_WORDS.has(w));
25
+ }
26
+
27
+ function collectMdFiles(dir: string, limit: number): string[] {
28
+ const results: string[] = [];
29
+ function walk(d: string) {
30
+ if (results.length >= limit) return;
31
+ let entries: fs.Dirent[];
32
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
33
+ for (const entry of entries) {
34
+ if (results.length >= limit) return;
35
+ if (entry.isDirectory()) {
36
+ if (!SKIP_DIRS.has(entry.name)) walk(path.join(d, entry.name));
37
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
38
+ results.push(path.relative(dir, path.join(d, entry.name)).replace(/\\/g, '/'));
39
+ }
40
+ }
41
+ }
42
+ walk(dir);
43
+ return results;
44
+ }
45
+
46
+ export function organizeAfterImport(
47
+ mindRoot: string,
48
+ createdFiles: string[],
49
+ targetSpace: string,
50
+ ): OrganizeResult {
51
+ for (const fp of createdFiles) {
52
+ try {
53
+ appendContentChange(mindRoot, {
54
+ op: 'import_file',
55
+ path: fp,
56
+ source: 'user',
57
+ summary: 'Imported file into knowledge base',
58
+ });
59
+ } catch { /* ignore */ }
60
+ }
61
+
62
+ let readmeUpdated = false;
63
+ const space = targetSpace.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').trim();
64
+
65
+ if (space && createdFiles.length > 0) {
66
+ const readmePath = path.posix.join(space, 'README.md');
67
+ try {
68
+ const resolved = resolveSafe(mindRoot, readmePath);
69
+ if (fs.existsSync(resolved)) {
70
+ const existing = fs.readFileSync(resolved, 'utf-8');
71
+ const bullets = createdFiles.map(f => {
72
+ const base = path.posix.basename(f);
73
+ return `- [${base}](./${base})`;
74
+ }).join('\n');
75
+ fs.writeFileSync(resolved, `${existing.trimEnd()}\n\n${bullets}\n`, 'utf-8');
76
+ readmeUpdated = true;
77
+ }
78
+ } catch { /* README missing or write failed */ }
79
+ }
80
+
81
+ const createdSet = new Set(createdFiles);
82
+ const allKeywords = new Set<string>();
83
+ for (const f of createdFiles) {
84
+ for (const kw of extractKeywords(f)) allKeywords.add(kw);
85
+ }
86
+
87
+ const relatedFiles: OrganizeResult['relatedFiles'] = [];
88
+ if (allKeywords.size > 0) {
89
+ const candidates = collectMdFiles(mindRoot, 50);
90
+ const kwArray = [...allKeywords];
91
+ for (const candidate of candidates) {
92
+ if (createdSet.has(candidate)) continue;
93
+ if (relatedFiles.length >= 10) break;
94
+ try {
95
+ const resolved = resolveSafe(mindRoot, candidate);
96
+ const content = fs.readFileSync(resolved, 'utf-8').toLowerCase();
97
+ if (kwArray.some(kw => content.includes(kw))) {
98
+ relatedFiles.push({ path: candidate, matchType: 'keyword' });
99
+ }
100
+ } catch { /* ignore */ }
101
+ }
102
+ }
103
+
104
+ return { readmeUpdated, relatedFiles };
105
+ }
@@ -91,9 +91,23 @@ export function searchFiles(mindRoot: string, query: string, opts: SearchOptions
91
91
  const index = lowerContent.indexOf(lowerQuery);
92
92
  if (index === -1) continue;
93
93
 
94
- const snippetStart = Math.max(0, index - 60);
95
- const snippetEnd = Math.min(content.length, index + query.length + 60);
96
- let snippet = content.slice(snippetStart, snippetEnd).replace(/\n/g, ' ').trim();
94
+ // Try to find natural boundaries (paragraphs) around the match
95
+ let snippetStart = content.lastIndexOf('\n\n', index);
96
+ if (snippetStart === -1) snippetStart = Math.max(0, index - 200);
97
+ else snippetStart += 2; // skip the newlines
98
+
99
+ let snippetEnd = content.indexOf('\n\n', index);
100
+ if (snippetEnd === -1) snippetEnd = Math.min(content.length, index + query.length + 200);
101
+
102
+ // Prevent massive blocks (cap at ~400 chars total)
103
+ if (index - snippetStart > 200) snippetStart = index - 200;
104
+ if (snippetEnd - index > 200) snippetEnd = index + query.length + 200;
105
+
106
+ let snippet = content.slice(snippetStart, snippetEnd).trim();
107
+
108
+ // Collapse internal whitespace for cleaner search result presentation, but preserve some structure
109
+ snippet = snippet.replace(/\n{3,}/g, '\n\n');
110
+
97
111
  if (snippetStart > 0) snippet = '...' + snippet;
98
112
  if (snippetEnd < content.length) snippet += '...';
99
113
 
package/app/lib/fs.ts CHANGED
@@ -525,8 +525,8 @@ function generateSnippet(
525
525
  }
526
526
  }
527
527
 
528
- const snippetStart = Math.max(0, bestStart - 60);
529
- const snippetEnd = Math.min(content.length, bestEnd + 61);
528
+ const snippetStart = Math.max(0, bestStart - 120);
529
+ const snippetEnd = Math.min(content.length, bestEnd + 120);
530
530
 
531
531
  let start = snippetStart;
532
532
  if (start > 0) {
@@ -539,7 +539,9 @@ function generateSnippet(
539
539
  if (spaceIdx > bestEnd) end = spaceIdx;
540
540
  }
541
541
 
542
- let snippet = content.slice(start, end).replace(/\n/g, ' ').trim();
542
+ let snippet = content.slice(start, end).trim();
543
+ // Collapse multiple newlines into spaces but keep single newlines
544
+ snippet = snippet.replace(/\n{2,}/g, ' ↵ ');
543
545
  if (start > 0) snippet = '...' + snippet;
544
546
  if (end < content.length) snippet = snippet + '...';
545
547
  return snippet;
@@ -657,6 +657,57 @@ export const en = {
657
657
  convertToSpace: 'Convert to Space',
658
658
  deleteFolder: 'Delete Folder',
659
659
  confirmDeleteFolder: (name: string) => `Delete folder "${name}" and all its contents? This cannot be undone.`,
660
+ newFile: 'New File',
661
+ importFile: 'Import File',
662
+ importToSpace: 'Import file to this space',
663
+ },
664
+ fileImport: {
665
+ title: 'Import Files',
666
+ subtitle: 'Save files to your knowledge base or let AI organize them',
667
+ dropzoneText: 'Drag files here, or',
668
+ dropzoneButton: 'click to select',
669
+ dropzoneCompact: 'Drag more files, or',
670
+ dropzoneCompactButton: 'click to add',
671
+ dropzoneMobile: 'Tap to select files',
672
+ fileCount: (n: number) => `${n} file${n !== 1 ? 's' : ''}`,
673
+ clearAll: 'Clear all',
674
+ unsupported: 'Unsupported file type',
675
+ tooLarge: (max: string) => `File too large (max ${max})`,
676
+ archiveTitle: 'Save to Knowledge Base',
677
+ archiveDesc: 'Save as-is to a space',
678
+ digestTitle: 'AI Organize',
679
+ digestDesc: 'Extract key points into notes',
680
+ archiveConfigTitle: 'Save to Knowledge Base',
681
+ back: '← Back',
682
+ targetSpace: 'Target space',
683
+ rootDir: 'Root',
684
+ conflictLabel: 'If file already exists',
685
+ conflictRename: 'Auto-rename (add number suffix)',
686
+ conflictSkip: 'Skip',
687
+ conflictOverwrite: 'Overwrite existing file',
688
+ overwriteWarn: 'This will permanently replace existing file content',
689
+ cancel: 'Cancel',
690
+ importButton: (n: number) => `Save ${n} file${n !== 1 ? 's' : ''}`,
691
+ importing: 'Saving...',
692
+ preparing: 'Preparing...',
693
+ successToast: (n: number, space: string) => `Saved ${n} file${n !== 1 ? 's' : ''} to ${space || 'knowledge base'}`,
694
+ updatedIndex: (n: number) => `Updated ${n} index file${n !== 1 ? 's' : ''}`,
695
+ partialToast: (ok: number, total: number) => `Saved ${ok}/${total} files`,
696
+ skipReason: 'File already exists',
697
+ failToast: 'Import failed',
698
+ retry: 'Retry',
699
+ undo: 'Undo',
700
+ discardTitle: 'Discard import?',
701
+ discardMessage: (n: number) => `Discard ${n} selected file${n !== 1 ? 's' : ''}?`,
702
+ discardConfirm: 'Discard',
703
+ discardCancel: 'Cancel',
704
+ dropOverlay: 'Drop files to import into knowledge base',
705
+ dropOverlayFormats: 'Supports .md .txt .pdf .csv .json .yaml .html',
706
+ onboardingHint: 'Already have notes? Import files →',
707
+ digestPromptSingle: (name: string) => `Please read ${name}, extract key information and organize it into the appropriate place in my knowledge base.`,
708
+ digestPromptMulti: (n: number) => `Please read these ${n} files, extract key information and organize each into the appropriate place in my knowledge base.`,
709
+ arrowTo: '→',
710
+ remove: 'Remove',
660
711
  },
661
712
  dirView: {
662
713
  gridView: 'Grid view',