@geminilight/mindos 0.5.18 → 0.5.19

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,7 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useState, useSyncExternalStore } from 'react';
4
- import { Copy, Check, RefreshCw, Trash2 } from 'lucide-react';
3
+ import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
4
+ import { Copy, Check, RefreshCw, Trash2, Sparkles } from 'lucide-react';
5
5
  import type { SettingsData } from './types';
6
6
  import { Field, Input, EnvBadge, SectionLabel } from './Primitives';
7
7
  import { apiFetch } from '@/lib/api';
@@ -16,6 +16,38 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
16
16
  const env = data.envOverrides ?? {};
17
17
  const k = t.settings.knowledge;
18
18
 
19
+ // Guide state toggle
20
+ const [guideActive, setGuideActive] = useState<boolean | null>(null);
21
+ const [guideDismissed, setGuideDismissed] = useState(false);
22
+
23
+ useEffect(() => {
24
+ fetch('/api/setup')
25
+ .then(r => r.json())
26
+ .then(d => {
27
+ const gs = d.guideState;
28
+ if (gs) {
29
+ setGuideActive(gs.active);
30
+ setGuideDismissed(!!gs.dismissed);
31
+ }
32
+ })
33
+ .catch(() => {});
34
+ }, []);
35
+
36
+ const handleGuideToggle = useCallback(() => {
37
+ const newDismissed = !guideDismissed;
38
+ setGuideDismissed(newDismissed);
39
+ // If re-enabling, also ensure active is true
40
+ const patch: Record<string, boolean> = { dismissed: newDismissed };
41
+ if (!newDismissed) patch.active = true;
42
+ fetch('/api/setup', {
43
+ method: 'PATCH',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ guideState: patch }),
46
+ })
47
+ .then(() => window.dispatchEvent(new Event('guide-state-updated')))
48
+ .catch(() => setGuideDismissed(!newDismissed)); // rollback on failure
49
+ }, [guideDismissed]);
50
+
19
51
  const origin = useSyncExternalStore(
20
52
  () => () => {},
21
53
  () => `${window.location.protocol}//${window.location.hostname}`,
@@ -158,6 +190,36 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
158
190
  )}
159
191
  </div>
160
192
  </Field>
193
+
194
+ {/* Getting Started Guide toggle */}
195
+ {guideActive !== null && (
196
+ <div className="border-t border-border pt-5">
197
+ <SectionLabel>{t.guide?.title ?? 'Getting Started'}</SectionLabel>
198
+ <div className="flex items-center justify-between py-2">
199
+ <div className="flex items-center gap-2">
200
+ <Sparkles size={14} style={{ color: 'var(--amber)' }} />
201
+ <div>
202
+ <div className="text-sm text-foreground">{t.guide?.showGuide ?? 'Show getting started guide'}</div>
203
+ </div>
204
+ </div>
205
+ <button
206
+ type="button"
207
+ role="switch"
208
+ aria-checked={!guideDismissed}
209
+ onClick={handleGuideToggle}
210
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
211
+ !guideDismissed ? 'bg-amber-500' : 'bg-muted'
212
+ }`}
213
+ >
214
+ <span
215
+ className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
216
+ !guideDismissed ? 'translate-x-4' : 'translate-x-0'
217
+ }`}
218
+ />
219
+ </button>
220
+ </div>
221
+ </div>
222
+ )}
161
223
  </div>
162
224
  );
163
225
  }
