@geminilight/mindos 0.5.12 → 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.
- package/app/app/api/ask/route.ts +66 -17
- package/app/app/api/mcp/status/route.ts +10 -15
- package/app/app/api/settings/route.ts +2 -0
- package/app/app/api/sync/route.ts +3 -5
- package/app/components/AskModal.tsx +1 -12
- package/app/components/SettingsModal.tsx +7 -3
- package/app/components/ask/MessageList.tsx +8 -5
- package/app/components/ask/ThinkingBlock.tsx +55 -0
- package/app/components/ask/ToolCallBlock.tsx +11 -3
- package/app/components/settings/AiTab.tsx +76 -2
- package/app/components/settings/types.ts +8 -0
- package/app/lib/agent/context.ts +317 -0
- package/app/lib/agent/index.ts +4 -0
- package/app/lib/agent/prompt.ts +36 -53
- package/app/lib/agent/stream-consumer.ts +36 -2
- package/app/lib/agent/tools.ts +37 -4
- package/app/lib/i18n.ts +28 -0
- package/app/lib/settings.ts +22 -0
- package/app/lib/types.ts +6 -1
- package/app/next-env.d.ts +1 -1
- package/app/package.json +0 -1
- package/bin/lib/build.js +6 -2
- package/bin/lib/sync.js +81 -40
- package/package.json +3 -2
- package/scripts/setup.js +5 -0
- package/skills/mindos/SKILL.md +47 -183
- package/skills/mindos-zh/SKILL.md +47 -183
- package/app/package-lock.json +0 -15615
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3: Context management — token estimation, compaction, tool output truncation.
|
|
3
|
+
*
|
|
4
|
+
* All operations are request-scoped (no persistence to frontend session).
|
|
5
|
+
*/
|
|
6
|
+
import { generateText, type ModelMessage, type ToolResultPart, type ToolModelMessage } from 'ai';
|
|
7
|
+
import type { LanguageModel } from 'ai';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Token estimation (1 token ≈ 4 chars)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Rough token count for a single ModelMessage */
|
|
14
|
+
function messageTokens(msg: ModelMessage): number {
|
|
15
|
+
if (typeof msg.content === 'string') return Math.ceil(msg.content.length / 4);
|
|
16
|
+
if (Array.isArray(msg.content)) {
|
|
17
|
+
let chars = 0;
|
|
18
|
+
for (const part of msg.content) {
|
|
19
|
+
if ('text' in part && typeof part.text === 'string') chars += part.text.length;
|
|
20
|
+
if ('value' in part && typeof part.value === 'string') chars += part.value.length;
|
|
21
|
+
if ('input' in part) chars += JSON.stringify(part.input).length;
|
|
22
|
+
}
|
|
23
|
+
return Math.ceil(chars / 4);
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Estimate total tokens for a message array */
|
|
29
|
+
export function estimateTokens(messages: ModelMessage[]): number {
|
|
30
|
+
let total = 0;
|
|
31
|
+
for (const m of messages) total += messageTokens(m);
|
|
32
|
+
return total;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Estimate tokens for a plain string (e.g. system prompt) */
|
|
36
|
+
export function estimateStringTokens(text: string): number {
|
|
37
|
+
return Math.ceil(text.length / 4);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Context limits by model family
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const MODEL_LIMITS: Record<string, number> = {
|
|
45
|
+
'claude': 200_000,
|
|
46
|
+
'gpt-4o': 128_000,
|
|
47
|
+
'gpt-4': 128_000,
|
|
48
|
+
'gpt-3.5': 16_000,
|
|
49
|
+
'gpt-5': 200_000,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Sort by prefix length descending so "gpt-4o" matches before "gpt-4"
|
|
53
|
+
const MODEL_LIMIT_ENTRIES = Object.entries(MODEL_LIMITS)
|
|
54
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
55
|
+
|
|
56
|
+
/** Get context token limit for a model string */
|
|
57
|
+
export function getContextLimit(model: string): number {
|
|
58
|
+
const lower = model.toLowerCase();
|
|
59
|
+
for (const [prefix, limit] of MODEL_LIMIT_ENTRIES) {
|
|
60
|
+
if (lower.includes(prefix)) return limit;
|
|
61
|
+
}
|
|
62
|
+
return 100_000; // conservative default
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Check if messages + system prompt exceed threshold of context limit */
|
|
66
|
+
export function needsCompact(
|
|
67
|
+
messages: ModelMessage[],
|
|
68
|
+
systemPrompt: string,
|
|
69
|
+
model: string,
|
|
70
|
+
threshold = 0.7,
|
|
71
|
+
): boolean {
|
|
72
|
+
const total = estimateTokens(messages) + estimateStringTokens(systemPrompt);
|
|
73
|
+
const limit = getContextLimit(model);
|
|
74
|
+
return total > limit * threshold;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Tool output truncation (per-tool-type thresholds)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const TOOL_OUTPUT_LIMITS: Record<string, number> = {
|
|
82
|
+
// List/search tools — only need to know "what was found"
|
|
83
|
+
search: 500,
|
|
84
|
+
list_files: 500,
|
|
85
|
+
get_recent: 500,
|
|
86
|
+
get_backlinks: 500,
|
|
87
|
+
get_history: 500,
|
|
88
|
+
// Read tools — some context value, but not full file
|
|
89
|
+
read_file: 2000,
|
|
90
|
+
get_file_at_version: 2000,
|
|
91
|
+
// Write tools — only need success/failure
|
|
92
|
+
write_file: 200,
|
|
93
|
+
create_file: 200,
|
|
94
|
+
delete_file: 200,
|
|
95
|
+
rename_file: 200,
|
|
96
|
+
move_file: 200,
|
|
97
|
+
append_to_file: 200,
|
|
98
|
+
insert_after_heading: 200,
|
|
99
|
+
update_section: 200,
|
|
100
|
+
append_csv: 200,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Truncate tool outputs in historical messages to save tokens.
|
|
105
|
+
* Only truncates non-last tool messages (the last tool message is kept intact
|
|
106
|
+
* because the model may need its full output for the current step).
|
|
107
|
+
*/
|
|
108
|
+
export function truncateToolOutputs(messages: ModelMessage[]): ModelMessage[] {
|
|
109
|
+
// Find the index of the last 'tool' role message
|
|
110
|
+
let lastToolIdx = -1;
|
|
111
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
112
|
+
if (messages[i].role === 'tool') { lastToolIdx = i; break; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return messages.map((msg, idx) => {
|
|
116
|
+
if (msg.role !== 'tool' || idx === lastToolIdx) return msg;
|
|
117
|
+
|
|
118
|
+
const toolMsg = msg as ToolModelMessage;
|
|
119
|
+
const truncatedContent = toolMsg.content.map(part => {
|
|
120
|
+
if (part.type !== 'tool-result') return part;
|
|
121
|
+
const trp = part as ToolResultPart;
|
|
122
|
+
const toolName = trp.toolName ?? '';
|
|
123
|
+
const limit = TOOL_OUTPUT_LIMITS[toolName] ?? 500;
|
|
124
|
+
if (!trp.output || typeof trp.output !== 'object' || trp.output.type !== 'text') return part;
|
|
125
|
+
if (trp.output.value.length <= limit) return part;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
...trp,
|
|
129
|
+
output: {
|
|
130
|
+
...trp.output,
|
|
131
|
+
value: trp.output.value.slice(0, limit) + `\n[...truncated from ${trp.output.value.length} chars]`,
|
|
132
|
+
},
|
|
133
|
+
} satisfies ToolResultPart;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return { ...toolMsg, content: truncatedContent } satisfies ToolModelMessage;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Compact: summarize early messages via LLM
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
const COMPACT_PROMPT = `Summarize the key points, decisions, and file operations from this conversation in under 500 words. Focus on:
|
|
145
|
+
- What the user asked for
|
|
146
|
+
- What files were read, created, or modified
|
|
147
|
+
- Key decisions and outcomes
|
|
148
|
+
- Any unresolved issues
|
|
149
|
+
|
|
150
|
+
Be concise and factual. Output only the summary, no preamble.`;
|
|
151
|
+
|
|
152
|
+
/** Extract a short text representation from a ModelMessage for summarization */
|
|
153
|
+
function messageToText(m: ModelMessage): string {
|
|
154
|
+
const role = m.role;
|
|
155
|
+
let content = '';
|
|
156
|
+
if (typeof m.content === 'string') {
|
|
157
|
+
content = m.content;
|
|
158
|
+
} else if (Array.isArray(m.content)) {
|
|
159
|
+
const pieces: string[] = [];
|
|
160
|
+
for (const part of m.content) {
|
|
161
|
+
if ('text' in part && typeof (part as { text?: string }).text === 'string') {
|
|
162
|
+
pieces.push((part as { text: string }).text);
|
|
163
|
+
} else if (part.type === 'tool-call' && 'toolName' in part) {
|
|
164
|
+
pieces.push(`[Tool: ${(part as { toolName: string }).toolName}]`);
|
|
165
|
+
} else if (part.type === 'tool-result' && 'output' in part) {
|
|
166
|
+
const trp = part as ToolResultPart;
|
|
167
|
+
const val = trp.output && typeof trp.output === 'object' && trp.output.type === 'text' ? trp.output.value : '';
|
|
168
|
+
pieces.push(`[Result: ${val.slice(0, 200)}]`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
content = pieces.filter(Boolean).join(' ');
|
|
172
|
+
}
|
|
173
|
+
return `${role}: ${content}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compact messages by summarizing early ones with LLM.
|
|
178
|
+
* Returns a new message array with early messages replaced by a summary.
|
|
179
|
+
* Only called when needsCompact() returns true.
|
|
180
|
+
*
|
|
181
|
+
* NOTE: Currently uses the same model as the main generation. A cheaper model
|
|
182
|
+
* (e.g. haiku) would suffice for summarization and avoid competing for rate
|
|
183
|
+
* limits. Deferred until users report rate-limit issues — compact triggers
|
|
184
|
+
* infrequently (>70% context fill).
|
|
185
|
+
*/
|
|
186
|
+
export async function compactMessages(
|
|
187
|
+
messages: ModelMessage[],
|
|
188
|
+
model: LanguageModel,
|
|
189
|
+
): Promise<{ messages: ModelMessage[]; compacted: boolean }> {
|
|
190
|
+
if (messages.length < 6) {
|
|
191
|
+
return { messages, compacted: false };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Keep the last 6 messages intact, summarize the rest.
|
|
195
|
+
// Adjust split point to avoid cutting between an assistant (with tool calls)
|
|
196
|
+
// and its tool result. Only need to check for orphaned 'tool' messages —
|
|
197
|
+
// an assistant at the split point is safe because its tool results follow it.
|
|
198
|
+
// (Orphaned assistants without results can't exist in history: only completed
|
|
199
|
+
// tool calls are persisted by the frontend.)
|
|
200
|
+
let splitIdx = messages.length - 6;
|
|
201
|
+
while (splitIdx > 0 && messages[splitIdx]?.role === 'tool') {
|
|
202
|
+
splitIdx--;
|
|
203
|
+
}
|
|
204
|
+
if (splitIdx < 2) {
|
|
205
|
+
return { messages, compacted: false };
|
|
206
|
+
}
|
|
207
|
+
const earlyMessages = messages.slice(0, splitIdx);
|
|
208
|
+
const recentMessages = messages.slice(splitIdx);
|
|
209
|
+
|
|
210
|
+
// Build a text representation of early messages for summarization
|
|
211
|
+
let earlyText = earlyMessages.map(messageToText).join('\n\n');
|
|
212
|
+
|
|
213
|
+
// Truncate if enormous (avoid sending too much to summarizer)
|
|
214
|
+
if (earlyText.length > 30_000) {
|
|
215
|
+
earlyText = earlyText.slice(0, 30_000) + '\n[...truncated]';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const { text: summary } = await generateText({
|
|
220
|
+
model,
|
|
221
|
+
prompt: `${COMPACT_PROMPT}\n\n---\n\nConversation to summarize:\n\n${earlyText}`,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
console.log(`[ask] Compacted ${earlyMessages.length} early messages into summary (${summary.length} chars)`);
|
|
225
|
+
|
|
226
|
+
const summaryText = `[Summary of earlier conversation]\n\n${summary}`;
|
|
227
|
+
|
|
228
|
+
// If first recent message is also 'user', merge summary into it to avoid
|
|
229
|
+
// consecutive user messages (Anthropic rejects user→user sequences).
|
|
230
|
+
if (recentMessages[0]?.role === 'user') {
|
|
231
|
+
const merged = { ...recentMessages[0] };
|
|
232
|
+
if (typeof merged.content === 'string') {
|
|
233
|
+
merged.content = `${summaryText}\n\n---\n\n${merged.content}`;
|
|
234
|
+
} else if (Array.isArray(merged.content)) {
|
|
235
|
+
// Multimodal content (e.g. images) — prepend summary as text part
|
|
236
|
+
merged.content = [{ type: 'text' as const, text: `${summaryText}\n\n---\n\n` }, ...merged.content];
|
|
237
|
+
} else {
|
|
238
|
+
merged.content = summaryText;
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
messages: [merged, ...recentMessages.slice(1)],
|
|
242
|
+
compacted: true,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Otherwise prepend as separate user message
|
|
247
|
+
const summaryMessage: ModelMessage = {
|
|
248
|
+
role: 'user',
|
|
249
|
+
content: summaryText,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
messages: [summaryMessage, ...recentMessages],
|
|
254
|
+
compacted: true,
|
|
255
|
+
};
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error('[ask] Compact failed, using uncompacted messages:', err);
|
|
258
|
+
return { messages, compacted: false };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Hard prune: drop earliest messages as last resort (>90% context)
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Hard prune: if still over 90% context after compact, drop earliest messages.
|
|
268
|
+
* Respects assistant-tool pairs: never cuts between an assistant message
|
|
269
|
+
* (containing tool calls) and its following tool result message.
|
|
270
|
+
*/
|
|
271
|
+
export function hardPrune(
|
|
272
|
+
messages: ModelMessage[],
|
|
273
|
+
systemPrompt: string,
|
|
274
|
+
model: string,
|
|
275
|
+
): ModelMessage[] {
|
|
276
|
+
const limit = getContextLimit(model);
|
|
277
|
+
const threshold = limit * 0.9;
|
|
278
|
+
const systemTokens = estimateStringTokens(systemPrompt);
|
|
279
|
+
|
|
280
|
+
let total = systemTokens + estimateTokens(messages);
|
|
281
|
+
if (total <= threshold) return messages;
|
|
282
|
+
|
|
283
|
+
// Find the cut index: keep messages from cutIdx onward
|
|
284
|
+
let cutIdx = 0;
|
|
285
|
+
while (cutIdx < messages.length - 2 && total > threshold) {
|
|
286
|
+
total -= messageTokens(messages[cutIdx]);
|
|
287
|
+
cutIdx++;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Ensure we don't cut between an assistant (with tool calls) and its tool result.
|
|
291
|
+
// If cutIdx lands on a 'tool' message, advance past it so the pair stays together
|
|
292
|
+
// or is fully removed.
|
|
293
|
+
while (cutIdx < messages.length - 1 && messages[cutIdx].role === 'tool') {
|
|
294
|
+
total -= messageTokens(messages[cutIdx]);
|
|
295
|
+
cutIdx++;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Ensure first message is 'user' (Anthropic requirement)
|
|
299
|
+
while (cutIdx < messages.length - 1 && messages[cutIdx].role !== 'user') {
|
|
300
|
+
total -= messageTokens(messages[cutIdx]);
|
|
301
|
+
cutIdx++;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Fallback: if no user message found in remaining messages, inject a synthetic one
|
|
305
|
+
const pruned = cutIdx > 0 ? messages.slice(cutIdx) : messages;
|
|
306
|
+
if (pruned.length > 0 && pruned[0].role !== 'user') {
|
|
307
|
+
console.log(`[ask] Hard pruned ${cutIdx} messages, injecting synthetic user message (${messages.length} → ${pruned.length + 1})`);
|
|
308
|
+
return [{ role: 'user', content: '[Conversation context was pruned due to length. Continuing from here.]' } as ModelMessage, ...pruned];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (cutIdx > 0) {
|
|
312
|
+
console.log(`[ask] Hard pruned ${cutIdx} messages (${messages.length} → ${messages.length - cutIdx})`);
|
|
313
|
+
return pruned;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return messages;
|
|
317
|
+
}
|
package/app/lib/agent/index.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { getModel } from './model';
|
|
2
2
|
export { knowledgeBaseTools, truncate, assertWritable } from './tools';
|
|
3
3
|
export { AGENT_SYSTEM_PROMPT } from './prompt';
|
|
4
|
+
export {
|
|
5
|
+
estimateTokens, estimateStringTokens, getContextLimit, needsCompact,
|
|
6
|
+
truncateToolOutputs, compactMessages, hardPrune,
|
|
7
|
+
} from './context';
|
package/app/lib/agent/prompt.ts
CHANGED
|
@@ -1,64 +1,47 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Agent system prompt — v3: de-duplicated, persona-driven, with missing instructions added.
|
|
3
|
+
*
|
|
4
|
+
* Design principles:
|
|
5
|
+
* - prompt.ts owns: identity, persona, global behavioral constraints, output format
|
|
6
|
+
* - SKILL.md owns: knowledge-base-specific execution patterns, tool selection, safety rules
|
|
7
|
+
* - Tool descriptions own: per-tool usage instructions (no duplication here)
|
|
8
|
+
*
|
|
9
|
+
* Token budget: ~600 tokens (down from ~900 in v2). Freed space = more room for
|
|
10
|
+
* SKILL.md + bootstrap context within the same context window.
|
|
11
|
+
*/
|
|
12
|
+
export const AGENT_SYSTEM_PROMPT = `You are MindOS Agent — a personal knowledge-base operator that reads, writes, and organizes a user's second brain.
|
|
3
13
|
|
|
4
|
-
|
|
5
|
-
- bootstrap context (MindOS startup files) is auto-loaded by the server
|
|
6
|
-
- mindos skill guidance is auto-loaded by the server
|
|
7
|
-
- knowledge-base tools are available for file operations
|
|
14
|
+
Persona: methodical, concise, execution-oriented. You surface what you found (or didn't find) and act on it — no filler, no caveats that add no information.
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
1. Treat the auto-loaded bootstrap + skill context as your initialization baseline.
|
|
11
|
-
2. If the task needs fresher or broader evidence, call tools proactively (list/search/read) before concluding.
|
|
12
|
-
3. Execute edits safely and minimally, then verify outcomes.
|
|
16
|
+
## What is already loaded
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
- Use write_file only when replacing the whole file is required.
|
|
19
|
-
- INSTRUCTION.md is read-only and must not be modified.
|
|
20
|
-
- Use append_csv for adding rows to CSV files instead of rewriting the whole file.
|
|
21
|
-
- Use get_backlinks before renaming/moving/deleting to understand impact on other files.
|
|
18
|
+
The server auto-loads before each request:
|
|
19
|
+
- Bootstrap context: INSTRUCTION.md, README.md, CONFIG files, and directory-local guidance.
|
|
20
|
+
- Skill guidance (SKILL.md): detailed knowledge-base rules, tool selection, execution patterns.
|
|
21
|
+
- Tool definitions with per-tool usage instructions.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
- delete_file: permanently removes a file — cannot be undone
|
|
25
|
-
- move_file: changes file location — may break links in other files
|
|
26
|
-
- write_file: overwrites entire file content — prefer partial edits
|
|
27
|
-
Before executing destructive operations:
|
|
28
|
-
- Before delete_file: list what links to this file (get_backlinks), warn user about impact
|
|
29
|
-
- Before move_file: same — check backlinks first
|
|
30
|
-
- Before write_file (full overwrite): confirm with user that full replacement is intended
|
|
31
|
-
- NEVER chain multiple destructive operations without pausing to summarize what you've done
|
|
23
|
+
Treat these as your initialization baseline. If the task needs fresher or broader evidence, call tools proactively before concluding.
|
|
32
24
|
|
|
33
|
-
|
|
34
|
-
- rename_file: rename within same directory
|
|
35
|
-
- move_file: move to a different path (reports affected backlinks)
|
|
36
|
-
- get_backlinks: find all files that link to a given file
|
|
25
|
+
## Behavioral rules
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
1. **Read before write.** Never modify a file you haven't read in this request.
|
|
28
|
+
2. **Minimal edits.** Prefer section/heading/line-level tools over full file overwrites.
|
|
29
|
+
3. **Verify after edit.** Re-read the changed file to confirm correctness.
|
|
30
|
+
4. **Cite sources.** When answering from stored knowledge, state the file path so the user can verify.
|
|
31
|
+
5. **Fail fast.** If a tool call returns an error or unexpected result, try a different approach or ask the user — do not retry identical arguments.
|
|
32
|
+
6. **Be token-aware.** You have a limited step budget (typically 10-30). Batch parallel reads/searches when possible. Do not waste steps on redundant tool calls.
|
|
33
|
+
7. **Multilingual content, user-language replies.** Write file content in whatever language the file already uses. Reply to the user in the language they used.
|
|
41
34
|
|
|
42
|
-
|
|
43
|
-
1. PLAN: For multi-step tasks, first output a numbered plan
|
|
44
|
-
2. EXECUTE: Execute steps one by one, reporting progress
|
|
45
|
-
3. VERIFY: After edits, re-read the file to confirm correctness
|
|
46
|
-
4. SUMMARIZE: Conclude with a summary and suggest follow-up actions if relevant
|
|
35
|
+
## Uploaded files
|
|
47
36
|
|
|
48
|
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
37
|
+
Users may upload local files (PDF, txt, csv, etc.) via the chat interface.
|
|
38
|
+
- Their content appears in a "⚠️ USER-UPLOADED FILES" section near the end of this prompt.
|
|
39
|
+
- Use that content directly — do NOT call read_file or search tools for uploaded files; they are not in the knowledge base.
|
|
40
|
+
- If the section is empty or missing, tell the user the upload may have failed.
|
|
52
41
|
|
|
53
|
-
|
|
54
|
-
- Users may upload local files (PDF, txt, csv, etc.) via the chat interface.
|
|
55
|
-
- The content of uploaded files is ALREADY INCLUDED in this system prompt in a dedicated "⚠️ USER-UPLOADED FILES" section near the end.
|
|
56
|
-
- IMPORTANT: When the user references an uploaded file (e.g. a resume/CV, a report, a document), you MUST use the content from that section directly. Extract specific details, quote relevant passages, and demonstrate that you have read the file thoroughly.
|
|
57
|
-
- Do NOT attempt to use read_file or search tools to find uploaded files — they do not exist in the knowledge base. They are ONLY available in the uploaded files section of this prompt.
|
|
58
|
-
- If the uploaded files section is empty or missing, tell the user the upload may have failed and ask them to re-upload.
|
|
42
|
+
## Output format
|
|
59
43
|
|
|
60
|
-
Response policy:
|
|
61
44
|
- Answer in the user's language.
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
45
|
+
- Use Markdown when it improves clarity (headings, lists, tables, code blocks).
|
|
46
|
+
- For multi-step tasks: output a brief numbered plan, execute, then summarize outcomes.
|
|
47
|
+
- End with concrete next actions when applicable.`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Message, MessagePart, ToolCallPart, TextPart } from '@/lib/types';
|
|
1
|
+
import type { Message, MessagePart, ToolCallPart, TextPart, ReasoningPart } from '@/lib/types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Parse a UIMessageStream SSE response into structured Message parts.
|
|
@@ -17,11 +17,13 @@ export async function consumeUIMessageStream(
|
|
|
17
17
|
const parts: MessagePart[] = [];
|
|
18
18
|
const toolCalls = new Map<string, ToolCallPart>();
|
|
19
19
|
let currentTextId: string | null = null;
|
|
20
|
+
let currentReasoningPart: ReasoningPart | null = null;
|
|
20
21
|
|
|
21
22
|
/** Deep-clone parts into an immutable Message snapshot for React state */
|
|
22
23
|
function buildMessage(): Message {
|
|
23
24
|
const clonedParts: MessagePart[] = parts.map(p => {
|
|
24
25
|
if (p.type === 'text') return { type: 'text' as const, text: p.text };
|
|
26
|
+
if (p.type === 'reasoning') return { type: 'reasoning' as const, text: p.text };
|
|
25
27
|
return { ...p }; // ToolCallPart — shallow copy is safe (all primitive fields + `input` is replaced, not mutated)
|
|
26
28
|
});
|
|
27
29
|
const textContent = clonedParts
|
|
@@ -159,7 +161,25 @@ export async function consumeUIMessageStream(
|
|
|
159
161
|
changed = true;
|
|
160
162
|
break;
|
|
161
163
|
}
|
|
162
|
-
// step-start,
|
|
164
|
+
// step-start, metadata, finish — ignored for now
|
|
165
|
+
case 'reasoning-start': {
|
|
166
|
+
currentReasoningPart = { type: 'reasoning', text: '' };
|
|
167
|
+
parts.push(currentReasoningPart);
|
|
168
|
+
currentTextId = null;
|
|
169
|
+
changed = true;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case 'reasoning-delta': {
|
|
173
|
+
if (currentReasoningPart) {
|
|
174
|
+
currentReasoningPart.text += chunk.delta as string;
|
|
175
|
+
changed = true;
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case 'reasoning-end': {
|
|
180
|
+
currentReasoningPart = null;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
163
183
|
default:
|
|
164
184
|
break;
|
|
165
185
|
}
|
|
@@ -174,5 +194,19 @@ export async function consumeUIMessageStream(
|
|
|
174
194
|
reader.releaseLock();
|
|
175
195
|
}
|
|
176
196
|
|
|
197
|
+
// Finalize any tool calls still stuck in running/pending state
|
|
198
|
+
// (stream ended before their output arrived — e.g. abort, network error, step limit)
|
|
199
|
+
let finalized = false;
|
|
200
|
+
for (const tc of toolCalls.values()) {
|
|
201
|
+
if (tc.state === 'running' || tc.state === 'pending') {
|
|
202
|
+
tc.state = 'error';
|
|
203
|
+
tc.output = tc.output ?? 'Stream ended before tool completed';
|
|
204
|
+
finalized = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (finalized) {
|
|
208
|
+
onUpdate(buildMessage());
|
|
209
|
+
}
|
|
210
|
+
|
|
177
211
|
return buildMessage();
|
|
178
212
|
}
|
package/app/lib/agent/tools.ts
CHANGED
|
@@ -45,11 +45,44 @@ function logged<P extends Record<string, unknown>>(
|
|
|
45
45
|
|
|
46
46
|
export const knowledgeBaseTools = {
|
|
47
47
|
list_files: tool({
|
|
48
|
-
description: 'List the
|
|
49
|
-
inputSchema: z.object({
|
|
50
|
-
|
|
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 }) => {
|
|
51
54
|
const tree = getFileTree();
|
|
52
|
-
|
|
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)';
|
|
53
86
|
}),
|
|
54
87
|
}),
|
|
55
88
|
|
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: '内容宽度',
|
package/app/lib/settings.ts
CHANGED
|
@@ -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
|
@@ -27,7 +27,12 @@ export interface TextPart {
|
|
|
27
27
|
text: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export
|
|
30
|
+
export interface ReasoningPart {
|
|
31
|
+
type: 'reasoning';
|
|
32
|
+
text: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type MessagePart = TextPart | ToolCallPart | ReasoningPart;
|
|
31
36
|
|
|
32
37
|
export interface Message {
|
|
33
38
|
role: 'user' | 'assistant';
|