From 34f0f270bcba19b3c25a05ad7307b7afbe86f3db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 12:48:28 +0000 Subject: [PATCH 1/2] perf: fix all performance issues from issue list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript fixes: - #77: Remove JSON.stringify indentation in store.ts and settings-store.ts - #76: Replace Math.max spread with reduce in agent-history/index.ts - #73: Replace JSON.stringify field check with .some() in gateway/bridge/input.ts - #71: Replace Math.max spread with loop in usage/store.ts - #70: Cache mergedPricing() to avoid repeated disk reads - #74: Replace Array.shift() with index pointer in ThinkingState.prune() - #75: Add LRU eviction to conversations/thinkingStates maps in adapter.ts - #69: Avoid duplicate collectFiles() calls in session-cache.ts - #68: Read only first 64KB per file in agent-history scanners (avoid full read) Rust fixes: - #63: Direct ElementInfo→HoverElement conversion without JSON round-trip - #60: Switch template scaling from Lanczos3 to Triangle in find_image.rs - #61: Remove intermediate Vec allocation in precompute_template - #59: Use Cow to avoid cloning full screenshot when no region - #58/#57: Add HashMap index to ScreenshotCache and ImageCache for O(1) lookup - #56: Eliminate JPEG→PNG round-trip in take_screenshot handler Agent-Logs-Url: https://github.com/unbug/tday/sessions/89e1f6c9-ee1f-48d4-a8ea-5684c1298897 Co-authored-by: unbug <799578+unbug@users.noreply.github.com> --- apps/desktop/src/main/agent-history/index.ts | 4 +- .../src/main/agent-history/scanners.ts | 42 +++++++++++++------ apps/desktop/src/main/agent-history/store.ts | 2 +- apps/desktop/src/main/gateway/adapter.ts | 18 +++++++- apps/desktop/src/main/gateway/bridge/input.ts | 3 +- .../src/main/gateway/deepseek/state.ts | 10 +++-- apps/desktop/src/main/settings-store.ts | 4 +- apps/desktop/src/main/usage/session-cache.ts | 21 ++++++---- apps/desktop/src/main/usage/store.ts | 19 +++++++-- crates/tday-nativecore/src/find_image.rs | 41 ++++++++++++------ .../src/handlers/screenshot.rs | 37 +++++++--------- .../tday-nativecore/src/platform/linux/mod.rs | 2 +- .../tday-nativecore/src/platform/macos/mod.rs | 2 +- crates/tday-nativecore/src/platform/mod.rs | 6 +-- .../src/platform/windows/mod.rs | 2 +- .../src/session/image_cache.rs | 28 +++++++++---- .../src/session/screenshot_cache.rs | 13 ++++-- .../src/tracking/hover_tracker.rs | 15 ++++++- 18 files changed, 178 insertions(+), 91 deletions(-) diff --git a/apps/desktop/src/main/agent-history/index.ts b/apps/desktop/src/main/agent-history/index.ts index 82ef09e..19a1183 100644 --- a/apps/desktop/src/main/agent-history/index.ts +++ b/apps/desktop/src/main/agent-history/index.ts @@ -117,7 +117,7 @@ function isDirty( ): boolean { const stored = scanState[agentId]; if (!stored) return true; - const maxMtime = files.length > 0 ? Math.max(...files.map((f) => f.mtime)) : 0; + const maxMtime = files.reduce((m, f) => f.mtime > m ? f.mtime : m, 0); return files.length !== stored.fileCount || maxMtime !== stored.maxMtime; } @@ -159,7 +159,7 @@ function runRefresh(): Promise { const scanned = watcher.scan(); nativeByAgent.set(watcher.agentId, scanned); - const maxMtime = files.length > 0 ? Math.max(...files.map((f) => f.mtime)) : 0; + const maxMtime = files.reduce((m, f) => f.mtime > m ? f.mtime : m, 0); store.scanState[watcher.agentId] = { fileCount: files.length, maxMtime, diff --git a/apps/desktop/src/main/agent-history/scanners.ts b/apps/desktop/src/main/agent-history/scanners.ts index fa31ae4..d9b62c2 100644 --- a/apps/desktop/src/main/agent-history/scanners.ts +++ b/apps/desktop/src/main/agent-history/scanners.ts @@ -22,7 +22,7 @@ * session histories into memory. */ -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { execFileSync } from 'node:child_process'; @@ -31,6 +31,26 @@ import type { AgentHistoryEntry } from '@tday/shared'; /** Maximum lines scanned per file to extract the title. */ const MAX_TITLE_SCAN_LINES = 150; +/** Read at most MAX_TITLE_SCAN_LINES lines from a file without loading it fully. + * Reads up to 64 KB synchronously — sufficient for 150 JSONL lines in practice. + */ +function readFirstLines(filePath: string): string[] { + const CHUNK_BYTES = 65536; // 64 KB + let fd: number | null = null; + try { + fd = openSync(filePath, 'r'); + const buf = Buffer.alloc(CHUNK_BYTES); + const bytesRead = readSync(fd, buf, 0, CHUNK_BYTES, 0); + const text = buf.slice(0, bytesRead).toString('utf8'); + const lines = text.split('\n'); + return lines.length > MAX_TITLE_SCAN_LINES ? lines.slice(0, MAX_TITLE_SCAN_LINES) : lines; + } catch { + return []; + } finally { + if (fd !== null) { try { closeSync(fd); } catch { /* ignore */ } } + } +} + // ── Helpers ────────────────────────────────────────────────────────────────── function truncateTitle(s: string, max = 80): string { @@ -121,10 +141,9 @@ export function scanClaudeHistory(): AgentHistoryEntry[] { let messageCount = 0; try { - const content = readFileSync(filePath, 'utf8'); let lineIdx = 0; - for (const line of content.split('\n')) { - if (lineIdx++ > MAX_TITLE_SCAN_LINES) break; + for (const line of readFirstLines(filePath)) { + lineIdx++; const trimmed = line.trim(); if (!trimmed) continue; try { @@ -204,10 +223,9 @@ export function scanCodexHistory(): AgentHistoryEntry[] { let messageCount = 0; try { - const content = readFileSync(filePath, 'utf8'); let lineIdx = 0; - for (const line of content.split('\n')) { - if (lineIdx++ > MAX_TITLE_SCAN_LINES) break; + for (const line of readFirstLines(filePath)) { + lineIdx++; const trimmed = line.trim(); if (!trimmed) continue; try { @@ -381,10 +399,9 @@ export function scanGeminiHistory(): AgentHistoryEntry[] { let messageCount = 0; try { - const content = readFileSync(filePath, 'utf8'); let lineIdx = 0; - for (const line of content.split('\n')) { - if (lineIdx++ > MAX_TITLE_SCAN_LINES) break; + for (const line of readFirstLines(filePath)) { + lineIdx++; const trimmed = line.trim(); if (!trimmed) continue; try { @@ -492,10 +509,9 @@ export function scanPiHistory(): AgentHistoryEntry[] { let cwd = ''; try { - const content = readFileSync(filePath, 'utf8'); let lineIdx = 0; - for (const line of content.split('\n')) { - if (lineIdx++ > MAX_TITLE_SCAN_LINES) break; + for (const line of readFirstLines(filePath)) { + lineIdx++; const trimmed = line.trim(); if (!trimmed) continue; try { diff --git a/apps/desktop/src/main/agent-history/store.ts b/apps/desktop/src/main/agent-history/store.ts index e331cb4..a6b5aa9 100644 --- a/apps/desktop/src/main/agent-history/store.ts +++ b/apps/desktop/src/main/agent-history/store.ts @@ -75,7 +75,7 @@ export function loadStore(): HistoryStore { export function saveStore(store: HistoryStore): void { try { if (!existsSync(TDAY_DIR)) mkdirSync(TDAY_DIR, { recursive: true }); - writeFileSync(INDEX_TMP, JSON.stringify(store, null, 2), 'utf8'); + writeFileSync(INDEX_TMP, JSON.stringify(store), 'utf8'); renameSync(INDEX_TMP, INDEX_FILE); hotCache = store; } catch { diff --git a/apps/desktop/src/main/gateway/adapter.ts b/apps/desktop/src/main/gateway/adapter.ts index 57c1252..41aa1a6 100644 --- a/apps/desktop/src/main/gateway/adapter.ts +++ b/apps/desktop/src/main/gateway/adapter.ts @@ -58,6 +58,16 @@ export function sessionKeyFromRequest(req: IncomingMessage): string { return ''; } +const MAX_CONVERSATION_ENTRIES = 500; + +/** Evict the oldest entry if the Map exceeds the given limit. */ +function evictOldest(map: Map, limit: number): void { + if (map.size > limit) { + const oldest = map.keys().next().value; + if (oldest !== undefined) map.delete(oldest); + } +} + // ─── Adapter ───────────────────────────────────────────────────────────────── export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter { @@ -130,7 +140,11 @@ export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter { const key = sessionKeyFromRequest(req); if (!key) return new ThinkingState(); let s = this.thinkingStates.get(key); - if (!s) { s = new ThinkingState(); this.thinkingStates.set(key, s); } + if (!s) { + s = new ThinkingState(); + this.thinkingStates.set(key, s); + evictOldest(this.thinkingStates, MAX_CONVERSATION_ENTRIES); + } return s; } @@ -211,6 +225,7 @@ export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter { const json = await upstream.json() as AResponse; const { output, outputText } = convertAnthropicResponse(json, thinkingState, responseId); this.conversations.set(responseId, [...allMessages, ...buildStoredMessages(json.content)]); + evictOldest(this.conversations, MAX_CONVERSATION_ENTRIES); appendUsage({ ts: Date.now(), agentId, @@ -315,6 +330,7 @@ export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter { responseId, [...allMessages, ...buildStoredMessagesFromStream(output, completedReasoningText)], ); + evictOldest(this.conversations, MAX_CONVERSATION_ENTRIES); appendUsage({ ts: Date.now(), agentId, diff --git a/apps/desktop/src/main/gateway/bridge/input.ts b/apps/desktop/src/main/gateway/bridge/input.ts index 59c0ac7..3781e68 100644 --- a/apps/desktop/src/main/gateway/bridge/input.ts +++ b/apps/desktop/src/main/gateway/bridge/input.ts @@ -234,8 +234,7 @@ export function resolveThinkingForAssistantText( */ export function stripReasoningContent(input: unknown): unknown { if (!Array.isArray(input)) return input; - const str = JSON.stringify(input); - if (!str.includes('reasoning_content')) return input; + if (!input.some((item) => item && typeof item === 'object' && 'reasoning_content' in (item as object))) return input; return input.map((item) => { if (!item || typeof item !== 'object') return item; const obj = item as Obj; diff --git a/apps/desktop/src/main/gateway/deepseek/state.ts b/apps/desktop/src/main/gateway/deepseek/state.ts index 9784503..561f12b 100644 --- a/apps/desktop/src/main/gateway/deepseek/state.ts +++ b/apps/desktop/src/main/gateway/deepseek/state.ts @@ -22,8 +22,10 @@ const DEFAULT_LIMIT = 1024; export class ThinkingState { private readonly records = new Map(); private readonly recordOrder: string[] = []; + private recordHead = 0; private readonly textRecords = new Map(); private readonly textOrder: string[] = []; + private textHead = 0; private readonly limit: number; constructor(limit = DEFAULT_LIMIT) { @@ -112,12 +114,12 @@ export class ThinkingState { // ─── Private ────────────────────────────────────────────────────────────── private prune(): void { - while (this.recordOrder.length > this.limit) { - const id = this.recordOrder.shift()!; + while (this.recordOrder.length - this.recordHead > this.limit) { + const id = this.recordOrder[this.recordHead++]; this.records.delete(id); } - while (this.textOrder.length > this.limit) { - const key = this.textOrder.shift()!; + while (this.textOrder.length - this.textHead > this.limit) { + const key = this.textOrder[this.textHead++]; this.textRecords.delete(key); } } diff --git a/apps/desktop/src/main/settings-store.ts b/apps/desktop/src/main/settings-store.ts index cf45c15..8287b36 100644 --- a/apps/desktop/src/main/settings-store.ts +++ b/apps/desktop/src/main/settings-store.ts @@ -58,12 +58,12 @@ async function persistSettings(snapshot: SettingsMap): Promise { ensureDir(); const tmp = join(TDAY_DIR, `.settings-${randomUUID()}.tmp`); try { - await writeFileAsync(tmp, JSON.stringify(snapshot, null, 2), 'utf8'); + await writeFileAsync(tmp, JSON.stringify(snapshot), 'utf8'); await renameAsync(tmp, SETTINGS_FILE); } catch { // Fallback: direct write (non-atomic but better than nothing) try { - writeFileSync(SETTINGS_FILE, JSON.stringify(snapshot, null, 2), 'utf8'); + writeFileSync(SETTINGS_FILE, JSON.stringify(snapshot), 'utf8'); } catch { /* ignore */ } } } diff --git a/apps/desktop/src/main/usage/session-cache.ts b/apps/desktop/src/main/usage/session-cache.ts index c96cf60..a412fbd 100644 --- a/apps/desktop/src/main/usage/session-cache.ts +++ b/apps/desktop/src/main/usage/session-cache.ts @@ -207,14 +207,14 @@ function statFiles(files: string[]): { fileCount: number; maxMtime: number } { return { fileCount: files.length, maxMtime }; } -/** Returns true if the agent has new or modified session files since last scan. */ -function isAgentDirty(watcher: AgentWatcher, index: SessionIndex): boolean { +/** Returns `{ dirty, files }` for the given watcher. */ +function isAgentDirty(watcher: AgentWatcher, index: SessionIndex): { dirty: boolean; files: string[] } { const entry = index.agents[watcher.agentId]; - if (!entry) return true; // never scanned yet - const files = watcher.collectFiles(); + if (!entry) return { dirty: true, files }; // never scanned yet + const { fileCount, maxMtime } = statFiles(files); - return fileCount !== entry.fileCount || maxMtime > entry.maxMtime; + return { dirty: fileCount !== entry.fileCount || maxMtime > entry.maxMtime, files }; } // ── Cache read / write ─────────────────────────────────────────────────────── @@ -292,7 +292,11 @@ async function doRefresh(): Promise { const index = loadIndex(); // Determine dirty agents using stat-only walk (no file reads yet). - const dirtyWatchers = AGENT_WATCHERS.filter((w) => isAgentDirty(w, index)); + const dirtyWatchers: Array<{ watcher: AgentWatcher; files: string[] }> = []; + for (const w of AGENT_WATCHERS) { + const { dirty, files } = isAgentDirty(w, index); + if (dirty) dirtyWatchers.push({ watcher: w, files }); + } if (dirtyWatchers.length === 0) return; // Load existing cache grouped by agentId. @@ -307,15 +311,14 @@ async function doRefresh(): Promise { } // Re-scan each dirty agent. - for (const watcher of dirtyWatchers) { + for (const { watcher, files } of dirtyWatchers) { // Yield to the event loop between agents so IPC and UI remain responsive. await new Promise((resolve) => setImmediate(resolve)); const freshRecords = watcher.scan(); agentRecords.set(watcher.agentId, freshRecords); - // Update dirty-check watermarks for this agent. - const files = watcher.collectFiles(); + // Update dirty-check watermarks for this agent (reuse already-stat'd file list). const { fileCount, maxMtime } = statFiles(files); index.agents[watcher.agentId] = { lastScanTs: Date.now(), diff --git a/apps/desktop/src/main/usage/store.ts b/apps/desktop/src/main/usage/store.ts index 1ed7516..a4298a6 100644 --- a/apps/desktop/src/main/usage/store.ts +++ b/apps/desktop/src/main/usage/store.ts @@ -35,8 +35,17 @@ function loadUserPricing(): Record { return {}; } +let _cachedPricing: Record | null = null; + function mergedPricing(): Record { - return { ...BUILTIN_PRICING, ...loadUserPricing() }; + if (_cachedPricing) return _cachedPricing; + _cachedPricing = { ...BUILTIN_PRICING, ...loadUserPricing() }; + return _cachedPricing; +} + +/** Invalidate the pricing cache (call after user updates pricing.json). */ +export function invalidatePricingCache(): void { + _cachedPricing = null; } /** Append one usage record to the JSONL log. Non-throwing. */ @@ -150,8 +159,12 @@ export function computeUsageSummary(records: UsageRecord[]): UsageSummary { const cacheHitRate = promptTokens > 0 ? totalCachedTokens / promptTokens : 0; // Compute token throughput: total tokens / span of actual records in minutes. - const tsValues = records.map((r) => r.ts); - const spanMs = tsValues.length > 1 ? Math.max(...tsValues) - Math.min(...tsValues) : 0; + let minTs = Infinity, maxTs = -Infinity; + for (const r of records) { + if (r.ts < minTs) minTs = r.ts; + if (r.ts > maxTs) maxTs = r.ts; + } + const spanMs = records.length > 1 ? maxTs - minTs : 0; const spanMin = Math.max(1, spanMs / 60_000); const throughputTokensPerMin = (totalInputTokens + totalOutputTokens) / spanMin; diff --git a/crates/tday-nativecore/src/find_image.rs b/crates/tday-nativecore/src/find_image.rs index e5b4b12..1f8ec1e 100644 --- a/crates/tday-nativecore/src/find_image.rs +++ b/crates/tday-nativecore/src/find_image.rs @@ -10,6 +10,7 @@ use image::{GrayImage, ImageReader}; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::io::Cursor; #[cfg(feature = "find_image_parallel")] @@ -85,10 +86,10 @@ pub fn find_image( None => (0, 0, ss_w, ss_h), }; - let search_view = if sr_x == 0 && sr_y == 0 && sr_w == ss_w && sr_h == ss_h { - screenshot.clone() + let search_view: Cow = if sr_x == 0 && sr_y == 0 && sr_w == ss_w && sr_h == ss_h { + Cow::Borrowed(&screenshot) } else { - image::imageops::crop_imm(&screenshot, sr_x, sr_y, sr_w, sr_h).to_image() + Cow::Owned(image::imageops::crop_imm(&screenshot, sr_x, sr_y, sr_w, sr_h).to_image()) }; // Build scale list @@ -134,8 +135,8 @@ pub fn find_image( if new_w == 0 || new_h == 0 { continue; } if new_w > sr_w || new_h > sr_h { continue; } - let scaled_t = image::imageops::resize(ref_t, new_w, new_h, image::imageops::FilterType::Lanczos3); - let scaled_m = ref_m.as_ref().map(|m| image::imageops::resize(m, new_w, new_h, image::imageops::FilterType::Lanczos3)); + let scaled_t = image::imageops::resize(ref_t, new_w, new_h, image::imageops::FilterType::Triangle); + let scaled_m = ref_m.as_ref().map(|m| image::imageops::resize(m, new_w, new_h, image::imageops::FilterType::Triangle)); // Scan with stride let match_w = sr_w - new_w; @@ -192,14 +193,28 @@ struct TemplateVals { mean: f64, norm: f64 } fn precompute_template(t: &GrayImage, mask: Option<&GrayImage>) -> TemplateVals { let (tw, th) = t.dimensions(); - let pixels: Vec = (0..th).flat_map(|y| (0..tw).map(move |x| (y, x))) - .filter(|&(y, x)| mask.map_or(true, |m| m.get_pixel(x, y).0[0] > 127u8)) - .map(|(y, x)| t.get_pixel(x, y).0[0] as f64) - .collect(); - - if pixels.is_empty() { return TemplateVals { mean: 0.0, norm: 1.0 }; } - let mean = pixels.iter().sum::() / pixels.len() as f64; - let norm = pixels.iter().map(|&v| (v - mean).powi(2)).sum::().sqrt(); + + // First pass: compute mean + let mut sum = 0.0f64; + let mut count = 0usize; + for y in 0..th { for x in 0..tw { + if mask.map_or(true, |m| m.get_pixel(x, y).0[0] > 127u8) { + sum += t.get_pixel(x, y).0[0] as f64; + count += 1; + } + }} + if count == 0 { return TemplateVals { mean: 0.0, norm: 1.0 }; } + let mean = sum / count as f64; + + // Second pass: compute norm + let mut norm_sq = 0.0f64; + for y in 0..th { for x in 0..tw { + if mask.map_or(true, |m| m.get_pixel(x, y).0[0] > 127u8) { + let v = t.get_pixel(x, y).0[0] as f64 - mean; + norm_sq += v * v; + } + }} + let norm = norm_sq.sqrt(); TemplateVals { mean, norm: if norm < 1e-9 { 1.0 } else { norm } } } diff --git a/crates/tday-nativecore/src/handlers/screenshot.rs b/crates/tday-nativecore/src/handlers/screenshot.rs index 3c89dfe..197225c 100644 --- a/crates/tday-nativecore/src/handlers/screenshot.rs +++ b/crates/tday-nativecore/src/handlers/screenshot.rs @@ -24,40 +24,35 @@ pub async fn handle_take_screenshot( ) -> Result { let window_id: Option = params.get("window_id").and_then(|v| v.as_u64()).map(|v| v as u32); - let (jpeg_bytes, origin_x, origin_y, scale, pw, ph) = tokio::task::spawn_blocking(move || { + let (jpeg_bytes, png_data, origin_x, origin_y, scale, pw, ph) = tokio::task::spawn_blocking(move || { match window_id { - Some(wid) => platform::capture_window_cg_jpeg(wid), + Some(wid) => { + let (jpeg, ox, oy, sc, pw, ph) = platform::capture_window_cg_jpeg(wid)?; + // Decode JPEG once to get PNG for the cache + let img = image::load_from_memory(&jpeg) + .map_err(|e| format!("jpeg→png: {e}"))?; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("png encode: {e}"))?; + Ok::<_, String>((jpeg, png, ox, oy, sc, pw, ph)) + } None => { - // Full screen JPEG + // Full screen: capture gives us PNG directly let ss = platform::capture_screen() .map_err(|e| format!("capture_screen: {e}"))?; - // encode screen capture as jpeg - let mut out = Vec::new(); + // Encode JPEG for sending to client let img = image::load_from_memory(&ss.png_data) .map_err(|e| format!("decode: {e}"))?; - img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Jpeg) + let mut jpeg = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg) .map_err(|e| format!("jpeg: {e}"))?; - Ok::<_, String>((out, ss.origin_x, ss.origin_y, ss.scale_factor, + Ok::<_, String>((jpeg, ss.png_data, ss.origin_x, ss.origin_y, ss.scale_factor, ss.pixel_width, ss.pixel_height)) } } }).await.map_err(|e| DevToolsError::Other(format!("task: {e}")))? .map_err(DevToolsError::Screenshot)?; - // Store PNG copy in cache (re-decode jpeg → png for find_image) - let png_data = tokio::task::spawn_blocking({ - let jb = jpeg_bytes.clone(); - move || { - let img = image::load_from_memory(&jb) - .map_err(|e| format!("jpeg→png: {e}"))?; - let mut png = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .map_err(|e| format!("png encode: {e}"))?; - Ok::<_, String>(png) - } - }).await.map_err(|e| DevToolsError::Other(format!("task: {e}")))? - .map_err(DevToolsError::Screenshot)?; - let meta = CacheMeta { origin_x, origin_y, scale, window_id, pixel_width: pw, pixel_height: ph }; let ss_id = ss_cache.write().await.store(png_data, meta); diff --git a/crates/tday-nativecore/src/platform/linux/mod.rs b/crates/tday-nativecore/src/platform/linux/mod.rs index 51082f2..ced48a0 100644 --- a/crates/tday-nativecore/src/platform/linux/mod.rs +++ b/crates/tday-nativecore/src/platform/linux/mod.rs @@ -28,6 +28,6 @@ pub use screenshot::{capture_region, capture_screen, capture_window, capture_win pub use atspi::{ AXRef, ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, - take_snapshot, ax_find_elements, ax_get_focused, + take_snapshot, ax_find_elements, ax_get_focused, ElementInfo, }; pub use window::{find_window_by_id_direct, find_windows_by_app, list_windows}; diff --git a/crates/tday-nativecore/src/platform/macos/mod.rs b/crates/tday-nativecore/src/platform/macos/mod.rs index 3dd1e08..c012224 100644 --- a/crates/tday-nativecore/src/platform/macos/mod.rs +++ b/crates/tday-nativecore/src/platform/macos/mod.rs @@ -14,7 +14,7 @@ pub mod window; pub use app::*; pub use ax::{AXRef, ax_click, ax_perform_action, ax_select, ax_set_value, element_at_point, find_text as ax_find_text, frontmost_pid, pid_for_window, raise_windows, take_snapshot, - resize_window_by_pid, ax_find_elements, ax_get_focused}; + resize_window_by_pid, ax_find_elements, ax_get_focused, ElementInfo}; pub use display::{backing_scale_for_point, get_displays, get_main_display, screenshot_px_to_screen}; pub use input::{check_accessibility, click, drag, get_cursor_position, move_mouse, press_key, scroll, type_text, MouseButton}; diff --git a/crates/tday-nativecore/src/platform/mod.rs b/crates/tday-nativecore/src/platform/mod.rs index 4d3d1fb..23f8851 100644 --- a/crates/tday-nativecore/src/platform/mod.rs +++ b/crates/tday-nativecore/src/platform/mod.rs @@ -27,7 +27,7 @@ pub use macos::{ // AX ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, take_snapshot, AXRef, - ax_find_elements, ax_get_focused, + ax_find_elements, ax_get_focused, ElementInfo, // Display backing_scale_for_point, get_displays, get_main_display, screenshot_px_to_screen, // Input @@ -55,7 +55,7 @@ pub use windows::{ // AX ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, take_snapshot, AXRef, - ax_find_elements, ax_get_focused, + ax_find_elements, ax_get_focused, ElementInfo, // Display backing_scale_for_point, get_displays, get_main_display, screenshot_px_to_screen, // Input @@ -83,7 +83,7 @@ pub use linux::{ // AX ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, take_snapshot, AXRef, - ax_find_elements, ax_get_focused, + ax_find_elements, ax_get_focused, ElementInfo, // Display backing_scale_for_point, get_displays, get_main_display, screenshot_px_to_screen, // Input diff --git a/crates/tday-nativecore/src/platform/windows/mod.rs b/crates/tday-nativecore/src/platform/windows/mod.rs index 15e581d..691334f 100644 --- a/crates/tday-nativecore/src/platform/windows/mod.rs +++ b/crates/tday-nativecore/src/platform/windows/mod.rs @@ -28,7 +28,7 @@ pub use screenshot::{capture_region, capture_screen, capture_window, capture_win pub use uia::{ AXRef, ax_click, ax_find_text, ax_perform_action, ax_select, ax_set_value, element_at_point, frontmost_pid, pid_for_window, raise_windows, resize_window_by_pid, - take_snapshot, ax_find_elements, ax_get_focused, + take_snapshot, ax_find_elements, ax_get_focused, ElementInfo, }; pub use window::{find_window_by_id_direct, find_windows_by_app, list_windows}; diff --git a/crates/tday-nativecore/src/session/image_cache.rs b/crates/tday-nativecore/src/session/image_cache.rs index 04d7fda..17ef4ce 100644 --- a/crates/tday-nativecore/src/session/image_cache.rs +++ b/crates/tday-nativecore/src/session/image_cache.rs @@ -4,7 +4,7 @@ /// LRU image cache for find_image template images (load_image tool). -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; const MAX_ENTRIES: usize = 20; @@ -17,6 +17,7 @@ pub struct CachedImage { #[derive(Default)] pub struct ImageCache { entries: VecDeque, + index: HashMap, // id → position in entries counter: u64, } @@ -26,21 +27,30 @@ impl ImageCache { self.counter += 1; let id = format!("img_{}", self.counter); if self.entries.len() >= MAX_ENTRIES { - self.entries.pop_front(); + if let Some(front) = self.entries.pop_front() { + self.index.remove(&front.id); + // Shift all indices down by 1 + for v in self.index.values_mut() { *v -= 1; } + } } + self.index.insert(id.clone(), self.entries.len()); self.entries.push_back(CachedImage { id: id.clone(), png_data }); id } /// Get (LRU bump via move-to-back). pub fn get(&mut self, id: &str) -> Option { - if let Some(pos) = self.entries.iter().position(|e| e.id == id) { - let entry = self.entries.remove(pos)?; - let cloned = entry.clone(); - self.entries.push_back(entry); - Some(cloned) - } else { - None + let pos = *self.index.get(id)?; + let entry = self.entries.remove(pos)?; + let cloned = entry.clone(); + // Shift all indices that were after `pos` down by 1 + for v in self.index.values_mut() { + if *v > pos { *v -= 1; } } + // Move to back + let new_pos = self.entries.len(); + self.index.insert(entry.id.clone(), new_pos); + self.entries.push_back(entry); + Some(cloned) } } diff --git a/crates/tday-nativecore/src/session/screenshot_cache.rs b/crates/tday-nativecore/src/session/screenshot_cache.rs index cc9e16b..d70acc1 100644 --- a/crates/tday-nativecore/src/session/screenshot_cache.rs +++ b/crates/tday-nativecore/src/session/screenshot_cache.rs @@ -4,7 +4,7 @@ /// LRU screenshot cache for find_image and coordinate re-use. -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; const MAX_ENTRIES: usize = 10; @@ -29,6 +29,7 @@ pub struct CachedScreenshot { #[derive(Default)] pub struct ScreenshotCache { entries: VecDeque, + index: HashMap, // id → position in entries counter: u64, } @@ -38,14 +39,20 @@ impl ScreenshotCache { self.counter += 1; let id = format!("ss_{}", self.counter); if self.entries.len() >= MAX_ENTRIES { - self.entries.pop_front(); + if let Some(front) = self.entries.pop_front() { + self.index.remove(&front.id); + // Shift all indices down by 1 + for v in self.index.values_mut() { *v -= 1; } + } } + self.index.insert(id.clone(), self.entries.len()); self.entries.push_back(CachedScreenshot { id: id.clone(), png_data, metadata: meta }); id } /// Peek (no LRU bump) — used by find_image which clones the data immediately. pub fn peek(&self, id: &str) -> Option<&CachedScreenshot> { - self.entries.iter().find(|e| e.id == id) + let pos = self.index.get(id)?; + self.entries.get(*pos) } } diff --git a/crates/tday-nativecore/src/tracking/hover_tracker.rs b/crates/tday-nativecore/src/tracking/hover_tracker.rs index a70817e..2710145 100644 --- a/crates/tday-nativecore/src/tracking/hover_tracker.rs +++ b/crates/tday-nativecore/src/tracking/hover_tracker.rs @@ -262,8 +262,7 @@ fn element_at_point_for_hover( #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] { let info = crate::platform::element_at_point(x, y, app_name)?; - let value = serde_json::to_value(&info).map_err(|e| e.to_string())?; - Ok(parse_hover_element(&value)) + Ok(hover_element_from_info(info)) } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { @@ -272,6 +271,18 @@ fn element_at_point_for_hover( } } +#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] +fn hover_element_from_info(info: crate::platform::ElementInfo) -> HoverElement { + HoverElement { + name: info.name.map(|s| truncate_field(&s)), + role: info.role.map(|s| truncate_field(&s)), + label: info.description.map(|s| truncate_field(&s)), + bounds: info.bounds.map(|r| ElementBounds { x: r.x, y: r.y, width: r.width, height: r.height }), + app_name: info.app_name.map(|s| truncate_field(&s)), + pid: Some(info.pid), + } +} + #[cfg(test)] mod tests { use super::*; From b154578fcfd2493adf0c87c9c6e7387ed1064caf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 12:53:49 +0000 Subject: [PATCH 2/2] perf: address code review feedback on cache implementations - Fix evictOldest() to use while loop instead of if (adapter.ts) - Fix UTF-8 boundary handling in readFirstLines() using StringDecoder (scanners.ts) - Rewrite ImageCache to use HashMap + VecDeque without position indices (image_cache.rs) - Rewrite ScreenshotCache to use HashMap + VecDeque without position indices (screenshot_cache.rs) Agent-Logs-Url: https://github.com/unbug/tday/sessions/89e1f6c9-ee1f-48d4-a8ea-5684c1298897 Co-authored-by: unbug <799578+unbug@users.noreply.github.com> --- .../src/main/agent-history/scanners.ts | 6 ++- apps/desktop/src/main/gateway/adapter.ts | 2 +- .../src/session/image_cache.rs | 44 +++++++++---------- .../src/session/screenshot_cache.rs | 29 ++++++------ 4 files changed, 44 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/main/agent-history/scanners.ts b/apps/desktop/src/main/agent-history/scanners.ts index d9b62c2..55d036e 100644 --- a/apps/desktop/src/main/agent-history/scanners.ts +++ b/apps/desktop/src/main/agent-history/scanners.ts @@ -26,6 +26,7 @@ import { existsSync, readdirSync, statSync, openSync, readSync, closeSync } from import { join } from 'node:path'; import { homedir } from 'node:os'; import { execFileSync } from 'node:child_process'; +import { StringDecoder } from 'node:string_decoder'; import type { AgentHistoryEntry } from '@tday/shared'; /** Maximum lines scanned per file to extract the title. */ @@ -33,6 +34,7 @@ const MAX_TITLE_SCAN_LINES = 150; /** Read at most MAX_TITLE_SCAN_LINES lines from a file without loading it fully. * Reads up to 64 KB synchronously — sufficient for 150 JSONL lines in practice. + * Uses StringDecoder to correctly handle multi-byte UTF-8 sequences at the chunk boundary. */ function readFirstLines(filePath: string): string[] { const CHUNK_BYTES = 65536; // 64 KB @@ -41,7 +43,9 @@ function readFirstLines(filePath: string): string[] { fd = openSync(filePath, 'r'); const buf = Buffer.alloc(CHUNK_BYTES); const bytesRead = readSync(fd, buf, 0, CHUNK_BYTES, 0); - const text = buf.slice(0, bytesRead).toString('utf8'); + const decoder = new StringDecoder('utf8'); + // write() handles any incomplete multi-byte sequence at the boundary + const text = decoder.write(buf.slice(0, bytesRead)); const lines = text.split('\n'); return lines.length > MAX_TITLE_SCAN_LINES ? lines.slice(0, MAX_TITLE_SCAN_LINES) : lines; } catch { diff --git a/apps/desktop/src/main/gateway/adapter.ts b/apps/desktop/src/main/gateway/adapter.ts index 41aa1a6..fdb7124 100644 --- a/apps/desktop/src/main/gateway/adapter.ts +++ b/apps/desktop/src/main/gateway/adapter.ts @@ -62,7 +62,7 @@ const MAX_CONVERSATION_ENTRIES = 500; /** Evict the oldest entry if the Map exceeds the given limit. */ function evictOldest(map: Map, limit: number): void { - if (map.size > limit) { + while (map.size > limit) { const oldest = map.keys().next().value; if (oldest !== undefined) map.delete(oldest); } diff --git a/crates/tday-nativecore/src/session/image_cache.rs b/crates/tday-nativecore/src/session/image_cache.rs index 17ef4ce..df2585a 100644 --- a/crates/tday-nativecore/src/session/image_cache.rs +++ b/crates/tday-nativecore/src/session/image_cache.rs @@ -14,43 +14,43 @@ pub struct CachedImage { pub png_data: Vec, } -#[derive(Default)] +/// LRU cache: `map` provides O(1) lookup by id; `order` is a VecDeque of ids +/// in insertion/access order (front = oldest, back = newest). pub struct ImageCache { - entries: VecDeque, - index: HashMap, // id → position in entries + map: HashMap>, + order: VecDeque, counter: u64, } +impl Default for ImageCache { + fn default() -> Self { + Self { map: HashMap::new(), order: VecDeque::new(), counter: 0 } + } +} + impl ImageCache { /// Store an image and return its generated ID. pub fn store(&mut self, png_data: Vec) -> String { self.counter += 1; let id = format!("img_{}", self.counter); - if self.entries.len() >= MAX_ENTRIES { - if let Some(front) = self.entries.pop_front() { - self.index.remove(&front.id); - // Shift all indices down by 1 - for v in self.index.values_mut() { *v -= 1; } + if self.map.len() >= MAX_ENTRIES { + if let Some(evicted) = self.order.pop_front() { + self.map.remove(&evicted); } } - self.index.insert(id.clone(), self.entries.len()); - self.entries.push_back(CachedImage { id: id.clone(), png_data }); + self.map.insert(id.clone(), png_data); + self.order.push_back(id.clone()); id } - /// Get (LRU bump via move-to-back). + /// Get with LRU bump (move to back of order queue). pub fn get(&mut self, id: &str) -> Option { - let pos = *self.index.get(id)?; - let entry = self.entries.remove(pos)?; - let cloned = entry.clone(); - // Shift all indices that were after `pos` down by 1 - for v in self.index.values_mut() { - if *v > pos { *v -= 1; } + let png_data = self.map.get(id)?.clone(); + // Move to back: remove from current position (O(n)) and push to back. + if let Some(pos) = self.order.iter().position(|k| k == id) { + self.order.remove(pos); } - // Move to back - let new_pos = self.entries.len(); - self.index.insert(entry.id.clone(), new_pos); - self.entries.push_back(entry); - Some(cloned) + self.order.push_back(id.to_string()); + Some(CachedImage { id: id.to_string(), png_data }) } } diff --git a/crates/tday-nativecore/src/session/screenshot_cache.rs b/crates/tday-nativecore/src/session/screenshot_cache.rs index d70acc1..2b61349 100644 --- a/crates/tday-nativecore/src/session/screenshot_cache.rs +++ b/crates/tday-nativecore/src/session/screenshot_cache.rs @@ -26,33 +26,36 @@ pub struct CachedScreenshot { pub metadata: ScreenshotMeta, } -#[derive(Default)] +/// Cache: `map` provides O(1) lookup by id; `order` tracks eviction order. pub struct ScreenshotCache { - entries: VecDeque, - index: HashMap, // id → position in entries + map: HashMap, + order: VecDeque, counter: u64, } +impl Default for ScreenshotCache { + fn default() -> Self { + Self { map: HashMap::new(), order: VecDeque::new(), counter: 0 } + } +} + impl ScreenshotCache { /// Store a screenshot and return its generated ID. pub fn store(&mut self, png_data: Vec, meta: ScreenshotMeta) -> String { self.counter += 1; let id = format!("ss_{}", self.counter); - if self.entries.len() >= MAX_ENTRIES { - if let Some(front) = self.entries.pop_front() { - self.index.remove(&front.id); - // Shift all indices down by 1 - for v in self.index.values_mut() { *v -= 1; } + if self.map.len() >= MAX_ENTRIES { + if let Some(evicted) = self.order.pop_front() { + self.map.remove(&evicted); } } - self.index.insert(id.clone(), self.entries.len()); - self.entries.push_back(CachedScreenshot { id: id.clone(), png_data, metadata: meta }); + self.order.push_back(id.clone()); + self.map.insert(id.clone(), CachedScreenshot { id: id.clone(), png_data, metadata: meta }); id } - /// Peek (no LRU bump) — used by find_image which clones the data immediately. + /// Peek (no LRU bump) — O(1) via HashMap. pub fn peek(&self, id: &str) -> Option<&CachedScreenshot> { - let pos = self.index.get(id)?; - self.entries.get(*pos) + self.map.get(id) } }