@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.
- package/app/app/api/ask/route.ts +122 -92
- package/app/app/api/file/import/route.ts +197 -0
- package/app/app/api/mcp/agents/route.ts +53 -2
- package/app/app/api/mcp/status/route.ts +1 -1
- package/app/app/api/skills/route.ts +10 -114
- package/app/components/ActivityBar.tsx +5 -7
- package/app/components/CreateSpaceModal.tsx +31 -6
- package/app/components/FileTree.tsx +68 -11
- package/app/components/GuideCard.tsx +197 -131
- package/app/components/HomeContent.tsx +85 -18
- 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 +96 -8
- package/app/components/SpaceInitToast.tsx +173 -0
- package/app/components/TableOfContents.tsx +1 -0
- package/app/components/agents/AgentDetailContent.tsx +69 -45
- package/app/components/agents/AgentsContentPage.tsx +2 -1
- package/app/components/agents/AgentsMcpSection.tsx +16 -12
- package/app/components/agents/AgentsOverviewSection.tsx +37 -36
- package/app/components/agents/AgentsPrimitives.tsx +41 -20
- package/app/components/agents/AgentsSkillsSection.tsx +16 -7
- package/app/components/agents/SkillDetailPopover.tsx +11 -11
- package/app/components/agents/agents-content-model.ts +16 -8
- package/app/components/ask/AskContent.tsx +148 -50
- package/app/components/ask/MentionPopover.tsx +16 -8
- package/app/components/ask/SlashCommandPopover.tsx +62 -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/components/settings/KnowledgeTab.tsx +61 -0
- package/app/components/walkthrough/steps.ts +11 -6
- package/app/hooks/useFileImport.ts +191 -0
- package/app/hooks/useFileUpload.ts +11 -0
- package/app/hooks/useMention.ts +14 -6
- package/app/hooks/useSlashCommand.ts +114 -0
- package/app/lib/actions.ts +79 -2
- package/app/lib/agent/index.ts +1 -1
- package/app/lib/agent/prompt.ts +2 -0
- package/app/lib/agent/tools.ts +252 -0
- package/app/lib/core/create-space.ts +11 -4
- package/app/lib/core/file-convert.ts +97 -0
- package/app/lib/core/index.ts +1 -1
- package/app/lib/core/organize.ts +105 -0
- package/app/lib/i18n-en.ts +102 -46
- package/app/lib/i18n-zh.ts +101 -45
- package/app/lib/mcp-agents.ts +8 -0
- package/app/lib/pdf-extract.ts +33 -0
- package/app/lib/pi-integration/extensions.ts +45 -0
- package/app/lib/pi-integration/mcporter.ts +219 -0
- package/app/lib/pi-integration/session-store.ts +62 -0
- package/app/lib/pi-integration/skills.ts +116 -0
- package/app/lib/settings.ts +1 -1
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package.json +2 -0
- package/mcp/src/index.ts +29 -0
- package/package.json +1 -1
package/app/lib/agent/tools.ts
CHANGED
|
@@ -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(/ /g, ' ')
|
|
479
|
+
.replace(/&/g, ' ')
|
|
480
|
+
.replace(/</g, '<')
|
|
481
|
+
.replace(/>/g, '>')
|
|
482
|
+
.replace(/"/g, '"')
|
|
483
|
+
.replace(/'/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(/ /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
|
+
}
|
package/app/lib/core/index.ts
CHANGED
|
@@ -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
|
+
}
|