diff --git a/.env.example b/.env.example index 462a595..40d19b0 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,8 @@ # OPENCLAW_BASE_URL=http://127.0.0.1:18789 # OPENCLAW_API_KEY= -# OpenRouter (voice messages, Ask mode, smart titles) is configured in the app -# under Settings → "Voice & Ask", not here — the key is stored in the local DB. +# OpenRouter (voice messages, smart titles) is configured in the app +# under Settings → "Voice", not here — the key is stored in the local DB. PORT=3000 # Default (when unset): ~/.iclaw/data/iclaw.db diff --git a/README.md b/README.md index 07906c4..f142ce3 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ ![iClaw screenshot](./docs/readme-screenshot.png) +## Demo + +https://github.com/user-attachments/assets/14a844e3-c1bb-4baf-98af-f52efefe77aa + +_Pick a mode, then watch iClaw inspect an untrusted shell script inside an isolated sandbox — ~50s, no sound._ + ## Install Requires [Node.js 20+](https://nodejs.org) @@ -28,7 +34,13 @@ Press **`g`** in the terminal to open the UI in your browser ## Star history -[![Star History Chart](https://api.star-history.com/svg?repos=iClawApp/iClaw&type=Date)](https://www.star-history.com/#iClawApp/iClaw&Date) + + + + + Star History Chart + + ## Chat modes @@ -41,6 +53,45 @@ Press **`g`** in the terminal to open the UI in your browser The three runtime modes need **Docker** running and an **OpenRouter key** (Settings); without either the composer falls back to Full Power. Architecture: [AGENTS.md](AGENTS.md). +## Why iClaw + +Today's agents run **as you** — your shell, your files, your credentials, your +cloud bill. That's exactly where it hurts: + +**🔒 "It ran `rm -rf ~/` and wiped my Mac."** A real [1,500-upvote thread](https://www.docker.com/blog/coding-agent-horror-stories-the-rm-rf-incident/) — +the agent runs on your filesystem with nothing between the model and the shell. +iClaw runs untrusted code in a Docker sandbox where the command *literally cannot +see the rest of your computer*, and touches real files only in folders you pick. + +**💸 $20 gone in a day, then rate-limited mid-task.** One vendor, one price they +can double overnight. iClaw is model-agnostic — cheap DeepSeek + Gemini out of +the box, or bring GPT / Claude / anything via OpenRouter. No lock-in, no per-seat plan. + +**📁 It forgot what it was doing and mixed two projects up.** iClaw seals each +project off — its own chats, memory and folders. Nothing bleeds between them, and +nothing leaves your machine: it's local and loopback-only. + +**⚡ No setup marathon.** `npx`, paste one OpenRouter key, and you're in — iClaw +pre-pulls the sandbox in the background and hands you one-click Install buttons +for Docker and OpenClaw. No compose files, no config to hand-edit. + +**✨ A clean UI, not a cockpit.** A minimalist, ChatGPT-style surface — +light/dark, a tight design system, nothing you don't need. + +| | **iClaw** | OpenClaw | Hermes | Claude Code | +|---|---|---|---|---| +| **Surface** | Local browser GUI | CLI | CLI + chat apps | Terminal | +| **Setup pain** | 💀💀 | 💀💀💀💀💀 | 💀💀💀💀 | 💀 | +| **Projects sealed off** — own chats, memory, folders | ✅ | ❌ | ❌ | ~ per-repo file | +| **Pick your trust level per chat** | ✅ 4 modes | ❌ | ~ | ~ | +| **Untrusted code can't escape its folders** | ✅ Docker | ❌ | ~ | ~ | +| **Any model · no lock-in** | ✅ | ✅ | ✅ | ❌ Anthropic only | +| **Encrypted, self-destructing share links** | ✅ | ❌ | ❌ | ❌ | + +✅ yes · ~ partial · ❌ no · 💀 = setup hassle (fewer = easier) + +**→ Only iClaw gives you sealed projects, a per-folder sandbox, and a GUI — with any model you want.** + ## More - **Encrypted chat sharing** — hit **Share** in any chat for an end-to-end encrypted link (AES-256-GCM; password, burn-after-read, TTL). Powered by [iClaw-cloud](https://github.com/iClawApp/iClaw-cloud). diff --git a/package-lock.json b/package-lock.json index 9b17974..7a27c7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@iclawapp/iclaw", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@iclawapp/iclaw", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index e26bfa6..fa47159 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@iclawapp/iclaw", - "version": "0.3.0", + "version": "0.4.0", "description": "Local web UI for OpenClaw Gateway", "license": "MIT", "homepage": "https://github.com/iClawApp/iClaw", diff --git a/packages/iclaw-runtime/src/agent/loop.ts b/packages/iclaw-runtime/src/agent/loop.ts index 47288f2..b7fd726 100644 --- a/packages/iclaw-runtime/src/agent/loop.ts +++ b/packages/iclaw-runtime/src/agent/loop.ts @@ -9,6 +9,7 @@ import OpenAI from 'openai'; import { TOOL_DEFINITIONS, WEB_FETCH_TOOL, WEB_SEARCH_TOOL, READ_SUMMARY_TOOL, ANALYZE_LINK_TOOL, SHOW_IMAGE_TOOL, executeTool, normalizeFetchUrl, normalizeSearchQuery, type ToolContext, type ToolName, type SavingsNote, type ImageRef } from './tools.js'; import { SOCIAL_SEARCH_TOOL } from './social.js'; import { dumpPrompt, newTurnId } from './prompt-dump.js'; +import { resolveTurnModel } from './model-capabilities.js'; export interface AgentOptions { apiKey: string; @@ -59,7 +60,7 @@ export type Message = OpenAI.Chat.ChatCompletionMessageParam; const MAX_ROUNDS = Math.max(1, Number(process.env.ICLAW_MAX_ROUNDS) || 40); /** Turn a provider/SDK error into a concise, user-facing message. */ -function describeApiError(err: unknown): string { +export function describeApiError(err: unknown): string { const e = err as { status?: number; code?: number | string; @@ -72,7 +73,16 @@ function describeApiError(err: unknown): string { return 'Rate limit reached on the model provider (429). Wait a few seconds and try again, ' + 'or switch to a model with higher limits (ICLAW_MODEL).'; } - return e?.message || String(err); + const msg = e?.message || String(err); + // Non-vision model handed an image → OpenRouter 404 "No endpoints found that + // support image input". The vision gate (resolveTurnModel) pre-empts this for + // confirmed text-only models; this is the safety net for the unknown-capability + // path (registry lookup failed, so we left the model as-is). + if (code === 404 && /image input|support image/i.test(msg)) { + return `This model can't accept images. Set ICLAW_VISION_MODEL to a vision-capable ` + + `OpenRouter model (e.g. google/gemini-2.5-flash) to use photos.`; + } + return msg; } // Mid-turn compaction budget: once the in-flight message array passes this many @@ -358,6 +368,21 @@ export async function* runAgentTurn( userMsg, ]; + // Vision gate: an image-bearing turn on a text-only model would 404 at + // OpenRouter ("No endpoints found that support image input"). Route it to a + // vision-capable fallback (ICLAW_VISION_MODEL) when needed; text turns are + // untouched and pay no lookup. See ./model-capabilities.ts. + const modelDecision = await resolveTurnModel({ + model: opts.model, + apiKey: opts.apiKey, + hasImages: !!opts.images?.length, + }); + if (modelDecision.error) { + yield { type: 'error', message: modelDecision.error }; + return; + } + const effectiveModel = modelDecision.model; + // Token usage across all rounds (dev-mode display). `turnCached` = how many // prompt tokens were served from the provider's prefix cache. let turnTokens = 0; @@ -388,13 +413,13 @@ export async function* runAgentTurn( const toolCallBuffers: Record = {}; // Dev mode: persist exactly what we're about to send (incl. tool schemas). - dumpPrompt({ turnId: dumpTurnId, mode: dumpMode, model: opts.model, round, messages, tools }); + dumpPrompt({ turnId: dumpTurnId, mode: dumpMode, model: effectiveModel, round, messages, tools }); let stream: AsyncIterable; try { stream = await client.chat.completions.create( { - model: opts.model, + model: effectiveModel, messages: withPromptCaching(messages), tools: tools as unknown as OpenAI.Chat.ChatCompletionTool[], // Normally 'auto'; after the dead-round breaker trips we send 'none' so diff --git a/packages/iclaw-runtime/src/agent/model-capabilities.ts b/packages/iclaw-runtime/src/agent/model-capabilities.ts new file mode 100644 index 0000000..c94f353 --- /dev/null +++ b/packages/iclaw-runtime/src/agent/model-capabilities.ts @@ -0,0 +1,163 @@ +/** + * Vision-capability gate for inbound user images. + * + * iClaw is OpenRouter-only, so we mirror OpenClaw's approach: read the model's + * `architecture.input_modalities` from OpenRouter's /models registry to learn + * whether it can accept image input. When the configured model is text-only and + * a turn carries images, we transparently route THAT turn to a vision-capable + * fallback (`ICLAW_VISION_MODEL`); text turns stay on the cheap default and pay + * no lookup at all. + * + * Without this gate both agent loops (Work/Incognito in loop.ts, Secure in + * secure-runner.ts) send `image_url` blocks to whatever `ICLAW_MODEL` is, and a + * text-only model (e.g. the default `deepseek/deepseek-v4-flash`) makes + * OpenRouter return `404 No endpoints found that support image input`. + * + * Capability source mirrors OpenClaw (same OpenRouter field); Hermes does the + * same check off models.dev because it's multi-provider — we don't need that + * extra dependency since every model we call is an OpenRouter slug. + */ + +const MODELS_URL = 'https://openrouter.ai/api/v1/models'; + +/** In-memory capability cache TTL. Mirrors Hermes' 1h models.dev cache. */ +const CACHE_TTL_MS = Math.max(60_000, Number(process.env.ICLAW_MODELS_CACHE_TTL_MS) || 60 * 60 * 1000); + +/** + * Default vision fallback. Gemini Flash is cheap, supports tool calling, and is + * already the host-side model for ask/title/stt — so an image turn behaves the + * same as a text turn, just on a model that can see. + */ +export const DEFAULT_VISION_MODEL = 'google/gemini-2.5-flash'; + +interface CacheEntry { at: number; vision: Set } +let cache: CacheEntry | null = null; +/** De-dupe concurrent fetches: many image turns can race the first lookup. */ +let inflight: Promise> | null = null; + +interface ORModel { + id?: string; + architecture?: { input_modalities?: unknown; modality?: unknown }; +} + +/** + * A model accepts images when `"image"` is in its `input_modalities`, or when + * its `modality` string ("text+image->text") names image on the INPUT side. + * This is exactly the derivation OpenClaw uses on the same OpenRouter field. + */ +function modelAcceptsImage(m: ORModel): boolean { + const arch = m.architecture ?? {}; + const mods = arch.input_modalities; + if (Array.isArray(mods) && mods.includes('image')) return true; + const modality = typeof arch.modality === 'string' ? arch.modality : ''; + // "->" — only the part before "->" is the input side. + return modality.split('->')[0]?.includes('image') ?? false; +} + +async function fetchVisionModels(apiKey: string): Promise> { + const res = await fetch(MODELS_URL, { headers: { authorization: `Bearer ${apiKey}` } }); + if (!res.ok) throw new Error(`OpenRouter /models returned ${res.status}`); + const json = (await res.json()) as { data?: ORModel[] }; + const vision = new Set(); + for (const m of json.data ?? []) { + if (typeof m.id === 'string' && modelAcceptsImage(m)) vision.add(m.id); + } + return vision; +} + +/** + * True / false when we can resolve the model's image capability; null when it's + * genuinely unknown (network/HTTP failure with no cache to fall back on). + * + * A manual allowlist wins first: `ICLAW_VISION_MODELS` (comma-separated slugs) + * lets a user declare a custom/local model vision-capable without it being in + * OpenRouter's registry — same escape hatch Hermes exposes via + * `model.supports_vision`. + */ +export async function isVisionModel(model: string, apiKey: string): Promise { + if (!model) return null; + const allow = (process.env.ICLAW_VISION_MODELS ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (allow.includes(model)) return true; + + if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.vision.has(model); + + if (!inflight) { + inflight = fetchVisionModels(apiKey) + .then((vision) => { + cache = { at: Date.now(), vision }; + return vision; + }) + .finally(() => { + inflight = null; + }); + } + try { + const vision = await inflight; + return vision.has(model); + } catch { + // Serve a stale cache on failure; otherwise we truly don't know. + return cache ? cache.vision.has(model) : null; + } +} + +export interface TurnModelDecision { + /** Model to actually send for THIS turn (possibly the vision fallback). */ + model: string; + /** Set when the turn carries images but no vision-capable model is available. */ + error?: string; + /** True when we routed off the configured model to the vision fallback. */ + switched?: boolean; +} + +/** + * Decide which model runs THIS turn. Only image-bearing turns pay the capability + * lookup; a text turn returns the configured model untouched (zero overhead, no + * network call). + * + * - configured model sees images → use it + * - capability unknown (lookup failed) → use it (don't silently swap a + * possibly-vision model; a 404, if + * it comes, is explained by + * describeApiError) + * - confirmed text-only + images present → route to ICLAW_VISION_MODEL + * (verified vision-capable), or + * return a clear error if none. + */ +export async function resolveTurnModel(args: { + model: string; + apiKey: string; + hasImages: boolean; +}): Promise { + const { model, apiKey, hasImages } = args; + if (!hasImages) return { model }; + + const mainSeesImages = await isVisionModel(model, apiKey); + if (mainSeesImages !== false) return { model }; // true or unknown → leave as-is + + const visionModel = (process.env.ICLAW_VISION_MODEL ?? '').trim() || DEFAULT_VISION_MODEL; + if (!visionModel || visionModel === model) { + return { + model, + error: + `The current model (${model}) can't accept images. Set ICLAW_VISION_MODEL to a ` + + `vision-capable OpenRouter model (e.g. ${DEFAULT_VISION_MODEL}) to use photos, or remove the attachment.`, + }; + } + + // Guard against a misconfigured fallback that also can't see (one cached + // lookup — same /models set). Unknown/true both allow the switch. + const fallbackSeesImages = await isVisionModel(visionModel, apiKey); + if (fallbackSeesImages === false) { + return { + model, + error: + `ICLAW_VISION_MODEL (${visionModel}) also can't accept images — set it to a ` + + `vision-capable OpenRouter model like ${DEFAULT_VISION_MODEL}.`, + }; + } + + return { model: visionModel, switched: true }; +} diff --git a/packages/iclaw-runtime/src/agent/social-fetch.mjs b/packages/iclaw-runtime/src/agent/social-fetch.mjs index 17d3b43..05d4e56 100644 --- a/packages/iclaw-runtime/src/agent/social-fetch.mjs +++ b/packages/iclaw-runtime/src/agent/social-fetch.mjs @@ -14,7 +14,8 @@ // mvanhorn/last30days-skill (MIT) — the .json API is dead (403), these endpoints // are the keyless state of the art. Post upvote score is NOT recoverable keyless. // -// Env in: SOCIAL_QUERY, SOCIAL_SOURCES(csv), SOCIAL_LIMIT, SOCIAL_TIME, SOCIAL_WITH_COMMENTS +// Env in: SOCIAL_QUERY, SOCIAL_SOURCES(csv), SOCIAL_LIMIT, SOCIAL_TIME, SOCIAL_WITH_COMMENTS, +// SOCIAL_URL (thread mode: fetch ONE Reddit post + its full nested comment tree). // Std out: "__SOCIAL_JSON__\n" + JSON {query, counts, errors, results[]} const UA = @@ -51,8 +52,38 @@ function relevance(query, title) { } // ── Reddit ──────────────────────────────────────────────────────────────────── -async function shredditComments(sub, postId, max = 3) { - const url = `https://www.reddit.com/svc/shreddit/comments/r/${sub}/t3_${postId}`; + +// The next comment's rtjson anchor — bounds a comment's body slice so a parent +// never swallows its nested replies' text (ported from last30days-skill's +// reddit_shreddit._body_for; matches both the "-comment-" and "-post-" anchors). +const NEXT_RTJSON = /id="t1_[A-Za-z0-9]+-(?:comment|post)-rtjson-content"/; + +// Extract one comment's text body, anchored on its unique thingId. The body div +// id embeds the thingId, so this maps body→comment correctly even for NESTED +// replies; the window is cut at the comment's own closing tag and the next +// comment's anchor, so a parent never absorbs a child comment's text. +function bodyFor(html, thingId) { + if (!thingId) return ''; + const anchor = `id="${thingId}-post-rtjson-content"`; + const idx = html.indexOf(anchor); + if (idx === -1) return ''; + let win = html.slice(idx + anchor.length, idx + anchor.length + 8000); + const end = win.indexOf(''); + if (end !== -1) win = win.slice(0, end); + const nxt = win.match(NEXT_RTJSON); + if (nxt) win = win.slice(0, nxt.index); + const ps = [...win.matchAll(/]*>([\s\S]*?)<\/p>/g)].map((x) => x[1]); + return ps.length ? stripTags(ps.join(' ')) : ''; +} + +// Fetch a post's comment tree via the keyless shreddit endpoint. Returns +// [total, comments] in DOM (tree pre-order) across ALL depths — each comment +// carries `depth` so callers can render nesting or flatten. sort=top front-loads +// the highest-scored comments on the first page (so big threads still surface +// their best). No pagination: only the first page of the tree is returned — +// deep "load more" branches are not followed. +async function shredditComments(sub, postId) { + const url = `https://www.reddit.com/svc/shreddit/comments/r/${sub}/t3_${postId}?sort=top`; let html; try { html = await get(url, { timeoutMs: 12000 }); } catch { return [null, []]; } const totalM = html.match(/total-comments="(\d+)"/); @@ -62,23 +93,63 @@ async function shredditComments(sub, postId, max = 3) { let m; while ((m = re.exec(html))) { const attrs = m[1]; - const attr = (k) => (attrs.match(new RegExp(`${k}="([^"]*)"`, 'i')) || [])[1]; - if (Number(attr('depth') || '0') !== 0) continue; // top-level only - const thingId = attr('thingId'); - let body = ''; - if (thingId) { - const bIdx = html.indexOf(`id="${thingId}-post-rtjson-content"`); - if (bIdx !== -1) { - const seg = html.slice(bIdx, bIdx + 2500); - const ps = [...seg.matchAll(/]*>([\s\S]*?)<\/p>/g)].map((x) => x[1]); - body = stripTags(ps.join(' ')).slice(0, 280); - } - } + const attr = (k) => (attrs.match(new RegExp(`\\b${k}="([^"]*)"`, 'i')) || [])[1]; + const author = attr('author') || null; + if (author === '[deleted]' || author === '[removed]') continue; + const body = bodyFor(html, attr('thingId')); + if (!body) continue; const score = attr('score'); - comments.push({ author: attr('author') || null, score: score != null ? Number(score) : null, body }); + comments.push({ + author, + score: score != null && score !== '' ? Number(score) : null, + depth: Number(attr('depth') || '0'), + body: body.slice(0, 300), + }); } - comments.sort((x, y) => (y.score ?? -1) - (x.score ?? -1)); - return [total, comments.slice(0, max)]; + return [total, comments]; +} + +// Parse a Reddit thread URL into {sub, id, slug}. The slug (5th path segment) is +// the title in underscore form — a free fallback title, since the comments +// endpoint carries no post title. +function extractPostRef(url) { + const m = (url || '').match(/\/r\/([^/]+)\/comments\/([A-Za-z0-9]+)(?:\/([^/?#]+))?/); + return m ? { sub: m[1], id: m[2], slug: m[3] || '' } : null; +} + +// Best-effort post title + selftext from old.reddit (server-rendered) — the +// shreddit comments endpoint carries neither, so a full "post + comments" grabs +// the body here. Defensive: any failure returns {} and the caller falls back to +// the slug title with no selftext. (Same server-rendered source web_fetch uses.) +async function fetchRedditPost(sub, id) { + let html; + try { + html = await get(`https://old.reddit.com/r/${sub}/comments/${id}/`, { accept: 'text/html', timeoutMs: 12000 }); + } catch { return {}; } + // Title: old.reddit renders " : " (sometimes "… : r/"). + // The post title itself may contain colons, so strip only the KNOWN sub suffix. + let title = stripTags((html.match(/([\s\S]*?)<\/title>/) || [])[1] || ''); + const subRe = sub.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + title = title.replace(new RegExp(`\\s*:\\s*(?:r/)?${subRe}\\s*$`, 'i'), '').trim(); + // Selftext: the post body is the FIRST `usertext-body` that sits BETWEEN the + // post's own `.thing` (anchored on its t3_<id>) and the comment area — NOT the + // sidebar's description, which precedes the post in old.reddit's markup. Slicing + // to that window also keeps the <p> scan from leaking into comments. Link/image + // posts with no body yield ''. + let selftext = ''; + let postIdx = html.indexOf(`thing_t3_${id}`); + if (postIdx === -1) postIdx = html.indexOf(`t3_${id}`); + const caIdx = html.indexOf('commentarea'); + if (postIdx !== -1) { + const region = html.slice(postIdx, caIdx > postIdx ? caIdx : postIdx + 20000); + const ub = region.indexOf('usertext-body'); + if (ub !== -1) { + const win = region.slice(ub, ub + 8000); + const ps = [...win.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/g)].map((x) => x[1]); + selftext = stripTags(ps.join(' ')).slice(0, 1500); + } + } + return { title, selftext }; } async function fetchReddit(query, limit, time, withComments) { @@ -109,7 +180,9 @@ async function fetchReddit(query, limit, time, withComments) { if (!pid) return; const [total, comments] = await shredditComments(it.subreddit, pid); it.num_comments = total; - if (comments.length) it.comments = comments; + // Discovery wants a flavour, not the tree: top 3 by score across all depths. + const top = comments.slice().sort((a, b) => (b.score ?? -1) - (a.score ?? -1)).slice(0, 3); + if (top.length) it.comments = top; })); } return ranked; @@ -136,11 +209,45 @@ const SOURCES = { reddit: fetchReddit, hackernews: fetchHackerNews }; async function main() { const query = (process.env.SOCIAL_QUERY || '').trim(); + const url = (process.env.SOCIAL_URL || '').trim(); const sources = (process.env.SOCIAL_SOURCES || 'reddit,hackernews').split(',').map((s) => s.trim()).filter(Boolean); const limit = Math.max(1, Math.min(50, Number(process.env.SOCIAL_LIMIT || '25'))); const time = process.env.SOCIAL_TIME || 'month'; const withComments = ['1', 'true', 'yes'].includes((process.env.SOCIAL_WITH_COMMENTS || '').toLowerCase()); + // ── URL/thread mode: fetch ONE Reddit post + its full (nested) comment tree ── + if (url) { + const out = { query: url, counts: {}, errors: {}, results: [] }; + const ref = extractPostRef(url); + if (!ref) { + out.errors.reddit = 'unrecognized Reddit thread URL'; + out.counts.reddit = 0; + } else { + try { + const [post, [total, comments]] = await Promise.all([ + fetchRedditPost(ref.sub, ref.id), + shredditComments(ref.sub, ref.id), + ]); + const slugTitle = ref.slug ? ref.slug.replace(/[_-]+/g, ' ').trim() : ''; + out.results.push({ + platform: 'reddit', type: 'post', + title: post.title || slugTitle || '(untitled)', + url, author: null, subreddit: ref.sub, + score: null, num_comments: total, created: null, + selftext: post.selftext || '', snippet: '', relevance: 0, + comments: comments.slice(0, 50), // first page, top-sorted; huge threads clamp + }); + out.counts.reddit = 1; + } catch (e) { + out.errors.reddit = `${e.name}: ${e.message}`; + out.counts.reddit = 0; + } + } + console.log('__SOCIAL_JSON__'); + console.log(JSON.stringify(out)); + return; + } + const out = { query, counts: {}, errors: {}, results: [] }; if (!query) { console.log('__SOCIAL_JSON__'); console.log(JSON.stringify({ ...out, error: 'empty query' })); return; } diff --git a/packages/iclaw-runtime/src/agent/social.ts b/packages/iclaw-runtime/src/agent/social.ts index e750ef0..8da3154 100644 --- a/packages/iclaw-runtime/src/agent/social.ts +++ b/packages/iclaw-runtime/src/agent/social.ts @@ -25,18 +25,21 @@ export const SOCIAL_SEARCH_TOOL = { function: { name: 'social_search', description: - 'Search social platforms by KEYWORDS and get real posts — free, no API keys. ' + - 'Sources: reddit (posts + optional top comments), hackernews (stories with points/comments). ' + - 'Runs in the sandbox. Use with_comments:true to also pull top comments for the top Reddit posts. ' + - 'Note: Reddit post upvote counts are not available without a paid key (comment counts/scores are).', + 'Reddit & HackerNews — free, no API keys, runs in the sandbox. Two modes: ' + + '(1) DISCOVERY — pass `query` keywords to find posts; with_comments:true also pulls ' + + 'top comments for the top Reddit posts. ' + + '(2) THREAD — pass a Reddit post `url` to fetch THAT post plus its FULL, nested comment ' + + 'tree (scored, one call). Prefer this over web_fetch for any specific Reddit link. ' + + 'Note: Reddit post upvote counts need a paid key (comment counts/scores do not).', parameters: { type: 'object', properties: { - query: { type: 'string', description: 'Keywords / search terms' }, + query: { type: 'string', description: 'Discovery keywords / search terms. Provide this OR `url`.' }, + url: { type: 'string', description: 'A specific Reddit post URL (…/r/<sub>/comments/<id>/…) to fetch with its full comment tree. Use instead of `query` for a known link.' }, sources: { type: 'array', items: { type: 'string', enum: SOCIAL_SOURCES as unknown as string[] }, - description: 'Which platforms to search (default: all).', + description: 'Which platforms to search in discovery mode (default: all).', }, time: { type: 'string', @@ -46,7 +49,7 @@ export const SOCIAL_SEARCH_TOOL = { limit: { type: 'number', description: 'Max results per source (default 25, max 50).' }, with_comments: { type: 'boolean', description: 'Also fetch top comments for the top Reddit posts.' }, }, - required: ['query'], + required: [], }, }, } as const; @@ -64,7 +67,8 @@ interface SocialItem { created: string | null; snippet?: string; relevance: number; - comments?: { author: string | null; score: number | null; body: string }[]; + selftext?: string; + comments?: { author: string | null; score: number | null; body: string; depth?: number }[]; } interface SocialPayload { query: string; @@ -89,7 +93,7 @@ function getFetcherB64(): string { } function buildSocialCommand(p: { - query: string; sources: string; limit: number; time: string; withComments: boolean; + query: string; url: string; sources: string; limit: number; time: string; withComments: boolean; }): string { // Write the fetcher to $HOME — writable in BOTH sandboxes regardless of the // container uid. Secure runs as `node` (owns /workspace), but Work runs as the @@ -100,7 +104,7 @@ function buildSocialCommand(p: { return [ 'set -e', `echo ${shQuote(getFetcherB64())} | base64 -d > ${script}`, - `SOCIAL_QUERY=${shQuote(p.query)} SOCIAL_SOURCES=${shQuote(p.sources)} ` + + `SOCIAL_QUERY=${shQuote(p.query)} SOCIAL_URL=${shQuote(p.url)} SOCIAL_SOURCES=${shQuote(p.sources)} ` + `SOCIAL_LIMIT=${shQuote(String(p.limit))} SOCIAL_TIME=${shQuote(p.time)} ` + `SOCIAL_WITH_COMMENTS=${shQuote(p.withComments ? '1' : '0')} ` + `node ${script}`, @@ -125,8 +129,10 @@ function formatPayload(d: SocialPayload): string { if (r.score != null) meta.push(`${r.score} points`); if (r.num_comments != null) meta.push(`${r.num_comments} comments`); if (meta.length) lines.push(` ${meta.join(' · ')}`); + if (r.selftext) lines.push(` ${r.selftext}`); for (const c of r.comments || []) { - lines.push(` • [${c.score ?? '?'}] ${c.author ?? 'anon'}: ${c.body || '(no text)'}`); + const indent = ' ' + ' '.repeat(Math.min(c.depth ?? 0, 6)); + lines.push(`${indent}• [${c.score ?? '?'}] ${c.author ?? 'anon'}: ${c.body || '(no text)'}`); } }); } @@ -142,21 +148,22 @@ export async function socialSearch( deps: { runInSandbox: (command: string) => Promise<string>; networkEnabled: boolean; onNote?: (note: SavingsNote) => void }, ): Promise<string> { const query = String(args.query ?? '').trim(); - if (!query) return 'social_search needs a non-empty query (keywords/terms).'; + const url = String(args.url ?? '').trim(); + if (!query && !url) return 'social_search needs a `query` (keywords) or a `url` (a Reddit post link).'; if (!deps.networkEnabled) { return 'social_search needs network, which is currently OFF for this chat. Ask the user to enable network, then retry.'; } const requested = Array.isArray(args.sources) ? args.sources.map(String) : [...SOCIAL_SOURCES]; const sources = requested.filter((s) => (SOCIAL_SOURCES as readonly string[]).includes(s)); - if (!sources.length) return `social_search: unknown source(s). Supported: ${SOCIAL_SOURCES.join(', ')}.`; + if (!url && !sources.length) return `social_search: unknown source(s). Supported: ${SOCIAL_SOURCES.join(', ')}.`; const limit = Math.max(1, Math.min(50, Number(args.limit) || 25)); const time = ['day', 'week', 'month', 'year', 'all'].includes(String(args.time)) ? String(args.time) : 'month'; const withComments = args.with_comments === true; let raw: string; try { - raw = await deps.runInSandbox(buildSocialCommand({ query, sources: sources.join(','), limit, time, withComments })); + raw = await deps.runInSandbox(buildSocialCommand({ query, url, sources: sources.join(','), limit, time, withComments })); } catch (err) { return `social_search failed to run in the sandbox: ${err instanceof Error ? err.message : String(err)}`; } @@ -173,7 +180,7 @@ export async function socialSearch( const total = Object.values(payload.counts || {}).reduce((a, b) => a + b, 0); if (!total) { const errs = Object.entries(payload.errors || {}).map(([k, v]) => `${k}: ${v}`).join('; '); - return `social_search found nothing for "${query}".${errs ? ` Source errors — ${errs}.` : ''} Try different keywords or sources.`; + return `social_search found nothing for "${url || query}".${errs ? ` Source errors — ${errs}.` : ''}${url ? ' Check the Reddit URL is a post link.' : ' Try different keywords or sources.'}`; } const text = formatPayload(payload); diff --git a/packages/iclaw-runtime/src/agent/tools.ts b/packages/iclaw-runtime/src/agent/tools.ts index a96c85a..d884e9a 100644 --- a/packages/iclaw-runtime/src/agent/tools.ts +++ b/packages/iclaw-runtime/src/agent/tools.ts @@ -24,6 +24,13 @@ const MAX_FILE_BYTES = Number(process.env.ICLAW_MAX_FILE_BYTES) || 5_000_000; const COMMAND_TIMEOUT = 30_000; const WEB_FETCH_TIMEOUT = 20_000; const WEB_FETCH_MAX_CHARS = 20_000; +// web_fetch in Secure Mode runs the fetch as `curl` INSIDE the sandbox; cap the +// body we pull back over the `docker exec` stdout pipe (well under its ~1MB +// buffer). The host then strips/summarizes it down to WEB_FETCH_MAX_CHARS anyway, +// so this only bounds the transport, not what the model sees. +const WEB_FETCH_SANDBOX_BODY_CAP = Number(process.env.ICLAW_WEB_FETCH_BODY_CAP) || 800_000; +const CURL_META_MARKER = '__ICLAW_FETCH_META__'; +const CURL_ERR_MARKER = '__ICLAW_FETCH_ERR__'; // ── Token-saving output caps ────────────────────────────────────────────────── // Tool outputs are the biggest token sink in multi-turn chats: they land in the @@ -52,7 +59,7 @@ const SEARCH_EXCLUDE_DIRS = (process.env.ICLAW_SEARCH_EXCLUDE_DIRS // Cheap-model summarizer (read_summary, web_fetch summarize). Moves the cost of // reading big content onto a cheap model; the expensive model + history only // carry the short summary. -const SUMMARY_MODEL = process.env.ICLAW_SUMMARY_MODEL || 'google/gemini-2.5-flash-lite'; +const SUMMARY_MODEL = process.env.ICLAW_SUMMARY_MODEL || 'minimax/minimax-m2.7'; const SUMMARY_MAX_INPUT_CHARS = Number(process.env.ICLAW_SUMMARY_MAX_INPUT) || 60_000; export const TOOL_OUTPUT_MAX_CHARS = MAX_CMD_OUTPUT_CHARS; @@ -985,6 +992,105 @@ async function webFetch(args: Record<string, unknown>, ctx?: ToolContext): Promi return `${note}HTTP ${status} — ${url}\n\n${text || '(empty response body)'}`; } +// ── web_fetch (Secure Mode: fetch runs INSIDE the sandbox) ──────────────────── +// +// Same ergonomics as webFetch (canonicalize → clean text → optional cheap summary +// → caps + within-turn cache), but the network fetch itself runs as `curl` inside +// the container via runInSandbox, so it honours the same --network gate as +// run_command. A host-side fetch (the plain webFetch above) would bypass a +// network-OFF sandbox, which is exactly why that one is never exposed to Secure. +// The optional summary is a host→OpenRouter call — the same trusted channel as +// the chat stream — so it doesn't widen the sandbox boundary. + +/** + * Build the sandbox curl command. Body → temp file (-o), headers → temp (-D), + * status+content-type → stdout via -w (captured into $meta). We echo the META + * marker line FIRST, then `head -c` the body, so even a huge page that overruns + * the `docker exec` stdout buffer still yields the status/type plus the head of + * the body. The URL is single-quoted (shQuote) so a model-chosen URL can't break + * out of the shell ($(), backticks, ;). Every step is guarded so we exit 0 with a + * parseable result (curl's --max-time bounds it below CONTAINER_TIMEOUT). + */ +function buildCurlFetchCommand(url: string): string { + const secs = Math.round(WEB_FETCH_TIMEOUT / 1000); + return [ + 'B=$(mktemp); H=$(mktemp)', + `meta=$(curl -sSL --max-time ${secs} --compressed ` + + `-A 'iClaw-Secure/1.0' ` + + `-H 'Accept: text/html,application/json;q=0.9,*/*;q=0.8' ` + + `-o "$B" -D "$H" -w '%{http_code} %{content_type}' ${shQuote(url)}) ` + + `|| { echo "${CURL_ERR_MARKER}exit $?"; rm -f "$B" "$H"; exit 0; }`, + `echo "${CURL_META_MARKER}$meta"`, + `head -c ${WEB_FETCH_SANDBOX_BODY_CAP} "$B"`, + 'rm -f "$B" "$H"', + ].join('\n'); +} + +/** Parse buildCurlFetchCommand output: a leading META marker line + body, or an ERR marker. */ +function parseCurlFetch(raw: string): { body: string; status: number; contentType: string; error?: string } { + const errIdx = raw.indexOf(CURL_ERR_MARKER); + if (errIdx !== -1) { + const after = raw.slice(errIdx + CURL_ERR_MARKER.length).trim().slice(0, 200); + return { body: '', status: 0, contentType: '', error: `curl failed (${after || 'network error'})` }; + } + const idx = raw.indexOf(CURL_META_MARKER); + if (idx === -1) return { body: '', status: 0, contentType: '', error: raw.trim().slice(0, 200) || 'no response' }; + const after = raw.slice(idx + CURL_META_MARKER.length); + const nl = after.indexOf('\n'); + const metaLine = (nl === -1 ? after : after.slice(0, nl)).trim(); + const body = nl === -1 ? '' : after.slice(nl + 1); + const sp = metaLine.indexOf(' '); + const status = Number(sp === -1 ? metaLine : metaLine.slice(0, sp)) || 0; + const contentType = sp === -1 ? '' : metaLine.slice(sp + 1).trim(); + return { body, status, contentType }; +} + +export async function webFetchSandboxed( + args: Record<string, unknown>, + deps: { + runInSandbox: (command: string) => Promise<string>; + networkEnabled: boolean; + fetchCache?: Map<string, string>; + }, +): Promise<string> { + const inputUrl = String(args.url ?? '').trim(); + if (!/^https?:\/\/\S+$/i.test(inputUrl)) return 'Only absolute http(s) URLs are allowed.'; + if (!deps.networkEnabled) { + return 'web_fetch needs network, which is currently OFF for this chat. Ask the user to enable network, then retry.'; + } + // GitHub repo/file → raw, reddit → old.reddit, before cache + fetch (as host webFetch). + const url = canonicalizeFetchUrl(inputUrl); + const key = normalizeFetchUrl(url); + const cache = deps.fetchCache; + + let text = cache?.get(key); + const fromCache = text !== undefined; + let status = 200; + if (text === undefined) { + let raw: string; + try { + raw = await deps.runInSandbox(buildCurlFetchCommand(url)); + } catch (err) { + return `Fetch failed (${url}): ${err instanceof Error ? err.message : String(err)}`; + } + const parsed = parseCurlFetch(raw); + if (parsed.error) return `Fetch failed (${url}): ${parsed.error}`; + status = parsed.status || 200; + text = /html/i.test(parsed.contentType) ? htmlToText(parsed.body) : parsed.body.trim(); + cache?.set(key, text); + } + + const note = fromCache ? '(already fetched this turn — served from cache; no new request)\n\n' : ''; + if (args.summarize !== false && text) { + const summary = await summarizeText(text, args.focus ? String(args.focus) : undefined); + return `${note}Summary of ${url}:\n\n${summary}`; + } + if (text.length > WEB_FETCH_MAX_CHARS) { + text = text.slice(0, WEB_FETCH_MAX_CHARS) + `\n\n[truncated at ${WEB_FETCH_MAX_CHARS} chars]`; + } + return `${note}HTTP ${status} — ${url}\n\n${text || '(empty response body)'}`; +} + // ── cheap-model summarizer (read_summary, web_fetch summarize) ──────────────── /** @@ -1053,7 +1159,7 @@ async function openRouterSearch(query: string, count: number, signal: AbortSigna const key = process.env.ICLAW_OPENROUTER_API_KEY || ''; if (!key) throw new Error('no OpenRouter key'); const base = (process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1').replace(/\/+$/, ''); - const model = process.env.ICLAW_SEARCH_MODEL || process.env.ICLAW_MODEL || 'google/gemini-2.5-flash'; + const model = process.env.ICLAW_SEARCH_MODEL || process.env.ICLAW_MODEL || 'minimax/minimax-m2.7'; const res = await fetch(`${base}/chat/completions`, { method: 'POST', signal, @@ -1122,6 +1228,41 @@ async function webSearch(args: Record<string, unknown>): Promise<string> { } } +/** + * web_search for Secure Mode. Uses ONLY the OpenRouter web plugin: the search + * runs on OpenRouter's servers, so the host makes the same kind of API call it + * already makes for the chat stream — no arbitrary host-side egress, and no + * DuckDuckGo fallback (that one fetches from the host and would dodge the + * sandbox's network gate). Gated on networkEnabled so a network-OFF chat does no + * web research. + */ +export async function webSearchSecure( + args: Record<string, unknown>, + deps: { networkEnabled: boolean }, +): Promise<string> { + const query = String(args.query ?? '').trim(); + if (!query) return 'web_search requires a query.'; + if (!deps.networkEnabled) { + return 'web_search needs network, which is currently OFF for this chat. Ask the user to enable network, then retry.'; + } + if (!process.env.ICLAW_OPENROUTER_API_KEY) { + return 'web_search is unavailable here (no OpenRouter key configured). Use run_command with curl for a specific API instead.'; + } + const count = Math.min(10, Math.max(1, Number(args.count) || 6)); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), WEB_FETCH_TIMEOUT); + try { + return formatHits(query, await openRouterSearch(query, count, ctrl.signal), 'OpenRouter'); + } catch (err) { + const msg = err instanceof Error && err.name === 'AbortError' + ? `timed out after ${WEB_FETCH_TIMEOUT / 1000}s` + : err instanceof Error ? err.message : String(err); + return `Search failed for "${query}": ${msg}.`; + } finally { + clearTimeout(timer); + } +} + // ── analyze_link (sandboxed link → content extraction) ─────────────────────── // // Engine: yt-dlp (1000+ site extractors → easy to grow past YouTube). Shipped as diff --git a/packages/iclaw-runtime/src/index.ts b/packages/iclaw-runtime/src/index.ts index 43ab917..a0c4465 100644 --- a/packages/iclaw-runtime/src/index.ts +++ b/packages/iclaw-runtime/src/index.ts @@ -17,7 +17,7 @@ import http from 'node:http'; import { ensureColimaEnv } from './colima.js'; -import { createSession, getSession, deleteSession, abortSession, attachSseClient, detachSseClient, sendMessage, getSessionInfo, exportSessionWorkspace, applySessionChanges, sweepExpiredSessions, startContainerReaper, loadPersistedSessions, type RuntimeAttachment } from './sessions.js'; +import { createSession, getSession, deleteSession, abortSession, attachSseClient, detachSseClient, sendMessage, getSessionInfo, exportSessionWorkspace, sweepExpiredSessions, startContainerReaper, loadPersistedSessions, type RuntimeAttachment } from './sessions.js'; import { killOrphanContainers } from './secure-runner.js'; import { startDockerIdleReaper, stopDockerOnShutdown } from './docker-lifecycle.js'; import { killOrphanWorkContainers } from './work-container.js'; @@ -31,7 +31,7 @@ const PORT = parseInt(process.env.ICLAW_RUNTIME_PORT || '7430', 10); const SECRET = process.env.ICLAW_RUNTIME_SECRET || ''; const API_KEY = process.env.ICLAW_OPENROUTER_API_KEY || ''; // Default agent model. Override per-install via ICLAW_MODEL in .env. -const DEFAULT_MODEL = process.env.ICLAW_MODEL || 'deepseek/deepseek-v4-flash'; +const DEFAULT_MODEL = process.env.ICLAW_MODEL || 'minimax/minimax-m2.7'; function authOk(req: http.IncomingMessage): boolean { if (!SECRET) { @@ -160,14 +160,6 @@ const server = http.createServer(async (req, res) => { return json(res, 200, result); } - // POST /sessions/:id/apply — copy the sandbox's changes back to the originals. - if (req.method === 'POST' && parts[0] === 'sessions' && parts[2] === 'apply') { - if (!getSession(parts[1])) return json(res, 404, { error: 'session not found' }); - const results = applySessionChanges(parts[1]); - if (!results) return json(res, 400, { error: 'not a Safe session' }); - return json(res, 200, { results }); - } - // DELETE /sessions/:id if (req.method === 'DELETE' && parts[0] === 'sessions' && parts.length === 2) { deleteSession(parts[1]); diff --git a/packages/iclaw-runtime/src/secure-export.ts b/packages/iclaw-runtime/src/secure-export.ts index 5bb9773..9cca9f4 100644 --- a/packages/iclaw-runtime/src/secure-export.ts +++ b/packages/iclaw-runtime/src/secure-export.ts @@ -1,31 +1,19 @@ /** - * Safe Mode export / apply. + * Safe Mode export. * - * After working in the sandbox the user may want the results OUT of it: - * export → copy the whole sandbox workspace to a host folder (a fresh place; - * nothing of theirs is overwritten). Good for "give me the output". - * apply → for each folder that was copied IN (see secure-ingest's - * .iclaw-ingest.json), copy the new/changed files back to the - * ORIGINAL folder. Additive + overwrite only — never deletes the - * user's files — and refuses secret roots. This is the explicit - * "apply the sandbox's changes to my real project" action. - * - * Both are user-initiated (button + confirm); apply is the only path that ever - * writes to the user's real files, and only to folders they themselves chose to - * ingest. + * After working in the sandbox the user may want the results OUT of it: export + * copies the whole sandbox workspace to a fresh host folder (nothing of theirs is + * overwritten). Good for "give me the output". User-initiated (button + confirm); + * it only ever reads the sandbox and writes to a new folder, never to originals. */ import { - cpSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync, readdirSync, + cpSync, mkdirSync, existsSync, readdirSync, } from 'node:fs'; import { homedir } from 'node:os'; -import { join, relative, sep, dirname } from 'node:path'; - -import { validateMountRoot } from './agent/security.js'; -import { log } from './log.js'; +import { join, relative, sep } from 'node:path'; -/** Workspace internals that must never be exported/applied. */ +/** Workspace internals that must never be exported. */ const INTERNAL = new Set(['.tools', '.iclaw-ingest.json']); -const SKIP_DIRS = new Set(['node_modules', '.git']); export interface ExportResult { ok: boolean; @@ -35,27 +23,6 @@ export interface ExportResult { error?: string; } -export interface ApplyFileChange { - /** Original-folder-relative path that was written. */ - path: string; - kind: 'created' | 'modified'; -} - -export interface ApplyResult { - ok: boolean; - /** Original host folder the changes were applied to. */ - source?: string; - applied?: ApplyFileChange[]; - error?: string; -} - -interface IngestLogEntry { - kind: string; - source: string; - ok: boolean; - target?: string; -} - /** Bounded recursive file count. */ function countFiles(dir: string, cap = 50_000): number { let total = 0; @@ -73,12 +40,37 @@ function countFiles(dir: string, cap = 50_000): number { return total; } +/** + * Local date-time slug like "2026-06-09_14-30-15" for naming exported folders, + * so a non-technical user can tell exports apart at a glance instead of reading + * an opaque epoch number. + */ +function exportStamp(d = new Date()): string { + const p = (n: number): string => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` + + `_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`; +} + +/** + * First folder name under `base` that doesn't already exist: starts from `name`, + * then appends "-2", "-3", … if the user exported more than once in the same + * second. Epoch fallback if (somehow) all are taken. + */ +function uniqueDir(base: string, name: string): string { + if (!existsSync(join(base, name))) return join(base, name); + for (let i = 2; i < 1000; i++) { + const candidate = join(base, `${name}-${i}`); + if (!existsSync(candidate)) return candidate; + } + return join(base, `${name}-${Date.now()}`); +} + /** Copy the whole sandbox (minus workspace internals) to a fresh host folder. */ export function exportWorkspace(workspaceDir: string, destDir?: string): ExportResult { try { const base = destDir && destDir.trim() ? destDir : join(homedir(), 'Downloads'); mkdirSync(base, { recursive: true }); - const target = join(base, `iclaw-sandbox-${Date.now()}`); + const target = uniqueDir(base, `iclaw-sandbox-${exportStamp()}`); cpSync(workspaceDir, target, { recursive: true, filter: (src) => { @@ -94,105 +86,7 @@ export function exportWorkspace(workspaceDir: string, destDir?: string): ExportR } } -function readIngestLog(workspaceDir: string): IngestLogEntry[] { - try { - const raw = JSON.parse(readFileSync(join(workspaceDir, '.iclaw-ingest.json'), 'utf-8')); - return Array.isArray(raw?.results) ? (raw.results as IngestLogEntry[]) : []; - } catch { - return []; - } -} - -/** True when two files differ by size or content (cheap size check first). */ -function differs(a: string, b: string): boolean { - try { - const sa = statSync(a); - const sb = statSync(b); - if (sa.size !== sb.size) return true; - return !readFileSync(a).equals(readFileSync(b)); - } catch { - return true; // missing target / unreadable → treat as changed - } -} - -/** - * Copy new/changed files from `sandboxRoot` back into `originalRoot`. Additive + - * overwrite only (never deletes); skips node_modules/.git. Returns what changed. - */ -function syncBack(sandboxRoot: string, originalRoot: string): ApplyFileChange[] { - const applied: ApplyFileChange[] = []; - const stack = ['']; - while (stack.length) { - const rel = stack.pop()!; - const cur = rel ? join(sandboxRoot, rel) : sandboxRoot; - let entries; - try { entries = readdirSync(cur, { withFileTypes: true }); } - catch { continue; } - for (const e of entries) { - const childRel = rel ? join(rel, e.name) : e.name; - if (e.isDirectory()) { - if (SKIP_DIRS.has(e.name)) continue; - stack.push(childRel); - continue; - } - const from = join(sandboxRoot, childRel); - const to = join(originalRoot, childRel); - const existed = existsSync(to); - if (existed && !differs(from, to)) continue; // unchanged - mkdirSync(dirname(to), { recursive: true }); - cpSync(from, to); - applied.push({ path: childRel, kind: existed ? 'modified' : 'created' }); - } - } - return applied; -} - -/** - * Apply the sandbox's changes back to the original folders that were ingested. - * Only `folder` sources have an original to write to (repo/zip/url don't). Each - * original is re-validated (refuses secret roots) before any write. - */ -export function applyChanges(workspaceDir: string): ApplyResult[] { - const entries = readIngestLog(workspaceDir).filter( - (e) => e.ok && e.kind === 'folder' && e.target, - ); - const out: ApplyResult[] = []; - for (const e of entries) { - try { - // Re-validate the destination root (refuses secret-bearing roots, resolves - // symlinks) — the same gate ingest used, now guarding the write-back. - const originalRoot = validateMountRoot(e.source); - const sandboxRoot = join(workspaceDir, e.target!); - if (!existsSync(sandboxRoot)) { - out.push({ ok: false, source: e.source, error: 'sandbox copy missing' }); - continue; - } - const applied = syncBack(sandboxRoot, originalRoot); - out.push({ ok: true, source: originalRoot, applied }); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - log.warn('Safe-mode apply failed', { source: e.source, error }); - out.push({ ok: false, source: e.source, error }); - } - } - return out; -} - -/** One-line summaries for the UI. */ +/** One-line summary for the UI. */ export function describeExport(r: ExportResult): string { return r.ok ? `Exported ${r.files ?? '?'} files to ${r.path}` : `Export failed: ${r.error}`; } - -export function describeApply(results: ApplyResult[]): string { - if (results.length === 0) return 'Nothing to apply — no folders were copied into this sandbox.'; - const lines: string[] = []; - for (const r of results) { - if (!r.ok) { - lines.push(`✗ ${r.source}: ${r.error}`); - continue; - } - const n = r.applied?.length ?? 0; - lines.push(n === 0 ? `• ${r.source}: no changes` : `• ${r.source}: applied ${n} file(s)`); - } - return lines.join('\n'); -} diff --git a/packages/iclaw-runtime/src/secure-runner.ts b/packages/iclaw-runtime/src/secure-runner.ts index 647cdab..340d847 100644 --- a/packages/iclaw-runtime/src/secure-runner.ts +++ b/packages/iclaw-runtime/src/secure-runner.ts @@ -19,7 +19,8 @@ import { promisify } from 'node:util'; import type OpenAI from 'openai'; import type { AgentEvent, AgentOptions, Message } from './agent/loop.js'; import type { SavingsNote } from './agent/tools.js'; -import { shrinkOldToolOutputs, withPromptCaching, makeToolGuard, HOST_INSTALL_POLICY } from './agent/loop.js'; +import { shrinkOldToolOutputs, withPromptCaching, makeToolGuard, HOST_INSTALL_POLICY, describeApiError } from './agent/loop.js'; +import { resolveTurnModel } from './agent/model-capabilities.js'; import { log } from './log.js'; import { INSTALL_LABEL } from './install-id.js'; import { dumpPrompt, newTurnId } from './agent/prompt-dump.js'; @@ -352,8 +353,8 @@ Preinstalled CLIs: git, rg (ripgrep), jq, curl, node, unzip/zip, less, tree. You can install more tools yourself, no root needed${networkEnabled ? '' : ' (requires network, which is currently OFF — ask the user to enable it)'}: download a static binary into /workspace/.tools/bin (already on PATH) with curl, or run \`npm i -g <pkg>\`. Self-installed tools live in the workspace, so they persist across turns and are removed automatically when the workspace expires. Network is ${networkEnabled ? 'enabled' : 'disabled'}.${ networkEnabled - ? '\nTo fetch web pages or APIs, use run_command with `curl -s <url>` (there is no browser in this sandbox).' + - '\nFor a link\'s actual content, prefer the analyze_link tool (YouTube videos: subtitles/transcript). ' + + ? '\nTo read a web page, use the web_fetch tool (returns clean text, or a cheap summary by default) — do NOT hand-roll `curl ... | jq`; only drop to `curl -s <url>` in run_command for an API or download web_fetch can\'t handle. To find pages, use web_search. There is no browser in this sandbox.' + + '\nFor a link\'s actual content, prefer analyze_link (YouTube videos: subtitles/transcript) and social_search (Reddit / HackerNews). ' + 'Use mode:"summary" with a short purpose by default to save tokens; mode:"full" only when you need exact wording.' : '' } @@ -394,17 +395,23 @@ async function* runSecureAgentLoop( networkEnabled: boolean, ): AsyncGenerator<SecureEvent> { const OpenAI = (await import('openai')).default; - const { TOOL_DEFINITIONS, ANALYZE_LINK_TOOL, SHOW_IMAGE_TOOL, analyzeLink, clampMiddle, compressCommandOutput, TOOL_OUTPUT_MAX_CHARS } = - await import('./agent/tools.js'); + const { + TOOL_DEFINITIONS, WEB_FETCH_TOOL, WEB_SEARCH_TOOL, ANALYZE_LINK_TOOL, SHOW_IMAGE_TOOL, + analyzeLink, webFetchSandboxed, webSearchSecure, clampMiddle, compressCommandOutput, TOOL_OUTPUT_MAX_CHARS, + } = await import('./agent/tools.js'); const { SOCIAL_SEARCH_TOOL, socialSearch } = await import('./agent/social.js'); - // analyze_link and social_search are appended (not part of core TOOL_DEFINITIONS) - // and run their network work INSIDE this container, so they respect the same - // network gate. show_image lets the agent surface an image it produced in /workspace. - const tools = [...TOOL_DEFINITIONS, ANALYZE_LINK_TOOL, SOCIAL_SEARCH_TOOL, SHOW_IMAGE_TOOL]; + // web_fetch, analyze_link and social_search are appended (not part of core + // TOOL_DEFINITIONS) and run their network work INSIDE this container via curl/ + // node, so they respect the same --network gate. web_search rides the OpenRouter + // web plugin (host→OpenRouter, like the chat stream), gated on network. show_image + // lets the agent surface an image it produced in /workspace. + const tools = [...TOOL_DEFINITIONS, WEB_FETCH_TOOL, WEB_SEARCH_TOOL, ANALYZE_LINK_TOOL, SOCIAL_SEARCH_TOOL, SHOW_IMAGE_TOOL]; // Buffer for analyze_link savings notes, flushed as `note` events per tool call. const savingsNotes: SavingsNote[] = []; + // Within-turn cache for web_fetch — dedup repeat pulls of the same URL this turn. + const fetchCache = new Map<string, string>(); // Buffer for show_image requests, flushed as `image` events per tool call. const pendingImages: { path: string; mime: string; fileName: string; bytes: number }[] = []; const SECURE_IMAGE_MIME: Record<string, string> = { @@ -434,6 +441,20 @@ async function* runSecureAgentLoop( userMsg, ]; + // Vision gate (see agent/model-capabilities.ts): route an image turn to a + // vision-capable model when the configured one is text-only, else OpenRouter + // 404s on the image_url block. Text turns are untouched. + const modelDecision = await resolveTurnModel({ + model: opts.model, + apiKey: opts.apiKey, + hasImages: !!opts.images?.length, + }); + if (modelDecision.error) { + yield { type: 'error', message: modelDecision.error }; + return; + } + const effectiveModel = modelDecision.model; + let turnTokens = 0; let turnCached = 0; const dumpTurnId = newTurnId(); @@ -450,14 +471,14 @@ async function* runSecureAgentLoop( let textBuffer = ''; const toolCallBuffers: Record<string, { name: string; arguments: string }> = {}; - dumpPrompt({ turnId: dumpTurnId, mode: 'secure', model: opts.model, round, messages, tools }); + dumpPrompt({ turnId: dumpTurnId, mode: 'secure', model: effectiveModel, round, messages, tools }); // eslint-disable-next-line @typescript-eslint/no-explicit-any let stream: AsyncIterable<any>; try { stream = await client.chat.completions.create( { - model: opts.model, + model: effectiveModel, messages: withPromptCaching(messages), // eslint-disable-next-line @typescript-eslint/no-explicit-any tools: tools as any, @@ -472,7 +493,7 @@ async function* runSecureAgentLoop( yield { type: 'done', tokens: turnTokens || undefined, cached: turnCached || undefined }; return; } - yield { type: 'error', message: err instanceof Error ? err.message : String(err) }; + yield { type: 'error', message: describeApiError(err) }; return; } @@ -508,7 +529,7 @@ async function* runSecureAgentLoop( yield { type: 'done', tokens: turnTokens || undefined, cached: turnCached || undefined }; return; } - yield { type: 'error', message: err instanceof Error ? err.message : String(err) }; + yield { type: 'error', message: describeApiError(err) }; return; } @@ -552,6 +573,16 @@ async function* runSecureAgentLoop( result = await execInContainer(containerName, 'ls -la /workspace'); } else if (tc.name === 'search_files') { result = await execInContainer(containerName, `grep -r ${JSON.stringify(String(args.query ?? ''))} /workspace 2>/dev/null | head -20`); + } else if (tc.name === 'web_fetch') { + // The fetch runs as curl inside this container, so it honours the gate. + result = await webFetchSandboxed(args, { + runInSandbox: (command) => execInContainer(containerName, command), + networkEnabled, + fetchCache, + }); + } else if (tc.name === 'web_search') { + // OpenRouter web plugin (host→OpenRouter, like the chat stream); gated on network. + result = await webSearchSecure(args, { networkEnabled }); } else if (tc.name === 'analyze_link') { // Runs its fetch inside this same container (network honours the gate). result = await analyzeLink(args, { diff --git a/packages/iclaw-runtime/src/sessions.ts b/packages/iclaw-runtime/src/sessions.ts index 4617a0b..6b06a53 100644 --- a/packages/iclaw-runtime/src/sessions.ts +++ b/packages/iclaw-runtime/src/sessions.ts @@ -20,7 +20,7 @@ import { } from './work-container.js'; import { ingestSources, describeIngest, type IngestSource } from './secure-ingest.js'; import { - exportWorkspace, applyChanges, type ExportResult, type ApplyResult, + exportWorkspace, type ExportResult, } from './secure-export.js'; import { ensureDockerForTask, markDockerUse } from './docker-lifecycle.js'; @@ -119,7 +119,7 @@ const HISTORY_KEEP_RECENT = Number(process.env.ICLAW_HISTORY_KEEP_RECENT) || 16; // Also compact by SIZE, not just message count: a few big messages bloat the // resent context as much as many small ones. ~24k chars ≈ ~6k tokens. const HISTORY_COMPACT_CHARS = Number(process.env.ICLAW_HISTORY_COMPACT_CHARS) || 24_000; -const SUMMARY_MODEL = process.env.ICLAW_SUMMARY_MODEL || 'google/gemini-2.5-flash-lite'; +const SUMMARY_MODEL = process.env.ICLAW_SUMMARY_MODEL || 'minimax/minimax-m2.7'; const OPENROUTER_BASE = process.env.OPENROUTER_BASE_URL?.replace(/\/+$/, '') || 'https://openrouter.ai/api/v1'; const SUMMARY_SYSTEM = 'You compress conversation history into a concise, information-dense summary. ' + @@ -404,13 +404,6 @@ export function exportSessionWorkspace(id: string, destDir?: string): ExportResu return exportWorkspace(session.secureWorkspaceDir, destDir); } -/** Apply a Safe session's changes back to the original ingested folders. */ -export function applySessionChanges(id: string): ApplyResult[] | null { - const session = sessions.get(id); - if (!session?.secureWorkspaceDir) return null; - return applyChanges(session.secureWorkspaceDir); -} - function getDirSize(dir: string): number { try { let total = 0; diff --git a/public/css/style.css b/public/css/style.css index 152355a..313697d 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -2383,17 +2383,21 @@ code { } .scheduled-item { display: flex; - align-items: center; - gap: 10px; + flex-direction: column; + gap: 6px; padding: 8px 12px; background: color-mix(in srgb, var(--accent, var(--text)) 6%, var(--surface)); border: 1px solid color-mix(in srgb, var(--accent, var(--text)) 16%, var(--border)); border-radius: 12px; font-size: 0.9em; } -.scheduled-item-main { - flex: 1; - min-width: 0; +/* Bottom strip: schedule time + "Show more" on the left, action buttons pushed + to the far right, spanning the full card width. */ +.scheduled-item-footer { + display: flex; + align-items: center; + gap: 10px; + width: 100%; } .scheduled-item-meta { display: flex; @@ -2401,7 +2405,6 @@ code { gap: 6px; color: var(--muted); font-size: 0.85em; - margin-bottom: 2px; } .scheduled-item-clock { font-size: 0.9em; @@ -2412,6 +2415,49 @@ code { word-break: break-word; color: var(--text); line-height: 1.35; + position: relative; + /* Collapse long scheduled/queue messages so a single card can't take over + the whole composer area. 2 lines is enough to preview; the footer's + "Show more" reveals the rest. Short messages stay fully visible. */ + max-height: calc(1.35em * 2); + overflow: hidden; +} +.scheduled-item.is-expanded .scheduled-item-text { + max-height: none; +} +/* Fade the last visible line of a collapsed, overflowing card so the cut feels + intentional. Mirrors the card background (see .scheduled-item). */ +.scheduled-item.is-clampable:not(.is-expanded) .scheduled-item-text::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1.1em; + background: linear-gradient( + to bottom, + transparent, + color-mix(in srgb, var(--accent, var(--text)) 6%, var(--surface)) + ); + pointer-events: none; +} +.scheduled-item-toggle { + display: none; + flex-shrink: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--accent, var(--text)); + font: inherit; + font-size: 0.82em; + font-weight: 600; + cursor: pointer; +} +.scheduled-item-toggle:hover { + text-decoration: underline; +} +.scheduled-item.is-clampable .scheduled-item-toggle { + display: inline-block; } /* Schedule menu — popover anchored to send button */ .composer-field { @@ -2618,7 +2664,7 @@ code { .scheduled-item-actions { display: flex; flex-shrink: 0; - align-self: center; + margin-left: auto; align-items: center; gap: 2px; } @@ -3544,6 +3590,24 @@ code { --_btn-bg-hover: color-mix(in srgb, var(--danger) 14%, transparent); } +/* Chat-header Share + Delete: icon-only by default, the label slides in on + hover/focus (matching the secure-workspace actions). Mobile already hides + these labels entirely; this only affects the desktop pill buttons. */ +.chat-header .share-btn .header-tool-label, +.chat-header .chat-delete-btn .header-tool-label { + max-width: 0; + overflow: hidden; + opacity: 0; + transition: max-width 0.2s ease, opacity 0.2s ease; +} +.chat-header .share-btn:hover .header-tool-label, +.chat-header .share-btn:focus-visible .header-tool-label, +.chat-header .chat-delete-btn:hover .header-tool-label, +.chat-header .chat-delete-btn:focus-visible .header-tool-label { + max-width: 80px; + opacity: 1; +} + .project-header .project-delete-btn.header-action-btn { border: 1px solid color-mix(in srgb, var(--border) 60%, var(--header-surface)); } @@ -5011,16 +5075,38 @@ code { .composer-recording__cancel:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); } -.composer-recording.is-locked .composer-recording__cancel { display: inline-flex; } +/* Locked: Cancel drops out of the bar and down to the toolbar row — pinned + bottom-left, vertically level with the send button — so the wave gets the + whole top row. (Field is position:relative, so this anchors to it.) */ +.composer-recording.is-locked .composer-recording__cancel { + display: inline-flex; + align-items: center; + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 8px; + height: 36px; +} +/* Locked: no text hint — the mic (now a send arrow) + Cancel say it all. */ +.composer-recording.is-locked .composer-recording__hint { display: none; } .composer-recording.is-cancel .composer-recording__dot { background: var(--danger); } .composer-recording.is-cancel .composer-recording__time, .composer-recording.is-cancel .composer-recording__hint { color: var(--danger); } -/* Quick-tap coaching hint */ +/* Quick-tap coaching hint — the lone "hold the mic to record" line sits dead + centre of the bar (both axes) instead of hugging the left edge. */ +.composer-recording.is-hint { + justify-content: center; + padding-top: 8px; + padding-bottom: 8px; +} .composer-recording.is-hint .composer-recording__dot, .composer-recording.is-hint .composer-recording__time, .composer-recording.is-hint .composer-recording__wave, .composer-recording.is-hint .composer-recording__cancel { display: none; } -.composer-recording.is-hint .composer-recording__hint { color: var(--muted); } +.composer-recording.is-hint .composer-recording__hint { + color: var(--muted); + text-align: center; +} /* Floating lock indicator above the mic. */ .composer-lock-hint { @@ -7662,43 +7748,79 @@ a.project-tab.project-tab--tasks:hover { .secure-workspace-bar[hidden] { display: none; } .secure-workspace-bar__size[hidden] { display: none; } .secure-workspace-bar__sep { opacity: 0.4; } -.secure-workspace-bar__change { +/* TTL text + its inline, clickable value (opens the retention menu). The span is + the positioning context so the menu anchors right under "expires in 6d". */ +.secure-workspace-bar__ttl { position: relative; } +.secure-workspace-bar__ttl-value { border: none; background: transparent; color: var(--muted); font: inherit; font-size: var(--text-xs); cursor: pointer; - text-decoration: underline; + /* Dotted underline marks it as an inline control, distinct from the solid + underline on the Save a copy / Apply / Destroy actions. */ + text-decoration: underline dotted; + text-underline-offset: 2px; padding: 0; } -.secure-workspace-bar__change:hover { color: var(--text); } +.secure-workspace-bar__ttl-value:hover { color: var(--text); } .secure-workspace-bar__action { + display: inline-flex; + align-items: center; border: none; background: transparent; color: var(--muted); font: inherit; font-size: var(--text-xs); cursor: pointer; - text-decoration: underline; padding: 0; margin-left: 8px; } +.secure-workspace-bar__action svg { display: block; } .secure-workspace-bar__action:hover { color: var(--text); } -.secure-workspace-bar__action:disabled { opacity: 0.6; cursor: default; text-decoration: none; } +.secure-workspace-bar__action:disabled { opacity: 0.6; cursor: default; } +/* Download icon by default; the "Save to my computer" label slides in on hover. */ +.secure-workspace-bar__action-label { + max-width: 0; + overflow: hidden; + white-space: nowrap; + opacity: 0; + transition: max-width 0.2s ease, opacity 0.2s ease; +} +.secure-workspace-bar__action:hover .secure-workspace-bar__action-label, +.secure-workspace-bar__action:focus-visible .secure-workspace-bar__action-label { + max-width: 160px; + opacity: 1; +} .secure-workspace-bar__destroy { + display: inline-flex; + align-items: center; border: none; background: transparent; color: var(--muted); font: inherit; font-size: var(--text-xs); cursor: pointer; - text-decoration: underline; padding: 0; margin-left: 8px; } +.secure-workspace-bar__destroy svg { display: block; } .secure-workspace-bar__destroy:hover { color: var(--down, #d9534f); } -.secure-workspace-bar__destroy:disabled { opacity: 0.6; cursor: default; text-decoration: none; } +.secure-workspace-bar__destroy:disabled { opacity: 0.6; cursor: default; } +/* Trash icon by default; the "Delete now" label slides in on hover/focus. */ +.secure-workspace-bar__destroy-label { + max-width: 0; + overflow: hidden; + white-space: nowrap; + opacity: 0; + transition: max-width 0.18s ease, opacity 0.18s ease; +} +.secure-workspace-bar__destroy:hover .secure-workspace-bar__destroy-label, +.secure-workspace-bar__destroy:focus-visible .secure-workspace-bar__destroy-label { + max-width: 90px; + opacity: 1; +} /* ── Incognito (ephemeral, read-only) ─────────────────────────────────────── */ /* "Blacked out" identity — neutral/dark, no colour. */ @@ -7789,8 +7911,8 @@ body.incognito-mode .composer-mode-btn__label { .secure-ttl-menu { top: auto; bottom: calc(100% + 4px); - right: calc(var(--space-2) * 2); - left: auto; + left: 0; + right: auto; min-width: 120px; } diff --git a/public/js/iclaw.js b/public/js/iclaw.js index 66dfefe..727c5d4 100644 --- a/public/js/iclaw.js +++ b/public/js/iclaw.js @@ -707,13 +707,21 @@ // ── Secure workspace bar ───────────────────────────────────────────────── const secureBar = document.getElementById('secure-workspace-bar'); const secureSizeEl = document.getElementById('secure-workspace-size'); - const secureTtlEl = document.getElementById('secure-workspace-ttl'); - const secureChangeBtn = document.getElementById('secure-workspace-change'); + const secureTtlPrefixEl = document.getElementById('secure-workspace-ttl-prefix'); + const secureTtlValueBtn = document.getElementById('secure-workspace-ttl-value'); const secureTtlMenu = document.getElementById('secure-ttl-menu'); + // Read the chat id live, not from the page-load `rawChatId` const. A chat that + // starts as a draft has no id until the first message adopts it (adoptDraftChat + // updates messagesEl.dataset.chatId); `rawChatId` stays empty, so keying the + // bar off it left it hidden until a reload re-rendered at /chats/:id. + const secureChatId = () => messagesEl?.dataset.chatId || ''; + function secureTtlKey() { - const pid = messagesEl?.dataset.projectId; - return `iclaw:secure-ttl:${pid ? 'project:' + pid : 'chat:' + rawChatId}`; + // Per-chat TTL: every new chat starts from the 7-day default, and changing + // it affects only that chat. (Previously chats in a project shared one TTL, + // so a new project chat could inherit 30 — we don't want that.) + return `iclaw:secure-ttl:chat:${secureChatId()}`; } function getSecureTtl() { @@ -735,23 +743,27 @@ return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } + // Returns { prefix, value } so the value can render as a separate inline, + // clickable element ("deletes in [6d]") while the prefix stays plain text. function formatTtlRemaining(ttlDays, lastActivity) { - if (ttlDays === 0) return 'never expires'; + if (ttlDays === 0) return { prefix: '', value: 'never deleted' }; // +15s demo buffer: holds the full day count for ~15s after each reset so // users can see the countdown tick down (proof the TTL resets on activity). const expiresAt = lastActivity + ttlDays * 86400_000 + 15_000; const remaining = expiresAt - Date.now(); - if (remaining <= 0) return 'expired'; + if (remaining <= 0) return { prefix: '', value: 'deleted' }; const days = Math.floor(remaining / 86400_000); const hours = Math.floor((remaining % 86400_000) / 3600_000); const mins = Math.floor((remaining % 3600_000) / 60_000); const secs = Math.floor((remaining % 60_000) / 1000); + let value; // More than 2 days: show only days (no hours) - if (days > 2) return `expires in ${days}d`; - if (days > 0) return `expires in ${days}d ${hours}h`; - if (hours > 0) return `expires in ${hours}h ${mins}m`; - if (mins > 0) return `expires in ${mins}m ${secs}s`; - return `expires in ${secs}s`; + if (days > 2) value = `${days}d`; + else if (days > 0) value = `${days}d ${hours}h`; + else if (hours > 0) value = `${hours}h ${mins}m`; + else if (mins > 0) value = `${mins}m ${secs}s`; + else value = `${secs}s`; + return { prefix: 'deletes in', value }; } // True when the secret hint UI is visible — it takes priority over the secure bar. @@ -764,42 +776,42 @@ function tickSecureTtl() { if (!secureBar || secureBar.hidden) return; const ttl = getSecureTtl(); - if (secureTtlEl) secureTtlEl.textContent = formatTtlRemaining(ttl.ttlDays, ttl.lastActivity); + const { prefix, value } = formatTtlRemaining(ttl.ttlDays, ttl.lastActivity); + // Trailing space separates the plain prefix from the clickable value. + if (secureTtlPrefixEl) secureTtlPrefixEl.textContent = prefix ? prefix + ' ' : ''; + if (secureTtlValueBtn) secureTtlValueBtn.textContent = value; } async function refreshSecureBar() { if (!secureBar) return; const mode = getComposerMode(); + const cid = secureChatId(); // Hide if not in Secure Mode, or if the secret UI is currently showing. - if (mode !== 'secure' || secretUiVisible() || !rawChatId) { secureBar.hidden = true; return; } + if (mode !== 'secure' || secretUiVisible() || !cid) { secureBar.hidden = true; return; } try { - const res = await fetch(`/chats/${rawChatId}/workspace-info`); + const res = await fetch(`/chats/${cid}/workspace-info`); const data = await res.json(); // Only surface the bar once a secure session actually exists — i.e. after // the first message. Nothing is created on the host until then. - if (!data.active) { secureBar.hidden = true; return; } + // Show the bar only once the sandbox actually holds something — an empty + // workspace has nothing to time, save, or destroy, so we surface nothing + // (not even the "Secure workspace" label) until the agent writes a file. + const size = data.active && data.workspaceSize != null ? data.workspaceSize : 0; + if (!size) { secureBar.hidden = true; return; } secureBar.hidden = false; - tickSecureTtl(); if (secureSizeEl) { - // Only show a size once there's actually something in the workspace — - // "0 B" is noise. - const size = data.workspaceSize != null ? data.workspaceSize : 0; - if (size > 0) { - secureSizeEl.textContent = formatBytes(size); - secureSizeEl.hidden = false; - } else { - secureSizeEl.textContent = ''; - secureSizeEl.hidden = true; - } + secureSizeEl.textContent = formatBytes(size); + secureSizeEl.hidden = false; } + tickSecureTtl(); } catch { secureBar.hidden = true; } } // Live countdown — updates the TTL text every second. setInterval(tickSecureTtl, 1000); - secureChangeBtn?.addEventListener('click', (e) => { + secureTtlValueBtn?.addEventListener('click', (e) => { e.stopPropagation(); if (secureTtlMenu) { secureTtlMenu.hidden = !secureTtlMenu.hidden; @@ -814,57 +826,34 @@ refreshSecureBar(); }); - // Export the sandbox out to a host folder (default ~/Downloads). Read-only — - // copies the sandbox contents to a fresh place, touches nothing of the user's. + // Save a copy of the sandbox out to a host folder (default ~/Downloads). + // Read-only — copies the sandbox contents to a fresh place, touches nothing of + // the user's. Confirmed first with a plain-language explanation. const secureExportBtn = document.getElementById('secure-workspace-export'); secureExportBtn?.addEventListener('click', async (e) => { e.stopPropagation(); - if (!rawChatId) return; + const cid = secureChatId(); + if (!cid) return; + if (!confirm( + 'Save a copy of this secure workspace to your Downloads folder?\n\n' + + 'Heads up - these files leave the safe sandbox and land on your real computer. ' + + 'If anything was downloaded in here, it could carry viruses or other nasty surprises\n\n' + + 'That\'s exactly what the sandbox is for - to keep anything dangerous trapped in there' + )) return; + // The button holds an icon + label (not text), so don't touch textContent — + // just disable it for the duration of the copy. secureExportBtn.disabled = true; - const prev = secureExportBtn.textContent; - secureExportBtn.textContent = 'Exporting…'; try { - const res = await fetch(`/chats/${rawChatId}/export-sandbox`, { + const res = await fetch(`/chats/${cid}/export-sandbox`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: '{}', }); const data = await res.json(); - if (data && data.ok) alert(`Exported ${data.files != null ? data.files + ' files' : 'sandbox'} to:\n${data.path}`); - else alert(`Export failed: ${(data && data.error) || 'unknown error'}`); - } catch { alert('Export failed.'); } + if (data && data.ok) alert(`Saved a copy${data.files != null ? ` (${data.files} files)` : ''} here:\n${data.path}`); + else alert(`Couldn't save the copy: ${(data && data.error) || 'unknown error'}`); + } catch { alert("Couldn't save the copy."); } secureExportBtn.disabled = false; - secureExportBtn.textContent = prev || 'Export'; - }); - - // Apply: copy the sandbox's new/changed files back to the ORIGINAL folders. - // This writes to the user's real files, so it's confirmed and additive only. - const secureApplyBtn = document.getElementById('secure-workspace-apply'); - secureApplyBtn?.addEventListener('click', async (e) => { - e.stopPropagation(); - if (!rawChatId) return; - if (!confirm('Apply the sandbox\'s new and changed files back to your original folders? Existing files may be overwritten; nothing is deleted.')) return; - secureApplyBtn.disabled = true; - const prev = secureApplyBtn.textContent; - secureApplyBtn.textContent = 'Applying…'; - try { - const res = await fetch(`/chats/${rawChatId}/apply-sandbox`, { - method: 'POST', - headers: { Accept: 'application/json' }, - }); - const data = await res.json(); - const results = (data && data.results) || []; - if (results.length === 0) { - alert('Nothing to apply — no folders were copied into this sandbox.'); - } else { - const lines = results.map((r) => r.ok - ? `• ${r.source}: ${(r.applied && r.applied.length) || 0} file(s)` - : `✗ ${r.source}: ${r.error}`); - alert('Applied changes:\n' + lines.join('\n')); - } - } catch { alert('Apply failed.'); } - secureApplyBtn.disabled = false; - secureApplyBtn.textContent = prev || 'Apply changes'; }); // Destroy the sandbox: deletes the copied workspace + container. The next @@ -872,24 +861,24 @@ const secureDestroyBtn = document.getElementById('secure-workspace-destroy'); secureDestroyBtn?.addEventListener('click', async (e) => { e.stopPropagation(); - if (!rawChatId) return; - if (!confirm('Destroy this sandbox? The copied files and anything created in it are deleted. Your original files are untouched.')) return; + const cid = secureChatId(); + if (!cid) return; + if (!confirm('Delete this sandbox now? Everything copied or created inside it is removed right away - your original files on your computer are not touched')) return; + // The button holds an icon + label (not text), so don't touch textContent — + // just disable it; the bar refreshes (and usually hides) once it's gone. secureDestroyBtn.disabled = true; - const prev = secureDestroyBtn.textContent; - secureDestroyBtn.textContent = 'Destroying…'; try { - await fetch(`/chats/${rawChatId}/destroy-workspace`, { + await fetch(`/chats/${cid}/destroy-workspace`, { method: 'POST', headers: { Accept: 'application/json' }, }); } catch { /* best-effort */ } secureDestroyBtn.disabled = false; - secureDestroyBtn.textContent = prev || 'Destroy'; refreshSecureBar(); }); document.addEventListener('click', (e) => { - if (secureTtlMenu && !secureTtlMenu.hidden && !secureChangeBtn?.contains(e.target) && !secureTtlMenu.contains(e.target)) { + if (secureTtlMenu && !secureTtlMenu.hidden && !secureTtlValueBtn?.contains(e.target) && !secureTtlMenu.contains(e.target)) { secureTtlMenu.hidden = true; } }); @@ -948,6 +937,13 @@ let meterRaf = 0; let timeData = null; let waveSamples = []; + // Advance the waveform on a fixed cadence (not once per animation frame) so + // the bars scroll at a calm, readable speed regardless of the display's + // refresh rate. Between pushes we keep the loudest level seen, so a brief + // peak still lands as a tall bar. + const WAVE_SAMPLE_MS = 55; + let lastWaveAt = 0; + let wavePeak = 0; let accentColor = '#4f8cff'; let dangerColor = '#e5484d'; @@ -1166,6 +1162,8 @@ sourceNode.connect(analyser); timeData = new Uint8Array(analyser.fftSize); waveSamples = []; + lastWaveAt = 0; + wavePeak = 0; } catch (_) { // Meter is best-effort; recording still works without it. } @@ -1196,11 +1194,22 @@ const v = (timeData[i] - 128) / 128; sum += v * v; } - level = Math.min(1, Math.sqrt(sum / timeData.length) * 2.4); + // Higher gain so normal speech clearly pushes the bars up: quiet stays + // near the baseline, loud reaches (near) full height. + level = Math.min(1, Math.sqrt(sum / timeData.length) * 3.0); } + // The halo tracks the live level every frame for a smooth pulse… micBtn.style.setProperty('--mic-amp', level.toFixed(3)); - waveSamples.push(level); - drawWave(); + // …but the waveform only advances every WAVE_SAMPLE_MS, carrying the peak + // level from the in-between frames so loud moments read as tall bars. + wavePeak = Math.max(wavePeak, level); + const now = performance.now(); + if (now - lastWaveAt >= WAVE_SAMPLE_MS) { + lastWaveAt = now; + waveSamples.push(wavePeak); + wavePeak = 0; + drawWave(); + } } function drawWave() { @@ -1301,11 +1310,10 @@ setCancelArmed(false); setMicState('locked'); if (recEl) recEl.classList.add('is-locked'); - if (recHintEl) recHintEl.textContent = 'tap mic to send'; - if (lockHintEl) { - lockHintEl.classList.add('is-locked'); - setLockProgress(1); - } + // Lock confirmed — hide the floating lock pill so it doesn't sit over the + // bar; the mic itself turns into the send button and Cancel drops to the + // toolbar row. + if (lockHintEl) lockHintEl.hidden = true; try { micBtn.releasePointerCapture(activePointerId); } catch (_) {} sizeWaveCanvas(); } @@ -3276,21 +3284,55 @@ '</span>' : '<span class="scheduled-item-when">' + escapeHtml(opts.metaText) + '</span>'; return ( - '<div class="scheduled-item-main">' + + '<div class="scheduled-item-text">' + + escapeHtml(opts.content) + + '</div>' + + '<div class="scheduled-item-footer">' + '<div class="scheduled-item-meta">' + '<span class="scheduled-item-clock" aria-hidden="true">' + opts.metaIcon + '</span>' + whenEl + '</div>' + - '<div class="scheduled-item-text">' + - escapeHtml(opts.content) + - '</div>' + - '</div>' + - opts.actionsHtml + '<button type="button" class="scheduled-item-toggle" aria-expanded="false">Show more</button>' + + opts.actionsHtml + + '</div>' ); } + // Measure whether a pending-row's message overflows its collapsed height and + // wire the "Show more" toggle accordingly. The full text always stays in the + // DOM (only visually clipped via CSS), so edit/read paths still see it all. + // Re-runnable: preserves an already-expanded row across content updates. + function applyPendingRowClamp(row) { + if (!row) return; + const textEl = row.querySelector('.scheduled-item-text'); + const toggle = row.querySelector('.scheduled-item-toggle'); + if (!textEl || !toggle) return; + const wasExpanded = row.classList.contains('is-expanded'); + // Collapse first so scrollHeight/clientHeight reflect the clamped box. + row.classList.remove('is-expanded'); + const clampable = textEl.scrollHeight - textEl.clientHeight > 4; + row.classList.toggle('is-clampable', clampable); + const expanded = clampable && wasExpanded; + row.classList.toggle('is-expanded', expanded); + toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + toggle.textContent = expanded ? 'Show less' : 'Show more'; + } + + // Shared click handler for the "Show more"/"Show less" toggle in either list. + // Returns true if it handled the event (so callers can early-return). + function handlePendingRowToggleClick(e) { + const toggle = e.target.closest('.scheduled-item-toggle'); + if (!toggle) return false; + const row = toggle.closest('.scheduled-item'); + if (!row) return false; + const expanded = row.classList.toggle('is-expanded'); + toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + toggle.textContent = expanded ? 'Show less' : 'Show more'; + return true; + } + function queueItemActionsHtml(id) { const esc = escapeHtml(String(id)); return ( @@ -3324,6 +3366,7 @@ actionsHtml: queueItemActionsHtml(rowId), }); queueListEl.appendChild(row); + applyPendingRowClamp(row); }); queueListEl.classList.toggle('is-empty', waitingItems.length === 0); } @@ -3479,6 +3522,7 @@ // Delete from queue + interrupt-and-promote via event delegation. if (queueListEl) { queueListEl.addEventListener('click', (e) => { + if (handlePendingRowToggleClick(e)) return; const row = e.target.closest('.scheduled-item--queue'); if (!row) return; const id = row.dataset.queueId; @@ -4897,6 +4941,11 @@ setWorkingDot(msg.chatId, false); if (msg.chatId !== activeChatId) return; setStopVisible(false); + // The first secure turn creates the sandbox host-side; surface the bar + // as soon as the turn finishes (no-op outside Secure Mode). The fixed + // retry timers on send can miss a slow container start, so refresh here + // too rather than wait for a reload. + refreshSecureBar(); // Tear down a streaming element that nobody finalized — e.g. an // abort with no streamed text (skipPersist on the server → no // `message-appended` to clean it up), or any other edge where the @@ -7044,6 +7093,7 @@ actionsHtml: scheduledItemActionsHtml(scheduled.id), }); scheduledListEl.appendChild(row); + applyPendingRowClamp(row); scheduledListEl.classList.remove('is-empty'); sortScheduledListDom(); } @@ -7064,6 +7114,7 @@ } const textEl = row.querySelector('.scheduled-item-text'); if (textEl) textEl.textContent = scheduled.content; + applyPendingRowClamp(row); sortScheduledListDom(); } @@ -8071,6 +8122,7 @@ if (scheduledListEl) { scheduledListEl.addEventListener('click', async (e) => { + if (handlePendingRowToggleClick(e)) return; const sendNowBtn = e.target.closest('.scheduled-item-send-now'); if (sendNowBtn) { const sid = Number(sendNowBtn.dataset.scheduledId); @@ -8102,6 +8154,8 @@ }); sortScheduledListDom(); refreshScheduledTimes(); + // Server-rendered rows (first paint) need the same overflow measurement. + scheduledListEl.querySelectorAll('.scheduled-item').forEach(applyPendingRowClamp); } // ------------------------------------------------------------------------- diff --git a/src/db/database.ts b/src/db/database.ts index edf58d8..fb0bdb8 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS messages ( reply_to_role TEXT, /** JSON array of {url, mimeType, fileName, sizeBytes} for user-attached files. NULL when no attachments. */ attachments TEXT, - /** Send mode: 'ask' | 'execute' (see services/chatModes.ts). Legacy rows default to 'execute'. */ + /** Send mode: see services/chatModes.ts (execute/work/secure/incognito). Legacy rows default to 'execute'. */ mode TEXT NOT NULL DEFAULT 'execute', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/src/routes/chats.ts b/src/routes/chats.ts index e2d1460..d4b199e 100644 --- a/src/routes/chats.ts +++ b/src/routes/chats.ts @@ -27,7 +27,7 @@ import { openclawWs } from '../services/openclawWs'; import { openclaw, cloudShareBaseUrl } from '../services/openclaw'; import { chatStatus } from '../services/chatStatus'; import { wsHub } from '../services/wsHub'; -import { sendMessage, getWorkSessionId, destroyWorkSession, exportChatSandbox, applyChatSandboxChanges } from '../services/chatRunner'; +import { sendMessage, getWorkSessionId, destroyWorkSession, exportChatSandbox } from '../services/chatRunner'; import { getWorkspaceInfo } from '../services/workRuntime'; import { defaultComposerMode, @@ -1131,15 +1131,3 @@ chatsRouter.post('/:id/export-sandbox', async (req, res) => { res.status(502).json({ ok: false, error: err instanceof Error ? err.message : String(err) }); } }); - -/** POST /chats/:id/apply-sandbox — apply the sandbox's changes to the originals. */ -chatsRouter.post('/:id/apply-sandbox', async (req, res) => { - const chatId = Number(req.params.id); - if (!Number.isFinite(chatId)) return res.status(400).json({ error: 'bad chat id' }); - try { - const results = await applyChatSandboxChanges(chatId); - res.json({ results }); - } catch (err) { - res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); - } -}); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 624ca25..00aae70 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -2,7 +2,7 @@ * GET /settings — Settings page. * * Sectioned scaffold: Remote Access + OpenRouter (the key that unlocks voice - * messages, Ask mode, and smart titles). The key is stored in the local DB via + * messages and smart titles). The key is stored in the local DB via * Settings — not an env var — and takes effect on the next page load. */ diff --git a/src/services/chatRunner.ts b/src/services/chatRunner.ts index 9629c9a..fd3f2c3 100644 --- a/src/services/chatRunner.ts +++ b/src/services/chatRunner.ts @@ -29,7 +29,7 @@ import { homedir } from 'node:os'; import type { ChatMode, Message, MessageAttachment } from '../types'; import { DEFAULT_MODE } from './chatModes'; import { buildCompactedHistory } from './contextCompaction'; -import { createWorkSession, sendWorkMessage, subscribeWorkEvents, stopWorkSession, abortWorkSession, exportSandbox, applySandboxChanges, type ExportResult, type ApplyResult } from './workRuntime'; +import { createWorkSession, sendWorkMessage, subscribeWorkEvents, stopWorkSession, abortWorkSession, exportSandbox, type ExportResult } from './workRuntime'; import { expandStoredSecretPlaceholdersForGateway, resolveInlineSecretMarkersInContent, @@ -662,7 +662,7 @@ export async function sendMessage(opts: { incomingAttachments?: IncomingAttachment[]; /** Files already on disk (queued-message flush). */ prePersistedAttachments?: MessageAttachment[]; - /** 'ask' | 'execute'. Defaults to 'execute' when omitted. */ + /** Chat mode (execute/work/secure/incognito); defaults to 'execute' when omitted. */ mode?: ChatMode; /** Allowed folders for Work Mode, each with a read-only / read&write flag. */ workFolders?: WorkFolder[]; @@ -856,13 +856,6 @@ export async function exportChatSandbox(chatId: number, destDir?: string): Promi return exportSandbox(sessionId, destDir); } -/** Apply a chat's Safe sandbox changes back to the original folders. */ -export async function applyChatSandboxChanges(chatId: number): Promise<ApplyResult[]> { - const sessionId = workSessions.get(chatId)?.sessionId; - if (!sessionId) return []; - return applySandboxChanges(sessionId); -} - /** Secure workspaces live here (mirrors the runtime's SECURE_DATA_DIR default). */ const SECURE_WORKSPACE_ROOT = process.env.ICLAW_SECURE_DATA_DIR || joinPath(homedir(), '.iclaw', 'secure'); diff --git a/src/services/config.ts b/src/services/config.ts index 226d207..55bc172 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -46,11 +46,9 @@ export function loadCloudShareBaseUrl(): string { } export interface OpenRouterConfig { - /** Empty when unconfigured — gates Ask/STT and title routing. */ + /** Empty when unconfigured — gates STT and title routing. */ apiKey: string; baseUrl: string; - /** Model for Ask turns. */ - askModel: string; /** Model for chat-title generation (cheapest sensible default). */ titleModel: string; /** Cheap model for context compaction (summarizing old turns). */ @@ -66,27 +64,28 @@ export interface OpenRouterConfig { const OPENROUTER_API_KEY_KV = 'openrouter.api_key'; /** - * Direct OpenRouter access for the tool-less features (Ask, titles, STT). + * Direct OpenRouter access for the tool-less features (titles, STT, sub-tasks). * * The API key is entered by the user in Settings and stored in the local DB - * (`iclaw_kv`) — NOT an env var. Models default to a cheap, fast, multimodal - * flash model; advanced users can still override per-feature via the optional - * `ICLAW_*_MODEL` env vars (undocumented). + * (`iclaw_kv`) — NOT an env var. Chat titles reuse the cheap summary-tier + * model; STT keeps a multimodal flash default. Advanced users can still + * override per-feature via the optional `ICLAW_*_MODEL` env vars (undocumented). */ export function loadOpenRouterConfig(): OpenRouterConfig { const apiKey = (kvGet(OPENROUTER_API_KEY_KV) ?? '').trim(); const baseUrl = ( process.env.OPENROUTER_BASE_URL?.trim() || 'https://openrouter.ai/api/v1' ).replace(/\/+$/, ''); - const askModel = process.env.ICLAW_ASK_MODEL?.trim() || 'google/gemini-2.5-flash'; - const titleModel = process.env.ICLAW_TITLE_MODEL?.trim() || 'google/gemini-2.5-flash'; // Cheap/fast model for compaction; overridable. Falls back to truncation if // the call fails, so an invalid slug degrades gracefully. - const summaryModel = process.env.ICLAW_SUMMARY_MODEL?.trim() || 'google/gemini-2.5-flash-lite'; + const summaryModel = process.env.ICLAW_SUMMARY_MODEL?.trim() || 'tencent/hy3-preview'; + // Chat titles are a throwaway one-liner — always reuse the summary-tier + // model. Tied to summaryModel directly; no separate ICLAW_TITLE_MODEL knob. + const titleModel = summaryModel; const sttModel = process.env.ICLAW_STT_MODEL?.trim() || 'google/gemini-2.5-flash'; const referer = process.env.OPENROUTER_REFERER?.trim() || 'https://iclaw.digital'; const appTitle = process.env.OPENROUTER_APP_TITLE?.trim() || 'iClaw'; - return { apiKey, baseUrl, askModel, titleModel, summaryModel, sttModel, referer, appTitle }; + return { apiKey, baseUrl, titleModel, summaryModel, sttModel, referer, appTitle }; } /** Persist the user's OpenRouter API key (from Settings). Empty/blank clears it. */ diff --git a/src/services/openRouter.ts b/src/services/openRouter.ts index b31a13f..aa9b9b5 100644 --- a/src/services/openRouter.ts +++ b/src/services/openRouter.ts @@ -1,23 +1,18 @@ /** - * Direct OpenRouter client — the FIRST non-OpenClaw LLM path in iClaw. - * - * Used for the lightweight, tool-less features that should NOT spin up a full - * OpenClaw agent run: + * Direct OpenRouter client — a non-OpenClaw LLM path for the lightweight, + * tool-less features that should NOT spin up a full OpenClaw agent run: * - Chat titles → a cheap single-shot completion. * - Background sub-tasks (fact extraction, fact compaction, skill review) via * services/subtaskLlm.ts (preferred over a throwaway OpenClaw turn). * - Speech-to-text → audio transcription via a multimodal model. * * Talks the OpenAI-compatible `/chat/completions` endpoint OpenRouter exposes. - * Streaming responses are parsed here (server-sent events over `fetch`) and the - * caller re-emits deltas through `wsHub` — the same shape `openclawWs.runTurn` - * uses, so the browser stream renderer is identical for both backends. * - * No SDK / extra deps: Node 18+ (we run 25) ships global `fetch` + streams. + * No SDK / extra deps: Node 18+ (we run 25) ships global `fetch`. * - * Availability is config-driven: when `OPENROUTER_API_KEY` is unset, the - * features that REQUIRE OpenRouter (Ask, STT) are hidden/refused, and title - * generation falls back to the OpenClaw path. See `loadOpenRouterConfig`. + * Availability is config-driven: when `OPENROUTER_API_KEY` is unset, STT is + * hidden/refused and title generation falls back to the OpenClaw path. See + * `loadOpenRouterConfig`. */ import { loadOpenRouterConfig } from './config'; @@ -27,7 +22,7 @@ export interface OpenRouterMessage { content: string; } -/** True when an API key is configured — gates Ask/STT and title routing. */ +/** True when an API key is configured — gates STT and title routing. */ export function openRouterEnabled(): boolean { return Boolean(loadOpenRouterConfig().apiKey); } @@ -59,8 +54,8 @@ function buildHeaders(apiKey: string, referer: string, appTitle: string): Record interface ChatCompletionOpts { messages: OpenRouterMessage[]; - /** Defaults to the configured Ask model. */ - model?: string; + /** OpenRouter model slug to call. */ + model: string; temperature?: number; maxTokens?: number; /** Abort the in-flight request (Stop button). */ @@ -74,7 +69,7 @@ interface ChatCompletionOpts { export async function complete(opts: ChatCompletionOpts): Promise<string> { const cfg = loadOpenRouterConfig(); if (!cfg.apiKey) fail('no API key configured (set OPENROUTER_API_KEY)'); - const model = opts.model ?? cfg.askModel; + const model = opts.model; let res: Response; try { @@ -102,94 +97,6 @@ export async function complete(opts: ChatCompletionOpts): Promise<string> { return json?.choices?.[0]?.message?.content ?? ''; } -interface StreamCompletionOpts extends ChatCompletionOpts { - /** Fires for every text delta as it streams in. */ - onDelta: (text: string) => void; -} - -/** - * Streaming completion. Calls `onDelta` for each token chunk and resolves with - * the full accumulated text. Parses OpenRouter's SSE stream (`data: {json}` - * lines, terminated by `data: [DONE]`). - */ -export async function streamComplete(opts: StreamCompletionOpts): Promise<string> { - const cfg = loadOpenRouterConfig(); - if (!cfg.apiKey) fail('no API key configured (set OPENROUTER_API_KEY)'); - const model = opts.model ?? cfg.askModel; - - let res: Response; - try { - res = await fetch(`${cfg.baseUrl}/chat/completions`, { - method: 'POST', - headers: buildHeaders(cfg.apiKey, cfg.referer, cfg.appTitle), - body: JSON.stringify({ - model, - messages: opts.messages, - stream: true, - // Disable extended thinking — Ask mode is for quick answers, not deep reasoning - thinking: { type: 'disabled' }, - ...(opts.temperature != null ? { temperature: opts.temperature } : {}), - ...(opts.maxTokens != null ? { max_tokens: opts.maxTokens } : {}), - }), - signal: opts.signal, - }); - } catch (err) { - fail(`request failed: ${err instanceof Error ? err.message : String(err)}`); - } - if (!res.ok) fail(`HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 300)}`); - if (!res.body) fail('no response body to stream'); - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let full = ''; - - // SSE frames are separated by a blank line. We accumulate across chunks - // because a single network chunk can split a `data:` line mid-JSON. - const consumeLine = (line: string): boolean => { - const trimmed = line.trim(); - if (!trimmed.startsWith('data:')) return false; - const payload = trimmed.slice('data:'.length).trim(); - if (payload === '[DONE]') return true; - try { - const json = JSON.parse(payload) as { - choices?: Array<{ delta?: { content?: string } }>; - }; - const delta = json.choices?.[0]?.delta?.content; - if (delta) { - full += delta; - opts.onDelta(delta); - } - } catch { - // OpenRouter sends `: OPENROUTER PROCESSING` keep-alive comments and the - // occasional partial line — ignore anything that isn't valid JSON. - } - return false; - }; - - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let nl: number; - while ((nl = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, nl); - buffer = buffer.slice(nl + 1); - if (consumeLine(line)) { - await reader.cancel().catch(() => {}); - return full; - } - } - } - // Flush any trailing buffered line (stream ended without final newline). - if (buffer.trim()) consumeLine(buffer); - } finally { - reader.releaseLock?.(); - } - return full; -} - /** * Transcribe audio to text via a multimodal model. Sends the clip as an * `input_audio` content part to /chat/completions and asks for a verbatim diff --git a/src/services/workRuntime.ts b/src/services/workRuntime.ts index e8870cf..be113b7 100644 --- a/src/services/workRuntime.ts +++ b/src/services/workRuntime.ts @@ -203,12 +203,6 @@ export async function getWorkspaceInfo(sessionId: string): Promise<{ workspaceSi } export interface ExportResult { ok: boolean; path?: string; files?: number; error?: string } -export interface ApplyResult { - ok: boolean; - source?: string; - applied?: { path: string; kind: 'created' | 'modified' }[]; - error?: string; -} /** Export a Safe sandbox to a host folder (default ~/Downloads). */ export async function exportSandbox(sessionId: string, destDir?: string): Promise<ExportResult> { @@ -217,13 +211,6 @@ export async function exportSandbox(sessionId: string, destDir?: string): Promis return res.data as ExportResult; } -/** Apply a Safe sandbox's changes back to the originally-ingested folders. */ -export async function applySandboxChanges(sessionId: string): Promise<ApplyResult[]> { - const res = await request('POST', `/sessions/${sessionId}/apply`, {}); - if (res.status !== 200) throw new Error(`Apply failed: ${JSON.stringify(res.data)}`); - return (res.data as { results: ApplyResult[] }).results; -} - /** True if the runtime service appears to be running. */ export async function runtimeAvailable(): Promise<boolean> { try { diff --git a/test/unit/modelCapabilities.test.ts b/test/unit/modelCapabilities.test.ts new file mode 100644 index 0000000..f055d57 --- /dev/null +++ b/test/unit/modelCapabilities.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Vision-capability gate (packages/iclaw-runtime/src/agent/model-capabilities.ts). + * + * The module caches the OpenRouter /models result at module scope, so each test + * loads a FRESH copy via vi.resetModules() + dynamic import — no cross-test cache + * bleed. `fetch` is stubbed; no network is ever hit. + */ + +const MODELS_BODY = { + data: [ + { id: 'google/gemini-2.5-flash', architecture: { input_modalities: ['text', 'image'] } }, + { id: 'deepseek/deepseek-v4-flash', architecture: { input_modalities: ['text'] } }, + { id: 'a/img-via-modality', architecture: { modality: 'text+image->text' } }, + { id: 'a/text-via-modality', architecture: { modality: 'text->text' } }, + ], +}; + +function okFetch() { + return vi.fn(async () => ({ ok: true, status: 200, json: async () => MODELS_BODY } as unknown as Response)); +} +function failFetch() { + return vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) } as unknown as Response)); +} + +async function freshModule() { + vi.resetModules(); + return import('../../packages/iclaw-runtime/src/agent/model-capabilities'); +} + +const ENV_KEYS = ['ICLAW_VISION_MODEL', 'ICLAW_VISION_MODELS', 'ICLAW_MODELS_CACHE_TTL_MS']; +let savedEnv: Record<string, string | undefined> = {}; + +beforeEach(() => { + savedEnv = {}; + for (const k of ENV_KEYS) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('isVisionModel', () => { + it('derives capability from input_modalities and the modality string', async () => { + vi.stubGlobal('fetch', okFetch()); + const { isVisionModel } = await freshModule(); + expect(await isVisionModel('google/gemini-2.5-flash', 'k')).toBe(true); + expect(await isVisionModel('deepseek/deepseek-v4-flash', 'k')).toBe(false); + expect(await isVisionModel('a/img-via-modality', 'k')).toBe(true); + expect(await isVisionModel('a/text-via-modality', 'k')).toBe(false); + expect(await isVisionModel('a/not-in-registry', 'k')).toBe(false); + }); + + it('honors the ICLAW_VISION_MODELS allowlist without a network call', async () => { + process.env.ICLAW_VISION_MODELS = 'my/local-vlm, other/vision'; + const fetchMock = okFetch(); + vi.stubGlobal('fetch', fetchMock); + const { isVisionModel } = await freshModule(); + expect(await isVisionModel('my/local-vlm', 'k')).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns null (unknown) when the registry fetch fails and nothing is cached', async () => { + vi.stubGlobal('fetch', failFetch()); + const { isVisionModel } = await freshModule(); + expect(await isVisionModel('deepseek/deepseek-v4-flash', 'k')).toBeNull(); + }); +}); + +describe('resolveTurnModel', () => { + it('returns the configured model and never hits the network for a text turn', async () => { + const fetchMock = okFetch(); + vi.stubGlobal('fetch', fetchMock); + const { resolveTurnModel } = await freshModule(); + const d = await resolveTurnModel({ model: 'deepseek/deepseek-v4-flash', apiKey: 'k', hasImages: false }); + expect(d).toEqual({ model: 'deepseek/deepseek-v4-flash' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('routes an image turn off a text-only model to the default vision fallback', async () => { + vi.stubGlobal('fetch', okFetch()); + const { resolveTurnModel } = await freshModule(); + const d = await resolveTurnModel({ model: 'deepseek/deepseek-v4-flash', apiKey: 'k', hasImages: true }); + expect(d.model).toBe('google/gemini-2.5-flash'); + expect(d.switched).toBe(true); + expect(d.error).toBeUndefined(); + }); + + it('leaves an image turn on a vision-capable model untouched', async () => { + vi.stubGlobal('fetch', okFetch()); + const { resolveTurnModel } = await freshModule(); + const d = await resolveTurnModel({ model: 'google/gemini-2.5-flash', apiKey: 'k', hasImages: true }); + expect(d).toEqual({ model: 'google/gemini-2.5-flash' }); + }); + + it('errors when neither the model nor the configured vision fallback can see', async () => { + process.env.ICLAW_VISION_MODEL = 'deepseek/deepseek-v4-flash'; // text-only + vi.stubGlobal('fetch', okFetch()); + const { resolveTurnModel } = await freshModule(); + const d = await resolveTurnModel({ model: 'a/not-in-registry', apiKey: 'k', hasImages: true }); + expect(d.error).toMatch(/can't accept images/i); + expect(d.switched).toBeUndefined(); + }); + + it('does not swap when capability is unknown (fetch failed) — lets the turn proceed', async () => { + vi.stubGlobal('fetch', failFetch()); + const { resolveTurnModel } = await freshModule(); + const d = await resolveTurnModel({ model: 'deepseek/deepseek-v4-flash', apiKey: 'k', hasImages: true }); + expect(d).toEqual({ model: 'deepseek/deepseek-v4-flash' }); + }); +}); diff --git a/test/unit/secureExport.test.ts b/test/unit/secureExport.test.ts index b3b5820..d83c634 100644 --- a/test/unit/secureExport.test.ts +++ b/test/unit/secureExport.test.ts @@ -5,10 +5,7 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { ingestSources } from '../../packages/iclaw-runtime/src/secure-ingest'; -import { - exportWorkspace, applyChanges, -} from '../../packages/iclaw-runtime/src/secure-export'; +import { exportWorkspace } from '../../packages/iclaw-runtime/src/secure-export'; let root: string; let workspace: string; @@ -39,52 +36,3 @@ describe('exportWorkspace', () => { expect(existsSync(join(r.path!, '.iclaw-ingest.json'))).toBe(false); }); }); - -describe('applyChanges', () => { - it('copies new and modified files back to the original, without deleting', async () => { - // Original project. - const src = join(root, 'project'); - mkdirSync(join(src, 'src'), { recursive: true }); - writeFileSync(join(src, 'src', 'a.ts'), 'A\n'); - writeFileSync(join(src, 'keep.txt'), 'KEEP\n'); - - // Ingest it (records the source→target mapping in .iclaw-ingest.json). - await ingestSources(workspace, [{ kind: 'folder', path: src }]); - const sandbox = join(workspace, 'project'); - - // Agent edits a.ts, adds b.ts, and "deletes" keep.txt inside the sandbox. - writeFileSync(join(sandbox, 'src', 'a.ts'), 'A changed\n'); - writeFileSync(join(sandbox, 'new.ts'), 'NEW\n'); - rmSync(join(sandbox, 'keep.txt'), { force: true }); - - const results = applyChanges(workspace); - expect(results).toHaveLength(1); - expect(results[0].ok).toBe(true); - const applied = results[0].applied!.map((c) => `${c.kind}:${c.path}`).sort(); - - // Modified + created are applied back to the original. - expect(readFileSync(join(src, 'src', 'a.ts'), 'utf-8')).toBe('A changed\n'); - expect(readFileSync(join(src, 'new.ts'), 'utf-8')).toBe('NEW\n'); - expect(applied.some((s) => s.includes('a.ts'))).toBe(true); - expect(applied.some((s) => s.includes('new.ts'))).toBe(true); - - // Deletion in the sandbox does NOT delete the user's original file. - expect(existsSync(join(src, 'keep.txt'))).toBe(true); - expect(readFileSync(join(src, 'keep.txt'), 'utf-8')).toBe('KEEP\n'); - }); - - it('applies nothing when the sandbox is unchanged', async () => { - const src = join(root, 'p2'); - mkdirSync(src, { recursive: true }); - writeFileSync(join(src, 'f.txt'), 'same\n'); - await ingestSources(workspace, [{ kind: 'folder', path: src }]); - - const results = applyChanges(workspace); - expect(results[0].ok).toBe(true); - expect(results[0].applied).toEqual([]); - }); - - it('returns empty when nothing was ingested', () => { - expect(applyChanges(workspace)).toEqual([]); - }); -}); diff --git a/test/unit/secureWebFetch.test.ts b/test/unit/secureWebFetch.test.ts new file mode 100644 index 0000000..fb42247 --- /dev/null +++ b/test/unit/secureWebFetch.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; + +import { webFetchSandboxed, webSearchSecure } from '../../packages/iclaw-runtime/src/agent/tools'; + +// webFetchSandboxed is the Secure-Mode web_fetch: it runs `curl` INSIDE the +// sandbox via the injected runInSandbox callback and parses a marker-delimited +// payload. These tests drive it with a fake runInSandbox (no Docker), so they +// cover URL handling, the META/ERR parsing, HTML stripping, the within-turn +// cache, and — most importantly — that a model-supplied URL is shell-quoted so +// it can never break out into arbitrary commands. + +const META = '__ICLAW_FETCH_META__'; +const ok = (ct: string, body: string) => async () => `${META}200 ${ct}\n${body}`; + +describe('webFetchSandboxed', () => { + it('refuses non-http(s) URLs without touching the sandbox', async () => { + let ran = false; + const r = await webFetchSandboxed( + { url: 'ftp://example.com/x' }, + { runInSandbox: async () => { ran = true; return ''; }, networkEnabled: true }, + ); + expect(ran).toBe(false); + expect(r).toMatch(/absolute http/i); + }); + + it('returns guidance (and runs nothing) when network is OFF', async () => { + let ran = false; + const r = await webFetchSandboxed( + { url: 'https://example.com' }, + { runInSandbox: async () => { ran = true; return ''; }, networkEnabled: false }, + ); + expect(ran).toBe(false); + expect(r).toMatch(/network.*OFF/i); + }); + + it('parses the META marker + body and reports the HTTP status', async () => { + const r = await webFetchSandboxed( + { url: 'https://example.com/data.txt', summarize: false }, + { runInSandbox: ok('text/plain', 'hello world'), networkEnabled: true }, + ); + expect(r).toContain('HTTP 200'); + expect(r).toContain('hello world'); + }); + + it('strips HTML when the content-type is html', async () => { + const r = await webFetchSandboxed( + { url: 'https://example.com', summarize: false }, + { runInSandbox: ok('text/html; charset=utf-8', '<html><body><p>Hi <b>there</b></p></body></html>'), networkEnabled: true }, + ); + expect(r).toContain('Hi'); + expect(r).toContain('there'); + expect(r).not.toContain('<b>'); + }); + + it('surfaces a curl error marker as a fetch failure', async () => { + const r = await webFetchSandboxed( + { url: 'https://nope.invalid', summarize: false }, + { runInSandbox: async () => '__ICLAW_FETCH_ERR__exit 6\ncurl: (6) Could not resolve host', networkEnabled: true }, + ); + expect(r).toMatch(/Fetch failed/i); + }); + + it('single-quotes the URL so shell metacharacters cannot break out', async () => { + let cmd = ''; + // No whitespace (so it passes the http(s) check), but packed with $(), backticks and ;. + const evil = 'https://evil.test/x?a=$(touch);b=`id`;c=1'; + await webFetchSandboxed( + { url: evil, summarize: false }, + { runInSandbox: async (c) => { cmd = c; return ok('text/plain', 'ok')(); }, networkEnabled: true }, + ); + // The whole URL must appear wrapped in a single-quoted arg → the shell treats + // $(), `` and ; as literal text, not commands. + expect(cmd).toContain(`'${evil}'`); + // And it must not appear bare (outside the quotes). + expect(cmd.replace(`'${evil}'`, '')).not.toContain('$(touch)'); + }); + + it('canonicalizes a github repo URL to its raw README before fetching', async () => { + let cmd = ''; + await webFetchSandboxed( + { url: 'https://github.com/openai/whisper', summarize: false }, + { runInSandbox: async (c) => { cmd = c; return ok('text/plain', 'x')(); }, networkEnabled: true }, + ); + expect(cmd).toContain('raw.githubusercontent.com/openai/whisper/HEAD/README.md'); + }); + + it('serves a repeat URL from the within-turn cache (one network call)', async () => { + let calls = 0; + const deps = { + runInSandbox: async () => { calls++; return ok('text/plain', 'hi')(); }, + networkEnabled: true, + fetchCache: new Map<string, string>(), + }; + await webFetchSandboxed({ url: 'https://example.com/p', summarize: false }, deps); + // Same page, only a #fragment added → normalizes to the same cache key. + const second = await webFetchSandboxed({ url: 'https://example.com/p#frag', summarize: false }, deps); + expect(calls).toBe(1); + expect(second).toMatch(/served from cache/i); + }); +}); + +describe('webSearchSecure', () => { + it('requires a query', async () => { + expect(await webSearchSecure({}, { networkEnabled: true })).toMatch(/requires a query/i); + }); + + it('gates on network OFF before doing any work', async () => { + expect(await webSearchSecure({ query: 'anything' }, { networkEnabled: false })).toMatch(/network.*OFF/i); + }); +}); diff --git a/views/partials/composer-pending-row.ejs b/views/partials/composer-pending-row.ejs index 7b88b37..d7d5ec7 100644 --- a/views/partials/composer-pending-row.ejs +++ b/views/partials/composer-pending-row.ejs @@ -17,14 +17,14 @@ data-scheduled-at="<%= scheduledAt %>" <% } %> > - <div class="scheduled-item-main"> + <div class="scheduled-item-text"><%= content %></div> + <div class="scheduled-item-footer"> <div class="scheduled-item-meta"> <span class="scheduled-item-clock" aria-hidden="true"><%= metaIcon %></span> <span class="scheduled-item-when"<% if (kind === 'scheduled' && scheduledAt) { %> data-when="<%= scheduledAt %>"<% } %>><%= metaText %></span> </div> - <div class="scheduled-item-text"><%= content %></div> - </div> - <div class="scheduled-item-actions"> + <button type="button" class="scheduled-item-toggle" aria-expanded="false">Show more</button> + <div class="scheduled-item-actions"> <% if (kind === 'scheduled') { %> <button type="button" @@ -76,5 +76,6 @@ title="Remove from queue" >×</button> <% } %> + </div> </div> </div> diff --git a/views/partials/composer.ejs b/views/partials/composer.ejs index 5b9611e..7962f16 100644 --- a/views/partials/composer.ejs +++ b/views/partials/composer.ejs @@ -351,17 +351,21 @@ <span class="secure-workspace-bar__label">Secure workspace</span> <span class="secure-workspace-bar__size" id="secure-workspace-size" hidden></span> <span class="secure-workspace-bar__sep" id="secure-workspace-sep">·</span> - <span class="secure-workspace-bar__ttl" id="secure-workspace-ttl"></span> - <button type="button" class="secure-workspace-bar__change" id="secure-workspace-change">change</button> - <button type="button" class="secure-workspace-bar__action" id="secure-workspace-export" title="Copy the sandbox out to a folder on your computer">Export</button> - <button type="button" class="secure-workspace-bar__action" id="secure-workspace-apply" title="Copy the sandbox's new/changed files back to your original folders">Apply changes</button> - <button type="button" class="secure-workspace-bar__destroy" id="secure-workspace-destroy" title="Delete the sandbox copy and everything in it">Destroy</button> - <!-- Anchored to the bar so it pops up in place instead of shifting layout --> - <div class="secure-ttl-menu menu" id="secure-ttl-menu" role="menu" hidden> - <button type="button" class="menu-item" data-ttl="1" role="menuitem">1 day</button> - <button type="button" class="menu-item" data-ttl="7" role="menuitem">7 days</button> - <button type="button" class="menu-item" data-ttl="30" role="menuitem">30 days</button> - </div> + <span class="secure-workspace-bar__ttl" id="secure-workspace-ttl"> + <span class="secure-workspace-bar__ttl-prefix" id="secure-workspace-ttl-prefix"></span><button type="button" class="secure-workspace-bar__ttl-value" id="secure-workspace-ttl-value" aria-haspopup="menu" title="Change how long this workspace is kept before it's deleted"></button> + <!-- Anchored to the TTL value so the menu pops up right under it --> + <div class="secure-ttl-menu menu" id="secure-ttl-menu" role="menu" hidden> + <button type="button" class="menu-item" data-ttl="1" role="menuitem">1 day</button> + <button type="button" class="menu-item" data-ttl="7" role="menuitem">7 days</button> + <button type="button" class="menu-item" data-ttl="30" role="menuitem">30 days</button> + </div> + </span> + <button type="button" class="secure-workspace-bar__destroy" id="secure-workspace-destroy" aria-label="Delete now"> + <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg><span class="secure-workspace-bar__destroy-label"> Delete now</span> + </button> + <button type="button" class="secure-workspace-bar__action" id="secure-workspace-export" aria-label="Save to my computer"> + <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span class="secure-workspace-bar__action-label"> Save to my computer</span> + </button> </div> <div class="composer-secret-modal" id="composer-secret-modal" hidden> <div class="composer-secret-modal__backdrop" id="composer-secret-modal-backdrop"></div> diff --git a/views/settings.ejs b/views/settings.ejs index eec4197..b5fdf89 100644 --- a/views/settings.ejs +++ b/views/settings.ejs @@ -11,12 +11,12 @@ </header> <nav class="settings-tabs" aria-label="Settings sections"> - <a class="settings-tab<%= settingsTab === 'voice-ask' ? ' is-active' : '' %>" href="/settings/voice-ask"<%- settingsTab === 'voice-ask' ? ' aria-current="page"' : '' %>>Voice & Ask</a> + <a class="settings-tab<%= settingsTab === 'voice-ask' ? ' is-active' : '' %>" href="/settings/voice-ask"<%- settingsTab === 'voice-ask' ? ' aria-current="page"' : '' %>>Voice</a> <a class="settings-tab<%= settingsTab === 'remote-access' ? ' is-active' : '' %>" href="/settings/remote-access"<%- settingsTab === 'remote-access' ? ' aria-current="page"' : '' %>>Remote Access</a> </nav> <% if (settingsTab === 'voice-ask') { %> - <!-- ─────────── Voice & Ask (OpenRouter) ─────────── --> + <!-- ─────────── Voice (OpenRouter) ─────────── --> <section class="settings-section"> <header class="settings-section-head"> <div class="settings-section-head-text"> @@ -24,14 +24,13 @@ <svg class="settings-section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/><path d="M5 3v4"/><path d="M3 5h4"/> </svg> - Voice & Ask + Voice </h2> <p class="settings-section-desc"> Paste your OpenRouter key to unlock: </p> <ul class="settings-section-feature-list"> <li>🎙 <strong>Voice messages</strong> — speak instead of type</li> - <li>💬 <strong>Ask mode</strong> — quick answers without agent execution</li> <li>🗂 <strong>Work mode</strong> — AI works inside your selected folders only, file writes need your approval</li> <li>✏️ <strong>Auto-named chats</strong> — titles generated automatically</li> </ul> @@ -807,7 +806,7 @@ padding: 0 var(--space-5) var(--space-5); } -/* ───────────── Voice & Ask (OpenRouter) ───────────── */ +/* ───────────── Voice (OpenRouter) ───────────── */ .or-card { background: var(--surface); border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); @@ -1511,7 +1510,7 @@ const disconnectBtn = document.getElementById('or-disconnect'); if (disconnectBtn) { disconnectBtn.addEventListener('click', async function () { - if (!confirm('Disconnect OpenRouter? Voice messages, Ask mode, and Work mode turn off until you add a key again.')) return; + if (!confirm('Disconnect OpenRouter? Voice messages and Work mode turn off until you add a key again.')) return; disconnectBtn.disabled = true; const orig = disconnectBtn.textContent; disconnectBtn.textContent = 'Disconnecting…';