@geminilight/mindos 0.5.19 → 0.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/app/app/api/ask/route.ts +308 -172
  2. package/app/app/api/file/route.ts +35 -11
  3. package/app/app/api/skills/route.ts +22 -3
  4. package/app/components/SettingsModal.tsx +52 -58
  5. package/app/components/Sidebar.tsx +21 -1
  6. package/app/components/settings/AiTab.tsx +4 -25
  7. package/app/components/settings/AppearanceTab.tsx +31 -13
  8. package/app/components/settings/KnowledgeTab.tsx +13 -28
  9. package/app/components/settings/McpAgentInstall.tsx +227 -0
  10. package/app/components/settings/McpServerStatus.tsx +172 -0
  11. package/app/components/settings/McpSkillsSection.tsx +583 -0
  12. package/app/components/settings/McpTab.tsx +16 -728
  13. package/app/components/settings/PluginsTab.tsx +4 -27
  14. package/app/components/settings/Primitives.tsx +69 -0
  15. package/app/components/settings/ShortcutsTab.tsx +2 -4
  16. package/app/components/settings/SyncTab.tsx +8 -24
  17. package/app/components/settings/types.ts +116 -2
  18. package/app/lib/agent/context.ts +151 -87
  19. package/app/lib/agent/index.ts +4 -3
  20. package/app/lib/agent/model.ts +76 -10
  21. package/app/lib/agent/stream-consumer.ts +73 -77
  22. package/app/lib/agent/to-agent-messages.ts +106 -0
  23. package/app/lib/agent/tools.ts +260 -266
  24. package/app/lib/i18n-en.ts +480 -0
  25. package/app/lib/i18n-zh.ts +505 -0
  26. package/app/lib/i18n.ts +4 -947
  27. package/app/next-env.d.ts +1 -1
  28. package/app/next.config.ts +7 -0
  29. package/app/package-lock.json +3258 -3093
  30. package/app/package.json +6 -3
  31. package/bin/cli.js +140 -5
  32. package/package.json +4 -1
  33. package/scripts/setup.js +13 -0
  34. package/skills/mindos/SKILL.md +10 -168
  35. package/skills/mindos-zh/SKILL.md +14 -172
  36. package/templates/skill-rules/en/skill-rules.md +222 -0
  37. package/templates/skill-rules/en/user-rules.md +20 -0
  38. package/templates/skill-rules/zh/skill-rules.md +222 -0
  39. package/templates/skill-rules/zh/user-rules.md +20 -0
@@ -1,107 +1,139 @@
1
1
  export const dynamic = 'force-dynamic';
2
- import { streamText, stepCountIs, type ModelMessage } from 'ai';
2
+ import { Agent, type AgentEvent, type BeforeToolCallContext, type BeforeToolCallResult, type AfterToolCallContext, type AfterToolCallResult } from '@mariozechner/pi-agent-core';
3
3
  import { NextRequest, NextResponse } from 'next/server';
4
4
  import fs from 'fs';
5
5
  import path from 'path';
6
6
  import { getFileContent, getMindRoot } from '@/lib/fs';
