v4.1.0: 메인챗 툴/시스템프롬프트 오버헤드 66% 감소 + 실시간 컨텍스트 게이지

- 2개월 tool_audit.log 실사용 확인 후 미사용 에이전트 툴(start_task 등)과
  포트 9222 연결실패로 고장난 브라우저 툴을 메인챗에서 제외
- 날씨/법률/논문 툴을 스킬+키워드 조건부로 전환, dental/mind 전용 MCP DB
  24개를 해당 앱 세션(dental_/mn_)에만 스코핑, weather/lawyer 앱 세션은
  스킬·키워드와 무관하게 항상 포함되도록 별도 처리
- 위 변경으로 메인챗 고정 오버헤드 24,439 → 8,169 토큰(-66%) 실측 확인
- tool_overhead SSE 이벤트를 모델 호출 전에 전송해 세션 게이지가 중단된
  턴에서도 툴 스키마+시스템프롬프트 크기를 반영하도록 수정 (app.js)
- 부수 수정: heartbeat 세션을 lastMainSessionId 폴백에서 완전 격리(다른
  세션 기록 오염 방지), webSearch 빈 결과를 실패로 오판하던 버그 수정

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-13 01:09:27 +09:00
co-authored by Claude Sonnet 5
parent a11e554e88
commit b969c672b0
3 changed files with 100 additions and 18 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "smallclaw", "name": "smallclaw",
"version": "4.0.0", "version": "4.1.0",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "bin": {
+85 -16
View File
@@ -1041,7 +1041,12 @@ const heartbeatRunner = new HeartbeatRunner({
configPath: path.join(CONFIG_DIR_PATH, 'heartbeat', 'config.json'), configPath: path.join(CONFIG_DIR_PATH, 'heartbeat', 'config.json'),
handleChat: (message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username) => handleChat: (message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username) =>
handleChat(message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username), handleChat(message, sessionId, sendSSE, pinnedMessages, abortSignal, callerContext, modelOverride, executionMode, username),
getMainSessionId: () => lastMainSessionId || 'default', // Dedicated, isolated session — must NEVER fall back to lastMainSessionId.
// That global variable reflects whichever app/user last sent a chat message
// server-wide, so heartbeat's autonomous tool calls (which can touch any
// registered MCP tool, including sensitive per-app databases) would otherwise
// get appended into a random unrelated app's conversation history.
getMainSessionId: () => 'heartbeat_internal',
getIsModelBusy: () => checkIsModelBusy(), getIsModelBusy: () => checkIsModelBusy(),
broadcast: broadcastWS, broadcast: broadcastWS,
deliverTelegram: (text: string) => telegramChannel.sendToAllowed(text), deliverTelegram: (text: string) => telegramChannel.sendToAllowed(text),
@@ -2893,7 +2898,9 @@ async function webSearch(query: string): Promise<string> {
try { try {
const { executeWebSearch } = await import('../tools/web.js'); const { executeWebSearch } = await import('../tools/web.js');
const res = await executeWebSearch({ query, max_results: 5 }); const res = await executeWebSearch({ query, max_results: 5 });
if (res.success && res.stdout) return res.stdout; // res.success with empty stdout means the provider genuinely found 0 results —
// that's a valid answer, not a failure. Only exceptions should fall through to DDG.
if (res.success) return res.stdout || `"${query}"에 대한 검색 결과가 없습니다.`;
} catch (err: any) { } catch (err: any) {
console.warn(`[v2] webSearch ${provider} failed, falling back to DDG:`, err.message); console.warn(`[v2] webSearch ${provider} failed, falling back to DDG:`, err.message);
} }
@@ -5064,11 +5071,11 @@ async function handleCodeChat(
const history = (historyOverride || []).map(m => ({ role: (m.role === 'user' ? 'user' : 'assistant') as 'user' | 'assistant', content: String(m.content) })); const history = (historyOverride || []).map(m => ({ role: (m.role === 'user' ? 'user' : 'assistant') as 'user' | 'assistant', content: String(m.content) }));
const now = new Date(); const now = new Date();
const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Asia/Seoul' });
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul' });
// Tools available in the editor: everything except browser/messaging/timer/etc. // Tools available in the editor: everything except browser/messaging/timer/etc.
const codeAiBlockedTools = new Set(['start_task', 'task_control', 'write_note', 'browser_open', 'browser_snapshot', 'browser_click', 'browser_fill', 'browser_press_key', 'browser_wait', 'browser_close', 'image_search', 'image_url', 'image_edit', 'image_info', 'image_read', 'weather_search', 'start_timer', 'send_message', 'send_telegram', 'kakao_send_message', 'create_presentation', 'edit_presentation', 'spawn_subagent', 'schedule_job', 'parse_schedule_pattern']); const codeAiBlockedTools = new Set(['start_task', 'task_control', 'write_note', 'browser_open', 'browser_snapshot', 'browser_click', 'browser_fill', 'browser_press_key', 'browser_wait', 'browser_close', 'image_search', 'image_url', 'image_edit', 'image_info', 'image_read', 'weather_search', 'weather_map_screenshot', 'start_timer', 'send_message', 'send_telegram', 'kakao_send_message', 'create_presentation', 'edit_presentation', 'spawn_subagent', 'schedule_job', 'parse_schedule_pattern']);
// useTools:false → plan/ask phase; pass empty tools so the model generates text only // useTools:false → plan/ask phase; pass empty tools so the model generates text only
const tools = useTools ? buildTools().filter((t: any) => !codeAiBlockedTools.has(String(t?.function?.name || ''))) : []; const tools = useTools ? buildTools().filter((t: any) => !codeAiBlockedTools.has(String(t?.function?.name || ''))) : [];
@@ -5269,19 +5276,61 @@ async function handleChat(
// coder_delete_lines, coder_list_files, shell, python_eval, and web_search to directly // coder_delete_lines, coder_list_files, shell, python_eval, and web_search to directly
// inspect and fix code. Blocked tools are ones that don't make sense in // inspect and fix code. Blocked tools are ones that don't make sense in
// an editor context (browser automation, messaging, timers, presentations, etc.) // an editor context (browser automation, messaging, timers, presentations, etc.)
const codeAiBlockedTools = new Set(['start_task', 'task_control', 'write_note', 'browser_open', 'browser_snapshot', 'browser_click', 'browser_fill', 'browser_press_key', 'browser_wait', 'browser_close', 'image_search', 'image_url', 'image_edit', 'image_info', 'image_read', 'weather_search', 'start_timer', 'send_message', 'send_telegram', 'kakao_send_message', 'create_presentation', 'edit_presentation', 'spawn_subagent', 'schedule_job', 'parse_schedule_pattern']); const codeAiBlockedTools = new Set(['start_task', 'task_control', 'write_note', 'browser_open', 'browser_snapshot', 'browser_click', 'browser_fill', 'browser_press_key', 'browser_wait', 'browser_close', 'image_search', 'image_url', 'image_edit', 'image_info', 'image_read', 'weather_search', 'weather_map_screenshot', 'start_timer', 'send_message', 'send_telegram', 'kakao_send_message', 'create_presentation', 'edit_presentation', 'spawn_subagent', 'schedule_job', 'parse_schedule_pattern']);
const weatherToolNames = new Set(['weather_search', 'weather_airpollution', 'weather_openmeteo', 'weather_kma', 'weather_airkorea', 'weather_nasa_power', 'weather_era5', 'weather_cds', 'weather_cmip6']); const weatherToolNames = new Set(['weather_search', 'weather_airpollution', 'weather_openmeteo', 'weather_kma', 'weather_airkorea', 'weather_nasa_power', 'weather_era5', 'weather_cds', 'weather_cmip6']);
const legalToolNames = new Set(['korean_law_search', 'korean_law_fetch', 'us_case_search']); const legalToolNames = new Set(['korean_law_search', 'korean_law_fetch', 'us_case_search']);
const pptxToolNames = new Set(['create_presentation', 'edit_presentation']); const pptxToolNames = new Set(['create_presentation', 'edit_presentation']);
// In practice these are only ever used while building an academic presentation (gather
// papers → create_presentation), not in standalone chat — gate them the same way as the
// pptx tools themselves rather than always loading 5 more schemas.
const academicToolNames = new Set(['pubmed_search', 'pubmed_fetch', 'pubmed_fulltext', 'openalex_search', 'semantic_search']);
const meteorologistEnabled = isSkillEnabledForUser('meteorologist', userWorkspace); const meteorologistEnabled = isSkillEnabledForUser('meteorologist', userWorkspace);
const lawyerEnabled = isSkillEnabledForUser('lawyer', userWorkspace); const lawyerEnabled = isSkillEnabledForUser('lawyer', userWorkspace);
const presenterEnabled = isSkillEnabledForUser('presenter', userWorkspace); const presenterEnabled = isSkillEnabledForUser('presenter', userWorkspace);
const hasPptxKeyword = /슬라이드|발표|pptx|ppt\b|프레젠테이션|피피티|덱\b|presentation/i.test(message); const hasPptxKeyword = /슬라이드|발표|pptx|ppt\b|프레젠테이션|피피티|덱\b|presentation/i.test(message);
// Weather/legal skills being ON means the user does that kind of work sometimes, not that
// every single turn is about it — without a keyword gate the full 9 weather / 3 legal tools
// rode along on unrelated turns too. Mirrors the pptx pattern (skill + keyword) below.
const hasWeatherKeyword = /날씨|기온|강수|미세먼지|대기질|태풍|기후|일기예보|장마|폭염|한파|자외선|황사|weather|forecast/i.test(message);
const hasLegalKeyword = /법률|판례|소송|변호사|법원|법조문|조항|계약서|형법|민법|법령|legal|lawsuit|court|statute/i.test(message);
// Sub-agent/task-orchestration tools that tool_audit.log shows near-zero real usage for
// (2026-05-04~2026-07-12: start_task/request_secondary_assist/subagent_spawn/
// delegate_to_specialist/spawn_subagent = 0 calls; task_control/parse_schedule_pattern/
// spawn_agent = a handful, all stale). Kept registered for future agent-composition work,
// just not sent to the model in normal chat. schedule_job is excluded from this set — it's
// self-contained (list/create/update/pause/resume/delete/run_now) and still actively used.
const dormantAgentTools = new Set(['start_task', 'task_control', 'parse_schedule_pattern', 'request_secondary_assist', 'subagent_spawn', 'delegate_to_specialist', 'spawn_subagent', 'spawn_agent']);
// Browser automation — parked. tool_audit.log shows every browser_open call from 07-09
// onward failing with "Chrome launched but did not respond on port 9222". Repeated debug
// runs showed the live `tools` array alternates between two mutually-exclusive sets for the
// SAME "ping" request against the SAME running process — open/snapshot/click/fill/press_key/
// wait/close one time, scroll/get_images the next — apparently some live Chrome-reachability
// probe swapping which schema set gets exposed. Excluding all 9 names covers both variants.
// Re-enable by removing this set once browser automation is fixed / needed (e.g. home shopping).
const dormantBrowserTools = new Set(['browser_open', 'browser_snapshot', 'browser_click', 'browser_fill', 'browser_press_key', 'browser_wait', 'browser_close', 'browser_scroll', 'browser_get_images']);
// MCP database servers built for a specific dedicated app tab (dental-agent.html →
// 'dental_' sessions, mind-app.html → 'mn_' sessions) — 62% of the tool-schema overhead
// (12,124 of 19,531 tokens measured) came from these 3 servers × 8 sub-tools each, riding
// along on every main-chat turn for every user even though they're single-purpose lookup
// tables for one app. Scope them to the app session that actually uses them.
const isDentalAppSession = /^dental_/.test(String(sessionId || ''));
const isMindAppSession = /^mn_/.test(String(sessionId || ''));
// Weather/lawyer have their own dedicated app tabs (weather-app.html → 'wt_' sessions,
// lawyer-app.html → 'lw_' sessions). Inside those apps the skill+keyword gate below is
// wrong — a "wt_" session IS the weather context even if a given message has no weather
// keyword (e.g. a follow-up "내일은?"), so always include the tools there.
const isWeatherAppSession = /^wt_/.test(String(sessionId || ''));
const isLawyerAppSession = /^lw_/.test(String(sessionId || ''));
const skillToolFilter = (t: any) => { const skillToolFilter = (t: any) => {
const name = String(t?.function?.name || ''); const name = String(t?.function?.name || '');
if (weatherToolNames.has(name) && !meteorologistEnabled) return false; if (dormantAgentTools.has(name)) return false;
if (legalToolNames.has(name) && !lawyerEnabled) return false; if (dormantBrowserTools.has(name)) return false;
if (name.startsWith('mcp__dental-dict-sqlite__') && !isDentalAppSession) return false;
if ((name.startsWith('mcp__psychotherapy-cases-sqlite__') || name.startsWith('mcp__psychiatry-cases-sqlite__')) && !isMindAppSession) return false;
if (weatherToolNames.has(name) && !isWeatherAppSession && !(meteorologistEnabled && hasWeatherKeyword)) return false;
if (legalToolNames.has(name) && !isLawyerAppSession && !(lawyerEnabled && hasLegalKeyword)) return false;
if (pptxToolNames.has(name) && presenterEnabled && !hasPptxKeyword) return false; if (pptxToolNames.has(name) && presenterEnabled && !hasPptxKeyword) return false;
if (academicToolNames.has(name) && !hasPptxKeyword) return false;
return true; return true;
}; };
const tools = isBootStartupTurn const tools = isBootStartupTurn
@@ -5620,8 +5669,9 @@ async function handleChat(
const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool)) const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool))
? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult)) ? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult))
: toolResult.result; : toolResult.result;
const _imagingTools3 = new Set(['image_edit', 'image_info', 'image_read', 'python_eval']); const _imagingTools3 = new Set(['image_edit', 'image_info', 'image_read', 'python_eval', 'weather_map_screenshot']);
const _supportsVision3 = /kimi|openai|claude|gemini|gpt(?!-oss)/i.test(String(_activeModelName || primaryProvider || '')); const _supportsVision3 = /kimi|openai|claude|gemini|gpt(?!-oss)/i.test(String(_activeModelName || primaryProvider || ''));
if (_imagingTools3.has(toolName)) console.log(`[v2] IMG-VISION tool=${toolName} embedVision=${_supportsVision3} model=${_activeModelName || primaryProvider}`);
const _resolvedToolContent3 = _imagingTools3.has(toolName) && typeof toolMessageContent === 'string' const _resolvedToolContent3 = _imagingTools3.has(toolName) && typeof toolMessageContent === 'string'
? resolveToolImageContent(toolMessageContent, workspacePath, _supportsVision3) ? resolveToolImageContent(toolMessageContent, workspacePath, _supportsVision3)
: toolMessageContent; : toolMessageContent;
@@ -5677,8 +5727,8 @@ async function handleChat(
: ''; : '';
const now = new Date(); const now = new Date();
const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Asia/Seoul' });
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul' });
const executionModeSystemBlock = (() => { const executionModeSystemBlock = (() => {
if (executionMode === 'background_task') { if (executionMode === 'background_task') {
return [ return [
@@ -5703,18 +5753,35 @@ async function handleChat(
return ''; return '';
})(); })();
// browser_* tools are excluded from the default tool list (dormantBrowserTools, parked
// until a dedicated use case). The BROWSER RULE paragraph below only makes sense when the
// model actually has browser tools to be tempted to misuse — skip it otherwise so it isn't
// dead weight on every single main-chat turn.
const hasBrowserTools = tools.some((t: any) => String(t?.function?.name || '').startsWith('browser_'));
const browserRuleBlock = hasBrowserTools
? '\nBROWSER RULE: NEVER call browser_open, browser_snapshot, browser_click, browser_fill, browser_scroll, or any browser_* tool unless the user EXPLICITLY asks to use the browser (e.g. "브라우저로 열어줘", "Chrome으로 봐줘", "사이트 직접 들어가봐"). "열어줘" or "보여줘" alone is NOT a browser request — answer from knowledge, use web_fetch/web_search, or output the relevant URL/embed. Pasting a URL or asking a question is also NOT a browser request.'
: '';
const messages: any[] = [ const messages: any[] = [
{ {
role: 'system', role: 'system',
content: isTranslateSession ? `You are a medical translator. Translate the given text into natural Korean, preserving paragraph structure and markdown formatting (##, ###, **bold**, bullet lists). Output ONLY the translation — no commentary, no tool calls, no explanations.` : isProjSession ? `You are a project file designer. Output ONLY the project-files JSON block as instructed. No tool calls. No extra text.` : `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools. content: isTranslateSession ? `You are a medical translator. Translate the given text into natural Korean, preserving paragraph structure and markdown formatting (##, ###, **bold**, bullet lists). Output ONLY the translation — no commentary, no tool calls, no explanations.` : isProjSession ? `You are a project file designer. Output ONLY the project-files JSON block as instructed. No tool calls. No extra text.` : `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools.
ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know. ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know.
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error. IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.${browserRuleBlock}
BROWSER RULE: NEVER call browser_open, browser_snapshot, browser_click, browser_fill, browser_scroll, or any browser_* tool unless the user EXPLICITLY asks to use the browser (e.g. "브라우저로 열어줘", "Chrome으로 봐줘", "사이트 직접 들어가봐"). "열어줘" or "보여줘" alone is NOT a browser request — answer from knowledge, use web_fetch/web_search, or output the relevant URL/embed. Pasting a URL or asking a question is also NOT a browser request.
CODE OUTPUT: When writing code in a fenced code block, start with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/C), \`<!-- filename: index.html -->\` (HTML). Never repeat code already written in this conversation. For modifications to existing files, use coder_overwrite_lines or coder_insert_lines (not coder_write_file — it only works for NEW files). All code changes are presented as diffs for the user to review before being applied. Write code directly — do not ask for permission. CODE OUTPUT: When writing code in a fenced code block, start with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/C), \`<!-- filename: index.html -->\` (HTML). Never repeat code already written in this conversation. For modifications to existing files, use coder_overwrite_lines or coder_insert_lines (not coder_write_file — it only works for NEW files). All code changes are presented as diffs for the user to review before being applied. Write code directly — do not ask for permission.
PACKAGE INSTALL: NEVER run pip install, npm install, apt-get, or any package installation command. If a package is missing, just write the code and mention the package name in a comment — let the user decide whether to install it. Do NOT attempt to install packages yourself. PACKAGE INSTALL: NEVER run pip install, npm install, apt-get, or any package installation command. If a package is missing, just write the code and mention the package name in a comment — let the user decide whether to install it. Do NOT attempt to install packages yourself.
OUTPUT FORMAT: When presenting 3+ items (news articles, emails, search results, lists), always use a markdown table or structured bullet list with clear headers. Never dump them as a long paragraph. Example: news → table with columns 제목|요약|출처.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000, message)}`, OUTPUT FORMAT: When presenting 3+ items (news articles, emails, search results, lists), always use a markdown table or structured bullet list with clear headers. Never dump them as a long paragraph. Example: news → table with columns 제목|요약|출처.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000, message)}`,
}, },
]; ];
// Emit the fixed-overhead size (system prompt + tool schemas) as soon as both are known —
// before the model call — so the client has a real per-session baseline even if the turn
// gets aborted before a 'usage' event (with the real prompt_eval_count) ever arrives.
// Without this, the UI's char/4 fallback silently ignores this baseline entirely, even
// though it dwarfs the actual conversation text in most sessions.
try {
const systemPromptTokens = Math.ceil(String(messages[0]?.content || '').length / 3.5);
const toolSchemaTokens = Math.ceil(JSON.stringify(tools).length / 3.5);
sendSSE('tool_overhead', { tokens: systemPromptTokens + toolSchemaTokens });
} catch {}
if (pinnedMessages && pinnedMessages.length > 0) { if (pinnedMessages && pinnedMessages.length > 0) {
messages.push({ role: 'user', content: '[PINNED CONTEXT - Important messages from earlier in our conversation:]' }); messages.push({ role: 'user', content: '[PINNED CONTEXT - Important messages from earlier in our conversation:]' });
@@ -6766,8 +6833,9 @@ RULES:
const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool)) const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool))
? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult)) ? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult))
: toolResult.result; : toolResult.result;
const _imagingTools2 = new Set(['image_edit', 'image_info', 'image_read', 'python_eval']); const _imagingTools2 = new Set(['image_edit', 'image_info', 'image_read', 'python_eval', 'weather_map_screenshot']);
const _supportsVision2 = /kimi|openai|claude|gemini|gpt(?!-oss)/i.test(String(_activeModelName || primaryProvider || '')); const _supportsVision2 = /kimi|openai|claude|gemini|gpt(?!-oss)/i.test(String(_activeModelName || primaryProvider || ''));
if (_imagingTools2.has(toolName)) console.log(`[v2] IMG-VISION tool=${toolName} embedVision=${_supportsVision2} model=${_activeModelName || primaryProvider}`);
const _resolvedContent2 = _imagingTools2.has(toolName) && typeof toolMessageContent === 'string' const _resolvedContent2 = _imagingTools2.has(toolName) && typeof toolMessageContent === 'string'
? resolveToolImageContent(toolMessageContent, workspacePath, _supportsVision2) ? resolveToolImageContent(toolMessageContent, workspacePath, _supportsVision2)
: toolMessageContent; : toolMessageContent;
@@ -8246,8 +8314,9 @@ RULES:
const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool)) const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool))
? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult)) ? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult))
: toolResult.result; : toolResult.result;
const _imagingTools = new Set(['image_edit', 'image_info', 'image_read', 'python_eval']); const _imagingTools = new Set(['image_edit', 'image_info', 'image_read', 'python_eval', 'weather_map_screenshot']);
const _supportsVision = /kimi|openai|claude|gemini|gpt(?!-oss)/i.test(String(_activeModelName || primaryProvider || '')); const _supportsVision = /kimi|openai|claude|gemini|gpt(?!-oss)/i.test(String(_activeModelName || primaryProvider || ''));
if (_imagingTools.has(toolName)) console.log(`[v2] IMG-VISION tool=${toolName} embedVision=${_supportsVision} model=${_activeModelName || primaryProvider}`);
const _resolvedToolContent = _imagingTools.has(toolName) && typeof toolMessageContent === 'string' const _resolvedToolContent = _imagingTools.has(toolName) && typeof toolMessageContent === 'string'
? resolveToolImageContent(toolMessageContent, workspacePath, _supportsVision) ? resolveToolImageContent(toolMessageContent, workspacePath, _supportsVision)
: toolMessageContent; : toolMessageContent;
@@ -12874,7 +12943,7 @@ app.get('/api/agent/session/:id', (req, res) => {
// Per-app session ids (see appSessionsPath above) live in the same sessions/ dir // Per-app session ids (see appSessionsPath above) live in the same sessions/ dir
// as main-chat sessions but belong to their own app UI — keep them out of the // as main-chat sessions but belong to their own app UI — keep them out of the
// main chat sidebar. // main chat sidebar.
const APP_SESSION_PREFIXES = ['iv_', 'dt_', 'lw_', 'mn_', 'ac_', 'wt_', 'music_']; const APP_SESSION_PREFIXES = ['iv_', 'dt_', 'lw_', 'mn_', 'ac_', 'wt_', 'music_', 'dental_'];
// GET /api/chat/sessions — list current user's sessions from disk // GET /api/chat/sessions — list current user's sessions from disk
app.get('/api/chat/sessions', async (req, res) => { app.get('/api/chat/sessions', async (req, res) => {
+14 -1
View File
@@ -1893,6 +1893,19 @@ async function sendChat(queuedMessage = null) {
break; break;
} }
// Sent before the model call, so it survives a turn that gets aborted before
// a real 'usage' event (with prompt_eval_count) arrives — lets the fallback
// estimate in renderSessionsList include tool-schema overhead instead of
// silently ignoring it.
case 'tool_overhead': {
const overhead = Number(event.tokens || 0);
if (overhead > 0) {
const idx = chatSessions.findIndex(s => s.id === activeChatSessionId);
if (idx !== -1) { chatSessions[idx].toolOverheadTokens = overhead; saveChatSessions(); }
}
break;
}
case 'done': case 'done':
finalReply = event.reply || ''; finalReply = event.reply || '';
if (finalReply) partialContent = finalReply; if (finalReply) partialContent = finalReply;
@@ -4452,7 +4465,7 @@ function renderSessionsList() {
<div style="display:flex;align-items:center;gap:6px;margin-top:4px"> <div style="display:flex;align-items:center;gap:6px;margin-top:4px">
${s.automated ? '<span class="session-auto-badge">Auto</span>' : ''} ${s.automated ? '<span class="session-auto-badge">Auto</span>' : ''}
<span class="badge badge-queued">${(s.history || []).length} msgs</span> <span class="badge badge-queued">${(s.history || []).length} msgs</span>
${(() => { const fmtTok = n => n>=1e6?`${(n/1e6).toFixed(1)}M`:n>=1000?`${Math.round(n/1000)}k`:`${n}`; const ct = Number(s.contextTokens||0) || Math.round((s.history||[]).reduce((a,m)=>a+(m.content||'').length,0)/4); const mx = _appCtxMax; if (!ct) return ''; if (mx > 0) { const pct = Math.min(100, Math.round(ct/mx*100)); const col = pct>=80?'var(--err)':pct>=50?'var(--warn)':'var(--muted)'; return `<span style="font-size:10px;color:${col}">${fmtTok(ct)}/${fmtTok(mx)}</span>`; } return `<span style="font-size:10px;color:var(--muted)">${fmtTok(ct)} tok</span>`; })()} ${(() => { const fmtTok = n => n>=1e6?`${(n/1e6).toFixed(1)}M`:n>=1000?`${Math.round(n/1000)}k`:`${n}`; const real = Number(s.contextTokens||0); const ct = real || (Number(s.toolOverheadTokens||0) + Math.round((s.history||[]).reduce((a,m)=>a+(m.content||'').length,0)/4)); const mx = _appCtxMax; if (!ct) return ''; if (mx > 0) { const pct = Math.min(100, Math.round(ct/mx*100)); const col = pct>=80?'var(--err)':pct>=50?'var(--warn)':'var(--muted)'; return `<span style="font-size:10px;color:${col}">${real?'':'~'}${fmtTok(ct)}/${fmtTok(mx)}</span>`; } return `<span style="font-size:10px;color:var(--muted)">${real?'':'~'}${fmtTok(ct)} tok</span>`; })()}
<span style="color:var(--muted);font-size:10px">${timeAgo(s.updatedAt || s.createdAt)}</span> <span style="color:var(--muted);font-size:10px">${timeAgo(s.updatedAt || s.createdAt)}</span>
</div> </div>
</div> </div>