@geminilight/mindos 0.5.11 → 0.5.13

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 (42) hide show
  1. package/README.md +9 -9
  2. package/README_zh.md +9 -9
  3. package/app/README.md +2 -2
  4. package/app/app/api/ask/route.ts +191 -19
  5. package/app/app/api/mcp/install/route.ts +1 -1
  6. package/app/app/api/mcp/status/route.ts +11 -16
  7. package/app/app/api/settings/route.ts +3 -1
  8. package/app/app/api/setup/route.ts +7 -7
  9. package/app/app/api/sync/route.ts +18 -15
  10. package/app/components/AskModal.tsx +28 -32
  11. package/app/components/SettingsModal.tsx +7 -3
  12. package/app/components/ask/MessageList.tsx +65 -3
  13. package/app/components/ask/ThinkingBlock.tsx +55 -0
  14. package/app/components/ask/ToolCallBlock.tsx +97 -0
  15. package/app/components/settings/AiTab.tsx +76 -2
  16. package/app/components/settings/types.ts +8 -0
  17. package/app/components/setup/StepReview.tsx +31 -25
  18. package/app/components/setup/index.tsx +6 -3
  19. package/app/lib/agent/context.ts +317 -0
  20. package/app/lib/agent/index.ts +4 -0
  21. package/app/lib/agent/prompt.ts +46 -31
  22. package/app/lib/agent/stream-consumer.ts +212 -0
  23. package/app/lib/agent/tools.ts +159 -4
  24. package/app/lib/i18n.ts +28 -0
  25. package/app/lib/settings.ts +22 -0
  26. package/app/lib/types.ts +23 -0
  27. package/app/package.json +2 -3
  28. package/bin/cli.js +41 -21
  29. package/bin/lib/build.js +6 -2
  30. package/bin/lib/gateway.js +24 -3
  31. package/bin/lib/mcp-install.js +2 -2
  32. package/bin/lib/mcp-spawn.js +3 -3
  33. package/bin/lib/stop.js +1 -1
  34. package/bin/lib/sync.js +81 -40
  35. package/mcp/README.md +5 -5
  36. package/mcp/src/index.ts +2 -2
  37. package/package.json +3 -2
  38. package/scripts/setup.js +17 -12
  39. package/scripts/upgrade-prompt.md +6 -6
  40. package/skills/mindos/SKILL.md +47 -183
  41. package/skills/mindos-zh/SKILL.md +47 -183
  42. package/app/package-lock.json +0 -15615
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import {
4
4
  searchFiles, getFileContent, getFileTree, getRecentlyModified,
5
5
  saveFileContent, createFile, appendToFile, insertAfterHeading, updateSection,
6
+ deleteFile, renameFile, moveFile, findBacklinks, gitLog, gitShowFile, appendCsvRow,
6
7
  } from '@/lib/fs';
7
8
  import { assertNotProtected } from '@/lib/core';
8
9
  import { logAgentOp } from './log';