7
- import { getModel, knowledgeBaseTools, truncate, AGENT_SYSTEM_PROMPT, estimateTokens, estimateStringTokens, getContextLimit, needsCompact, truncateToolOutputs, compactMessages, hardPrune } from '@/lib/agent';
8
- import { effectiveAiConfig, readSettings } from '@/lib/settings';
9
- import type { Message as FrontendMessage, ToolCallPart as FrontendToolCallPart } from '@/lib/types';
10
-
11
- /**
12
- * Convert frontend Message[] (with parts containing tool calls + results)
13
- * into AI SDK ModelMessage[] that streamText expects.
14
- *
15
- * Frontend format:
16
- * { role: 'assistant', content: '...', parts: [TextPart, ToolCallPart(with output/state)] }
17
- *
18
- * AI SDK format:
19
- * { role: 'assistant', content: [TextPart, ToolCallPart(no output)] }
20
- * { role: 'tool', content: [ToolResultPart] } // one per completed tool call
21
- */
22
- function convertToModelMessages(messages: FrontendMessage[]): ModelMessage[] {
23
- const result: ModelMessage[] = [];
24
-
25
- for (const msg of messages) {
26
- if (msg.role === 'user') {
27
- result.push({ role: 'user', content: msg.content });
28
- continue;
29
- }
7
+ import { getModelConfig } from '@/lib/agent/model';
8
+ import { knowledgeBaseTools, WRITE_TOOLS, truncate } from '@/lib/agent/tools';
9
+ import { AGENT_SYSTEM_PROMPT } from '@/lib/agent/prompt';
10
+ import { toAgentMessages } from '@/lib/agent/to-agent-messages';
11
+ import {
12
+ estimateTokens, estimateStringTokens, getContextLimit,
13
+ createTransformContext,
14
+ } from '@/lib/agent/context';
15
+ import { logAgentOp } from '@/lib/agent/log';
16
+ import { readSettings } from '@/lib/settings';
17
+ import { assertNotProtected } from '@/lib/core';
18
+ import type { Message as FrontendMessage } from '@/lib/types';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // MindOS SSE format — 6 event types (front-back contract)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ type MindOSSSEvent =
25
+ | { type: 'text_delta'; delta: string }
26
+ | { type: 'thinking_delta'; delta: string }
27
+ | { type: 'tool_start'; toolCallId: string; toolName: string; args: unknown }
28
+ | { type: 'tool_end'; toolCallId: string; output: string; isError: boolean }
29
+ | { type: 'done'; usage?: { input: number; output: number } }
30
+ | { type: 'error'; message: string };
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Type Guards for AgentEvent variants (safe event handling)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function isTextDeltaEvent(e: AgentEvent): boolean {
37
+ return e.type === 'message_update' && (e as any).assistantMessageEvent?.type === 'text_delta';
38
+ }
30
39
 
31
- // Skip error placeholder messages from frontend
32
- if (msg.content.startsWith('__error__')) continue;
40
+ function getTextDelta(e: AgentEvent): string {
41
+ return (e as any).assistantMessageEvent?.delta ?? '';
42
+ }
33
43
 
34
- // Assistant message
35
- if (!msg.parts || msg.parts.length === 0) {
36
- // Plain text assistant message — no tool calls
37
- if (msg.content) {
38
- result.push({ role: 'assistant', content: msg.content });
39
- }
40
- continue;
41
- }
44
+ function isThinkingDeltaEvent(e: AgentEvent): boolean {
45
+ return e.type === 'message_update' && (e as any).assistantMessageEvent?.type === 'thinking_delta';
46
+ }
42
47
 
43
- // Build assistant message content array (text parts + tool call parts)
44
- const assistantContent: Array<
45
- { type: 'text'; text: string } |
46
- { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
47
- > = [];
48
- const completedToolCalls: FrontendToolCallPart[] = [];
49
-
50
- for (const part of msg.parts) {
51
- if (part.type === 'text') {
52
- if (part.text) {
53
- assistantContent.push({ type: 'text', text: part.text });
54
- }
55
- } else if (part.type === 'tool-call') {
56
- assistantContent.push({
57
- type: 'tool-call',
58
- toolCallId: part.toolCallId,
59
- toolName: part.toolName,
60
- input: part.input ?? {},
61
- });
62
- // Always emit a tool result for every tool call. Orphaned tool calls
63
- // (running/pending from interrupted streams) get an empty result;
64
- // without one the API rejects the request.
65
- completedToolCalls.push(part);
66
- }
67
- // 'reasoning' parts are display-only; not sent back to model
68
- }
48
+ function getThinkingDelta(e: AgentEvent): string {
49
+ return (e as any).assistantMessageEvent?.delta ?? '';
50
+ }
69
51
 
70
- if (assistantContent.length > 0) {
71
- result.push({ role: 'assistant', content: assistantContent });
72
- }
52
+ function isToolExecutionStartEvent(e: AgentEvent): boolean {
53
+ return e.type === 'tool_execution_start';
54
+ }
73
55
 
74
- // Add tool result messages for completed tool calls
75
- if (completedToolCalls.length > 0) {
76
- result.push({
77
- role: 'tool',
78
- content: completedToolCalls.map(tc => ({
79
- type: 'tool-result' as const,
80
- toolCallId: tc.toolCallId,
81
- toolName: tc.toolName,
82
- output: { type: 'text' as const, value: tc.output ?? '' },
83
- })),
84
- });
85
- }
86
- }
56
+ function getToolExecutionStart(e: AgentEvent): { toolCallId: string; toolName: string; args: unknown } {
57
+ const evt = e as any;
58
+ return {
59
+ toolCallId: evt.toolCallId ?? '',
60
+ toolName: evt.toolName ?? 'unknown',
61
+ args: evt.args ?? {},
62
+ };
63
+ }
64
+
65
+ function isToolExecutionEndEvent(e: AgentEvent): boolean {
66
+ return e.type === 'tool_execution_end';
67
+ }
68
+
69
+ function getToolExecutionEnd(e: AgentEvent): { toolCallId: string; output: string; isError: boolean } {
70
+ const evt = e as any;
71
+ const outputText = evt.result?.content
72
+ ?.filter((p: any) => p.type === 'text')
73
+ .map((p: any) => p.text)
74
+ .join('') ?? '';
75
+ return {
76
+ toolCallId: evt.toolCallId ?? '',
77
+ output: outputText,
78
+ isError: !!evt.isError,
79
+ };
80
+ }
81
+
82
+ function isTurnEndEvent(e: AgentEvent): boolean {
83
+ return e.type === 'turn_end';
84
+ }
87
85
 
88
- return result;
86
+ function getTurnEndData(e: AgentEvent): { toolResults: Array<{ toolName: string; content: unknown }> } {
87
+ return {
88
+ toolResults: ((e as any).toolResults as any[]) ?? [],
89
+ };
89
90
  }
90
91
 
91
- function readKnowledgeFile(filePath: string): { ok: boolean; content: string; error?: string } {
92
+ // ---------------------------------------------------------------------------
93
+ // Helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ function readKnowledgeFile(filePath: string): { ok: boolean; content: string; truncated: boolean; error?: string } {
92
97
  try {
93
- return { ok: true, content: truncate(getFileContent(filePath)) };
98
+ const raw = getFileContent(filePath);
99
+ if (raw.length > 20_000) {
100
+ return {
101
+ ok: true,
102
+ content: truncate(raw),
103
+ truncated: true,
104
+ error: undefined,
105
+ };
106
+ }
107
+ return { ok: true, content: raw, truncated: false };
94
108
  } catch (err) {
95
- return { ok: false, content: '', error: err instanceof Error ? err.message : String(err) };
109
+ return {
110
+ ok: false,
111
+ content: '',
112
+ truncated: false,
113
+ error: err instanceof Error ? err.message : String(err),
114
+ };
96
115
  }
97
116
  }
98
117
 
99
- function readAbsoluteFile(absPath: string): { ok: boolean; content: string; error?: string } {
118
+ function readAbsoluteFile(absPath: string): { ok: boolean; content: string; truncated: boolean; error?: string } {
100
119
  try {
101
120
  const raw = fs.readFileSync(absPath, 'utf-8');
102
- return { ok: true, content: truncate(raw) };
121
+ if (raw.length > 20_000) {
122
+ return {
123
+ ok: true,
124
+ content: truncate(raw),
125
+ truncated: true,
126
+ error: undefined,
127
+ };
128
+ }
129
+ return { ok: true, content: raw, truncated: false };
103
130
  } catch (err) {
104
- return { ok: false, content: '', error: err instanceof Error ? err.message : String(err) };
131
+ return {
132
+ ok: false,
133
+ content: '',
134
+ truncated: false,
135
+ error: err instanceof Error ? err.message : String(err),
136
+ };
105
137
  }
106
138
  }
107
139
 
@@ -113,6 +145,10 @@ function dirnameOf(filePath?: string): string | null {
113
145
  return normalized.slice(0, idx);
114
146
  }
115
147
 
148
+ // ---------------------------------------------------------------------------
149
+ // POST /api/ask
150
+ // ---------------------------------------------------------------------------
151
+
116
152
  export async function POST(req: NextRequest) {
117
153
  let body: {
118
154
  messages: FrontendMessage[];
@@ -130,8 +166,6 @@ export async function POST(req: NextRequest) {
130
166
  const { messages, currentFile, attachedFiles, uploadedFiles } = body;
131
167
 
132
168
  // Read agent config from settings
133
- // NOTE: readSettings() is also called inside getModel() → effectiveAiConfig().
134
- // Acceptable duplication — both are sync fs reads with identical results.
135
169
  const serverSettings = readSettings();
136
170
  const agentConfig = serverSettings.agent ?? {};
137
171
  const stepLimit = Number.isFinite(body.maxSteps)
@@ -142,6 +176,9 @@ export async function POST(req: NextRequest) {
142
176
  const contextStrategy = agentConfig.contextStrategy ?? 'auto';
143
177
 
144
178
  // Auto-load skill + bootstrap context for each request.
179
+ // TODO (optimization): Consider caching bootstrap files with TTL to reduce per-request IO overhead.
180
+ // Current behavior: 8 synchronous file reads per request. For users with large knowledge bases,
181
+ // this adds ~10-50ms latency. Mitigation: Cache with 5min TTL or lazy-load on-demand.
145
182
  const skillPath = path.resolve(process.cwd(), 'data/skills/mindos/SKILL.md');
146
183
  const skill = readAbsoluteFile(skillPath);
147
184
 
@@ -157,21 +194,31 @@ export async function POST(req: NextRequest) {
157
194
  target_config_md: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.md`) : null,
158
195
  };
159
196
 
160
- // Only report failures when everything loads fine, a single summary line suffices.
197
+ // Only report failures + truncation warnings
161
198
  const initFailures: string[] = [];
199
+ const truncationWarnings: string[] = [];
162
200
  if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
201
+ if (skill.ok && skill.truncated) truncationWarnings.push('skill.mindos was truncated');
163
202
  if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
203
+ if (bootstrap.instruction.ok && bootstrap.instruction.truncated) truncationWarnings.push('bootstrap.instruction was truncated');
164
204
  if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
205
+ if (bootstrap.index.ok && bootstrap.index.truncated) truncationWarnings.push('bootstrap.index was truncated');
165
206
  if (!bootstrap.config_json.ok) initFailures.push(`bootstrap.config_json: failed (${bootstrap.config_json.error})`);
207
+ if (bootstrap.config_json.ok && bootstrap.config_json.truncated) truncationWarnings.push('bootstrap.config_json was truncated');
166
208
  if (!bootstrap.config_md.ok) initFailures.push(`bootstrap.config_md: failed (${bootstrap.config_md.error})`);
209
+ if (bootstrap.config_md.ok && bootstrap.config_md.truncated) truncationWarnings.push('bootstrap.config_md was truncated');
167
210
  if (bootstrap.target_readme && !bootstrap.target_readme.ok) initFailures.push(`bootstrap.target_readme: failed (${bootstrap.target_readme.error})`);
211
+ if (bootstrap.target_readme?.ok && bootstrap.target_readme.truncated) truncationWarnings.push('bootstrap.target_readme was truncated');
168
212
  if (bootstrap.target_instruction && !bootstrap.target_instruction.ok) initFailures.push(`bootstrap.target_instruction: failed (${bootstrap.target_instruction.error})`);
213
+ if (bootstrap.target_instruction?.ok && bootstrap.target_instruction.truncated) truncationWarnings.push('bootstrap.target_instruction was truncated');
169
214
  if (bootstrap.target_config_json && !bootstrap.target_config_json.ok) initFailures.push(`bootstrap.target_config_json: failed (${bootstrap.target_config_json.error})`);
215
+ if (bootstrap.target_config_json?.ok && bootstrap.target_config_json.truncated) truncationWarnings.push('bootstrap.target_config_json was truncated');
170
216
  if (bootstrap.target_config_md && !bootstrap.target_config_md.ok) initFailures.push(`bootstrap.target_config_md: failed (${bootstrap.target_config_md.error})`);
217
+ if (bootstrap.target_config_md?.ok && bootstrap.target_config_md.truncated) truncationWarnings.push('bootstrap.target_config_md was truncated');
171
218
 
172
219
  const initStatus = initFailures.length === 0
173
- ? `All initialization contexts loaded successfully. mind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}`
174
- : `Initialization issues:\n${initFailures.join('\n')}\nmind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}`;
220
+ ? `All initialization contexts loaded successfully. mind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? ` ⚠️ ${truncationWarnings.length} files truncated` : ''}`
221
+ : `Initialization issues:\n${initFailures.join('\n')}\nmind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}${truncationWarnings.length > 0 ? `\n⚠️ Warnings:\n${truncationWarnings.join('\n')}` : ''}`;
175
222
 
176
223
  const initContextBlocks: string[] = [];
177
224
  if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
@@ -190,13 +237,13 @@ export async function POST(req: NextRequest) {
190
237
  const hasAttached = Array.isArray(attachedFiles) && attachedFiles.length > 0;
191
238
 
192
239
  if (hasAttached) {
193
- for (const filePath of attachedFiles) {
240
+ for (const filePath of attachedFiles!) {
194
241
  if (seen.has(filePath)) continue;
195
242
  seen.add(filePath);
196
243
  try {
197
244
  const content = truncate(getFileContent(filePath));
198
245
  contextParts.push(`## Attached: ${filePath}\n\n${content}`);
199
- } catch {}
246
+ } catch { /* ignore missing files */ }
200
247
  }
201
248
  }
