@geminilight/mindos 0.5.57 → 0.5.58

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.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState, useCallback } from 'react';
3
+ import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
4
4
  import { Sparkles, Send, AtSign, Paperclip, StopCircle, RotateCcw, History, X, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
5
5
  import { useLocale } from '@/lib/LocaleContext';
6
6
  import type { Message } from '@/lib/types';
@@ -21,6 +21,27 @@ const PANEL_COMPOSER_MIN = 84;
21
21
  const PANEL_COMPOSER_MAX_ABS = 440;
22
22
  const PANEL_COMPOSER_MAX_VIEW = 0.48;
23
23
  const PANEL_COMPOSER_KEY_STEP = 24;
24
+ /** 输入框随内容增高,超过此行数后在框内滚动(与常见 IM 一致) */
25
+ const PANEL_TEXTAREA_MAX_VISIBLE_LINES = 8;
26
+
27
+ function syncPanelTextareaToContent(el: HTMLTextAreaElement, maxVisibleLines: number, availableHeight?: number): void {
28
+ const style = getComputedStyle(el);
29
+ const parsedLh = parseFloat(style.lineHeight);
30
+ const parsedFs = parseFloat(style.fontSize);
31
+ const fontSize = Number.isFinite(parsedFs) ? parsedFs : 14;
32
+ const lineHeight = Number.isFinite(parsedLh) ? parsedLh : fontSize * 1.375;
33
+ const pad =
34
+ (Number.isFinite(parseFloat(style.paddingTop)) ? parseFloat(style.paddingTop) : 0) +
35
+ (Number.isFinite(parseFloat(style.paddingBottom)) ? parseFloat(style.paddingBottom) : 0);
36
+ let maxH = lineHeight * maxVisibleLines + pad;
37
+ if (availableHeight && Number.isFinite(availableHeight) && availableHeight > 0) {
38
+ maxH = Math.min(maxH, availableHeight);
39
+ }
40
+ if (!Number.isFinite(maxH) || maxH <= 0) return;
41
+ el.style.height = '0px';
42
+ const next = Math.min(el.scrollHeight, maxH);
43
+ el.style.height = `${Number.isFinite(next) ? next : maxH}px`;
44
+ }
24
45
 
25
46
  function panelComposerMaxForViewport(): number {
26
47
  if (typeof window === 'undefined') return PANEL_COMPOSER_MAX_ABS;
@@ -67,10 +88,18 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
67
88
  const firstMessageFired = useRef(false);
68
89
  const { t } = useLocale();
69
90
 
70
- const [panelComposerHeight, setPanelComposerHeight] = useState(readStoredPanelComposerHeight);
91
+ const [panelComposerHeight, setPanelComposerHeight] = useState(PANEL_COMPOSER_DEFAULT);
71
92
  const panelComposerHRef = useRef(panelComposerHeight);
72
93
  panelComposerHRef.current = panelComposerHeight;
73
94
 
95
+ useEffect(() => {
96
+ const stored = readStoredPanelComposerHeight();
97
+ if (stored !== PANEL_COMPOSER_DEFAULT) {
98
+ setPanelComposerHeight(stored);
99
+ panelComposerHRef.current = stored;
100
+ }
101
+ }, []);
102
+
74
103
  const getPanelComposerHeight = useCallback(() => panelComposerHRef.current, []);
75
104
  const persistPanelComposerHeight = useCallback((h: number) => {
76
105
  try {
@@ -89,7 +118,11 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
89
118
  persist: persistPanelComposerHeight,
90
119
  });
91
120
 
92
- const [panelComposerViewportMax, setPanelComposerViewportMax] = useState(panelComposerMaxForViewport);
121
+ const [panelComposerViewportMax, setPanelComposerViewportMax] = useState(PANEL_COMPOSER_MAX_ABS);
122
+
123
+ useEffect(() => {
124
+ setPanelComposerViewportMax(panelComposerMaxForViewport());
125
+ }, []);
93
126
 
94
127
  const applyPanelComposerClampAndPersist = useCallback(() => {
95
128
  const maxH = panelComposerMaxForViewport();
@@ -195,11 +228,28 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
195
228
  return () => window.removeEventListener('resize', applyPanelComposerClampAndPersist);
196
229
  }, [isPanel, applyPanelComposerClampAndPersist]);
197
230
 
231
+ const formRef = useRef<HTMLFormElement>(null);
232
+
233
+ useLayoutEffect(() => {
234
+ if (!isPanel || !visible) return;
235
+ const el = inputRef.current;
236
+ if (!el || !(el instanceof HTMLTextAreaElement)) return;
237
+ const form = formRef.current;
238
+ const availableH = form ? form.clientHeight - 40 : undefined;
239
+ syncPanelTextareaToContent(el, PANEL_TEXTAREA_MAX_VISIBLE_LINES, availableH);
240
+ }, [input, isPanel, isLoading, visible, panelComposerHeight]);
241
+
242
+ const mentionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
198
243
  const handleInputChange = useCallback((val: string) => {
199
244
  setInput(val);
200
- mention.updateMentionFromInput(val);
245
+ if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current);
246
+ mentionTimerRef.current = setTimeout(() => mention.updateMentionFromInput(val), 80);
201
247
  }, [mention]);
202
248
 
249
+ useEffect(() => {
250
+ return () => { if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current); };
251
+ }, []);
252
+
203
253
  const selectMention = useCallback((filePath: string) => {
204
254
  const atIdx = input.lastIndexOf('@');
205
255
  setInput(input.slice(0, atIdx));
@@ -498,6 +548,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
498
548
  )}
499
549
 
500
550
  <form
551
+ ref={formRef}
501
552
  onSubmit={handleSubmit}
502
553
  className={cn(
503
554
  'flex',
@@ -551,7 +602,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
551
602
  placeholder={t.ask.placeholder}
552
603
  disabled={isLoading}
553
604
  rows={1}
554
- className="min-h-0 min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-2 text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none transition-[height] duration-75 disabled:opacity-50"
605
+ className="min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-2 text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
555
606
  />
556
607
  ) : (
557
608
  <input
@@ -598,15 +649,6 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
598
649
  <kbd className="font-mono ml-0.5">↵</kbd> {t.ask.newlineHint}
599
650
  </span>
600
651
  ) : null}
601
- {isPanel ? (
602
- <span
603
- className="hidden sm:inline"
604
- suppressHydrationWarning
605
- title={`${t.ask.panelComposerResize} · ${t.ask.panelComposerResetHint} · ${t.ask.panelComposerKeyboard}`}
606
- >
607
- <kbd className="font-mono">↕</kbd> {t.ask.panelComposerFooter}
608
- </span>
609
- ) : null}
610
652
  <span suppressHydrationWarning>
611
653
  <kbd className="font-mono">@</kbd> {t.ask.attachFile}
612
654
  </span>
@@ -1,6 +1,7 @@
1
1
  'use server';
2
2
 
3
- import { createFile, deleteFile, renameFile } from '@/lib/fs';
3
+ import { createFile, deleteFile, deleteDirectory, convertToSpace, renameFile, renameSpace, getMindRoot, invalidateCache } from '@/lib/fs';
4
+ import { createSpaceFilesystem } from '@/lib/core/create-space';
4
5
  import { revalidatePath } from 'next/cache';
5
6
 
6
7
  export async function createFileAction(dirPath: string, fileName: string): Promise<{ success: boolean; filePath?: string; error?: string }> {
@@ -39,6 +40,55 @@ export async function renameFileAction(oldPath: string, newName: string): Promis
39
40
  }
40
41
  }
