Skip to content
Merged

Dev #24

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
<a href="https://www.star-history.com/#iClawApp/iClaw&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=iClawApp/iClaw&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=iClawApp/iClaw&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=iClawApp/iClaw&type=Date" />
</picture>
</a>

## Chat modes

Expand All @@ -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** | ✅ | ❌ | ❌ | ❌ |

<sub>✅ yes · ~ partial · ❌ no · 💀 = setup hassle (fewer = easier)</sub>

**→ 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).
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
33 changes: 29 additions & 4 deletions packages/iclaw-runtime/src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -388,13 +413,13 @@ export async function* runAgentTurn(
const toolCallBuffers: Record<string, { name: string; arguments: string }> = {};

// 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<OpenAI.Chat.ChatCompletionChunk>;
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
Expand Down
163 changes: 163 additions & 0 deletions packages/iclaw-runtime/src/agent/model-capabilities.ts
Original file line number Diff line number Diff line change
@@ -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<string> }
let cache: CacheEntry | null = null;
/** De-dupe concurrent fetches: many image turns can race the first lookup. */
let inflight: Promise<Set<string>> | 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 : '';
// "<inputs>-><outputs>" — only the part before "->" is the input side.
return modality.split('->')[0]?.includes('image') ?? false;
}

async function fetchVisionModels(apiKey: string): Promise<Set<string>> {
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<string>();
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<boolean | null> {
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<TurnModelDecision> {
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 };
}
Loading
Loading