202
249
 
@@ -205,11 +252,10 @@ export async function POST(req: NextRequest) {
205
252
  try {
206
253
  const content = truncate(getFileContent(currentFile));
207
254
  contextParts.push(`## Current file: ${currentFile}\n\n${content}`);
208
- } catch {}
255
+ } catch { /* ignore */ }
209
256
  }
210
257
 
211
- // Uploaded files go into a SEPARATE top-level section so the Agent
212
- // treats them with high priority and never tries to look them up via tools.
258
+ // Uploaded files
213
259
  const uploadedParts: string[] = [];
214
260
  if (Array.isArray(uploadedFiles) && uploadedFiles.length > 0) {
215
261
  for (const f of uploadedFiles.slice(0, 8)) {
@@ -242,97 +288,187 @@ export async function POST(req: NextRequest) {
242
288
  const systemPrompt = promptParts.join('\n\n');
243
289
 
244
290
  try {
245
- const model = getModel();
246
- const cfg = effectiveAiConfig();
247
- const modelName = cfg.provider === 'openai' ? cfg.openaiModel : cfg.anthropicModel;
248
- let modelMessages = convertToModelMessages(messages);
249
-
250
- // Phase 3: Context management pipeline
251
- // 1. Truncate tool outputs in historical messages
252
- modelMessages = truncateToolOutputs(modelMessages);
253
-
254
- const preTokens = estimateTokens(modelMessages);
255
- const sysTokens = estimateStringTokens(systemPrompt);
256
- const ctxLimit = getContextLimit(modelName);
257
- console.log(`[ask] Context: ~${preTokens + sysTokens} tokens (messages=${preTokens}, system=${sysTokens}), limit=${ctxLimit}`);
258
-
259
- // 2. Compact if >70% context limit (skip if user disabled)
260
- if (contextStrategy === 'auto' && needsCompact(modelMessages, systemPrompt, modelName)) {
261
- console.log('[ask] Context >70% limit, compacting...');
262
- const result = await compactMessages(modelMessages, model);
263
- modelMessages = result.messages;
264
- if (result.compacted) {
265
- const postTokens = estimateTokens(modelMessages);
266
- console.log(`[ask] After compact: ~${postTokens + sysTokens} tokens`);
267
- } else {
268
- console.log('[ask] Compact skipped (too few messages), hard prune will handle overflow if needed');
269
- }
270
- }
291
+ const { model, modelName, apiKey, provider } = getModelConfig();
271
292
 
272
- // 3. Hard prune if still >90% context limit
273
- modelMessages = hardPrune(modelMessages, systemPrompt, modelName);
293
+ // Convert frontend messages to AgentMessage[]
294
+ const agentMessages = toAgentMessages(messages);
274
295
 
275
- // Phase 2: Step monitoring + loop detection
276
- const stepHistory: Array<{ tool: string; input: string }> = [];
277
- let loopDetected = false;
278
- let loopCooldown = 0; // skip detection for N steps after warning
279
-
280
- const result = streamText({
281
- model,
282
- system: systemPrompt,
283
- messages: modelMessages,
284
- tools: knowledgeBaseTools,
285
- stopWhen: stepCountIs(stepLimit),
286
- ...(enableThinking && cfg.provider === 'anthropic' ? {
287
- providerOptions: {
288
- anthropic: {
289
- thinking: { type: 'enabled', budgetTokens: thinkingBudget },
290
- },
291
- },
292
- } : {}),
296
+ // Extract the last user message for agent.prompt()
297
+ const lastUserContent = messages.length > 0 && messages[messages.length - 1].role === 'user'
298
+ ? messages[messages.length - 1].content
299
+ : '';
293
300
 
294
- onStepFinish: ({ toolCalls, usage }) => {
295
- if (toolCalls) {
296
- for (const tc of toolCalls) {
297
- stepHistory.push({ tool: tc.toolName, input: JSON.stringify(tc.input) });
298
- }
299
- }
300
- // Loop detection: same tool + same args 3 times in a row
301
- // Skip detection during cooldown to avoid repeated warnings
302
- if (loopCooldown > 0) {
303
- loopCooldown--;
304
- } else if (stepHistory.length >= 3) {
305
- const last3 = stepHistory.slice(-3);
306
- if (last3.every(s => s.tool === last3[0].tool && s.input === last3[0].input)) {
307
- loopDetected = true;
301
+ // History = all messages except the last user message (agent.prompt adds it)
302
+ const historyMessages = agentMessages.slice(0, -1);
303
+
304
+ // Capture API key for this request — safe since each POST creates a new Agent instance.
305
+ // Even though JS closures are lexically scoped, being explicit guards against future refactors.
306
+ const requestApiKey = apiKey;
307
+
308
+ // ── Loop detection state ──
309
+ const stepHistory: Array<{ tool: string; input: string }> = [];
310
+ let stepCount = 0;
311
+ let loopCooldown = 0;
312
+
313
+ // ── Create Agent (per-request lifecycle) ──
314
+ const agent = new Agent({
315
+ initialState: {
316
+ systemPrompt,
317
+ model,
318
+ thinkingLevel: (enableThinking && provider === 'anthropic') ? 'medium' : 'off',
319
+ tools: knowledgeBaseTools,
320
+ messages: historyMessages,
321
+ },
322
+ getApiKey: async () => requestApiKey,
323
+ toolExecution: 'parallel',
324
+
325
+ // Context management: truncate → compact → prune
326
+ transformContext: createTransformContext(
327
+ systemPrompt,
328
+ modelName,
329
+ () => model,
330
+ apiKey,
331
+ contextStrategy,
332
+ ),
333
+
334
+ // Write-protection: block writes to protected files
335
+ beforeToolCall: async (context: BeforeToolCallContext): Promise<BeforeToolCallResult | undefined> => {
336
+ const { toolCall, args } = context;
337
+ // toolCall is an object with type "toolCall" and contains the tool name and ID
338
+ const toolName = (toolCall as any).toolName ?? (toolCall as any).name;
339
+ if (toolName && WRITE_TOOLS.has(toolName)) {
340
+ const filePath = (args as any).path ?? (args as any).from_path;
341
+ if (filePath) {
342
+ try {
343
+ assertNotProtected(filePath, 'modified by AI agent');
344
+ } catch (e) {
345
+ const errorMsg = e instanceof Error ? e.message : String(e);
346
+ return {
347
+ block: true,
348
+ reason: `Write-protection error: ${errorMsg}`,
349
+ };
350
+ }
308
351
  }
309
352
  }
310
- console.log(`[ask] Step ${stepHistory.length}/${stepLimit}, tokens=${usage?.totalTokens ?? '?'}`);
353
+ return undefined;
311
354
  },
312
355
 
313
- prepareStep: ({ messages: stepMessages }) => {
314
- if (loopDetected) {
315
- loopDetected = false;
316
- loopCooldown = 3; // suppress re-detection for 3 steps
317
- return {
318
- messages: [
319
- ...stepMessages,
320
- {
321
- role: 'user' as const,
322
- content: '[SYSTEM WARNING] You have called the same tool with identical arguments 3 times in a row. This appears to be a loop. Try a completely different approach or ask the user for clarification.',
323
- },
324
- ],
325
- };
326
- }
327
- return {}; // no modification
356
+ // Logging: record all tool executions
357
+ afterToolCall: async (context: AfterToolCallContext): Promise<AfterToolCallResult | undefined> => {
358
+ const ts = new Date().toISOString();
359
+ const { toolCall, args, result, isError } = context;
360
+ const toolName = (toolCall as any).toolName ?? (toolCall as any).name;
361
+ const outputText = result?.content
362
+ ?.filter((p: any) => p.type === 'text')
363
+ .map((p: any) => p.text)
364
+ .join('') ?? '';
365
+ try {
366
+ logAgentOp({
367
+ ts,
368
+ tool: toolName ?? 'unknown',
369
+ params: args as Record<string, unknown>,
370
+ result: isError ? 'error' : 'ok',
371
+ message: outputText.slice(0, 200),
372
+ });
373
+ } catch { /* logging must never kill the stream */ }
374
+ return undefined;
328
375
  },
329
376
 
330
- onError: ({ error }) => {
331
- console.error('[ask] Stream error:', error);
377
+ ...(enableThinking && provider === 'anthropic' ? {
378
+ thinkingBudgets: { medium: thinkingBudget },
379
+ } : {}),
380
+ });
381
+
382
+ // ── SSE Stream ──
383
+ const encoder = new TextEncoder();
384
+ const stream = new ReadableStream({
385
+ start(controller) {
386
+ function send(event: MindOSSSEvent) {
387
+ try {
388
+ controller.enqueue(encoder.encode(`data:${JSON.stringify(event)}\n\n`));
389
+ } catch { /* controller may be closed */ }
390
+ }
391
+
392
+ agent.subscribe((event: AgentEvent) => {
393
+ if (isTextDeltaEvent(event)) {
394
+ send({ type: 'text_delta', delta: getTextDelta(event) });
395
+ } else if (isThinkingDeltaEvent(event)) {
396
+ send({ type: 'thinking_delta', delta: getThinkingDelta(event) });
397
+ } else if (isToolExecutionStartEvent(event)) {
398
+ const { toolCallId, toolName, args } = getToolExecutionStart(event);
399
+ send({
400
+ type: 'tool_start',
401
+ toolCallId,
402
+ toolName,
403
+ args,
404
+ });
405
+ } else if (isToolExecutionEndEvent(event)) {
406
+ const { toolCallId, output, isError } = getToolExecutionEnd(event);
407
+ send({
408
+ type: 'tool_end',
409
+ toolCallId,
410
+ output,
411
+ isError,
412
+ });
413
+ } else if (isTurnEndEvent(event)) {
414
+ stepCount++;
415
+
416
+ // Track tool calls for loop detection (lock-free batch update).
417
+ // Deterministic JSON.stringify ensures consistent input comparison.
418
+ const { toolResults } = getTurnEndData(event);
419
+ if (Array.isArray(toolResults) && toolResults.length > 0) {
420
+ const newEntries = toolResults.map(tr => ({
421
+ tool: tr.toolName ?? 'unknown',
422
+ input: JSON.stringify(tr.content, null, 0), // Deterministic (no whitespace)
423
+ }));
424
+ stepHistory.push(...newEntries);
425
+ }
426
+
427
+ // Loop detection: same tool + same args 3 times in a row.
428
+ // Only trigger if we have 3+ history entries (prevent false positives on first turn).
429
+ const LOOP_DETECTION_THRESHOLD = 3;
430
+ if (loopCooldown > 0) {
431
+ loopCooldown--;
432
+ } else if (stepHistory.length >= LOOP_DETECTION_THRESHOLD) {
433
+ const lastN = stepHistory.slice(-LOOP_DETECTION_THRESHOLD);
434
+ if (lastN.every(s => s.tool === lastN[0].tool && s.input === lastN[0].input)) {
435
+ loopCooldown = 3;
436
+ // TODO (metrics): Track loop detection rate — metrics.increment('agent.loop_detected', { model: modelName })
437
+ agent.steer({
438
+ role: 'user',
439
+ content: '[SYSTEM WARNING] You have called the same tool with identical arguments 3 times in a row. This appears to be a loop. Try a completely different approach or ask the user for clarification.',
440
+ timestamp: Date.now(),
441
+ } as any);
442
+ }
443
+ }
444
+
445
+ // Step limit enforcement
446
+ if (stepCount >= stepLimit) {
447
+ agent.abort();
448
+ }
449
+
450
+ console.log(`[ask] Step ${stepCount}/${stepLimit}`);
451
+ }
452
+ });
453
+
454
+ agent.prompt(lastUserContent).then(() => {
455
+ send({ type: 'done' });
456
+ controller.close();
457
+ }).catch((err) => {
458
+ send({ type: 'error', message: err instanceof Error ? err.message : String(err) });
459
+ controller.close();
460
+ });
332
461
  },
333
462
  });
334
463
 
335
- return result.toUIMessageStreamResponse();
464
+ return new Response(stream, {
465
+ headers: {
466
+ 'Content-Type': 'text/event-stream',
467
+ 'Cache-Control': 'no-cache, no-transform',
468
+ 'Connection': 'keep-alive',
469
+ 'X-Accel-Buffering': 'no',
470
+ },
471
+ });
336
472
  } catch (err) {
337
473
  console.error('[ask] Failed to initialize model:', err);
338
474
  return NextResponse.json(