@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.
- package/app/app/api/ask/route.ts +12 -4
- package/app/app/api/file/import/route.ts +197 -0
- package/app/app/api/mcp/install/route.ts +99 -12
- package/app/app/api/mcp/status/route.ts +1 -1
- package/app/components/ActivityBar.tsx +3 -4
- package/app/components/FileTree.tsx +35 -9
- package/app/components/ImportModal.tsx +415 -0
- package/app/components/OnboardingView.tsx +9 -0
- package/app/components/Panel.tsx +4 -2
- package/app/components/SidebarLayout.tsx +83 -8
- package/app/components/TableOfContents.tsx +1 -0
- package/app/components/agents/AgentDetailContent.tsx +37 -28
- package/app/components/agents/AgentsMcpSection.tsx +16 -12
- package/app/components/agents/AgentsOverviewSection.tsx +48 -34
- package/app/components/agents/AgentsPrimitives.tsx +41 -20
- package/app/components/agents/AgentsSkillsSection.tsx +16 -7
- package/app/components/agents/SkillDetailPopover.tsx +13 -11
- package/app/components/ask/AskContent.tsx +11 -0
- package/app/components/panels/AgentsPanelAgentGroups.tsx +8 -6
- package/app/components/panels/AgentsPanelHubNav.tsx +3 -3
- package/app/components/panels/DiscoverPanel.tsx +88 -2
- package/app/hooks/useFileImport.ts +191 -0
- package/app/hooks/useFileUpload.ts +11 -0
- package/app/lib/agent/context.ts +7 -2
- package/app/lib/agent/tools.ts +245 -6
- package/app/lib/core/backlinks.ts +12 -4
- package/app/lib/core/file-convert.ts +97 -0
- package/app/lib/core/organize.ts +105 -0
- package/app/lib/core/search.ts +17 -3
- package/app/lib/fs.ts +5 -3
- package/app/lib/i18n-en.ts +51 -0
- package/app/lib/i18n-zh.ts +51 -0
- package/package.json +1 -1
package/app/lib/agent/tools.ts
CHANGED
|
@@ -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
|
-
|
|
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(/ /g, ' ')
|
|
373
|
+
.replace(/&/g, ' ')
|
|
374
|
+
.replace(/</g, '<')
|
|
375
|
+
.replace(/>/g, '>')
|
|
376
|
+
.replace(/"/g, '"')
|
|
377
|
+
.replace(/'/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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(/ /g, ' ')
|
|
37
|
+
.replace(/&/g, '&')
|
|
38
|
+
.replace(/</g, '<')
|
|
39
|
+
.replace(/>/g, '>')
|
|
40
|
+
.replace(/"/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
|
+
}
|
package/app/lib/core/search.ts
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 -
|
|
529
|
-
const snippetEnd = Math.min(content.length, bestEnd +
|
|
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).
|
|
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;
|
package/app/lib/i18n-en.ts
CHANGED
|
@@ -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',
|