Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/desktop/src/main/agent-history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -159,7 +159,7 @@ function runRefresh(): Promise<void> {
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,
Expand Down
46 changes: 33 additions & 13 deletions apps/desktop/src/main/agent-history/scanners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,39 @@
* 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';
import { StringDecoder } from 'node:string_decoder';
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.
* 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
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 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 {
return [];
} finally {
if (fd !== null) { try { closeSync(fd); } catch { /* ignore */ } }
}
}

// ── Helpers ──────────────────────────────────────────────────────────────────

function truncateTitle(s: string, max = 80): string {
Expand Down Expand Up @@ -121,10 +145,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 {
Expand Down Expand Up @@ -204,10 +227,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 {
Expand Down Expand Up @@ -381,10 +403,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 {
Expand Down Expand Up @@ -492,10 +513,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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/agent-history/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/main/gateway/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<V>(map: Map<string, V>, limit: number): void {
while (map.size > limit) {
const oldest = map.keys().next().value;
if (oldest !== undefined) map.delete(oldest);
}
}

// ─── Adapter ─────────────────────────────────────────────────────────────────

export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/src/main/gateway/bridge/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/main/gateway/deepseek/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ const DEFAULT_LIMIT = 1024;
export class ThinkingState {
private readonly records = new Map<string, ThinkingEntry>();
private readonly recordOrder: string[] = [];
private recordHead = 0;
private readonly textRecords = new Map<string, ThinkingEntry>();
private readonly textOrder: string[] = [];
private textHead = 0;
private readonly limit: number;

constructor(limit = DEFAULT_LIMIT) {
Expand Down Expand Up @@ -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);
}
}
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ async function persistSettings(snapshot: SettingsMap): Promise<void> {
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 */ }
}
}
Expand Down
21 changes: 12 additions & 9 deletions apps/desktop/src/main/usage/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -292,7 +292,11 @@ async function doRefresh(): Promise<void> {
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.
Expand All @@ -307,15 +311,14 @@ async function doRefresh(): Promise<void> {
}

// 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<void>((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(),
Expand Down
19 changes: 16 additions & 3 deletions apps/desktop/src/main/usage/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,17 @@ function loadUserPricing(): Record<string, ModelPricing> {
return {};
}

let _cachedPricing: Record<string, ModelPricing> | null = null;

function mergedPricing(): Record<string, ModelPricing> {
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. */
Expand Down Expand Up @@ -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;

Expand Down
41 changes: 28 additions & 13 deletions crates/tday-nativecore/src/find_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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<GrayImage> = 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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<f64> = (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::<f64>() / pixels.len() as f64;
let norm = pixels.iter().map(|&v| (v - mean).powi(2)).sum::<f64>().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 } }
}

Expand Down
Loading