@geminilight/mindos 0.5.10 → 0.5.12
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/README.md +9 -9
- package/README_zh.md +9 -9
- package/app/README.md +2 -2
- package/app/app/api/ask/route.ts +126 -3
- package/app/app/api/mcp/install/route.ts +1 -1
- package/app/app/api/mcp/status/route.ts +1 -1
- package/app/app/api/settings/route.ts +1 -1
- package/app/app/api/settings/test-key/route.ts +111 -0
- package/app/app/api/setup/route.ts +7 -7
- package/app/app/api/sync/route.ts +30 -40
- package/app/components/AskModal.tsx +28 -21
- package/app/components/ask/MessageList.tsx +62 -3
- package/app/components/ask/ToolCallBlock.tsx +89 -0
- package/app/components/settings/AiTab.tsx +120 -2
- package/app/components/setup/StepReview.tsx +31 -25
- package/app/components/setup/index.tsx +6 -3
- package/app/instrumentation.ts +19 -0
- package/app/lib/agent/prompt.ts +32 -0
- package/app/lib/agent/stream-consumer.ts +178 -0
- package/app/lib/agent/tools.ts +122 -0
- package/app/lib/i18n.ts +18 -0
- package/app/lib/types.ts +18 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package.json +2 -2
- package/bin/cli.js +49 -22
- package/bin/lib/gateway.js +24 -3
- package/bin/lib/mcp-install.js +2 -2
- package/bin/lib/mcp-spawn.js +3 -3
- package/bin/lib/stop.js +1 -1
- package/bin/lib/sync.js +61 -11
- package/mcp/README.md +5 -5
- package/mcp/src/index.ts +2 -2
- package/package.json +4 -2
- package/scripts/setup.js +12 -12
- package/scripts/upgrade-prompt.md +6 -6
- package/assets/images/demo-flow-dark.png +0 -0
- package/assets/images/demo-flow-light.png +0 -0
- package/assets/images/demo-flow-zh-dark.png +0 -0
- package/assets/images/demo-flow-zh-light.png +0 -0
- package/assets/images/gui-sync-cv.png +0 -0
- package/assets/images/wechat-qr.png +0 -0
- package/mcp/package-lock.json +0 -1717
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { Message, MessagePart, ToolCallPart, TextPart } from '@/lib/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a UIMessageStream SSE response into structured Message parts.
|
|
5
|
+
* The stream format is Server-Sent Events where each data line is a JSON-encoded UIMessageChunk.
|
|
6
|
+
*/
|
|
7
|
+
export async function consumeUIMessageStream(
|
|
8
|
+
body: ReadableStream<Uint8Array>,
|
|
9
|
+
onUpdate: (message: Message) => void,
|
|
10
|
+
signal?: AbortSignal,
|
|
11
|
+
): Promise<Message> {
|
|
12
|
+
const reader = body.getReader();
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
let buffer = '';
|
|
15
|
+
|
|
16
|
+
// Mutable working copies — we deep-clone when emitting to React
|
|
17
|
+
const parts: MessagePart[] = [];
|
|
18
|
+
const toolCalls = new Map<string, ToolCallPart>();
|
|
19
|
+
let currentTextId: string | null = null;
|
|
20
|
+
|
|
21
|
+
/** Deep-clone parts into an immutable Message snapshot for React state */
|
|
22
|
+
function buildMessage(): Message {
|
|
23
|
+
const clonedParts: MessagePart[] = parts.map(p => {
|
|
24
|
+
if (p.type === 'text') return { type: 'text' as const, text: p.text };
|
|
25
|
+
return { ...p }; // ToolCallPart — shallow copy is safe (all primitive fields + `input` is replaced, not mutated)
|
|
26
|
+
});
|
|
27
|
+
const textContent = clonedParts
|
|
28
|
+
.filter((p): p is TextPart => p.type === 'text')
|
|
29
|
+
.map(p => p.text)
|
|
30
|
+
.join('');
|
|
31
|
+
return {
|
|
32
|
+
role: 'assistant',
|
|
33
|
+
content: textContent,
|
|
34
|
+
parts: clonedParts,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findOrCreateTextPart(id: string): TextPart {
|
|
39
|
+
if (currentTextId === id) {
|
|
40
|
+
const last = parts[parts.length - 1];
|
|
41
|
+
if (last && last.type === 'text') return last;
|
|
42
|
+
}
|
|
43
|
+
const part: TextPart = { type: 'text', text: '' };
|
|
44
|
+
parts.push(part);
|
|
45
|
+
currentTextId = id;
|
|
46
|
+
return part;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findOrCreateToolCall(toolCallId: string, toolName?: string): ToolCallPart {
|
|
50
|
+
let tc = toolCalls.get(toolCallId);
|
|
51
|
+
if (!tc) {
|
|
52
|
+
tc = {
|
|
53
|
+
type: 'tool-call',
|
|
54
|
+
toolCallId,
|
|
55
|
+
toolName: toolName ?? 'unknown',
|
|
56
|
+
input: undefined,
|
|
57
|
+
state: 'pending',
|
|
58
|
+
};
|
|
59
|
+
toolCalls.set(toolCallId, tc);
|
|
60
|
+
parts.push(tc);
|
|
61
|
+
currentTextId = null; // break text continuity
|
|
62
|
+
}
|
|
63
|
+
return tc;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
while (true) {
|
|
68
|
+
if (signal?.aborted) break;
|
|
69
|
+
const { done, value } = await reader.read();
|
|
70
|
+
if (done) break;
|
|
71
|
+
|
|
72
|
+
buffer += decoder.decode(value, { stream: true });
|
|
73
|
+
|
|
74
|
+
// Process complete SSE lines
|
|
75
|
+
const lines = buffer.split('\n');
|
|
76
|
+
buffer = lines.pop() ?? ''; // keep incomplete last line
|
|
77
|
+
|
|
78
|
+
let changed = false;
|
|
79
|
+
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
|
|
83
|
+
// SSE format: the ai SDK v6 UIMessageStream uses "d:{json}\n"
|
|
84
|
+
// Also handle standard "data:{json}" for robustness
|
|
85
|
+
let jsonStr: string | null = null;
|
|
86
|
+
if (trimmed.startsWith('d:')) {
|
|
87
|
+
jsonStr = trimmed.slice(2);
|
|
88
|
+
} else if (trimmed.startsWith('data:')) {
|
|
89
|
+
jsonStr = trimmed.slice(5).trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!jsonStr) continue;
|
|
93
|
+
|
|
94
|
+
let chunk: Record<string, unknown>;
|
|
95
|
+
try {
|
|
96
|
+
chunk = JSON.parse(jsonStr);
|
|
97
|
+
} catch {
|
|
98
|
+
continue; // skip malformed lines
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const type = chunk.type as string;
|
|
102
|
+
|
|
103
|
+
switch (type) {
|
|
104
|
+
case 'text-start': {
|
|
105
|
+
findOrCreateTextPart(chunk.id as string);
|
|
106
|
+
changed = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'text-delta': {
|
|
110
|
+
const part = findOrCreateTextPart(chunk.id as string);
|
|
111
|
+
part.text += chunk.delta as string;
|
|
112
|
+
changed = true;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case 'text-end': {
|
|
116
|
+
// Text part is complete — no state change needed
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case 'tool-input-start': {
|
|
120
|
+
const tc = findOrCreateToolCall(chunk.toolCallId as string, chunk.toolName as string);
|
|
121
|
+
tc.state = 'running';
|
|
122
|
+
changed = true;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'tool-input-delta': {
|
|
126
|
+
// Streaming input — we wait for input-available for the complete input
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'tool-input-available': {
|
|
130
|
+
const tc = findOrCreateToolCall(chunk.toolCallId as string, chunk.toolName as string);
|
|
131
|
+
tc.input = chunk.input;
|
|
132
|
+
tc.state = 'running';
|
|
133
|
+
changed = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'tool-output-available': {
|
|
137
|
+
const tc = toolCalls.get(chunk.toolCallId as string);
|
|
138
|
+
if (tc) {
|
|
139
|
+
tc.output = typeof chunk.output === 'string' ? chunk.output : JSON.stringify(chunk.output);
|
|
140
|
+
tc.state = 'done';
|
|
141
|
+
changed = true;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'tool-output-error':
|
|
146
|
+
case 'tool-input-error': {
|
|
147
|
+
const tc = toolCalls.get(chunk.toolCallId as string);
|
|
148
|
+
if (tc) {
|
|
149
|
+
tc.output = (chunk.errorText as string) ?? 'Error';
|
|
150
|
+
tc.state = 'error';
|
|
151
|
+
changed = true;
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'error': {
|
|
156
|
+
const errorText = (chunk.errorText as string) ?? 'Unknown error';
|
|
157
|
+
parts.push({ type: 'text', text: `\n\n**Error:** ${errorText}` });
|
|
158
|
+
currentTextId = null;
|
|
159
|
+
changed = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
// step-start, reasoning-*, metadata, finish — ignored for now
|
|
163
|
+
default:
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Emit once per reader.read() batch, not per SSE line
|
|
169
|
+
if (changed) {
|
|
170
|
+
onUpdate(buildMessage());
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
reader.releaseLock();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return buildMessage();
|
|
178
|
+
}
|
package/app/lib/agent/tools.ts
CHANGED
|
@@ -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';
|
|
@@ -169,4 +170,125 @@ export const knowledgeBaseTools = {
|
|
|
169
170
|
}
|
|
170
171
|
}),
|
|
171
172
|
}),
|
|
173
|
+
|
|
174
|
+
// ─── New tools (Phase 1a) ──────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
delete_file: tool({
|
|
177
|
+
description: 'Permanently delete a file from the knowledge base. This is destructive and cannot be undone.',
|
|
178
|
+
inputSchema: z.object({
|
|
179
|
+
path: z.string().describe('Relative file path to delete'),
|
|
180
|
+
}),
|
|
181
|
+
execute: logged('delete_file', async ({ path }) => {
|
|
182
|
+
try {
|
|
183
|
+
assertWritable(path);
|
|
184
|
+
deleteFile(path);
|
|
185
|
+
return `File deleted: ${path}`;
|
|
186
|
+
} catch (e: unknown) {
|
|
187
|
+
return `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
188
|
+
}
|
|
189
|
+
}),
|
|
190
|
+
}),
|
|
191
|
+
|
|
192
|
+
rename_file: tool({
|
|
193
|
+
description: 'Rename a file within its current directory. Only the filename changes, not the directory.',
|
|
194
|
+
inputSchema: z.object({
|
|
195
|
+
path: z.string().describe('Current relative file path'),
|
|
196
|
+
new_name: z.string().describe('New filename (no path separators, e.g. "new-name.md")'),
|
|
197
|
+
}),
|
|
198
|
+
execute: logged('rename_file', async ({ path, new_name }) => {
|
|
199
|
+
try {
|
|
200
|
+
assertWritable(path);
|
|
201
|
+
const newPath = renameFile(path, new_name);
|
|
202
|
+
return `File renamed: ${path} → ${newPath}`;
|
|
203
|
+
} catch (e: unknown) {
|
|
204
|
+
return `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
205
|
+
}
|
|
206
|
+
}),
|
|
207
|
+
}),
|
|
208
|
+
|
|
209
|
+
move_file: tool({
|
|
210
|
+
description: 'Move a file to a new location. Also returns any files that had backlinks affected by the move.',
|
|
211
|
+
inputSchema: z.object({
|
|
212
|
+
from_path: z.string().describe('Current relative file path'),
|
|
213
|
+
to_path: z.string().describe('New relative file path'),
|
|
214
|
+
}),
|
|
215
|
+
execute: logged('move_file', async ({ from_path, to_path }) => {
|
|
216
|
+
try {
|
|
217
|
+
assertWritable(from_path);
|
|
218
|
+
const result = moveFile(from_path, to_path);
|
|
219
|
+
const affected = result.affectedFiles.length > 0
|
|
220
|
+
? `\nAffected backlinks in: ${result.affectedFiles.join(', ')}`
|
|
221
|
+
: '';
|
|
222
|
+
return `File moved: ${from_path} → ${result.newPath}${affected}`;
|
|
223
|
+
} catch (e: unknown) {
|
|
224
|
+
return `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
225
|
+
}
|
|
226
|
+
}),
|
|
227
|
+
}),
|
|
228
|
+
|
|
229
|
+
get_backlinks: tool({
|
|
230
|
+
description: 'Find all files that reference a given file path. Useful for understanding connections between notes.',
|
|
231
|
+
inputSchema: z.object({
|
|
232
|
+
path: z.string().describe('Relative file path to find backlinks for'),
|
|
233
|
+
}),
|
|
234
|
+
execute: logged('get_backlinks', async ({ path }) => {
|
|
235
|
+
try {
|
|
236
|
+
const backlinks = findBacklinks(path);
|
|
237
|
+
if (backlinks.length === 0) return `No backlinks found for: ${path}`;
|
|
238
|
+
return backlinks.map(b => `- **${b.source}** (L${b.line}): ${b.context}`).join('\n');
|
|
239
|
+
} catch (e: unknown) {
|
|
240
|
+
return `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
241
|
+
}
|
|
242
|
+
}),
|
|
243
|
+
}),
|
|
244
|
+
|
|
245
|
+
get_history: tool({
|
|
246
|
+
description: 'Get git commit history for a file. Shows recent commits that modified this file.',
|
|
247
|
+
inputSchema: z.object({
|
|
248
|
+
path: z.string().describe('Relative file path'),
|
|
249
|
+
limit: z.number().min(1).max(50).default(10).describe('Number of commits to return'),
|
|
250
|
+
}),
|
|
251
|
+
execute: logged('get_history', async ({ path, limit }) => {
|
|
252
|
+
try {
|
|
253
|
+
const commits = gitLog(path, limit);
|
|
254
|
+
if (commits.length === 0) return `No git history found for: ${path}`;
|
|
255
|
+
return commits.map(c => `- \`${c.hash.slice(0, 7)}\` ${c.date} — ${c.message} (${c.author})`).join('\n');
|
|
256
|
+
} catch (e: unknown) {
|
|
257
|
+
return `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
258
|
+
}
|
|
259
|
+
}),
|
|
260
|
+
}),
|
|
261
|
+
|
|
262
|
+
get_file_at_version: tool({
|
|
263
|
+
description: 'Read the content of a file at a specific git commit. Use get_history first to find commit hashes.',
|
|
264
|
+
inputSchema: z.object({
|
|
265
|
+
path: z.string().describe('Relative file path'),
|
|
266
|
+
commit: z.string().describe('Git commit hash (full or abbreviated)'),
|
|
267
|
+
}),
|
|
268
|
+
execute: logged('get_file_at_version', async ({ path, commit }) => {
|
|
269
|
+
try {
|
|
270
|
+
const content = gitShowFile(path, commit);
|
|
271
|
+
return truncate(content);
|
|
272
|
+
} catch (e: unknown) {
|
|
273
|
+
return `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
274
|
+
}
|
|
275
|
+
}),
|
|
276
|
+
}),
|
|
277
|
+
|
|
278
|
+
append_csv: tool({
|
|
279
|
+
description: 'Append a row to a CSV file. Values are automatically escaped per RFC 4180.',
|
|
280
|
+
inputSchema: z.object({
|
|
281
|
+
path: z.string().describe('Relative path to .csv file'),
|
|
282
|
+
row: z.array(z.string()).describe('Array of cell values for the new row'),
|
|
283
|
+
}),
|
|
284
|
+
execute: logged('append_csv', async ({ path, row }) => {
|
|
285
|
+
try {
|
|
286
|
+
assertWritable(path);
|
|
287
|
+
const result = appendCsvRow(path, row);
|
|
288
|
+
return `Row appended to ${path} (now ${result.newRowCount} rows)`;
|
|
289
|
+
} catch (e: unknown) {
|
|
290
|
+
return `Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
291
|
+
}
|
|
292
|
+
}),
|
|
293
|
+
}),
|
|
172
294
|
};
|
package/app/lib/i18n.ts
CHANGED
|
@@ -119,6 +119,15 @@ export const messages = {
|
|
|
119
119
|
resetToEnv: 'Reset to env value',
|
|
120
120
|
restoreFromEnv: 'Restore from env',
|
|
121
121
|
noApiKey: 'API key is not set. AI features will be unavailable until you add one.',
|
|
122
|
+
testKey: 'Test',
|
|
123
|
+
testKeyTesting: 'Testing...',
|
|
124
|
+
testKeyOk: (ms: number) => `\u2713 ${ms}ms`,
|
|
125
|
+
testKeyAuthError: 'Invalid API key',
|
|
126
|
+
testKeyModelNotFound: 'Model not found',
|
|
127
|
+
testKeyRateLimited: 'Rate limited, try again later',
|
|
128
|
+
testKeyNetworkError: 'Network error',
|
|
129
|
+
testKeyNoKey: 'No API key configured',
|
|
130
|
+
testKeyUnknown: 'Test failed',
|
|
122
131
|
},
|
|
123
132
|
appearance: {
|
|
124
133
|
readingFont: 'Reading font',
|
|
@@ -514,6 +523,15 @@ export const messages = {
|
|
|
514
523
|
resetToEnv: '恢复为环境变量',
|
|
515
524
|
restoreFromEnv: '从环境变量恢复',
|
|
516
525
|
noApiKey: 'API 密钥未设置,AI 功能暂不可用,请在此填写。',
|
|
526
|
+
testKey: '测试',
|
|
527
|
+
testKeyTesting: '测试中...',
|
|
528
|
+
testKeyOk: (ms: number) => `\u2713 ${ms}ms`,
|
|
529
|
+
testKeyAuthError: 'API Key 无效',
|
|
530
|
+
testKeyModelNotFound: '模型不存在',
|
|
531
|
+
testKeyRateLimited: '请求频率限制,稍后重试',
|
|
532
|
+
testKeyNetworkError: '网络错误',
|
|
533
|
+
testKeyNoKey: '未配置 API Key',
|
|
534
|
+
testKeyUnknown: '测试失败',
|
|
517
535
|
},
|
|
518
536
|
appearance: {
|
|
519
537
|
readingFont: '正文字体',
|
package/app/lib/types.ts
CHANGED
|
@@ -13,9 +13,27 @@ 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 type MessagePart = TextPart | ToolCallPart;
|
|
31
|
+
|
|
16
32
|
export interface Message {
|
|
17
33
|
role: 'user' | 'assistant';
|
|
18
34
|
content: string;
|
|
35
|
+
/** Structured parts for assistant messages (tool calls + text segments) */
|
|
36
|
+
parts?: MessagePart[];
|
|
19
37
|
}
|
|
20
38
|
|
|
21
39
|
export interface LocalAttachment {
|
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/app/next.config.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from "path";
|
|
|
3
3
|
|
|
4
4
|
const nextConfig: NextConfig = {
|
|
5
5
|
transpilePackages: ['github-slugger'],
|
|
6
|
-
serverExternalPackages: ['pdfjs-dist', 'pdf-parse'],
|
|
6
|
+
serverExternalPackages: ['pdfjs-dist', 'pdf-parse', 'chokidar'],
|
|
7
7
|
outputFileTracingRoot: path.join(__dirname),
|
|
8
8
|
turbopack: {
|
|
9
9
|
root: path.join(__dirname),
|
package/app/package.json
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
|
-
"dev": "next dev -p ${MINDOS_WEB_PORT:-
|
|
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:-
|
|
9
|
+
"start": "next start -p ${MINDOS_WEB_PORT:-3456}",
|
|
10
10
|
"lint": "eslint",
|
|
11
11
|
"test": "vitest run"
|
|
12
12
|
},
|
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 || '
|
|
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 ||
|
|
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 || '
|
|
201
|
-
const mcpPort = process.env.MINDOS_MCP_PORT || '
|
|
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 || '
|
|
235
|
-
const mcpPort = process.env.MINDOS_MCP_PORT || '
|
|
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
|
-
|
|
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 || '
|
|
265
|
-
const mcpPort = process.env.MINDOS_MCP_PORT || '
|
|
266
|
-
|
|
267
|
-
|
|
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 || '
|
|
310
|
-
const webPort = process.env.MINDOS_WEB_PORT || '
|
|
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 || '
|
|
332
|
-
const newMcpPort = Number(process.env.MINDOS_MCP_PORT || '
|
|
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 ||
|
|
469
|
-
const mcpPort = Number(config?.mcpPort || process.env.MINDOS_MCP_PORT ||
|
|
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
|
-
|
|
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 ??
|
|
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'));
|
|
@@ -803,7 +823,14 @@ ${bold('Examples:')}
|
|
|
803
823
|
}
|
|
804
824
|
|
|
805
825
|
if (sub === 'now') {
|
|
806
|
-
|
|
826
|
+
try {
|
|
827
|
+
console.log(dim('Pulling...'));
|
|
828
|
+
manualSync(mindRoot);
|
|
829
|
+
console.log(green('✔ Sync complete'));
|
|
830
|
+
} catch (err) {
|
|
831
|
+
console.error(red(err.message));
|
|
832
|
+
process.exit(1);
|
|
833
|
+
}
|
|
807
834
|
return;
|
|
808
835
|
}
|
|
809
836
|
|
package/bin/lib/gateway.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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);
|
package/bin/lib/mcp-install.js
CHANGED
|
@@ -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 =
|
|
257
|
-
try { mcpPort = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')).mcpPort ||
|
|
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
|
}
|
package/bin/lib/mcp-spawn.js
CHANGED
|
@@ -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 || '
|
|
9
|
-
const webPort = process.env.MINDOS_WEB_PORT || '
|
|
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://
|
|
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'], {
|