41
42
 
43
+ export async function convertToSpaceAction(
44
+ dirPath: string,
45
+ ): Promise<{ success: boolean; error?: string }> {
46
+ try {
47
+ convertToSpace(dirPath);
48
+ revalidatePath('/', 'layout');
49
+ return { success: true };
50
+ } catch (err) {
51
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to convert to space' };
52
+ }
53
+ }
54
+
55
+ export async function deleteFolderAction(
56
+ dirPath: string,
57
+ ): Promise<{ success: boolean; error?: string }> {
58
+ try {
59
+ deleteDirectory(dirPath);
60
+ revalidatePath('/', 'layout');
61
+ return { success: true };
62
+ } catch (err) {
63
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to delete folder' };
64
+ }
65
+ }
66
+
67
+ export async function renameSpaceAction(
68
+ spacePath: string,
69
+ newName: string,
70
+ ): Promise<{ success: boolean; newPath?: string; error?: string }> {
71
+ try {
72
+ const newPath = renameSpace(spacePath, newName);
73
+ revalidatePath('/', 'layout');
74
+ return { success: true, newPath };
75
+ } catch (err) {
76
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to rename space' };
77
+ }
78
+ }
79
+
80
+ export async function deleteSpaceAction(
81
+ spacePath: string,
82
+ ): Promise<{ success: boolean; error?: string }> {
83
+ try {
84
+ deleteDirectory(spacePath);
85
+ revalidatePath('/', 'layout');
86
+ return { success: true };
87
+ } catch (err) {
88
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to delete space' };
89
+ }
90
+ }
91
+
42
92
  /**
43
93
  * Create a new Mind Space (top-level directory) with README.md + auto-scaffolded INSTRUCTION.md.
44
94
  * The description is written into README.md so it appears on the homepage Space card
@@ -50,34 +100,12 @@ export async function createSpaceAction(
50
100
  parentPath: string = ''
51
101
  ): Promise<{ success: boolean; path?: string; error?: string }> {
52
102
  try {
53
- const trimmed = name.trim();
54
- if (!trimmed) return { success: false, error: 'Space name is required' };
55
- if (trimmed.includes('/') || trimmed.includes('\\')) {
56
- return { success: false, error: 'Space name must not contain path separators' };
57
- }
58
-
59
- // Sanitize parentPath — reject traversal attempts
60
- const cleanParent = parentPath.replace(/\/+$/, '').trim();
61
- if (cleanParent.includes('..') || cleanParent.startsWith('/') || cleanParent.includes('\\')) {
62
- return { success: false, error: 'Invalid parent path' };
63
- }
64
-
65
- // Build full path: parentPath + name
66
- const prefix = cleanParent ? cleanParent + '/' : '';
67
- const fullPath = `${prefix}${trimmed}`;
68
-
69
- // Strip emoji for clean title in README content
70
- const cleanName = trimmed.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || trimmed;
71
- const desc = description.trim() || '(Describe the purpose and usage of this space.)';
72
- 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`;
73
-
74
- // createFile triggers scaffoldIfNewSpace → auto-generates INSTRUCTION.md
75
- createFile(`${fullPath}/README.md`, readmeContent);
103
+ const { path: fullPath } = createSpaceFilesystem(getMindRoot(), name, description, parentPath);
104
+ invalidateCache();
76
105
  revalidatePath('/', 'layout');
77
106
  return { success: true, path: fullPath };
78
107
  } catch (err) {
79
108
  const msg = err instanceof Error ? err.message : 'Failed to create space';
80
- // Make "already exists" error more user-friendly
81
109
  if (msg.includes('already exists')) {
82
110
  return { success: false, error: 'A space with this name already exists' };
83
111
  }
@@ -0,0 +1,36 @@
1
+ import { MindOSError, ErrorCodes } from '@/lib/errors';
2
+ import { createFile } from './fs-ops';
3
+
4
+ /**
5
+ * Create a Mind Space on disk: `{fullPath}/README.md` plus scaffold from {@link createFile} / scaffoldIfNewSpace.
6
+ * Caller must invalidate app file-tree cache (e.g. `invalidateCache()` in `lib/fs.ts`).
7
+ */
8
+ export function createSpaceFilesystem(
9
+ mindRoot: string,
10
+ name: string,
11
+ description: string,
12
+ parentPath = '',
13
+ ): { path: string } {
14
+ const trimmed = name.trim();
15
+ if (!trimmed) {
16
+ throw new MindOSError(ErrorCodes.INVALID_REQUEST, 'Space name is required', { name });
17
+ }
18
+ if (trimmed.includes('/') || trimmed.includes('\\')) {
19
+ throw new MindOSError(ErrorCodes.INVALID_PATH, 'Space name must not contain path separators', { name: trimmed });
20
+ }
21
+
22
+ const cleanParent = parentPath.replace(/\/+$/, '').trim();
23
+ if (cleanParent.includes('..') || cleanParent.startsWith('/') || cleanParent.includes('\\')) {
24
+ throw new MindOSError(ErrorCodes.INVALID_PATH, 'Invalid parent path', { parentPath });
25
+ }
26
+
27
+ const prefix = cleanParent ? `${cleanParent}/` : '';
28
+ 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`;
33
+
34
+ createFile(mindRoot, `${fullPath}/README.md`, readmeContent);
35
+ return { path: fullPath };
36
+ }
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { resolveSafe, assertWithinRoot } from './security';
4
4
  import { MindOSError, ErrorCodes } from '@/lib/errors';
5
- import { scaffoldIfNewSpace } from './space-scaffold';
5
+ import { scaffoldIfNewSpace, cleanDirName, INSTRUCTION_TEMPLATE, README_TEMPLATE } from './space-scaffold';
6
6
 
7
7
  /**
8
8
  * Reads the content of a file given a relative path from mindRoot.
@@ -55,6 +55,48 @@ export function deleteFile(mindRoot: string, filePath: string): void {
55
55
  fs.unlinkSync(resolved);
56
56
  }
57
57
 
58
+ /**
59
+ * Recursively deletes a directory and all its contents.
60
+ * Throws if the path does not exist, is outside mindRoot, or is not a directory.
61
+ */
62
+ export function deleteDirectory(mindRoot: string, dirPath: string): void {
63
+ const resolved = resolveSafe(mindRoot, dirPath);
64
+ if (!fs.existsSync(resolved)) {
65
+ throw new MindOSError(ErrorCodes.FILE_NOT_FOUND, `Directory not found: ${dirPath}`, { dirPath });
66
+ }
67
+ if (!fs.statSync(resolved).isDirectory()) {
68
+ throw new MindOSError(ErrorCodes.INVALID_PATH, `Not a directory: ${dirPath}`, { dirPath });
69
+ }
70
+ fs.rmSync(resolved, { recursive: true, force: true });
71
+ }
72
+
73
+ /**
74
+ * Converts an existing directory into a Space by creating INSTRUCTION.md
75
+ * (and README.md if missing). Idempotent — skips files that already exist.
76
+ */
77
+ export function convertToSpace(mindRoot: string, dirPath: string): void {
78
+ const resolved = resolveSafe(mindRoot, dirPath);
79
+ if (!fs.existsSync(resolved)) {
80
+ throw new MindOSError(ErrorCodes.FILE_NOT_FOUND, `Directory not found: ${dirPath}`, { dirPath });
81
+ }
82
+ if (!fs.statSync(resolved).isDirectory()) {
83
+ throw new MindOSError(ErrorCodes.INVALID_PATH, `Not a directory: ${dirPath}`, { dirPath });
84
+ }
85
+
86
+ const dirName = path.basename(resolved);
87
+ const name = cleanDirName(dirName);
88
+
89
+ const instructionPath = path.join(resolved, 'INSTRUCTION.md');
90
+ if (!fs.existsSync(instructionPath)) {
91
+ fs.writeFileSync(instructionPath, INSTRUCTION_TEMPLATE(name), 'utf-8');
92
+ }
93
+
94
+ const readmePath = path.join(resolved, 'README.md');
95
+ if (!fs.existsSync(readmePath)) {
96
+ fs.writeFileSync(readmePath, README_TEMPLATE(name), 'utf-8');
97
+ }
98
+ }
99
+
58
100
  /**
59
101
  * Renames a file within its current directory.
60
102
  * newName must be a plain filename (no path separators).
@@ -79,6 +121,41 @@ export function renameFile(mindRoot: string, oldPath: string, newName: string):
79
121
  return path.relative(root, newResolved);
80
122
  }
81
123
 
124
+ /**
125
+ * Renames a directory (Mind Space) under mindRoot. `newName` must be a single segment (no `/` or `\\`).
126
+ * Does not rewrite links inside markdown files.
127
+ */
128
+ export function renameSpaceDirectory(mindRoot: string, spacePath: string, newName: string): string {
129
+ const trimmedName = newName.trim();
130
+ if (!trimmedName || trimmedName.includes('/') || trimmedName.includes('\\')) {
131
+ throw new MindOSError(ErrorCodes.INVALID_PATH, 'Invalid space name: must not contain path separators', { newName });
132
+ }
133
+ const normalized = spacePath.replace(/\/+$/g, '').trim();
134
+ if (!normalized) {
135
+ throw new MindOSError(ErrorCodes.INVALID_PATH, 'Space path is required', { spacePath });
136
+ }
137
+
138
+ const root = path.resolve(mindRoot);
139
+ const oldResolved = resolveSafe(mindRoot, normalized);
140
+ if (!fs.existsSync(oldResolved)) {
141
+ throw new MindOSError(ErrorCodes.FILE_NOT_FOUND, `Space not found: ${normalized}`, { spacePath: normalized });
142
+ }
143
+ if (!fs.statSync(oldResolved).isDirectory()) {
144
+ throw new MindOSError(ErrorCodes.INVALID_PATH, `Not a directory: ${normalized}`, { spacePath: normalized });
145
+ }
146
+
147
+ const parentDir = path.dirname(oldResolved);
148
+ const newResolved = path.join(parentDir, trimmedName);
149
+ assertWithinRoot(newResolved, root);
150
+
151
+ if (fs.existsSync(newResolved)) {
152
+ throw new MindOSError(ErrorCodes.FILE_ALREADY_EXISTS, 'A space with that name already exists', { newName: trimmedName });
153
+ }
154
+
155
+ fs.renameSync(oldResolved, newResolved);
156
+ return path.relative(root, newResolved);
157
+ }
158
+
82
159
  /**
83
160
  * Moves a file from one path to another within mindRoot.
84
161
  * Returns the new path and a list of files that referenced the old path.
@@ -22,7 +22,10 @@ export {
22
22
  writeFile,
23
23
  createFile,
24
24
  deleteFile,
25
+ deleteDirectory,
26
+ convertToSpace,
25
27
  renameFile,
28
+ renameSpaceDirectory,
26
29
  moveFile,
27
30
  getRecentlyModified,
28
31
  } from './fs-ops';
@@ -56,3 +59,8 @@ export { findBacklinks } from './backlinks';
56
59
 
57
60
  // Git
58
61
  export { isGitRepo, gitLog, gitShowFile } from './git';
62
+
63
+ // Mind Space
64
+ export { createSpaceFilesystem } from './create-space';
65
+ export { summarizeTopLevelSpaces } from './list-spaces';
66
+ export type { MindSpaceSummary } from './list-spaces';
@@ -1,3 +1,8 @@
1
+ export interface SpacePreview {
2
+ instructionLines: string[];
3
+ readmeLines: string[];
4
+ }
5
+
1
6
  export interface FileNode {
2
7
  name: string;
3
8
  path: string;
@@ -5,6 +10,8 @@ export interface FileNode {
5
10
  children?: FileNode[];
6
11
  extension?: string;
7
12
  mtime?: number;
13
+ isSpace?: boolean;
14
+ spacePreview?: SpacePreview;
8
15
  }
9
16
 
10
17
  export interface SearchResult {
package/app/lib/fs.ts CHANGED
@@ -7,7 +7,10 @@ import {
7
7
  writeFile as coreWriteFile,
8
8
  createFile as coreCreateFile,
9
9
  deleteFile as coreDeleteFile,
10
+ deleteDirectory as coreDeleteDirectory,
11
+ convertToSpace as coreConvertToSpace,
10
12
  renameFile as coreRenameFile,
13
+ renameSpaceDirectory as coreRenameSpaceDirectory,
11
14
  moveFile as coreMoveFile,
12
15
  readLines as coreReadLines,
13
16
  insertLines as coreInsertLines,
@@ -21,8 +24,10 @@ import {
21
24
  gitLog as coreGitLog,
22
25
  gitShowFile as coreGitShowFile,
23
26
  invalidateSearchIndex,
27
+ summarizeTopLevelSpaces,
24
28
  } from './core';
25
- import { FileNode } from './core/types';
29
+ import type { MindSpaceSummary } from './core';
30
+ import { FileNode, SpacePreview } from './core/types';
26
31
  import { SearchMatch } from './types';
27
32
  import { effectiveSopRoot } from './settings';
28
33
 
@@ -77,8 +82,31 @@ function ensureCache(): FileTreeCache {
77
82
 
78
83
  // ─── Internal builders ────────────────────────────────────────────────────────
79
84
 
80
- function buildFileTree(dirPath: string): FileNode[] {
81
- const root = getMindRoot();
85
+ const SPACE_PREVIEW_MAX_LINES = 3;
86
+
87
+ function extractBodyLines(filePath: string, maxLines: number): string[] {
88
+ try {
89
+ const content = fs.readFileSync(filePath, 'utf-8');
90
+ const bodyLines: string[] = [];
91
+ for (const line of content.split('\n')) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed || trimmed.startsWith('#')) continue;
94
+ bodyLines.push(trimmed);
95
+ if (bodyLines.length >= maxLines) break;
96
+ }
97
+ return bodyLines;
98
+ } catch { return []; }
99
+ }
100
+
101
+ function buildSpacePreview(dirAbsPath: string) {
102
+ return {
103
+ instructionLines: extractBodyLines(path.join(dirAbsPath, 'INSTRUCTION.md'), SPACE_PREVIEW_MAX_LINES),
104
+ readmeLines: extractBodyLines(path.join(dirAbsPath, 'README.md'), SPACE_PREVIEW_MAX_LINES),
105
+ };
106
+ }
107
+
108
+ function buildFileTree(dirPath: string, rootOverride?: string): FileNode[] {
109
+ const root = rootOverride ?? getMindRoot();
82
110
  let entries: fs.Dirent[];
83
111
  try {
84
112
  entries = fs.readdirSync(dirPath, { withFileTypes: true });
@@ -94,9 +122,15 @@ function buildFileTree(dirPath: string): FileNode[] {
94
122
 
95
123
  if (entry.isDirectory()) {
96
124
  if (IGNORED_DIRS.has(entry.name)) continue;
97
- const children = buildFileTree(fullPath);
125
+ const children = buildFileTree(fullPath, root);
98
126
  if (children.length > 0) {
99
- nodes.push({ name: entry.name, path: relativePath, type: 'directory', children });
127
+ const hasInstruction = children.some(c => c.type === 'file' && c.name === 'INSTRUCTION.md');
128
+ const node: FileNode = { name: entry.name, path: relativePath, type: 'directory', children };
129
+ if (hasInstruction) {
130
+ node.isSpace = true;
131
+ node.spacePreview = buildSpacePreview(fullPath);
132
+ }
133
+ nodes.push(node);
100
134
  }
101
135
  } else if (entry.isFile()) {
102
136
  const ext = path.extname(entry.name).toLowerCase();
@@ -114,6 +148,11 @@ function buildFileTree(dirPath: string): FileNode[] {
114
148
  return nodes;
115
149
  }
116
150
 
151
+ /** Exposed for testing only — builds a file tree from an arbitrary root path. */
152
+ export function buildFileTreeForTest(rootPath: string): FileNode[] {
153
+ return buildFileTree(rootPath, rootPath);
154
+ }
155
+
117
156
  function buildAllFiles(dirPath: string): string[] {
118
157
  const root = getMindRoot();
119
158
  let entries: fs.Dirent[];
@@ -146,6 +185,20 @@ export function getFileTree(): FileNode[] {
146
185
  return ensureCache().tree;
147
186
  }
148
187
 
188
+ /** Top-level Mind Spaces (same cached tree as home Spaces grid). */
189
+ export function listMindSpaces(): MindSpaceSummary[] {
190
+ return summarizeTopLevelSpaces(getMindRoot(), ensureCache().tree);
191
+ }
192
+
193
+ /** Returns space preview (INSTRUCTION + README excerpts) for a directory, or null if not a space. */
194
+ export function getSpacePreview(dirPath: string): SpacePreview | null {
195
+ const root = getMindRoot();
196
+ const abs = path.join(root, dirPath);
197
+ const instructionPath = path.join(abs, 'INSTRUCTION.md');
198
+ if (!fs.existsSync(instructionPath)) return null;
199
+ return buildSpacePreview(abs);
200
+ }
201
+
149
202
  /** Returns cached list of all file paths (relative to MIND_ROOT). */
150
203
  export function collectAllFiles(): string[] {
151
204
  return ensureCache().allFiles;
@@ -251,6 +304,25 @@ export function renameFile(oldPath: string, newName: string): string {
251
304
  return result;
252
305
  }
253
306
 
307
+ /** Renames a Space directory under MIND_ROOT. newName must be a single path segment. */
308
+ export function renameSpace(spacePath: string, newName: string): string {
309
+ const result = coreRenameSpaceDirectory(getMindRoot(), spacePath, newName);
310
+ invalidateCache();
311
+ return result;
312
+ }
313
+
314
+ /** Recursively deletes a directory under MIND_ROOT. */
315
+ export function deleteDirectory(dirPath: string): void {
316
+ coreDeleteDirectory(getMindRoot(), dirPath);
317
+ invalidateCache();
318
+ }
319
+
320
+ /** Converts a regular folder into a Space by adding INSTRUCTION.md + README.md. */
321
+ export function convertToSpace(dirPath: string): void {
322
+ coreConvertToSpace(getMindRoot(), dirPath);
323
+ invalidateCache();
324
+ }
325
+
254
326
  // ─── Public API: Line-level operations (delegated to @mindos/core) ───────────
255
327
 
256
328
  export function readLines(filePath: string): string[] {
@@ -471,6 +543,7 @@ export function gitShowFile(filePath: string, commit: string): string {
471
543
 
472
544
  import type { BacklinkEntry } from './core/types';
473
545
  export type { BacklinkEntry } from './core/types';
546
+ export type { MindSpaceSummary } from './core';
474
547
 
475
548
  export function findBacklinks(targetPath: string): BacklinkEntry[] {
476
549
  return coreFindBacklinks(getMindRoot(), targetPath);
@@ -278,6 +278,16 @@ export const en = {
278
278
  enterFileName: 'Enter a file name',
279
279
  failed: 'Failed',
280
280
  confirmDelete: (name: string) => `Delete "${name}"? This cannot be undone.`,
281
+ rules: 'Rules',
282
+ about: 'About',
283
+ viewAll: 'View all',
284
+ editRules: 'Edit Rules',
285
+ renameSpace: 'Rename Space',
286
+ deleteSpace: 'Delete Space',
287
+ confirmDeleteSpace: (name: string) => `Delete space "${name}" and all its files? This cannot be undone.`,
288
+ convertToSpace: 'Convert to Space',
289
+ deleteFolder: 'Delete Folder',
290
+ confirmDeleteFolder: (name: string) => `Delete folder "${name}" and all its contents? This cannot be undone.`,
281
291
  },
282
292
  dirView: {
283
293
  gridView: 'Grid view',
@@ -302,6 +302,16 @@ export const zh = {
302
302
  enterFileName: '请输入文件名',
303
303
  failed: '操作失败',
304
304
  confirmDelete: (name: string) => `确定删除「${name}」?此操作不可撤销。`,
305
+ rules: '规则',
306
+ about: '说明',
307
+ viewAll: '查看全部',
308
+ editRules: '编辑规则',
309
+ renameSpace: '重命名空间',
310
+ deleteSpace: '删除空间',
311
+ confirmDeleteSpace: (name: string) => `删除空间「${name}」及其所有文件?此操作不可撤销。`,
312
+ convertToSpace: '转为空间',
313
+ deleteFolder: '删除文件夹',
314
+ confirmDeleteFolder: (name: string) => `删除文件夹「${name}」及其所有内容?此操作不可撤销。`,
305
315
  },
306
316
  dirView: {
307
317
  gridView: '网格视图',
package/mcp/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MindOS MCP Server
2
2
 
3
- Pure HTTP client wrapper that maps 20 MCP tools to the App REST API via `fetch`. Zero business logic — all operations are delegated to the App.
3
+ Pure HTTP client wrapper that maps 22 MCP tools to the App REST API via `fetch`. Zero business logic — all operations are delegated to the App.
4
4
 
5
5
  ## Architecture
6
6
 
@@ -41,7 +41,7 @@ MCP_TRANSPORT=stdio mindos mcp # stdio mode
41
41
  | `MCP_PORT` | `8781` | HTTP listen port (configurable via `mindos onboard`) |
42
42
  | `MCP_ENDPOINT` | `/mcp` | HTTP endpoint path |
43
43
 
44
- ## MCP Tools (20)
44
+ ## MCP Tools (22)
45
45
 
46
46
  | Tool | App API | Description |
47
47
  |------|---------|-------------|
@@ -49,6 +49,8 @@ MCP_TRANSPORT=stdio mindos mcp # stdio mode
49
49
  | `mindos_read_file` | `GET /api/file?path=...` | Read file content (with offset/limit pagination) |
50
50
  | `mindos_write_file` | `POST /api/file` op=save_file | Overwrite file content |
51
51
  | `mindos_create_file` | `POST /api/file` op=create_file | Create a new .md or .csv file |
52
+ | `mindos_create_space` | `POST /api/file` op=create_space | Create a Mind Space (README + INSTRUCTION scaffold) |
53
+ | `mindos_rename_space` | `POST /api/file` op=rename_space | Rename a Space directory (folder only) |
52
54
  | `mindos_delete_file` | `POST /api/file` op=delete_file | Delete a file |
53
55
  | `mindos_rename_file` | `POST /api/file` op=rename_file | Rename a file (same directory) |
54
56
  | `mindos_move_file` | `POST /api/file` op=move_file | Move a file to a new path |
package/mcp/src/index.ts CHANGED
@@ -173,6 +173,56 @@ server.registerTool("mindos_create_file", {
173
173
  } catch (e) { logOp("mindos_create_file", { path }, "error", String(e)); return error(String(e)); }
174
174
  });
175
175
 
176
+ // ── mindos_create_space ─────────────────────────────────────────────────────
177
+
178
+ server.registerTool("mindos_create_space", {
179
+ title: "Create Mind Space",
180
+ description:
181
+ "Create a new Mind Space (top-level or under parent_path): directory + README.md + INSTRUCTION.md scaffold. Use this instead of create_file when adding a new cognitive zone to the knowledge base.",
182
+ inputSchema: z.object({
183
+ name: z.string().min(1).describe("Space directory name (no path separators)"),
184
+ description: z.string().default("").describe("Short purpose text stored in README.md"),
185
+ parent_path: z.string().default("").describe("Optional parent directory under MIND_ROOT (empty = top-level Space)"),
186
+ }),
187
+ }, async ({ name, description, parent_path }) => {
188
+ try {
189
+ const json = await post("/api/file", {
190
+ op: "create_space",
191
+ path: "_",
192
+ name,
193
+ description,
194
+ parent_path,
195
+ });
196
+ const p = json.path as string;
197
+ logOp("mindos_create_space", { name, parent_path }, "ok", p);
198
+ return ok(`Created Mind Space at "${p}"`);
199
+ } catch (e) {
200
+ logOp("mindos_create_space", { name, parent_path }, "error", String(e));
201
+ return error(String(e));
202
+ }
203
+ });
204
+
205
+ // ── mindos_rename_space ─────────────────────────────────────────────────────
206
+
207
+ server.registerTool("mindos_rename_space", {
208
+ title: "Rename Mind Space",
209
+ description:
210
+ "Rename a Space directory (relative path to the folder, e.g. Notes or Work/Notes). Only the final folder name changes; new_name must be a single segment. Does not rewrite links inside files.",
211
+ inputSchema: z.object({
212
+ path: z.string().min(1).describe("Relative path to the space directory to rename"),
213
+ new_name: z.string().min(1).describe("New folder name only (no slashes)"),
214
+ }),
215
+ }, async ({ path: spacePath, new_name }) => {
216
+ try {
217
+ const json = await post("/api/file", { op: "rename_space", path: spacePath, new_name });
218
+ logOp("mindos_rename_space", { path: spacePath, new_name }, "ok", String(json.newPath));
219
+ return ok(`Renamed space "${spacePath}" → "${json.newPath}"`);
220
+ } catch (e) {
221
+ logOp("mindos_rename_space", { path: spacePath, new_name }, "error", String(e));
222
+ return error(String(e));
223
+ }
224
+ });
225
+
176
226
  // ── mindos_delete_file ──────────────────────────────────────────────────────
177
227
 
178
228
  server.registerTool("mindos_delete_file", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geminilight/mindos",
3
- "version": "0.5.57",
3
+ "version": "0.5.58",
4
4
  "description": "MindOS — Human-Agent Collaborative Mind System. Local-first knowledge base that syncs your mind to all AI Agents via MCP.",
5
5
  "keywords": [
6
6
  "mindos",