From b969c672b0fbe2b4322c3d14989bac43dde6538d Mon Sep 17 00:00:00 2001 From: kim Date: Mon, 13 Jul 2026 01:09:27 +0900 Subject: [PATCH] =?UTF-8?q?v4.1.0:=20=EB=A9=94=EC=9D=B8=EC=B1=97=20?= =?UTF-8?q?=ED=88=B4/=EC=8B=9C=EC=8A=A4=ED=85=9C=ED=94=84=EB=A1=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8=20=EC=98=A4=EB=B2=84=ED=97=A4=EB=93=9C=2066%?= =?UTF-8?q?=20=EA=B0=90=EC=86=8C=20+=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 2 +- src/gateway/server-v2.ts | 101 ++++++++++++++++++++++++++++++++------- web-ui/app.js | 15 +++++- 3 files changed, 100 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 4bffeea..f63cee1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smallclaw", - "version": "4.0.0", + "version": "4.1.0", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "main": "dist/index.js", "bin": { diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 33181e3..999c567 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -1041,7 +1041,12 @@ const heartbeatRunner = new HeartbeatRunner({ 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), - 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(), broadcast: broadcastWS, deliverTelegram: (text: string) => telegramChannel.sendToAllowed(text), @@ -2893,7 +2898,9 @@ async function webSearch(query: string): Promise { try { const { executeWebSearch } = await import('../tools/web.js'); 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) { 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 now = new Date(); - const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + 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', timeZone: 'Asia/Seoul' }); // 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 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 // inspect and fix code. Blocked tools are ones that don't make sense in // 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 legalToolNames = new Set(['korean_law_search', 'korean_law_fetch', 'us_case_search']); 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 lawyerEnabled = isSkillEnabledForUser('lawyer', userWorkspace); const presenterEnabled = isSkillEnabledForUser('presenter', userWorkspace); 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 name = String(t?.function?.name || ''); - if (weatherToolNames.has(name) && !meteorologistEnabled) return false; - if (legalToolNames.has(name) && !lawyerEnabled) return false; + if (dormantAgentTools.has(name)) 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 (academicToolNames.has(name) && !hasPptxKeyword) return false; return true; }; const tools = isBootStartupTurn @@ -5620,8 +5669,9 @@ async function handleChat( const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool)) ? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult)) : 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 || '')); + if (_imagingTools3.has(toolName)) console.log(`[v2] IMG-VISION tool=${toolName} embedVision=${_supportsVision3} model=${_activeModelName || primaryProvider}`); const _resolvedToolContent3 = _imagingTools3.has(toolName) && typeof toolMessageContent === 'string' ? resolveToolImageContent(toolMessageContent, workspacePath, _supportsVision3) : toolMessageContent; @@ -5677,8 +5727,8 @@ async function handleChat( : ''; const now = new Date(); - const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); - const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + 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', timeZone: 'Asia/Seoul' }); const executionModeSystemBlock = (() => { if (executionMode === 'background_task') { return [ @@ -5703,18 +5753,35 @@ async function handleChat( 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[] = [ { 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. 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. -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. +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} 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), \`\` (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. 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) { messages.push({ role: 'user', content: '[PINNED CONTEXT - Important messages from earlier in our conversation:]' }); @@ -6766,8 +6833,9 @@ RULES: const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool)) ? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult)) : 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 || '')); + if (_imagingTools2.has(toolName)) console.log(`[v2] IMG-VISION tool=${toolName} embedVision=${_supportsVision2} model=${_activeModelName || primaryProvider}`); const _resolvedContent2 = _imagingTools2.has(toolName) && typeof toolMessageContent === 'string' ? resolveToolImageContent(toolMessageContent, workspacePath, _supportsVision2) : toolMessageContent; @@ -8246,8 +8314,9 @@ RULES: const toolMessageContent = (multiAgentActive && (isBrowserTool || isDesktopTool)) ? (isBrowserTool ? buildBrowserAck(toolName, toolResult) : buildDesktopAck(toolName, toolResult)) : 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 || '')); + if (_imagingTools.has(toolName)) console.log(`[v2] IMG-VISION tool=${toolName} embedVision=${_supportsVision} model=${_activeModelName || primaryProvider}`); const _resolvedToolContent = _imagingTools.has(toolName) && typeof toolMessageContent === 'string' ? resolveToolImageContent(toolMessageContent, workspacePath, _supportsVision) : 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 // as main-chat sessions but belong to their own app UI — keep them out of the // 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 app.get('/api/chat/sessions', async (req, res) => { diff --git a/web-ui/app.js b/web-ui/app.js index 168999d..77817ff 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -1893,6 +1893,19 @@ async function sendChat(queuedMessage = null) { 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': finalReply = event.reply || ''; if (finalReply) partialContent = finalReply; @@ -4452,7 +4465,7 @@ function renderSessionsList() {
${s.automated ? 'Auto' : ''} ${(s.history || []).length} msgs - ${(() => { 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 `${fmtTok(ct)}/${fmtTok(mx)}`; } return `${fmtTok(ct)} tok`; })()} + ${(() => { 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 `${real?'':'~'}${fmtTok(ct)}/${fmtTok(mx)}`; } return `${real?'':'~'}${fmtTok(ct)} tok`; })()} ${timeAgo(s.updatedAt || s.createdAt)}