@geminilight/mindos 0.5.12 → 0.5.14

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.
@@ -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
+ }
@@ -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';
@@ -1,64 +1,47 @@
1
- // Agent system prompt — v2: uploaded-file awareness + pdfjs extraction fix
2
- export const AGENT_SYSTEM_PROMPT = `You are MindOS Agent an execution-oriented AI assistant for a personal knowledge base.
1
+ /**
2
+ * Agent system promptv3: 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
- Runtime capabilities already available in this request:
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
- How to operate:
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
- Tool policy:
15
- - Always read a file before modifying it.
16
- - Use search/list tools first when file location is unclear.
17
- - Prefer targeted edits (update_section / insert_after_heading / append_to_file) over full overwrite.
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
- Destructive operations (use with caution):
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
- File management tools:
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
- Git history tools:
39
- - get_history: view commit log for a file
40
- - get_file_at_version: read file content at a past commit (use get_history first to find hashes)
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
- Complex task protocol:
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
- Step awareness:
49
- - You have a limited number of steps (configured by user, typically 10-30).
50
- - If a tool call fails or returns unexpected results, do NOT retry with the same arguments.
51
- - Try a different approach or ask the user for clarification.
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
- Uploaded files:
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
- - Be concise, concrete, and action-oriented.
63
- - Use Markdown for structure when it improves clarity.
64
- - When relevant, explicitly state whether initialization context appears sufficient or if additional tool reads were needed.`;
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, reasoning-*, metadata, finish — ignored for now
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
  }
@@ -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 full file tree of the knowledge base. Use this to browse what files exist.',
49
- inputSchema: z.object({}),
50
- 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 }) => {
51
54
  const tree = getFileTree();
52
- 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)';
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: '内容宽度',
@@ -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 type MessagePart = TextPart | ToolCallPart;
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';