@geminilight/mindos 0.5.69 → 0.6.0

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 (58) hide show
  1. package/app/app/api/ask/route.ts +122 -92
  2. package/app/app/api/file/import/route.ts +197 -0
  3. package/app/app/api/mcp/agents/route.ts +53 -2
  4. package/app/app/api/mcp/status/route.ts +1 -1
  5. package/app/app/api/skills/route.ts +10 -114
  6. package/app/components/ActivityBar.tsx +5 -7
  7. package/app/components/CreateSpaceModal.tsx +31 -6
  8. package/app/components/FileTree.tsx +68 -11
  9. package/app/components/GuideCard.tsx +197 -131
  10. package/app/components/HomeContent.tsx +85 -18
  11. package/app/components/ImportModal.tsx +415 -0
  12. package/app/components/OnboardingView.tsx +9 -0
  13. package/app/components/Panel.tsx +4 -2
  14. package/app/components/SidebarLayout.tsx +96 -8
  15. package/app/components/SpaceInitToast.tsx +173 -0
  16. package/app/components/TableOfContents.tsx +1 -0
  17. package/app/components/agents/AgentDetailContent.tsx +69 -45
  18. package/app/components/agents/AgentsContentPage.tsx +2 -1
  19. package/app/components/agents/AgentsMcpSection.tsx +16 -12
  20. package/app/components/agents/AgentsOverviewSection.tsx +37 -36
  21. package/app/components/agents/AgentsPrimitives.tsx +41 -20
  22. package/app/components/agents/AgentsSkillsSection.tsx +16 -7
  23. package/app/components/agents/SkillDetailPopover.tsx +11 -11
  24. package/app/components/agents/agents-content-model.ts +16 -8
  25. package/app/components/ask/AskContent.tsx +148 -50
  26. package/app/components/ask/MentionPopover.tsx +16 -8
  27. package/app/components/ask/SlashCommandPopover.tsx +62 -0
  28. package/app/components/panels/AgentsPanelAgentGroups.tsx +8 -6
  29. package/app/components/panels/AgentsPanelHubNav.tsx +3 -3
  30. package/app/components/panels/DiscoverPanel.tsx +88 -2
  31. package/app/components/settings/KnowledgeTab.tsx +61 -0
  32. package/app/components/walkthrough/steps.ts +11 -6
  33. package/app/hooks/useFileImport.ts +191 -0
  34. package/app/hooks/useFileUpload.ts +11 -0
  35. package/app/hooks/useMention.ts +14 -6
  36. package/app/hooks/useSlashCommand.ts +114 -0
  37. package/app/lib/actions.ts +79 -2
  38. package/app/lib/agent/index.ts +1 -1
  39. package/app/lib/agent/prompt.ts +2 -0
  40. package/app/lib/agent/tools.ts +252 -0
  41. package/app/lib/core/create-space.ts +11 -4
  42. package/app/lib/core/file-convert.ts +97 -0
  43. package/app/lib/core/index.ts +1 -1
  44. package/app/lib/core/organize.ts +105 -0
  45. package/app/lib/i18n-en.ts +102 -46
  46. package/app/lib/i18n-zh.ts +101 -45
  47. package/app/lib/mcp-agents.ts +8 -0
  48. package/app/lib/pdf-extract.ts +33 -0
  49. package/app/lib/pi-integration/extensions.ts +45 -0
  50. package/app/lib/pi-integration/mcporter.ts +219 -0
  51. package/app/lib/pi-integration/session-store.ts +62 -0
  52. package/app/lib/pi-integration/skills.ts +116 -0
  53. package/app/lib/settings.ts +1 -1
  54. package/app/next-env.d.ts +1 -1
  55. package/app/next.config.ts +1 -1
  56. package/app/package.json +2 -0
  57. package/mcp/src/index.ts +29 -0
  58. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import path from 'path';
1
2
  import { Type, type Static } from '@sinclair/typebox';
2
3
  import type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core';