@@ -44,11 +45,44 @@ function logged<P extends Record<string, unknown>>(
44
45
 
45
46
  export const knowledgeBaseTools = {
46
47
  list_files: tool({
47
- description: 'List the full file tree of the knowledge base. Use this to browse what files exist.',
48
- inputSchema: z.object({}),
49
- execute: logged('list_files', async () => {
48
+ 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
+ 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.'),
52
+ }),
53
+ execute: logged('list_files', async ({ path: subdir, depth: maxDepth }) => {
50
54
  const tree = getFileTree();
51
- return JSON.stringify(tree, null, 2);
55
+ const limit = maxDepth ?? 3;
56
+ const lines: string[] = [];
57
+ function walk(nodes: Array<{ name: string; type: string; children?: unknown[] }>, depth: number) {
58
+ for (const n of nodes) {
59
+ lines.push(' '.repeat(depth) + (n.type === 'directory' ? `${n.name}/` : n.name));
60
+ if (n.type === 'directory' && Array.isArray(n.children)) {
61
+ if (depth + 1 < limit) {
62
+ walk(n.children as typeof nodes, depth + 1);
63
+ } else {
64
+ lines.push(' '.repeat(depth + 1) + `... (${n.children.length} items)`);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ if (subdir) {
71
+ const segments = subdir.replace(/\/$/, '').split('/').filter(Boolean);
72
+ let current: Array<{ name: string; type: string; path?: string; children?: unknown[] }> = tree as any;
73
+ for (const seg of segments) {
74
+ const found = current.find(n => n.name === seg && n.type === 'directory');
75
+ if (!found || !Array.isArray(found.children)) {
76
+ return `Directory not found: ${subdir}`;
77
+ }
78
+ current = found.children as typeof current;
79
+ }
80
+ walk(current as any, 0);
81
+ } else {
82
+ walk(tree as any, 0);
83
+ }
84
+
85
+ return lines.length > 0 ? lines.join('\n') : '(empty directory)';
52
86
  }),
53
87
  }),
54
88
 
@@ -169,4 +203,125 @@ export const knowledgeBaseTools = {
169
203
  }
170
204
  }),
171
205
  }),
206
+
207
+ // ─── New tools (Phase 1a) ──────────────────────────────────────────────────
208
+
209
+ delete_file: tool({
210
+ description: 'Permanently delete a file from the knowledge base. This is destructive and cannot be undone.',
211
+ inputSchema: z.object({
212
+ path: z.string().describe('Relative file path to delete'),
213
+ }),
214
+ execute: logged('delete_file', async ({ path }) => {
215
+ try {
216
+ assertWritable(path);
217
+ deleteFile(path);
218
+ return `File deleted: ${path}`;
219
+ } catch (e: unknown) {
220
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
221
+ }
222
+ }),
223
+ }),
224
+
225
+ rename_file: tool({
226
+ description: 'Rename a file within its current directory. Only the filename changes, not the directory.',
227
+ inputSchema: z.object({
228
+ path: z.string().describe('Current relative file path'),
229
+ new_name: z.string().describe('New filename (no path separators, e.g. "new-name.md")'),
230
+ }),
231
+ execute: logged('rename_file', async ({ path, new_name }) => {
232
+ try {
233
+ assertWritable(path);
234
+ const newPath = renameFile(path, new_name);
235
+ return `File renamed: ${path} → ${newPath}`;
236
+ } catch (e: unknown) {
237
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
238
+ }
239
+ }),
240
+ }),
241
+
242
+ move_file: tool({
243
+ description: 'Move a file to a new location. Also returns any files that had backlinks affected by the move.',
244
+ inputSchema: z.object({
245
+ from_path: z.string().describe('Current relative file path'),
246
+ to_path: z.string().describe('New relative file path'),
247
+ }),
248
+ execute: logged('move_file', async ({ from_path, to_path }) => {
249
+ try {
250
+ assertWritable(from_path);
251
+ const result = moveFile(from_path, to_path);
252
+ const affected = result.affectedFiles.length > 0
253
+ ? `\nAffected backlinks in: ${result.affectedFiles.join(', ')}`
254
+ : '';
255
+ return `File moved: ${from_path} → ${result.newPath}${affected}`;
256
+ } catch (e: unknown) {
257
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
258
+ }
259
+ }),
260
+ }),
261
+
262
+ get_backlinks: tool({
263
+ description: 'Find all files that reference a given file path. Useful for understanding connections between notes.',
264
+ inputSchema: z.object({
265
+ path: z.string().describe('Relative file path to find backlinks for'),
266
+ }),
267
+ execute: logged('get_backlinks', async ({ path }) => {
268
+ try {
269
+ const backlinks = findBacklinks(path);
270
+ if (backlinks.length === 0) return `No backlinks found for: ${path}`;
271
+ return backlinks.map(b => `- **${b.source}** (L${b.line}): ${b.context}`).join('\n');
272
+ } catch (e: unknown) {
273
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
274
+ }
275
+ }),
276
+ }),
277
+
278
+ get_history: tool({
279
+ description: 'Get git commit history for a file. Shows recent commits that modified this file.',
280
+ inputSchema: z.object({
281
+ path: z.string().describe('Relative file path'),
282
+ limit: z.number().min(1).max(50).default(10).describe('Number of commits to return'),
283
+ }),
284
+ execute: logged('get_history', async ({ path, limit }) => {
285
+ try {
286
+ const commits = gitLog(path, limit);
287
+ if (commits.length === 0) return `No git history found for: ${path}`;
288
+ return commits.map(c => `- \`${c.hash.slice(0, 7)}\` ${c.date} — ${c.message} (${c.author})`).join('\n');
289
+ } catch (e: unknown) {
290
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
291
+ }
292
+ }),
293
+ }),
294
+
295
+ get_file_at_version: tool({
296
+ description: 'Read the content of a file at a specific git commit. Use get_history first to find commit hashes.',
297
+ inputSchema: z.object({
298
+ path: z.string().describe('Relative file path'),
299
+ commit: z.string().describe('Git commit hash (full or abbreviated)'),
300
+ }),
301
+ execute: logged('get_file_at_version', async ({ path, commit }) => {
302
+ try {
303
+ const content = gitShowFile(path, commit);
304
+ return truncate(content);
305
+ } catch (e: unknown) {
306
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
307
+ }
308
+ }),
309
+ }),
310
+
311
+ append_csv: tool({
312
+ description: 'Append a row to a CSV file. Values are automatically escaped per RFC 4180.',
313
+ inputSchema: z.object({
314
+ path: z.string().describe('Relative path to .csv file'),
315
+ row: z.array(z.string()).describe('Array of cell values for the new row'),
316
+ }),
317
+ execute: logged('append_csv', async ({ path, row }) => {
318
+ try {
319
+ assertWritable(path);
320
+ const result = appendCsvRow(path, row);
321
+ return `Row appended to ${path} (now ${result.newRowCount} rows)`;
322
+ } catch (e: unknown) {
323
+ return `Error: ${e instanceof Error ? e.message : String(e)}`;
324
+ }
325
+ }),
326
+ }),
172
327
  };
package/app/lib/i18n.ts CHANGED
@@ -72,6 +72,7 @@ export const messages = {
72
72
  stopTitle: 'Stop',
73
73
  connecting: 'Thinking with your mind...',
74
74
  thinking: 'Thinking...',
75
+ thinkingLabel: 'Thinking',
75
76
  searching: 'Searching knowledge base...',
76
77
  generating: 'Generating response...',
77
78
  stopped: 'Generation stopped.',
@@ -129,6 +130,19 @@ export const messages = {
129
130
  testKeyNoKey: 'No API key configured',
130
131
  testKeyUnknown: 'Test failed',
131
132
  },
133
+ agent: {
134
+ title: 'Agent Behavior',
135
+ maxSteps: 'Max Steps',
136
+ maxStepsHint: 'Maximum tool call steps per request (1-30)',
137
+ contextStrategy: 'Context Strategy',
138
+ contextStrategyHint: 'Auto: summarize early messages when context fills up. Off: no summarization (emergency pruning still applies).',
139
+ contextStrategyAuto: 'Auto (compact + prune)',
140
+ contextStrategyOff: 'Off',
141
+ thinking: 'Extended Thinking',
142
+ thinkingHint: "Show Claude's reasoning process (uses more tokens)",
143
+ thinkingBudget: 'Thinking Budget',
144
+ thinkingBudgetHint: 'Max tokens for reasoning (1000-50000)',
145
+ },
132
146
  appearance: {
133
147
  readingFont: 'Reading font',
134
148
  contentWidth: 'Content width',
@@ -476,6 +490,7 @@ export const messages = {
476
490
  stopTitle: '停止',
477
491
  connecting: '正在与你的心智一起思考...' ,
478
492
  thinking: '思考中...',
493
+ thinkingLabel: '思考中',
479
494
  searching: '正在搜索知识库...',
480
495
  generating: '正在生成回复...',
481
496
  stopped: '已停止生成。',
@@ -533,6 +548,19 @@ export const messages = {
533
548
  testKeyNoKey: '未配置 API Key',
534
549
  testKeyUnknown: '测试失败',
535
550
  },
551
+ agent: {
552
+ title: 'Agent 行为',
553
+ maxSteps: '最大步数',
554
+ maxStepsHint: '每次请求的最大工具调用步数(1-30)',
555
+ contextStrategy: '上下文策略',
556
+ contextStrategyHint: '自动:上下文填满时摘要早期消息。关闭:不进行摘要(紧急裁剪仍会生效)。',
557
+ contextStrategyAuto: '自动(压缩 + 裁剪)',
558
+ contextStrategyOff: '关闭',
559
+ thinking: '深度思考',
560
+ thinkingHint: '显示 Claude 的推理过程(消耗更多 token)',
561
+ thinkingBudget: '思考预算',
562
+ thinkingBudgetHint: '推理最大 token 数(1000-50000)',
563
+ },
536
564
  appearance: {
537
565
  readingFont: '正文字体',
538
566
  contentWidth: '内容宽度',
@@ -18,8 +18,16 @@ export interface AiConfig {
18
18
  };
19
19
  }
20
20
 
21
+ export interface AgentConfig {
22
+ maxSteps?: number; // default 20, range 1-30
23
+ enableThinking?: boolean; // default false, Anthropic only
24
+ thinkingBudget?: number; // default 5000
25
+ contextStrategy?: 'auto' | 'off'; // default 'auto'
26
+ }
27
+
21
28
  export interface ServerSettings {
22
29
  ai: AiConfig;
30
+ agent?: AgentConfig;
23
31
  mindRoot: string; // empty = use env var / default
24
32
  port?: number;
25
33
  mcpPort?: number;
@@ -99,12 +107,25 @@ function migrateAi(parsed: Record<string, unknown>): AiConfig {
99
107
  };
100
108
  }
101
109
 
110
+ /** Parse agent config from unknown input */
111
+ function parseAgent(raw: unknown): AgentConfig | undefined {
112
+ if (!raw || typeof raw !== 'object') return undefined;
113
+ const obj = raw as Record<string, unknown>;
114
+ const result: AgentConfig = {};
115
+ if (typeof obj.maxSteps === 'number') result.maxSteps = Math.min(30, Math.max(1, obj.maxSteps));
116
+ if (typeof obj.enableThinking === 'boolean') result.enableThinking = obj.enableThinking;
117
+ if (typeof obj.thinkingBudget === 'number') result.thinkingBudget = Math.min(50000, Math.max(1000, obj.thinkingBudget));
118
+ if (obj.contextStrategy === 'auto' || obj.contextStrategy === 'off') result.contextStrategy = obj.contextStrategy;
119
+ return Object.keys(result).length > 0 ? result : undefined;
120
+ }
121
+
102
122
  export function readSettings(): ServerSettings {
103
123
  try {
104
124
  const raw = fs.readFileSync(SETTINGS_PATH, 'utf-8');
105
125
  const parsed = JSON.parse(raw) as Record<string, unknown>;
106
126
  return {
107
127
  ai: migrateAi(parsed),
128
+ agent: parseAgent(parsed.agent),
108
129
  mindRoot: (parsed.mindRoot ?? parsed.sopRoot ?? DEFAULTS.mindRoot) as string,
109
130
  webPassword: typeof parsed.webPassword === 'string' ? parsed.webPassword : undefined,
110
131
  authToken: typeof parsed.authToken === 'string' ? parsed.authToken : undefined,
@@ -126,6 +147,7 @@ export function writeSettings(settings: ServerSettings): void {
126
147
  let existing: Record<string, unknown> = {};
127
148
  try { existing = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); } catch { /* ignore */ }
128
149
  const merged: Record<string, unknown> = { ...existing, ai: settings.ai, mindRoot: settings.mindRoot };
150
+ if (settings.agent !== undefined) merged.agent = settings.agent;
129
151
  if (settings.webPassword !== undefined) merged.webPassword = settings.webPassword;
130
152
  if (settings.authToken !== undefined) merged.authToken = settings.authToken;
131
153
  if (settings.port !== undefined) merged.port = settings.port;
package/app/lib/types.ts CHANGED
@@ -13,9 +13,32 @@ export interface BacklinkItem {
13
13
  snippets: string[];
14
14
  }
15
15
 
16
+ export interface ToolCallPart {
17
+ type: 'tool-call';
18
+ toolCallId: string;
19
+ toolName: string;
20
+ input: unknown;
21
+ output?: string;
22
+ state: 'pending' | 'running' | 'done' | 'error';
23
+ }
24
+
25
+ export interface TextPart {
26
+ type: 'text';
27
+ text: string;
28
+ }
29
+
30
+ export interface ReasoningPart {
31
+ type: 'reasoning';
32
+ text: string;
33
+ }
34
+
35
+ export type MessagePart = TextPart | ToolCallPart | ReasoningPart;
36
+
16
37
  export interface Message {
17
38
  role: 'user' | 'assistant';
18
39
  content: string;
40
+ /** Structured parts for assistant messages (tool calls + text segments) */
41
+ parts?: MessagePart[];
19
42
  }
20
43
 
21
44
  export interface LocalAttachment {
package/app/package.json CHANGED
@@ -3,17 +3,16 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "scripts": {
6
- "dev": "next dev -p ${MINDOS_WEB_PORT:-3000}",
6
+ "dev": "next dev -p ${MINDOS_WEB_PORT:-3456}",
7
7
  "prebuild": "node ../scripts/gen-renderer-index.js",
8
8
  "build": "next build",
9
- "start": "next start -p ${MINDOS_WEB_PORT:-3000}",
9
+ "start": "next start -p ${MINDOS_WEB_PORT:-3456}",
10
10
  "lint": "eslint",
11
11
  "test": "vitest run"
12
12
  },
13
13
  "dependencies": {
14
14
  "@ai-sdk/anthropic": "^3.0.58",
15
15
  "@ai-sdk/openai": "^3.0.41",
16
- "@ai-sdk/react": "^3.0.118",
17
16
  "@base-ui/react": "^1.2.0",
18
17
  "@codemirror/lang-markdown": "^6.5.0",
19
18
  "@codemirror/state": "^6.5.4",
package/bin/cli.js CHANGED
@@ -50,7 +50,7 @@ import { needsBuild, writeBuildStamp, clearBuildLock, cleanNextDir, ensureAppDep
50
50
  import { isPortInUse, assertPortFree } from './lib/port.js';
51
51
  import { savePids, clearPids } from './lib/pid.js';
52
52
  import { stopMindos } from './lib/stop.js';
53
- import { getPlatform, ensureMindosDir, waitForHttp, runGatewayCommand } from './lib/gateway.js';
53
+ import { getPlatform, ensureMindosDir, waitForHttp, waitForPortFree, runGatewayCommand } from './lib/gateway.js';
54
54
  import { printStartupInfo, getLocalIP } from './lib/startup.js';
55
55
  import { spawnMcp } from './lib/mcp-spawn.js';
56
56
  import { mcpInstall } from './lib/mcp-install.js';
@@ -84,7 +84,7 @@ const commands = {
84
84
  // ── open ───────────────────────────────────────────────────────────────────
85
85
  open: () => {
86
86
  loadConfig();
87
- const webPort = process.env.MINDOS_WEB_PORT || '3000';
87
+ const webPort = process.env.MINDOS_WEB_PORT || '3456';
88
88
  const url = `http://localhost:${webPort}`;
89
89
  let cmd;
90
90
  if (process.platform === 'darwin') {
@@ -121,7 +121,7 @@ const commands = {
121
121
  console.log(dim('No auth token set. Run `mindos onboard` to configure one.'));
122
122
  process.exit(0);
123
123
  }
124
- const mcpPort = config.mcpPort || 8787;
124
+ const mcpPort = config.mcpPort || 8781;
125
125
  const localIP = getLocalIP();
126
126
 
127
127
  const localUrl = `http://localhost:${mcpPort}/mcp`;
@@ -197,8 +197,8 @@ const commands = {
197
197
  // ── dev ────────────────────────────────────────────────────────────────────
198
198
  dev: async () => {
199
199
  loadConfig();
200
- const webPort = process.env.MINDOS_WEB_PORT || '3000';
201
- const mcpPort = process.env.MINDOS_MCP_PORT || '8787';
200
+ const webPort = process.env.MINDOS_WEB_PORT || '3456';
201
+ const mcpPort = process.env.MINDOS_MCP_PORT || '8781';
202
202
  await assertPortFree(Number(webPort), 'web');
203
203
  await assertPortFree(Number(mcpPort), 'mcp');
204
204
  ensureAppDeps();
@@ -231,11 +231,13 @@ const commands = {
231
231
  console.warn(yellow('Warning: daemon mode not supported on this platform. Falling back to foreground.'));
232
232
  } else {
233
233
  loadConfig();
234
- const webPort = process.env.MINDOS_WEB_PORT || '3000';
235
- const mcpPort = process.env.MINDOS_MCP_PORT || '8787';
234
+ const webPort = process.env.MINDOS_WEB_PORT || '3456';
235
+ const mcpPort = process.env.MINDOS_MCP_PORT || '8781';
236
236
  console.log(cyan(`Installing MindOS as a background service (${platform})...`));
237
237
  await runGatewayCommand('install');
238
- await runGatewayCommand('start');
238
+ // install() already starts the service via launchctl bootstrap + RunAtLoad=true.
239
+ // Do NOT call start() here — kickstart -k would kill the just-started process,
240
+ // causing a port-conflict race condition with KeepAlive restart loops.
239
241
  console.log(dim(' (First run may take a few minutes to install dependencies and build the app.)'));
240
242
  console.log(dim(' Follow live progress with: mindos logs\n'));
241
243
  const ready = await waitForHttp(Number(webPort), { retries: 120, intervalMs: 2000, label: 'Web UI' });
@@ -261,10 +263,26 @@ const commands = {
261
263
  }
262
264
  }
263
265
  loadConfig();
264
- const webPort = process.env.MINDOS_WEB_PORT || '3000';
265
- const mcpPort = process.env.MINDOS_MCP_PORT || '8787';
266
- await assertPortFree(Number(webPort), 'web');
267
- await assertPortFree(Number(mcpPort), 'mcp');
266
+ const webPort = process.env.MINDOS_WEB_PORT || '3456';
267
+ const mcpPort = process.env.MINDOS_MCP_PORT || '8781';
268
+
269
+ // When launched by a daemon manager (launchd/systemd), wait for ports to
270
+ // free instead of exiting immediately — the previous instance may still be
271
+ // shutting down after a restart/update.
272
+ const launchedByDaemon = process.env.LAUNCHED_BY_LAUNCHD === '1'
273
+ || !!process.env.INVOCATION_ID; /* systemd sets INVOCATION_ID */
274
+
275
+ if (launchedByDaemon) {
276
+ const webOk = await waitForPortFree(Number(webPort), { retries: 60, intervalMs: 500 });
277
+ const mcpOk = await waitForPortFree(Number(mcpPort), { retries: 60, intervalMs: 500 });
278
+ if (!webOk || !mcpOk) {
279
+ console.error('Ports still in use after 30s, exiting.');
280
+ process.exit(1); // KeepAlive will retry after ThrottleInterval
281
+ }
282
+ } else {
283
+ await assertPortFree(Number(webPort), 'web');
284
+ await assertPortFree(Number(mcpPort), 'mcp');
285
+ }
268
286
  ensureAppDeps();
269
287
  if (needsBuild()) {
270
288
  console.log(yellow('Building MindOS (first run or new version detected)...\n'));
@@ -306,8 +324,8 @@ const commands = {
306
324
  run('npm install --prefer-offline --no-workspaces', resolve(ROOT, 'mcp'));
307
325
  }
308
326
  // Map config env vars to what the MCP server expects
309
- const mcpPort = process.env.MINDOS_MCP_PORT || '8787';
310
- const webPort = process.env.MINDOS_WEB_PORT || '3000';
327
+ const mcpPort = process.env.MINDOS_MCP_PORT || '8781';
328
+ const webPort = process.env.MINDOS_WEB_PORT || '3456';
311
329
  process.env.MCP_PORT = mcpPort;
312
330
  process.env.MINDOS_URL = `http://localhost:${webPort}`;
313
331
  run(`npx tsx src/index.ts`, resolve(ROOT, 'mcp'));
@@ -328,8 +346,8 @@ const commands = {
328
346
  loadConfig();
329
347
 
330
348
  // After loadConfig, env vars reflect the NEW config (or old if unchanged).
331
- const newWebPort = Number(process.env.MINDOS_WEB_PORT || '3000');
332
- const newMcpPort = Number(process.env.MINDOS_MCP_PORT || '8787');
349
+ const newWebPort = Number(process.env.MINDOS_WEB_PORT || '3456');
350
+ const newMcpPort = Number(process.env.MINDOS_MCP_PORT || '8781');
333
351
 
334
352
  // Collect old ports that differ from new ones — processes may still be
335
353
  // listening there even though config already points to the new ports.
@@ -465,8 +483,8 @@ ${dim('Shortcut: mindos start --daemon → install + start in one step')}
465
483
  }
466
484
 
467
485
  // 6. Ports
468
- const webPort = Number(config?.port || process.env.MINDOS_WEB_PORT || 3000);
469
- const mcpPort = Number(config?.mcpPort || process.env.MINDOS_MCP_PORT || 8787);
486
+ const webPort = Number(config?.port || process.env.MINDOS_WEB_PORT || 3456);
487
+ const mcpPort = Number(config?.mcpPort || process.env.MINDOS_MCP_PORT || 8781);
470
488
  const webInUse = await isPortInUse(webPort);
471
489
  const mcpInUse = await isPortInUse(mcpPort);
472
490
  if (webInUse) {
@@ -582,11 +600,13 @@ ${dim('Shortcut: mindos start --daemon → install + start in one step')}
582
600
  console.log(cyan('\n Daemon is running — restarting to apply the new version...'));
583
601
  await runGatewayCommand('stop');
584
602
  await runGatewayCommand('install');
585
- await runGatewayCommand('start');
603
+ // Note: install() already starts the service via launchctl bootstrap + RunAtLoad=true.
604
+ // Do NOT call start() here — kickstart -k would kill the just-started process,
605
+ // causing a port-conflict race condition with KeepAlive restart loops.
586
606
  const webPort = (() => {
587
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')).port ?? 3000; } catch { return 3000; }
607
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')).port ?? 3456; } catch { return 3456; }
588
608
  })();
589
- console.log(dim(' (Waiting for Web UI to come back up...)'));
609
+ console.log(dim(' (Waiting for Web UI to come back up — first run after update includes a rebuild...)'));
590
610
  const ready = await waitForHttp(Number(webPort), { retries: 120, intervalMs: 2000, label: 'Web UI' });
591
611
  if (ready) {
592
612
  console.log(green('✔ MindOS restarted and ready.\n'));
package/bin/lib/build.js CHANGED
@@ -38,9 +38,13 @@ export function cleanNextDir() {
38
38
  }
39
39
 
40
40
  function depsHash() {
41
- const lockPath = resolve(ROOT, 'app', 'package-lock.json');
41
+ // Use package.json (not package-lock.json) so we don't need to ship the
42
+ // 560kB lock file in the npm tarball. package.json changes whenever
43
+ // dependencies are added/removed/bumped, which is the only case that
44
+ // requires a fresh `npm install`.
45
+ const pkgPath = resolve(ROOT, 'app', 'package.json');
42
46
  try {
43
- const content = readFileSync(lockPath);
47
+ const content = readFileSync(pkgPath);
44
48
  return createHash('sha256').update(content).digest('hex').slice(0, 16);
45
49
  } catch {
46
50
  return null;
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, statSync, renameSync, unlinkSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { MINDOS_DIR, LOG_PATH, CLI_PATH, NODE_BIN, CONFIG_PATH } from './constants.js';
@@ -8,6 +8,18 @@ import { isPortInUse } from './port.js';
8
8
 
9
9
  // ── Helpers ──────────────────────────────────────────────────────────────────
10
10
 
11
+ /** Rotate log file when it exceeds 2 MB. Keeps at most one .old backup. */
12
+ function rotateLogs() {
13
+ try {
14
+ const stat = statSync(LOG_PATH);
15
+ if (stat.size > 2 * 1024 * 1024) {
16
+ const old = LOG_PATH + '.old';
17
+ try { unlinkSync(old); } catch {}
18
+ renameSync(LOG_PATH, old);
19
+ }
20
+ } catch { /* file doesn't exist yet, nothing to rotate */ }
21
+ }
22
+
11
23
  export function getPlatform() {
12
24
  if (process.platform === 'darwin') return 'launchd';
13
25
  if (process.platform === 'linux') return 'systemd';
@@ -72,6 +84,7 @@ const systemd = {
72
84
  install() {
73
85
  if (!existsSync(SYSTEMD_DIR)) mkdirSync(SYSTEMD_DIR, { recursive: true });
74
86
  ensureMindosDir();
87
+ rotateLogs();
75
88
  const currentPath = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin';
76
89
  const unit = [
77
90
  '[Unit]',
@@ -100,6 +113,7 @@ const systemd = {
100
113
  },
101
114
 
102
115
  async start() {
116
+ rotateLogs();
103
117
  execSync('systemctl --user start mindos', { stdio: 'inherit' });
104
118
  const ok = await waitForService(() => {
105
119
  try {
@@ -153,6 +167,7 @@ const launchd = {
153
167
  install() {
154
168
  if (!existsSync(LAUNCHD_DIR)) mkdirSync(LAUNCHD_DIR, { recursive: true });
155
169
  ensureMindosDir();
170
+ rotateLogs();
156
171
  const currentPath = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin';
157
172
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
158
173
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -166,13 +181,18 @@ const launchd = {
166
181
  <string>start</string>
167
182
  </array>
168
183
  <key>RunAtLoad</key><true/>
169
- <key>KeepAlive</key><true/>
184
+ <key>KeepAlive</key>
185
+ <dict>
186
+ <key>SuccessfulExit</key><false/>
187
+ </dict>
188
+ <key>ThrottleInterval</key><integer>5</integer>
170
189
  <key>StandardOutPath</key><string>${LOG_PATH}</string>
171
190
  <key>StandardErrorPath</key><string>${LOG_PATH}</string>
172
191
  <key>EnvironmentVariables</key>
173
192
  <dict>
174
193
  <key>HOME</key><string>${homedir()}</string>
175
194
  <key>PATH</key><string>${currentPath}</string>
195
+ <key>LAUNCHED_BY_LAUNCHD</key><string>1</string>
176
196
  </dict>
177
197
  </dict>
178
198
  </plist>
@@ -192,6 +212,7 @@ const launchd = {
192
212
  },
193
213
 
194
214
  async start() {
215
+ rotateLogs();
195
216
  execSync(`launchctl kickstart -k gui/${launchctlUid()}/${LAUNCHD_LABEL}`, { stdio: 'inherit' });
196
217
  const ok = await waitForService(() => {
197
218
  try {
@@ -209,7 +230,7 @@ const launchd = {
209
230
 
210
231
  async stop() {
211
232
  // Read ports before bootout so we can wait for them to be freed
212
- let webPort = 3000, mcpPort = 8787;
233
+ let webPort = 3456, mcpPort = 8781;
213
234
  try {
214
235
  const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
215
236
  if (config.port) webPort = Number(config.port);
@@ -253,8 +253,8 @@ export async function mcpInstall() {
253
253
  const ask2 = (q) => new Promise(r => rl2.question(q, r));
254
254
 
255
255
  if (!url) {
256
- let mcpPort = 8787;
257
- try { mcpPort = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')).mcpPort || 8787; } catch {}
256
+ let mcpPort = 8781;
257
+ try { mcpPort = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')).mcpPort || 8781; } catch {}
258
258
  const defaultUrl = `http://localhost:${mcpPort}/mcp`;
259
259
  url = hasYesFlag ? defaultUrl : (await ask2(`${bold('MCP URL')} ${dim(`[${defaultUrl}]:`)} `)).trim() || defaultUrl;
260
260
  }
@@ -5,8 +5,8 @@ import { ROOT } from './constants.js';
5
5
  import { bold, red, yellow } from './colors.js';
6
6
 
7
7
  export function spawnMcp(verbose = false) {
8
- const mcpPort = process.env.MINDOS_MCP_PORT || '8787';
9
- const webPort = process.env.MINDOS_WEB_PORT || '3000';
8
+ const mcpPort = process.env.MINDOS_MCP_PORT || '8781';
9
+ const webPort = process.env.MINDOS_WEB_PORT || '3456';
10
10
  // Ensure mcp/node_modules exists (auto-install on first run)
11
11
  const mcpSdk = resolve(ROOT, 'mcp', 'node_modules', '@modelcontextprotocol', 'sdk', 'package.json');
12
12
  if (!existsSync(mcpSdk)) {
@@ -16,7 +16,7 @@ export function spawnMcp(verbose = false) {
16
16
  const env = {
17
17
  ...process.env,
18
18
  MCP_PORT: mcpPort,
19
- MINDOS_URL: `http://localhost:${webPort}`,
19
+ MINDOS_URL: process.env.MINDOS_URL || `http://127.0.0.1:${webPort}`,
20
20
  ...(verbose ? { MCP_VERBOSE: '1' } : {}),
21
21
  };
22
22
  const child = spawn('npx', ['tsx', 'src/index.ts'], {
package/bin/lib/stop.js CHANGED
@@ -75,7 +75,7 @@ function killTree(pid) {
75
75
  */
76
76
  export function stopMindos(opts = {}) {
77
77
  // Read ports from config for port-based cleanup
78
- let webPort = '3000', mcpPort = '8787';
78
+ let webPort = '3456', mcpPort = '8781';
79
79
  try {
80
80
  const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
81
81
  if (config.port) webPort = String(config.port);