@@ -45,8 +45,16 @@ export default function StepAI({ state, update, s }: StepAIProps) {
45
45
  <ApiKeyInput
46
46
  value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
47
47
  onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
48
- placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
48
+ placeholder={
49
+ (state.provider === 'anthropic' ? state.anthropicKeyMask : state.openaiKeyMask)
50
+ || (state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...')
51
+ }
49
52
  />
53
+ {(state.provider === 'anthropic' ? state.anthropicKeyMask : state.openaiKeyMask) && !(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey) && (
54
+ <p className="text-xs mt-1" style={{ color: 'var(--muted-foreground)' }}>
55
+ {s.apiKeyExisting ?? 'Existing key configured. Leave blank to keep it.'}
56
+ </p>
57
+ )}
50
58
  </Field>
51
59
  <Field label={s.model}>
52
60
  <Input
@@ -129,9 +129,11 @@ export default function SetupWizard() {
129
129
  provider: 'anthropic',
130
130
  anthropicKey: '',
131
131
  anthropicModel: 'claude-sonnet-4-6',
132
+ anthropicKeyMask: '',
132
133
  openaiKey: '',
133
134
  openaiModel: 'gpt-5.4',
134
135
  openaiBaseUrl: '',
136
+ openaiKeyMask: '',
135
137
  webPort: 3000,
136
138
  mcpPort: 8787,
137
139
  authToken: '',
@@ -172,8 +174,10 @@ export default function SetupWizard() {
172
174
  webPassword: data.webPassword || prev.webPassword,
173
175
  provider: (data.provider === 'anthropic' || data.provider === 'openai') ? data.provider : prev.provider,
174
176
  anthropicModel: data.anthropicModel || prev.anthropicModel,
177
+ anthropicKeyMask: data.anthropicApiKey || '',
175
178
  openaiModel: data.openaiModel || prev.openaiModel,
176
179
  openaiBaseUrl: data.openaiBaseUrl ?? prev.openaiBaseUrl,
180
+ openaiKeyMask: data.openaiApiKey || '',
177
181
  }));
178
182
  // Generate a new token only if none exists yet
179
183
  if (!data.authToken) {
@@ -14,9 +14,11 @@ export interface SetupState {
14
14
  provider: 'anthropic' | 'openai' | 'skip';
15
15
  anthropicKey: string;
16
16
  anthropicModel: string;
17
+ anthropicKeyMask: string; // masked existing key from server (display only)
17
18
  openaiKey: string;
18
19
  openaiModel: string;
19
20
  openaiBaseUrl: string;
21
+ openaiKeyMask: string; // masked existing key from server (display only)
20
22
  webPort: number;
21
23
  mcpPort: number;
22
24
  authToken: string;
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore, useCallback } from 'react';
4
+
5
+ /**
6
+ * Lightweight pub/sub store for cross-component AskModal control.
7
+ * Replaces KeyboardEvent dispatch pattern with typed, testable API.
8
+ * No external dependencies (no zustand needed).
9
+ */
10
+
11
+ interface AskModalState {
12
+ open: boolean;
13
+ initialMessage: string;
14
+ source: 'user' | 'guide' | 'guide-next'; // who triggered the open
15
+ }
16
+
17
+ let state: AskModalState = { open: false, initialMessage: '', source: 'user' };
18
+ const listeners = new Set<() => void>();
19
+
20
+ function emit() { listeners.forEach(l => l()); }
21
+ function subscribe(listener: () => void) {
22
+ listeners.add(listener);
23
+ return () => { listeners.delete(listener); };
24
+ }
25
+ function getSnapshot() { return state; }
26
+
27
+ export function openAskModal(message = '', source: AskModalState['source'] = 'user') {
28
+ state = { open: true, initialMessage: message, source };
29
+ emit();
30
+ }
31
+
32
+ export function closeAskModal() {
33
+ state = { open: false, initialMessage: '', source: 'user' };
34
+ emit();
35
+ }
36
+
37
+ export function useAskModal() {
38
+ const snap = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
39
+ return {
40
+ open: snap.open,
41
+ initialMessage: snap.initialMessage,
42
+ source: snap.source,
43
+ openWith: useCallback((message: string, source: AskModalState['source'] = 'user') => openAskModal(message, source), []),
44
+ close: useCallback(() => closeAskModal(), []),
45
+ };
46
+ }
@@ -138,7 +138,9 @@ export async function consumeUIMessageStream(
138
138
  case 'tool-output-available': {
139
139
  const tc = toolCalls.get(chunk.toolCallId as string);
140
140
  if (tc) {
141
- tc.output = typeof chunk.output === 'string' ? chunk.output : JSON.stringify(chunk.output);
141
+ tc.output = chunk.output != null
142
+ ? (typeof chunk.output === 'string' ? chunk.output : JSON.stringify(chunk.output))
143
+ : '';
142
144
  tc.state = 'done';
143
145
  changed = true;
144
146
  }
@@ -148,7 +150,7 @@ export async function consumeUIMessageStream(
148
150
  case 'tool-input-error': {
149
151
  const tc = toolCalls.get(chunk.toolCallId as string);
150
152
  if (tc) {
151
- tc.output = (chunk.errorText as string) ?? 'Error';
153
+ tc.output = (chunk.errorText as string) ?? (chunk.error as string) ?? 'Tool error';
152
154
  tc.state = 'error';
153
155
  changed = true;
154
156
  }
@@ -4,6 +4,7 @@ import {
4
4
  searchFiles, getFileContent, getFileTree, getRecentlyModified,
5
5
  saveFileContent, createFile, appendToFile, insertAfterHeading, updateSection,
6
6
  deleteFile, renameFile, moveFile, findBacklinks, gitLog, gitShowFile, appendCsvRow,
7
+ getMindRoot,
7
8
  } from '@/lib/fs';
8
9
  import { assertNotProtected } from '@/lib/core';
9
10
  import { logAgentOp } from './log';
@@ -21,7 +22,13 @@ export function assertWritable(filePath: string): void {
21
22
  assertNotProtected(filePath, 'modified by AI agent');
22
23
  }
23
24
 
24
- /** Helper: wrap a tool execute fn with agent-op logging */
25
+ /**
26
+ * Wrap a tool execute fn with agent-op logging.
27
+ * Catches ALL exceptions and returns an error string — never throws.
28
+ * This is critical: an unhandled throw from a tool execute function kills
29
+ * the AI SDK stream and corrupts the session message state, causing
30
+ * "Cannot read properties of undefined" on every subsequent request.
31
+ */
25
32
  function logged<P extends Record<string, unknown>>(
26
33
  toolName: string,
27
34
  fn: (params: P) => Promise<string>,
@@ -31,12 +38,12 @@ function logged<P extends Record<string, unknown>>(
31
38
  try {
32
39
  const result = await fn(params);
33
40
  const isError = result.startsWith('Error:');
34
- logAgentOp({ ts, tool: toolName, params, result: isError ? 'error' : 'ok', message: result.slice(0, 200) });
41
+ try { logAgentOp({ ts, tool: toolName, params, result: isError ? 'error' : 'ok', message: result.slice(0, 200) }); } catch { /* logging must never kill the stream */ }
35
42
  return result;
36
43
  } catch (e) {
37
44
  const msg = e instanceof Error ? e.message : String(e);
38
- logAgentOp({ ts, tool: toolName, params, result: 'error', message: msg.slice(0, 200) });
39
- throw e;
45
+ try { logAgentOp({ ts, tool: toolName, params, result: 'error', message: msg.slice(0, 200) }); } catch { /* swallow — logging must never kill the stream */ }
46
+ return `Error: ${msg}`;
40
47
  }
41
48
  };
42
49
  }
@@ -47,12 +54,19 @@ export const knowledgeBaseTools = {
47
54
  list_files: tool({
48
55
  description: 'List files in the knowledge base as an indented tree. Directories beyond `depth` show "... (N items)". Pass `path` to list only a subdirectory, or `depth` to control how deep to expand (default 3).',
49
56
  inputSchema: z.object({
50
- path: z.string().optional().describe('Optional subdirectory to list (e.g. "Projects/Products"). Omit to list everything.'),
51
- depth: z.number().min(1).max(10).optional().describe('Max tree depth to expand (default 3). Directories deeper than this show item count only.'),
57
+ path: z.string().nullish().describe('Optional subdirectory to list (e.g. "Projects/Products"). Omit to list everything.'),
58
+ depth: z.number().min(1).max(10).nullish().describe('Max tree depth to expand (default 3). Directories deeper than this show item count only.'),
52
59
  }),
53
60
  execute: logged('list_files', async ({ path: subdir, depth: maxDepth }) => {
54
61
  try {
55
62
  const tree = getFileTree();
63
+
64
+ // Empty tree at root level → likely a misconfigured mindRoot
65
+ if (tree.length === 0 && !subdir) {
66
+ const root = getMindRoot();
67
+ return `(empty — no .md or .csv files found under mind_root: ${root})`;
68
+ }
69
+
56
70
  const limit = maxDepth ?? 3;
57
71
  const lines: string[] = [];
58
72
  function walk(nodes: Array<{ name: string; type: string; children?: unknown[] }>, depth: number) {
@@ -114,9 +128,9 @@ export const knowledgeBaseTools = {
114
128
 
115
129
  get_recent: tool({
116
130
  description: 'Get the most recently modified files in the knowledge base.',
117
- inputSchema: z.object({ limit: z.number().min(1).max(50).default(10).describe('Number of files to return') }),
131
+ inputSchema: z.object({ limit: z.number().min(1).max(50).nullish().describe('Number of files to return (default 10)') }),
118
132
  execute: logged('get_recent', async ({ limit }) => {
119
- const files = getRecentlyModified(limit);
133
+ const files = getRecentlyModified(limit ?? 10);
120
134
  return files.map(f => `- ${f.path} (${new Date(f.mtime).toISOString()})`).join('\n');
121
135
  }),
122
136
  }),
@@ -142,12 +156,12 @@ export const knowledgeBaseTools = {
142
156
  description: 'Create a new file. Only .md and .csv files are allowed. Parent directories are created automatically.',
143
157
  inputSchema: z.object({
144
158
  path: z.string().describe('Relative file path (must end in .md or .csv)'),
145
- content: z.string().default('').describe('Initial file content'),
159
+ content: z.string().nullish().describe('Initial file content'),
146
160
  }),
147
161
  execute: logged('create_file', async ({ path, content }) => {
148
162
  try {
149
163
  assertWritable(path);
150
- createFile(path, content);
164
+ createFile(path, content ?? '');
151
165
  return `File created: ${path}`;
152
166
  } catch (e: unknown) {
153
167
  return `Error: ${e instanceof Error ? e.message : String(e)}`;
@@ -283,11 +297,11 @@ export const knowledgeBaseTools = {
283
297
  description: 'Get git commit history for a file. Shows recent commits that modified this file.',
284
298
  inputSchema: z.object({
285
299
  path: z.string().describe('Relative file path'),
286
- limit: z.number().min(1).max(50).default(10).describe('Number of commits to return'),
300
+ limit: z.number().min(1).max(50).nullish().describe('Number of commits to return (default 10)'),
287
301
  }),
288
302
  execute: logged('get_history', async ({ path, limit }) => {
289
303
  try {
290
- const commits = gitLog(path, limit);
304
+ const commits = gitLog(path, limit ?? 10);
291
305
  if (commits.length === 0) return `No git history found for: ${path}`;
292
306
  return commits.map(c => `- \`${c.hash.slice(0, 7)}\` ${c.date} — ${c.message} (${c.author})`).join('\n');
293
307
  } catch (e: unknown) {
package/app/lib/fs.ts CHANGED
@@ -59,7 +59,15 @@ function ensureCache(): FileTreeCache {
59
59
  if (isCacheValid()) return _cache!;
60
60
  const root = getMindRoot();
61
61
  const tree = buildFileTree(root);
62
- const allFiles = buildAllFiles(root);
62
+ // Extract all file paths from the tree to avoid a second full traversal.
63
+ const allFiles: string[] = [];
64
+ function collect(nodes: FileNode[]) {
65
+ for (const n of nodes) {
66
+ if (n.type === 'file') allFiles.push(n.path);
67
+ else if (n.children) collect(n.children);
68
+ }
69
+ }
70
+ collect(tree);
63
71
  _cache = { tree, allFiles, timestamp: Date.now() };
64
72
  return _cache;
65
73
  }
@@ -25,6 +25,15 @@ export interface AgentConfig {
25
25
  contextStrategy?: 'auto' | 'off'; // default 'auto'
26
26
  }
27
27
 
28
+ export interface GuideState {
29
+ active: boolean; // setup 完成时写入 true
30
+ dismissed: boolean; // 用户关闭 Guide Card 时写入 true
31
+ template: 'en' | 'zh' | 'empty'; // setup 时写入
32
+ step1Done: boolean; // 至少浏览过 1 个文件
33
+ askedAI: boolean; // 至少发过 1 条 AI 消息
34
+ nextStepIndex: number; // 0=C2, 1=C3, 2=C4, 3=全部完成
35
+ }
36
+
28
37
  export interface ServerSettings {
29
38
  ai: AiConfig;
30
39
  agent?: AgentConfig;
@@ -36,6 +45,7 @@ export interface ServerSettings {
36
45
  startMode?: 'dev' | 'start' | 'daemon';
37
46
  setupPending?: boolean; // true → / redirects to /setup
38
47
  disabledSkills?: string[];
48
+ guideState?: GuideState;
39
49
  }
40
50
 
41
51
  const DEFAULTS: ServerSettings = {
@@ -119,6 +129,23 @@ function parseAgent(raw: unknown): AgentConfig | undefined {
119
129
  return Object.keys(result).length > 0 ? result : undefined;
120
130
  }
121
131
 
132
+ /** Parse guideState from unknown input */
133
+ function parseGuideState(raw: unknown): GuideState | undefined {
134
+ if (!raw || typeof raw !== 'object') return undefined;
135
+ const obj = raw as Record<string, unknown>;
136
+ if (obj.active !== true) return undefined;
137
+ const template = obj.template === 'en' || obj.template === 'zh' || obj.template === 'empty'
138
+ ? obj.template : 'en';
139
+ return {
140
+ active: true,
141
+ dismissed: obj.dismissed === true,
142
+ template,
143
+ step1Done: obj.step1Done === true,
144
+ askedAI: obj.askedAI === true,
145
+ nextStepIndex: typeof obj.nextStepIndex === 'number' ? obj.nextStepIndex : 0,
146
+ };
147
+ }
148
+
122
149
  export function readSettings(): ServerSettings {
123
150
  try {
124
151
  const raw = fs.readFileSync(SETTINGS_PATH, 'utf-8');
@@ -134,6 +161,7 @@ export function readSettings(): ServerSettings {
134
161
  startMode: typeof parsed.startMode === 'string' ? parsed.startMode as ServerSettings['startMode'] : undefined,
135
162
  setupPending: parsed.setupPending === true ? true : undefined,
136
163
  disabledSkills: Array.isArray(parsed.disabledSkills) ? parsed.disabledSkills as string[] : undefined,
164
+ guideState: parseGuideState(parsed.guideState),
137
165
  };
138
166
  } catch {
139
167
  return { ...DEFAULTS, ai: { ...DEFAULTS.ai, providers: { ...DEFAULTS.ai.providers } } };
@@ -154,6 +182,7 @@ export function writeSettings(settings: ServerSettings): void {
154
182
  if (settings.mcpPort !== undefined) merged.mcpPort = settings.mcpPort;
155
183
  if (settings.startMode !== undefined) merged.startMode = settings.startMode;
156
184
  if (settings.disabledSkills !== undefined) merged.disabledSkills = settings.disabledSkills;
185
+ if (settings.guideState !== undefined) merged.guideState = settings.guideState;
157
186
  // setupPending: false/undefined → remove the field (cleanup); true → set it
158
187
  if ('setupPending' in settings) {
159
188
  if (settings.setupPending) merged.setupPending = true;
package/app/next-env.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
3
+ import "./.next/dev/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package/bin/cli.js CHANGED
@@ -45,7 +45,7 @@ import { homedir } from 'node:os';
45
45
 
46
46
  import { ROOT, CONFIG_PATH, BUILD_STAMP, LOG_PATH, MINDOS_DIR } from './lib/constants.js';
47
47
  import { bold, dim, cyan, green, red, yellow } from './lib/colors.js';
48
- import { run } from './lib/utils.js';
48
+ import { run, npmInstall } from './lib/utils.js';
49
49
  import { loadConfig, getStartMode, isDaemonMode } from './lib/config.js';
50
50
  import { needsBuild, writeBuildStamp, clearBuildLock, cleanNextDir, ensureAppDeps } from './lib/build.js';
51
51
  import { isPortInUse, assertPortFree } from './lib/port.js';
@@ -325,13 +325,7 @@ const commands = {
325
325
  const mcpSdk = resolve(ROOT, 'mcp', 'node_modules', '@modelcontextprotocol', 'sdk', 'package.json');
326
326
  if (!existsSync(mcpSdk)) {
327
327
  console.log(yellow('Installing MCP dependencies (first run)...\n'));
328
- const mcpCwd = resolve(ROOT, 'mcp');
329
- try {
330
- execSync('npm install --prefer-offline --no-workspaces', { cwd: mcpCwd, stdio: 'inherit' });
331
- } catch {
332
- console.log(yellow('Offline install failed, retrying online...\n'));
333
- run('npm install --no-workspaces', mcpCwd);
334
- }
328
+ npmInstall(resolve(ROOT, 'mcp'), '--no-workspaces');
335
329
  }
336
330
  // Map config env vars to what the MCP server expects
337
331
  const mcpPort = process.env.MINDOS_MCP_PORT || '8781';
package/bin/lib/build.js CHANGED
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import { resolve } from 'node:path';
5
5
  import { ROOT, BUILD_STAMP, DEPS_STAMP } from './constants.js';
6
6
  import { red, dim, yellow } from './colors.js';
7
- import { run } from './utils.js';
7
+ import { run, npmInstall } from './utils.js';
8
8
 
9
9
  export function needsBuild() {
10
10
  const nextDir = resolve(ROOT, 'app', '.next');
@@ -105,12 +105,7 @@ export function ensureAppDeps() {
105
105
  ? 'Updating app dependencies (package-lock.json changed)...\n'
106
106
  : 'Installing app dependencies (first run)...\n';
107
107
  console.log(yellow(label));
108
- try {
109
- execSync('npm install --prefer-offline --no-workspaces', { cwd: resolve(ROOT, 'app'), stdio: 'inherit' });
110
- } catch {
111
- console.log(yellow('Offline install failed, retrying online...\n'));
112
- run('npm install --no-workspaces', resolve(ROOT, 'app'));
113
- }
108
+ npmInstall(resolve(ROOT, 'app'), '--no-workspaces');
114
109
 
115
110
  // Verify critical deps — npm tar extraction can silently fail (ENOENT race)
116
111
  if (!verifyDeps()) {
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  import { ROOT } from './constants.js';
5
5
  import { bold, red, yellow } from './colors.js';
6
+ import { npmInstall } from './utils.js';
6
7
 
7
8
  export function spawnMcp(verbose = false) {
8
9
  const mcpPort = process.env.MINDOS_MCP_PORT || '8781';
@@ -11,19 +12,7 @@ export function spawnMcp(verbose = false) {
11
12
  const mcpSdk = resolve(ROOT, 'mcp', 'node_modules', '@modelcontextprotocol', 'sdk', 'package.json');
12
13
  if (!existsSync(mcpSdk)) {
13
14
  console.log(yellow('Installing MCP dependencies (first run)...\n'));
14
- const mcpCwd = resolve(ROOT, 'mcp');
15
- try {
16
- execSync('npm install --prefer-offline --no-workspaces', { cwd: mcpCwd, stdio: 'inherit' });
17
- } catch {
18
- console.log(yellow('Offline install failed, retrying online...\n'));
19
- try {
20
- execSync('npm install --no-workspaces', { cwd: mcpCwd, stdio: 'inherit' });
21
- } catch (err) {
22
- console.error(red('Failed to install MCP dependencies.'));
23
- console.error(` Try manually: cd ${mcpCwd} && npm install\n`);
24
- process.exit(1);
25
- }
26
- }
15
+ npmInstall(resolve(ROOT, 'mcp'), '--no-workspaces');
27
16
  }
28
17
  const env = {
29
18
  ...process.env,
package/bin/lib/utils.js CHANGED
@@ -11,6 +11,29 @@ export function run(command, cwd = ROOT) {
11
11
  }
12
12
  }
13
13
 
14
+ /**
15
+ * Run `npm install` with --prefer-offline for speed, auto-fallback to online
16
+ * if the local cache is stale or missing a required package version.
17
+ *
18
+ * @param {string} cwd - Directory to run in
19
+ * @param {string} [extraFlags=''] - Additional npm flags (e.g. '--no-workspaces')
20
+ */
21
+ export function npmInstall(cwd, extraFlags = '') {
22
+ const base = `npm install ${extraFlags}`.trim();
23
+ try {
24
+ execSync(`${base} --prefer-offline`, { cwd, stdio: 'inherit', env: process.env });
25
+ } catch {
26
+ // Cache miss or stale packument — retry online
27
+ try {
28
+ execSync(base, { cwd, stdio: 'inherit', env: process.env });
29
+ } catch (err) {
30
+ console.error(`\nFailed to install dependencies in ${cwd}`);
31
+ console.error(` Try manually: cd ${cwd} && ${base}\n`);
32
+ process.exit(err.status || 1);
33
+ }
34
+ }
35
+ }
36
+
14
37
  export function expandHome(p) {
15
38
  return p.startsWith('~/') ? resolve(homedir(), p.slice(2)) : p;
16
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geminilight/mindos",
3
- "version": "0.5.18",
3
+ "version": "0.5.19",
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",