3
4
  import {
@@ -6,6 +7,8 @@ import {
6
7
  deleteFile, renameFile, moveFile, findBacklinks, gitLog, gitShowFile, appendCsvRow,
7
8
  getMindRoot,
8
9
  } from '@/lib/fs';
10
+ import { readSkillContentByName, scanSkillDirs } from '@/lib/pi-integration/skills';
11
+ import { callMcporterTool, createMcporterAgentTools, listMcporterServers, listMcporterTools } from '@/lib/pi-integration/mcporter';
9
12
 
10
13
  // Max chars per file to avoid token overflow (~100k chars ≈ ~25k tokens)
11
14
  const MAX_FILE_CHARS = 20_000;
@@ -94,6 +97,14 @@ const AppendParams = Type.Object({
94
97
  content: Type.String({ description: 'Content to append' }),
95
98
  });
96
99
 
100
+ const FetchUrlParams = Type.Object({
101
+ url: Type.String({ description: 'The HTTP/HTTPS URL to fetch' }),
102
+ });
103
+
104
+ const WebSearchParams = Type.Object({
105
+ query: Type.String({ description: 'The search query or keywords to look up on the internet' }),
106
+ });
107
+
97
108
  const InsertHeadingParams = Type.Object({
98
109
  path: Type.String({ description: 'Relative file path' }),
99
110
  heading: Type.String({ description: 'Heading text to find (e.g. "## Tasks" or just "Tasks")' }),
@@ -138,6 +149,22 @@ const CsvAppendParams = Type.Object({
138
149
  row: Type.Array(Type.String(), { description: 'Array of cell values for the new row' }),
139
150
  });
140
151
 
152
+ const ListSkillsParams = Type.Object({});
153
+
154
+ const LoadSkillParams = Type.Object({
155
+ name: Type.String({ description: 'Skill name, e.g. "mindos" or "context7"' }),
156
+ });
157
+
158
+ const ListMcpToolsParams = Type.Object({
159
+ server: Type.Optional(Type.String({ description: 'Optional MCP server name. Omit to list discovered servers only.' })),
160
+ });
161
+
162
+ const CallMcpToolParams = Type.Object({
163
+ server: Type.String({ description: 'MCP server name discovered via list_mcp_tools' }),
164
+ tool: Type.String({ description: 'Exact MCP tool name to invoke' }),
165
+ arguments_json: Type.Optional(Type.String({ description: 'Optional JSON object string of tool arguments. Example: {"query":"react hooks"}' })),
166
+ });
167
+
141
168
  // ─── Tool Definitions (AgentTool interface) ─────────────────────────────────
142
169
 
143
170
  // Write-operation tool names — used by beforeToolCall for write-protection
@@ -146,6 +173,28 @@ export const WRITE_TOOLS = new Set([
146
173
  'update_section', 'edit_lines', 'delete_file', 'rename_file', 'move_file', 'append_csv',
147
174
  ]);
148
175
 
176
+ export async function getRequestScopedTools(): Promise<AgentTool<any>[]> {
177
+ try {
178
+ const result = await listMcporterServers();
179
+ const okServers = (result.servers ?? []).filter((server) => server.status === 'ok');
180
+ if (okServers.length === 0) return knowledgeBaseTools;
181
+
182
+ const detailedServers = await Promise.all(okServers.map(async (server) => {
183
+ try {
184
+ return await listMcporterTools(server.name);
185
+ } catch {
186
+ return server;
187
+ }
188
+ }));
189
+
190
+ const dynamicMcpTools = createMcporterAgentTools(detailedServers);
191
+ if (dynamicMcpTools.length === 0) return knowledgeBaseTools;
192
+ return [...knowledgeBaseTools, ...dynamicMcpTools];
193
+ } catch {
194
+ return knowledgeBaseTools;
195
+ }
196
+ }
197
+
149
198
  export const knowledgeBaseTools: AgentTool<any>[] = [
150
199
  {
151
200
  name: 'list_files',
@@ -243,6 +292,209 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
243
292
  }),
244
293
  },
245
294
 
295
+ {
296
+ name: 'list_skills',
297
+ label: 'List Skills',
298
+ description: 'List available MindOS skills discovered from app/data/skills, skills, {mindRoot}/.skills, and ~/.mindos/skills. Use this before load_skill when you need a skill by name.',
299
+ parameters: ListSkillsParams,
300
+ execute: safeExecute(async () => {
301
+ const projectRoot = process.env.MINDOS_PROJECT_ROOT || path.resolve(process.cwd(), '..');
302
+ const skills = scanSkillDirs({ projectRoot, mindRoot: getMindRoot() });
303
+ if (skills.length === 0) return textResult('No skills found.');
304
+ return textResult(skills.map((skill) => `- **${skill.name}** [${skill.origin}]${skill.enabled ? '' : ' (disabled)'} — ${skill.description || 'No description'}\n Path: ${skill.path}`).join('\n'));
305
+ }),
306
+ },
307
+
308
+ {
309
+ name: 'load_skill',
310
+ label: 'Load Skill',
311
+ description: 'Load the full content of a specific skill by name. Use list_skills first if you do not know the exact skill name.',
312
+ parameters: LoadSkillParams,
313
+ execute: safeExecute(async (_id, params: Static<typeof LoadSkillParams>) => {
314
+ const projectRoot = process.env.MINDOS_PROJECT_ROOT || path.resolve(process.cwd(), '..');
315
+ const content = readSkillContentByName(params.name, { projectRoot, mindRoot: getMindRoot() });
316
+ if (!content) return textResult(`Skill not found: ${params.name}`);
317
+ return textResult(truncate(content));
318
+ }),
319
+ },
320
+
321
+ {
322
+ name: 'list_mcp_tools',
323
+ label: 'List MCP Tools',
324
+ description: 'List MCP servers configured in ~/.mindos/mcp.json. Without `server`, lists discovered servers and their health. With `server`, lists that server\'s tools and JSON schemas.',
325
+ parameters: ListMcpToolsParams,
326
+ execute: safeExecute(async (_id, params: Static<typeof ListMcpToolsParams>) => {
327
+ if (!params.server) {
328
+ const result = await listMcporterServers();
329
+ if (!result.servers || result.servers.length === 0) return textResult('No external MCP servers configured. The MindOS built-in MCP server is always available.');
330
+ return textResult(result.servers.map((server) => `- **${server.name}** — status: ${server.status}${server.transport ? ` | transport: ${server.transport}` : ''}${server.error ? ` | error: ${server.error}` : ''}`).join('\n'));
331
+ }
332
+
333
+ const server = await listMcporterTools(params.server);
334
+ if (!server.tools || server.tools.length === 0) {
335
+ return textResult(`No tools found for MCP server: ${params.server}`);
336
+ }
337
+ return textResult(server.tools.map((tool) => `## ${tool.name}\n${tool.description || 'No description'}\n\nSchema:\n${JSON.stringify(tool.inputSchema ?? {}, null, 2)}`).join('\n\n'));
338
+ }),
339
+ },
340
+
341
+ {
342
+ name: 'call_mcp_tool',
343
+ label: 'Call MCP Tool',
344
+ description: 'Call a specific MCP tool by server and tool name. Pass `arguments_json` as a JSON object string. Use list_mcp_tools first to discover names and schemas.',
345
+ parameters: CallMcpToolParams,
346
+ execute: safeExecute(async (_id, params: Static<typeof CallMcpToolParams>) => {
347
+ let parsedArgs: Record<string, unknown> = {};
348
+ if (params.arguments_json?.trim()) {
349
+ try {
350
+ parsedArgs = JSON.parse(params.arguments_json) as Record<string, unknown>;
351
+ } catch (error) {
352
+ return textResult(`Invalid arguments_json. Expected a JSON object string. Error: ${formatToolError(error)}`);
353
+ }
354
+ }
355
+ const output = await callMcporterTool(params.server, params.tool, parsedArgs);
356
+ return textResult(output || '(empty MCP response)');
357
+ }),
358
+ },
359
+
360
+ {
361
+ name: 'web_search',
362
+ label: 'Web Search',
363
+ description: 'Search the internet for up-to-date information. Uses DuckDuckGo HTML search. Returns top search results with titles, snippets, and URLs.',
364
+ parameters: WebSearchParams,
365
+ execute: safeExecute(async (_id, params: Static<typeof WebSearchParams>) => {
366
+ try {
367
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(params.query)}`;
368
+ const res = await fetch(url, {
369
+ headers: {
370
+ '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',
371
+ 'Accept-Language': 'en-US,en;q=0.9',
372
+ },
373
+ signal: AbortSignal.timeout(10000)
374
+ });
375
+
376
+ if (!res.ok) {
377
+ return textResult(`Failed to search: HTTP ${res.status}`);
378
+ }
379
+
380
+ const html = await res.text();
381
+ const results: string[] = [];
382
+
383
+ // Simple regex parsing for DuckDuckGo HTML results
384
+ const resultBlocks = html.split('class="result__body"').slice(1);
385
+
386
+ for (let i = 0; i < Math.min(resultBlocks.length, 5); i++) {
387
+ const block = resultBlocks[i];
388
+
389
+ const titleMatch = block.match(/class="result__title"[^>]*>[\s\S]*?<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
390
+ const snippetMatch = block.match(/class="result__snippet[^>]*>([\s\S]*?)(?:<\/a>|<\/div>)/i);
391
+
392
+ if (titleMatch) {
393
+ let link = titleMatch[1];
394
+ // Decode DuckDuckGo redirect URL if necessary
395
+ if (link.startsWith('//duckduckgo.com/l/?uddg=')) {
396
+ const urlParam = new URL('https:' + link).searchParams.get('uddg');
397
+ if (urlParam) link = decodeURIComponent(urlParam);
398
+ }
399
+
400
+ // Clean up tags
401
+ const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
402
+ const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
403
+
404
+ results.push(`### ${i+1}. ${title}\n**URL:** ${link}\n**Snippet:** ${snippet}\n`);
405
+ }
406
+ }
407
+
408
+ if (results.length === 0) {
409
+ return textResult(`No web search results found for: ${params.query}`);
410
+ }
411
+
412
+ 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.*`);
413
+ } catch (err) {
414
+ return textResult(`Web search failed: ${formatToolError(err)}`);
415
+ }
416
+ }),
417
+ },
418
+
419
+ {
420
+ name: 'web_fetch',
421
+ label: 'Web Fetch',
422
+ 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.',
423
+ parameters: FetchUrlParams,
424
+ execute: safeExecute(async (_id, params: Static<typeof FetchUrlParams>) => {
425
+ let url = params.url;
426
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
427
+ url = 'https://' + url;
428
+ }
429
+
430
+ try {
431
+ const res = await fetch(url, {
432
+ headers: {
433
+ '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',
434
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
435
+ },
436
+ // Don't wait forever
437
+ signal: AbortSignal.timeout(10000)
438
+ });
439
+
440
+ if (!res.ok) {
441
+ return textResult(`Failed to fetch URL: HTTP ${res.status} ${res.statusText}`);
442
+ }
443
+
444
+ const contentType = res.headers.get('content-type') || '';
445
+
446
+ // If it's a raw file (like raw.githubusercontent.com or a raw text file)
447
+ if (contentType.includes('text/plain') || contentType.includes('application/json') || url.includes('raw.githubusercontent.com')) {
448
+ const text = await res.text();
449
+ return textResult(truncate(text));
450
+ }
451
+
452
+ // 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)
453
+ let html = await res.text();
454
+
455
+ // Extract title if possible
456
+ const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
457
+ const title = titleMatch ? titleMatch[1].trim() : url;
458
+
459
+ // Strip out scripts, styles, svg, and headers/footers roughly
460
+ html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
461
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
462
+ .replace(/<svg\b[^<]*(?:(?!<\/svg>)<[^<]*)*<\/svg>/gi, ' ')
463
+ .replace(/<header\b[^<]*(?:(?!<\/header>)<[^<]*)*<\/header>/gi, ' ')
464
+ .replace(/<footer\b[^<]*(?:(?!<\/footer>)<[^<]*)*<\/footer>/gi, ' ')
465
+ .replace(/<nav\b[^<]*(?:(?!<\/nav>)<[^<]*)*<\/nav>/gi, ' ');
466
+
467
+ // Convert some basic tags to markdown equivalents roughly before stripping all HTML
468
+ html = html.replace(/<h[1-2][^>]*>(.*?)<\/h[1-2]>/gi, '\n\n# $1\n\n')
469
+ .replace(/<h[3-6][^>]*>(.*?)<\/h[3-6]>/gi, '\n\n## $1\n\n')
470
+ .replace(/<p[^>]*>(.*?)<\/p>/gi, '\n\n$1\n\n')
471
+ .replace(/<li[^>]*>(.*?)<\/li>/gi, '\n- $1')
472
+ .replace(/<br\s*\/?>/gi, '\n');
473
+
474
+ // Strip remaining HTML tags
475
+ let text = html.replace(/<[^>]+>/g, ' ');
476
+
477
+ // Decode common HTML entities
478
+ text = text.replace(/&nbsp;/g, ' ')
479
+ .replace(/&amp;/g, ' ')
480
+ .replace(/&lt;/g, '<')
481
+ .replace(/&gt;/g, '>')
482
+ .replace(/&quot;/g, '"')
483
+ .replace(/&#39;/g, "'");
484
+
485
+ // Clean up whitespace: remove empty lines and extra spaces
486
+ text = text.replace(/[ \t]+/g, ' ')
487
+ .replace(/\n\s*\n\s*\n/g, '\n\n')
488
+ .trim();
489
+
490
+ const result = `# ${title}\nSource: ${url}\n\n${text}`;
491
+ return textResult(truncate(result));
492
+ } catch (err) {
493
+ return textResult(`Failed to fetch URL: ${formatToolError(err)}`);
494
+ }
495
+ }),
496
+ },
497
+
246
498
  {
247
499
  name: 'get_recent',
248
500
  label: 'Recent Files',
@@ -1,6 +1,16 @@
1
1
  import { MindOSError, ErrorCodes } from '@/lib/errors';
2
2
  import { createFile } from './fs-ops';
3
3
 
4
+ /**
5
+ * Generate the template README.md content for a new space.
6
+ * Extracted so both createSpaceFilesystem and revert can produce identical content.
7
+ */
8
+ export function generateReadmeTemplate(fullPath: string, name: string, description: string): string {
9
+ const cleanName = name.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || name;
10
+ const desc = description.trim() || '(Describe the purpose and usage of this space.)';
11
+ return `# ${cleanName}\n\n${desc}\n\n## 📁 Structure\n\n\`\`\`bash\n${fullPath}/\n├── INSTRUCTION.md\n├── README.md\n└── (your files here)\n\`\`\`\n\n## 💡 Usage\n\n(Add usage guidelines for this space.)\n`;
12
+ }
13
+
4
14
  /**
5
15
  * Create a Mind Space on disk: `{fullPath}/README.md` plus scaffold from {@link createFile} / scaffoldIfNewSpace.
6
16
  * Caller must invalidate app file-tree cache (e.g. `invalidateCache()` in `lib/fs.ts`).
@@ -26,10 +36,7 @@ export function createSpaceFilesystem(
26
36
 
27
37
  const prefix = cleanParent ? `${cleanParent}/` : '';
28
38
  const fullPath = `${prefix}${trimmed}`;
29
-
30
- const cleanName = trimmed.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || trimmed;
31
- const desc = description.trim() || '(Describe the purpose and usage of this space.)';
32
- const readmeContent = `# ${cleanName}\n\n${desc}\n\n## 📁 Structure\n\n\`\`\`bash\n${fullPath}/\n├── INSTRUCTION.md\n├── README.md\n└── (your files here)\n\`\`\`\n\n## 💡 Usage\n\n(Add usage guidelines for this space.)\n`;
39
+ const readmeContent = generateReadmeTemplate(fullPath, trimmed, description);
33
40
 
34
41
  createFile(mindRoot, `${fullPath}/README.md`, readmeContent);
35
42
  return { path: fullPath };
@@ -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
+ }
@@ -61,7 +61,7 @@ export { findBacklinks } from './backlinks';
61
61
  export { isGitRepo, gitLog, gitShowFile } from './git';
62
62
 
63
63
  // Mind Space
64
- export { createSpaceFilesystem } from './create-space';
64
+ export { createSpaceFilesystem, generateReadmeTemplate } from './create-space';
65
65
  export { summarizeTopLevelSpaces } from './list-spaces';
66
66
  export type { MindSpaceSummary } from './list-spaces';
67
67
 
@@ -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
+ }