diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..f276799 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"bedd47ed-bfa0-41da-9a03-93d41159b4cd","pid":24606,"acquiredAt":1776194767342} \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json index 0967ef4..c4beb6f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1 +1,5 @@ -{} +{ + "sandbox": { + "enabled": false + } +} diff --git a/.claude/skills/add-atomic-chat-tool/SKILL.md b/.claude/skills/add-atomic-chat-tool/SKILL.md new file mode 100644 index 0000000..6a6d858 --- /dev/null +++ b/.claude/skills/add-atomic-chat-tool/SKILL.md @@ -0,0 +1,243 @@ +--- +name: add-atomic-chat-tool +description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API. +--- + +# Add Atomic Chat Integration + +This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible). + +Tools exposed: +- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`) +- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`) + +Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library. + +The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent. + +## Phase 1: Pre-flight + +### Check if already applied + +Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure). + +### Check prerequisites + +Verify Atomic Chat is installed and its local API server is running. On the host: + +```bash +curl -s http://127.0.0.1:1337/v1/models | head +``` + +If the request fails: + +1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`). +2. Open the app. +3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`. +4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B). +5. Load the model once by sending any message in Atomic Chat's UI to warm it up. + +## Phase 2: Apply Code Changes + +### Copy the MCP server source + +```bash +cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts +``` + +### Register the MCP server in the agent-runner + +Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this: + +```ts + const mcpServers: Record }> = { + nanoclaw: { + command: 'bun', + args: ['run', mcpServerPath], + env: {}, + }, + }; +``` + +Add an `atomic_chat` entry alongside `nanoclaw`: + +```ts + const mcpServers: Record }> = { + nanoclaw: { + command: 'bun', + args: ['run', mcpServerPath], + env: {}, + }, + atomic_chat: { + command: 'bun', + args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')], + env: { + ...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}), + ...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}), + }, + }, + }; +``` + +### Add the tool glob to the allowlist + +Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line: + +```ts + 'mcp__nanoclaw__*', + 'mcp__atomic_chat__*', +]; +``` + +### Forward host env vars into the container + +Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line: + +```ts + args.push('-e', `TZ=${TIMEZONE}`); +``` + +Add ATOMIC_CHAT forwarding right after it: + +```ts + args.push('-e', `TZ=${TIMEZONE}`); + + // Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337). + if (process.env.ATOMIC_CHAT_HOST) { + args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`); + } + if (process.env.ATOMIC_CHAT_API_KEY) { + args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`); + } +``` + +### Surface `[ATOMIC]` log lines at info level + +In the same file, find the stderr logger: + +```ts + container.stderr?.on('data', (data) => { + for (const line of data.toString().trim().split('\n')) { + if (line) log.debug(line, { container: agentGroup.folder }); + } + }); +``` + +Replace it with: + +```ts + container.stderr?.on('data', (data) => { + for (const line of data.toString().trim().split('\n')) { + if (!line) continue; + if (line.includes('[ATOMIC]')) { + log.info(line, { container: agentGroup.folder }); + } else { + log.debug(line, { container: agentGroup.folder }); + } + } + }); +``` + +### Add env-var stubs to `.env.example` + +Append to `.env.example`: + +```bash +# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool) +# Override the host where Atomic Chat exposes its OpenAI-compatible API. +# Default: http://host.docker.internal:1337 (with fallback to localhost) +# ATOMIC_CHAT_HOST=http://host.docker.internal:1337 + +# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth. +# ATOMIC_CHAT_API_KEY= +``` + +### Validate code changes + +```bash +pnpm run build +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit +./container/build.sh +``` + +All three must be clean before proceeding. + +## Phase 3: Configure + +### Set Atomic Chat host (optional) + +By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`: + +```bash +ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337 +``` + +### Set API key (optional) + +Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth: + +```bash +ATOMIC_CHAT_API_KEY=sk-... +``` + +### Restart the service + +```bash +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# Linux: systemctl --user restart nanoclaw +``` + +## Phase 4: Verify + +### Test inference + +Tell the user: + +> Send a message like: "use atomic chat to tell me the capital of France" +> +> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response. + +### Check logs if needed + +```bash +tail -f logs/nanoclaw.log | grep -i atomic +``` + +Look for: +- `[ATOMIC] Listing models...` — list request started +- `[ATOMIC] Found N models` — models discovered +- `[ATOMIC] >>> Generating with ` — generation started +- `[ATOMIC] <<< Done: | Xs | N tokens | M chars` — generation completed + +## Troubleshooting + +### Agent says "Atomic Chat is not installed" or tries to run a CLI + +The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means: +1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists +2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` +3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST` +4. The container wasn't rebuilt — run `./container/build.sh` + +### "Failed to connect to Atomic Chat" + +1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models` +2. Confirm the Local API Server is enabled in Atomic Chat's settings +3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models` +4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env` + +### `model not found` / 404 on generate + +The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list. + +### Slow first response + +Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast. + +### Agent doesn't use Atomic Chat tools + +The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..." + +### Context window or output size issues + +Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI. diff --git a/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts b/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts new file mode 100644 index 0000000..0198644 --- /dev/null +++ b/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts @@ -0,0 +1,229 @@ +/** + * Atomic Chat MCP Server for NanoClaw + * Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent. + * Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +import fs from 'fs'; +import path from 'path'; + +const ATOMIC_CHAT_HOST = + process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337'; +const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || ''; +const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json'; + +function log(msg: string): void { + console.error(`[ATOMIC] ${msg}`); +} + +function writeStatus(status: string, detail?: string): void { + try { + const data = { status, detail, timestamp: new Date().toISOString() }; + const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`; + fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true }); + fs.writeFileSync(tmpPath, JSON.stringify(data)); + fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE); + } catch { + /* best-effort */ + } +} + +async function atomicFetch( + apiPath: string, + options?: RequestInit, +): Promise { + const url = `${ATOMIC_CHAT_HOST}${apiPath}`; + const headers: Record = { + ...((options?.headers as Record) || {}), + }; + if (ATOMIC_CHAT_API_KEY) { + headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`; + } + const finalOptions: RequestInit = { ...options, headers }; + try { + return await fetch(url, finalOptions); + } catch (err) { + // Fallback to localhost if host.docker.internal fails + if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) { + const fallbackUrl = url.replace('host.docker.internal', 'localhost'); + return await fetch(fallbackUrl, finalOptions); + } + throw err; + } +} + +const server = new McpServer({ + name: 'atomic_chat', + version: '1.0.0', +}); + +server.tool( + 'atomic_chat_list_models', + 'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.', + {}, + async () => { + log('Listing models...'); + writeStatus('listing', 'Listing available models'); + try { + const res = await atomicFetch('/v1/models'); + if (!res.ok) { + return { + content: [ + { + type: 'text' as const, + text: `Atomic Chat API error: ${res.status} ${res.statusText}`, + }, + ], + isError: true, + }; + } + + const data = (await res.json()) as { + data?: Array<{ id: string; owned_by?: string }>; + }; + const models = data.data || []; + + if (models.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.', + }, + ], + }; + } + + const list = models + .map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`) + .join('\n'); + + log(`Found ${models.length} models`); + return { + content: [ + { type: 'text' as const, text: `Available models:\n${list}` }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, +); + +server.tool( + 'atomic_chat_generate', + 'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.', + { + model: z + .string() + .describe( + 'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")', + ), + prompt: z.string().describe('The prompt to send to the model'), + system: z + .string() + .optional() + .describe('Optional system prompt to set model behavior'), + temperature: z + .number() + .optional() + .describe('Sampling temperature (0.0–2.0). Defaults to model default.'), + max_tokens: z + .number() + .optional() + .describe('Maximum number of tokens to generate in the response.'), + }, + async (args) => { + log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`); + writeStatus('generating', `Generating with ${args.model}`); + try { + const messages: Array<{ role: string; content: string }> = []; + if (args.system) { + messages.push({ role: 'system', content: args.system }); + } + messages.push({ role: 'user', content: args.prompt }); + + const body: Record = { + model: args.model, + messages, + stream: false, + }; + if (args.temperature !== undefined) body.temperature = args.temperature; + if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens; + + const startedAt = Date.now(); + const res = await atomicFetch('/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + return { + content: [ + { + type: 'text' as const, + text: `Atomic Chat error (${res.status}): ${errorText}`, + }, + ], + isError: true, + }; + } + + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + }; + + const response = data.choices?.[0]?.message?.content ?? ''; + const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); + const completionTokens = data.usage?.completion_tokens; + + const meta = `\n\n[${args.model} | ${elapsedSec}s${ + completionTokens !== undefined ? ` | ${completionTokens} tokens` : '' + }]`; + + log( + `<<< Done: ${args.model} | ${elapsedSec}s | ${ + completionTokens ?? '?' + } tokens | ${response.length} chars`, + ); + writeStatus( + 'done', + `${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`, + ); + + return { content: [{ type: 'text' as const, text: response + meta }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md new file mode 100644 index 0000000..14b3072 --- /dev/null +++ b/.claude/skills/add-codex/SKILL.md @@ -0,0 +1,161 @@ +--- +name: add-codex +description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner). +--- + +# Codex agent provider + +NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`). + +Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image. + +The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in. + +## Install + +### Pre-flight + +If all of the following are already present, skip to **Configuration**: + +- `src/providers/codex.ts` +- `container/agent-runner/src/providers/codex.ts` +- `container/agent-runner/src/providers/codex-app-server.ts` +- `container/agent-runner/src/providers/codex.factory.test.ts` +- `import './codex.js';` line in `src/providers/index.ts` +- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts` +- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile` + +Missing pieces — continue below. All steps are idempotent; re-running is safe. + +### 1. Fetch the providers branch + +```bash +git fetch origin providers +``` + +### 2. Copy the Codex source files + +Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed): + +```bash +git show origin/providers:src/providers/codex.ts > src/providers/codex.ts +git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts +git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts +git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts +``` + +### 3. Append the self-registration imports + +Each barrel gets one line — alphabetical placement keeps diffs small. + +`src/providers/index.ts`: + +```typescript +import './codex.js'; +``` + +`container/agent-runner/src/providers/index.ts`: + +```typescript +import './codex.js'; +``` + +### 4. Add the Codex CLI to the container Dockerfile + +Two edits to `container/Dockerfile`, both idempotent (skip if already present): + +**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`: + +```dockerfile +ARG CODEX_VERSION=0.124.0 +``` + +**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@openai/codex@${CODEX_VERSION}" +``` + +Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`. + +### 5. Build + +```bash +pnpm run build # host +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck +./container/build.sh # agent image +``` + +## Configuration + +Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup. + +### Option A — ChatGPT subscription (recommended for individuals) + +On the host (not inside the container), run Codex's OAuth login: + +```bash +codex login +``` + +This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched. + +No `.env` variables required for this mode. + +### Option B — API key (recommended for CI or API billing) + +```env +OPENAI_API_KEY=sk-... +CODEX_MODEL=gpt-5.4-mini +``` + +The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription. + +### Option C — BYO OpenAI-compatible endpoint (experimental) + +Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc. + +```env +OPENAI_API_KEY=... +OPENAI_BASE_URL=https://api.groq.com/openai/v1 +CODEX_MODEL=llama-3.3-70b-versatile +``` + +Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration. + +**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing. + +### Per group / per session + +Set `"provider": "codex"` in the group's **`container.json`** (`groups//container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`. + +`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group. + +Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers. + +## Operational notes + +- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions. +- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config. +- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error. +- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode). +- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped. +- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has. + +## Verify + +```bash +grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK" +grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK" +grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK" +cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd - +``` + +After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like: + +- `init` event with a stable thread ID as continuation +- One or more `activity` / `progress` events during the turn +- `result` event with the model's reply + +If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. diff --git a/.claude/skills/add-compact/SKILL.md b/.claude/skills/add-compact/SKILL.md deleted file mode 100644 index 0c46165..0000000 --- a/.claude/skills/add-compact/SKILL.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -name: add-compact -description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only. ---- - -# Add /compact Command - -Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts. - -**Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context. - -## Phase 1: Pre-flight - -Check if `src/session-commands.ts` exists: - -```bash -test -f src/session-commands.ts && echo "Already applied" || echo "Not applied" -``` - -If already applied, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -Merge the skill branch: - -```bash -git fetch upstream skill/compact -git merge upstream/skill/compact -``` - -> **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly. - -This adds: -- `src/session-commands.ts` (extract and authorize session commands) -- `src/session-commands.test.ts` (unit tests for command parsing and auth) -- Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`) -- Slash command handling in `container/agent-runner/src/index.ts` - -### Validate - -```bash -npm test -npm run build -``` - -### Rebuild container - -```bash -./container/build.sh -``` - -### Restart service - -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 3: Verify - -### Integration Test - -1. Start NanoClaw in dev mode: `npm run dev` -2. From the **main group** (self-chat), send exactly: `/compact` -3. Verify: - - The agent acknowledges compaction (e.g., "Conversation compacted.") - - The session continues — send a follow-up message and verify the agent responds coherently - - A conversation archive is written to `groups/{folder}/conversations/` (by the PreCompact hook) - - Container logs show `Compact boundary observed` (confirms SDK actually compacted) - - If `compact_boundary` was NOT observed, the response says "compact_boundary was not observed" -4. From a **non-main group** as a non-admin user, send: `@ /compact` -5. Verify: - - The bot responds with "Session commands require admin access." - - No compaction occurs, no container is spawned for the command -6. From a **non-main group** as the admin (device owner / `is_from_me`), send: `@ /compact` -7. Verify: - - Compaction proceeds normally (same behavior as main group) -8. While an **active container** is running for the main group, send `/compact` -9. Verify: - - The active container is signaled to close (authorized senders only — untrusted senders cannot kill in-flight work) - - Compaction proceeds via a new container once the active one exits - - The command is not dropped (no cursor race) -10. Send a normal message, then `/compact`, then another normal message in quick succession (same polling batch): -11. Verify: - - Pre-compact messages are sent to the agent first (check container logs for two `runAgent` calls) - - Compaction proceeds after pre-compact messages are processed - - Messages **after** `/compact` in the batch are preserved (cursor advances to `/compact`'s timestamp only) and processed on the next poll cycle -12. From a **non-main group** as a non-admin user, send `@ /compact`: -13. Verify: - - Denial message is sent ("Session commands require admin access.") - - The `/compact` is consumed (cursor advanced) — it does NOT replay on future polls - - Other messages in the same batch are also consumed (cursor is a high-water mark — this is an accepted tradeoff for the narrow edge case of denied `/compact` + other messages in the same polling interval) - - No container is killed or interrupted -14. From a **non-main group** (with `requiresTrigger` enabled) as a non-admin user, send bare `/compact` (no trigger prefix): -15. Verify: - - No denial message is sent (trigger policy prevents untrusted bot responses) - - The `/compact` is consumed silently - - Note: in groups where `requiresTrigger` is `false`, a denial message IS sent because the sender is considered reachable -16. After compaction, verify **no auto-compaction** behavior — only manual `/compact` triggers it - -### Validation on Fresh Clone - -```bash -git clone /tmp/nanoclaw-test -cd /tmp/nanoclaw-test -claude # then run /add-compact -npm run build -npm test -./container/build.sh -# Manual: send /compact from main group, verify compaction + continuation -# Manual: send @ /compact from non-main as non-admin, verify denial -# Manual: send @ /compact from non-main as admin, verify allowed -# Manual: verify no auto-compaction behavior -``` - -## Security Constraints - -- **Main-group or trusted/admin sender only.** The main group is the user's private self-chat and is trusted (see `docs/SECURITY.md`). Non-main groups are untrusted — a careless or malicious user could wipe the agent's short-term memory. However, the device owner (`is_from_me`) is always trusted and can compact from any group. -- **No auto-compaction.** This skill implements manual compaction only. Automatic threshold-based compaction is a separate concern and should be a separate skill. -- **No config file.** NanoClaw's philosophy is customization through code changes, not configuration sprawl. -- **Transcript archived before compaction.** The existing `PreCompact` hook in the agent-runner archives the full transcript to `conversations/` before the SDK compacts it. -- **Session continues after compaction.** This is not a destructive reset. The conversation continues with summarized context. - -## What This Does NOT Do - -- No automatic compaction threshold (add separately if desired) -- No `/clear` command (separate skill, separate semantics — `/clear` is a destructive reset) -- No cross-group compaction (each group's session is isolated) -- No changes to the container image, Dockerfile, or build script - -## Troubleshooting - -- **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied. -- **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded. -- **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt. diff --git a/.claude/skills/add-dashboard/SKILL.md b/.claude/skills/add-dashboard/SKILL.md new file mode 100644 index 0000000..c891563 --- /dev/null +++ b/.claude/skills/add-dashboard/SKILL.md @@ -0,0 +1,138 @@ +--- +name: add-dashboard +description: Add a monitoring dashboard to NanoClaw. Installs @nanoco/nanoclaw-dashboard and a pusher that sends periodic JSON snapshots. +--- + +# /add-dashboard — NanoClaw Dashboard + +Adds a local monitoring dashboard showing agent groups, sessions, channels, users, token usage, context windows, message activity, and real-time logs. + +## Architecture + +``` +NanoClaw (pusher) Dashboard (npm package) +┌──────────┐ POST JSON ┌──────────────┐ +│ collects │ ────────────────→ │ /api/ingest │ +│ DB data │ every 60s │ in-memory │ +│ tails │ ────────────────→ │ /api/logs/ │ +│ log file │ every 2s │ push │ +└──────────┘ │ serves UI │ + └──────────────┘ +``` + +## Steps + +### 1. Install the npm package + +```bash +pnpm install @nanoco/nanoclaw-dashboard +``` + +### 2. Copy the pusher module + +Copy the resource file into src: + +``` +.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts +``` + +### 3. Add exports to src/db/index.ts + +Add these two export blocks if not already present: + +```typescript +// After the messaging-groups exports, add: +export { + getMessagingGroupsByAgentGroup, +} from './messaging-groups.js'; + +// Before the credentials exports, add: +export { + createDestination, + getDestinations, + getDestinationByName, + getDestinationByTarget, + hasDestination, + deleteDestination, +} from './agent-destinations.js'; +``` + +### 4. Wire into src/index.ts + +Add the `readEnvFile` import at the top if not already present: + +```typescript +import { readEnvFile } from './env.js'; +``` + +Add after step 7 (OneCLI approval handler), before the `log.info('NanoClaw running')` line: + +```typescript + // 8. Dashboard (optional) + const dashboardEnv = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']); + const dashboardSecret = process.env.DASHBOARD_SECRET || dashboardEnv.DASHBOARD_SECRET; + const dashboardPort = parseInt(process.env.DASHBOARD_PORT || dashboardEnv.DASHBOARD_PORT || '3100', 10); + if (dashboardSecret) { + const { startDashboard } = await import('@nanoco/nanoclaw-dashboard'); + const { startDashboardPusher } = await import('./dashboard-pusher.js'); + startDashboard({ port: dashboardPort, secret: dashboardSecret }); + startDashboardPusher({ port: dashboardPort, secret: dashboardSecret, intervalMs: 60000 }); + } else { + log.info('Dashboard disabled (no DASHBOARD_SECRET)'); + } +``` + +### 5. Add environment variables to .env + +``` +DASHBOARD_SECRET= +DASHBOARD_PORT=3100 +``` + +Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes(16).toString('hex'))"` + +### 6. Build and restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# or: launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +### 7. Verify + +```bash +curl -s http://localhost:3100/api/status +curl -s -H "Authorization: Bearer " http://localhost:3100/api/overview +``` + +Open `http://localhost:3100/dashboard` in a browser. + +## Dashboard Pages + +| Page | Shows | +|------|-------| +| Overview | Stats, token usage + cache hit rate, context windows, activity chart | +| Agent Groups | Sessions, wirings, destinations, members, admins | +| Sessions | Status, container state, context window usage bars | +| Channels | Live/offline status, messaging groups, sender policies | +| Messages | Per-session inbound/outbound messages | +| Users | Privilege hierarchy: owner > admin > member | +| Logs | Real-time log streaming with level filter | + +## Troubleshooting + +- **"No data yet"**: Wait 60s for first push, or check logs for push errors +- **401 errors**: Verify `DASHBOARD_SECRET` matches in `.env` +- **Port conflict**: Change `DASHBOARD_PORT` in `.env` +- **No logs**: Check `logs/nanoclaw.log` exists + +## Removal + +```bash +pnpm uninstall @nanoco/nanoclaw-dashboard +rm src/dashboard-pusher.ts +# Remove the dashboard block from src/index.ts +# Remove DASHBOARD_SECRET and DASHBOARD_PORT from .env +pnpm run build +``` diff --git a/.claude/skills/add-dashboard/resources/dashboard-pusher.ts b/.claude/skills/add-dashboard/resources/dashboard-pusher.ts new file mode 100644 index 0000000..5e1cc21 --- /dev/null +++ b/.claude/skills/add-dashboard/resources/dashboard-pusher.ts @@ -0,0 +1,495 @@ +/** + * Dashboard pusher — collects NanoClaw state and POSTs a JSON + * snapshot to the dashboard's /api/ingest endpoint every interval. + */ +import fs from 'fs'; +import path from 'path'; +import http from 'http'; +import Database from 'better-sqlite3'; + +import { getAllAgentGroups, getAgentGroup } from './db/agent-groups.js'; +import { getSessionsByAgentGroup } from './db/sessions.js'; +import { getAllMessagingGroups, getMessagingGroupAgents } from './db/messaging-groups.js'; +import { getDestinations } from './db/agent-destinations.js'; +import { getMembers } from './db/agent-group-members.js'; +import { getAllUsers, getUser } from './db/users.js'; +import { getUserRoles, getAdminsOfAgentGroup } from './db/user-roles.js'; +import { getUserDmsForUser } from './db/user-dms.js'; +import { getActiveAdapters, getRegisteredChannelNames } from './channels/channel-registry.js'; +import { DATA_DIR, ASSISTANT_NAME } from './config.js'; +import { getDb } from './db/connection.js'; +import { log } from './log.js'; + +interface PusherConfig { + port: number; + secret: string; + intervalMs?: number; +} + +let timer: ReturnType | null = null; +let logTimer: ReturnType | null = null; +let logOffset = 0; + +export function startDashboardPusher(config: PusherConfig): void { + const interval = config.intervalMs || 60000; + + // Push immediately on start, then on interval + push(config).catch((err) => log.error('Dashboard push failed', { err })); + timer = setInterval(() => { + push(config).catch((err) => log.error('Dashboard push failed', { err })); + }, interval); + + // Start log file tailing + startLogTail(config); + + log.info('Dashboard pusher started', { intervalMs: interval }); +} + +export function stopDashboardPusher(): void { + if (timer) { + clearInterval(timer); + timer = null; + } + if (logTimer) { + clearInterval(logTimer); + logTimer = null; + } +} + +/** Fire-and-forget POST to the dashboard. */ +function postJson(config: PusherConfig, urlPath: string, data: unknown): void { + const body = JSON.stringify(data); + const req = http.request({ + hostname: '127.0.0.1', + port: config.port, + path: urlPath, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Authorization: `Bearer ${config.secret}`, + }, + }); + req.on('error', () => {}); + req.write(body); + req.end(); +} + +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function startLogTail(config: PusherConfig): void { + const logFile = path.resolve(process.cwd(), 'logs', 'nanoclaw.log'); + if (!fs.existsSync(logFile)) return; + + // Send last 200 lines as backfill + try { + const allLines = fs.readFileSync(logFile, 'utf-8').split('\n').filter((l) => l.trim()); + logOffset = fs.statSync(logFile).size; + const tail = allLines.slice(-200).map((l) => l.replace(ANSI_RE, '')); + if (tail.length > 0) postJson(config, '/api/logs/push', { lines: tail }); + } catch { return; } + + // Poll every 2s for new lines + logTimer = setInterval(() => { + try { + const stat = fs.statSync(logFile); + if (stat.size <= logOffset) { logOffset = stat.size; return; } + const buf = Buffer.alloc(stat.size - logOffset); + const fd = fs.openSync(logFile, 'r'); + fs.readSync(fd, buf, 0, buf.length, logOffset); + fs.closeSync(fd); + logOffset = stat.size; + const lines = buf.toString().split('\n').filter((l) => l.trim()).map((l) => l.replace(ANSI_RE, '')); + if (lines.length > 0) postJson(config, '/api/logs/push', { lines }); + } catch { /* ignore */ } + }, 2000); +} + +async function push(config: PusherConfig): Promise { + const snapshot = collectSnapshot(); + postJson(config, '/api/ingest', snapshot); + log.debug('Dashboard snapshot pushed'); +} + +function collectSnapshot(): Record { + return { + timestamp: new Date().toISOString(), + assistant_name: ASSISTANT_NAME, + uptime: Math.floor(process.uptime()), + agent_groups: collectAgentGroups(), + sessions: collectSessions(), + channels: collectChannels(), + users: collectUsers(), + tokens: collectTokens(), + context_windows: collectContextWindows(), + activity: collectActivity(), + messages: collectMessages(), + }; +} + +function collectAgentGroups() { + return getAllAgentGroups().map((g) => { + const sessions = getSessionsByAgentGroup(g.id); + const running = sessions.filter((s) => s.container_status === 'running' || s.container_status === 'idle'); + const destinations = getDestinations(g.id); + const members = getMembers(g.id).map((m) => { + const user = getUser(m.user_id); + return { ...m, display_name: user?.display_name ?? null }; + }); + const admins = getAdminsOfAgentGroup(g.id).map((a) => { + const user = getUser(a.user_id); + return { ...a, display_name: user?.display_name ?? null }; + }); + + // Wirings + const db = getDb(); + const wirings = db + .prepare( + `SELECT mga.*, mg.channel_type, mg.platform_id, mg.name as mg_name, mg.is_group, mg.unknown_sender_policy + FROM messaging_group_agents mga + JOIN messaging_groups mg ON mg.id = mga.messaging_group_id + WHERE mga.agent_group_id = ?`, + ) + .all(g.id) as Array>; + + return { + id: g.id, + name: g.name, + folder: g.folder, + agent_provider: g.agent_provider, + container_config: g.container_config ? JSON.parse(g.container_config) : null, + sessionCount: sessions.length, + runningSessions: running.length, + wirings, + destinations, + members, + admins, + created_at: g.created_at, + }; + }); +} + +function collectSessions() { + const db = getDb(); + return db + .prepare( + `SELECT s.*, ag.name as agent_group_name, ag.folder as agent_group_folder, + mg.channel_type, mg.platform_id, mg.name as messaging_group_name + FROM sessions s + LEFT JOIN agent_groups ag ON ag.id = s.agent_group_id + LEFT JOIN messaging_groups mg ON mg.id = s.messaging_group_id + ORDER BY s.last_active DESC NULLS LAST`, + ) + .all() as Array>; +} + +function collectChannels() { + const messagingGroups = getAllMessagingGroups(); + const liveAdapters = getActiveAdapters().map((a) => a.channelType); + const registeredChannels = getRegisteredChannelNames(); + + const byType: Record = {}; + + for (const mg of messagingGroups) { + if (!byType[mg.channel_type]) { + byType[mg.channel_type] = { + channelType: mg.channel_type, + isLive: liveAdapters.includes(mg.channel_type), + isRegistered: registeredChannels.includes(mg.channel_type), + groups: [], + }; + } + + const agents = getMessagingGroupAgents(mg.id).map((a) => { + const group = getAgentGroup(a.agent_group_id); + return { agent_group_id: a.agent_group_id, agent_group_name: group?.name ?? null, priority: a.priority }; + }); + + byType[mg.channel_type].groups.push({ + messagingGroup: { + id: mg.id, + platform_id: mg.platform_id, + name: mg.name, + is_group: mg.is_group, + unknown_sender_policy: (mg as unknown as Record).unknown_sender_policy ?? 'strict', + }, + agents, + }); + } + + // Include live adapters with no messaging groups + for (const ct of liveAdapters) { + if (!byType[ct]) { + byType[ct] = { channelType: ct, isLive: true, isRegistered: true, groups: [] }; + } + } + + return Object.values(byType).sort((a, b) => a.channelType.localeCompare(b.channelType)); +} + +function collectUsers() { + return getAllUsers().map((u) => { + const roles = getUserRoles(u.id); + const dms = getUserDmsForUser(u.id); + + const db = getDb(); + const memberships = db + .prepare( + `SELECT agm.agent_group_id, ag.name as agent_group_name + FROM agent_group_members agm + JOIN agent_groups ag ON ag.id = agm.agent_group_id + WHERE agm.user_id = ?`, + ) + .all(u.id) as Array>; + + let privilege = 'none'; + if (roles.some((r) => r.role === 'owner')) privilege = 'owner'; + else if (roles.some((r) => r.role === 'admin' && !r.agent_group_id)) privilege = 'global_admin'; + else if (roles.some((r) => r.role === 'admin')) privilege = 'admin'; + else if (memberships.length > 0) privilege = 'member'; + + return { + id: u.id, + kind: u.kind, + display_name: u.display_name, + privilege, + roles, + memberships, + dmChannels: dms.map((d) => ({ channel_type: d.channel_type })), + created_at: u.created_at, + }; + }); +} + +function collectTokens() { + const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); + const allEntries: Array<{ model: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; agentGroupId: string }> = []; + const agentGroups = getAllAgentGroups(); + const nameMap = new Map(agentGroups.map((g) => [g.id, g.name])); + + if (fs.existsSync(sessionsDir)) { + for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { + const entries = scanJsonlTokens(path.join(sessionsDir, agDir)); + allEntries.push(...entries.map((e) => ({ ...e, agentGroupId: agDir }))); + } + } + + const byModel: Record = {}; + const byGroup: Record = {}; + const totals = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }; + + for (const e of allEntries) { + if (!byModel[e.model]) byModel[e.model] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }; + byModel[e.model].requests++; + byModel[e.model].inputTokens += e.inputTokens; + byModel[e.model].outputTokens += e.outputTokens; + byModel[e.model].cacheReadTokens += e.cacheReadTokens; + byModel[e.model].cacheCreationTokens += e.cacheCreationTokens; + + if (!byGroup[e.agentGroupId]) byGroup[e.agentGroupId] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, name: nameMap.get(e.agentGroupId) || e.agentGroupId }; + byGroup[e.agentGroupId].requests++; + byGroup[e.agentGroupId].inputTokens += e.inputTokens; + byGroup[e.agentGroupId].outputTokens += e.outputTokens; + byGroup[e.agentGroupId].cacheReadTokens += e.cacheReadTokens; + byGroup[e.agentGroupId].cacheCreationTokens += e.cacheCreationTokens; + + totals.requests++; + totals.inputTokens += e.inputTokens; + totals.outputTokens += e.outputTokens; + totals.cacheReadTokens += e.cacheReadTokens; + totals.cacheCreationTokens += e.cacheCreationTokens; + } + + return { totals, byModel, byGroup }; +} + +function scanJsonlTokens(agentDir: string) { + const claudeDir = path.join(agentDir, '.claude-shared', 'projects'); + if (!fs.existsSync(claudeDir)) return []; + + const entries: Array<{ model: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number }> = []; + + const walk = (dir: string): void => { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.jsonl')) { + try { + for (const line of fs.readFileSync(full, 'utf-8').split('\n')) { + if (!line.trim()) continue; + try { + const r = JSON.parse(line); + if (r.type === 'assistant' && r.message?.usage) { + const u = r.message.usage; + entries.push({ + model: r.message.model || 'unknown', + inputTokens: u.input_tokens || 0, + outputTokens: u.output_tokens || 0, + cacheReadTokens: u.cache_read_input_tokens || 0, + cacheCreationTokens: u.cache_creation_input_tokens || 0, + }); + } + } catch { /* skip line */ } + } + } catch { /* skip file */ } + } + } + } catch { /* skip dir */ } + }; + walk(claudeDir); + return entries; +} + +function collectContextWindows() { + const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); + if (!fs.existsSync(sessionsDir)) return []; + + const results: unknown[] = []; + const agentGroups = getAllAgentGroups(); + const nameMap = new Map(agentGroups.map((g) => [g.id, g.name])); + + for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { + const claudeDir = path.join(sessionsDir, agDir, '.claude-shared', 'projects'); + if (!fs.existsSync(claudeDir)) continue; + + // Find most recent JSONL + const jsonlFiles: string[] = []; + const walk = (dir: string): void => { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.jsonl')) jsonlFiles.push(full); + } + } catch { /* skip */ } + }; + walk(claudeDir); + if (jsonlFiles.length === 0) continue; + + jsonlFiles.sort((a, b) => { + try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; } + }); + + // Read last assistant turn from newest file + const content = fs.readFileSync(jsonlFiles[0], 'utf-8'); + const lines = content.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + if (!lines[i].trim()) continue; + try { + const r = JSON.parse(lines[i]); + if (r.type === 'assistant' && r.message?.usage) { + const u = r.message.usage; + const model = r.message.model || 'unknown'; + const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); + const max = 200000; + results.push({ + agentGroupId: agDir, + agentGroupName: nameMap.get(agDir), + sessionId: path.basename(jsonlFiles[0], '.jsonl'), + model, + contextTokens: ctx, + outputTokens: u.output_tokens || 0, + cacheReadTokens: u.cache_read_input_tokens || 0, + cacheCreationTokens: u.cache_creation_input_tokens || 0, + maxContext: max, + usagePercent: max > 0 ? Math.round((ctx / max) * 100) : 0, + timestamp: r.timestamp || '', + }); + break; + } + } catch { /* skip */ } + } + } + + return results; +} + +function collectActivity() { + const now = Date.now(); + const buckets: Record = {}; + + for (let i = 0; i < 24; i++) { + const key = new Date(now - i * 3600000).toISOString().slice(0, 13); + buckets[key] = { inbound: 0, outbound: 0 }; + } + + const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); + if (!fs.existsSync(sessionsDir)) return toBucketArray(buckets); + + const cutoff = new Date(now - 86400000).toISOString(); + + try { + for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { + const agPath = path.join(sessionsDir, agDir); + for (const sessDir of fs.readdirSync(agPath).filter((d) => d.startsWith('sess-'))) { + for (const [dbName, direction] of [['outbound.db', 'outbound'], ['inbound.db', 'inbound']] as const) { + const dbPath = path.join(agPath, sessDir, dbName); + if (!fs.existsSync(dbPath)) continue; + try { + const db = new Database(dbPath, { readonly: true }); + const table = direction === 'outbound' ? 'messages_out' : 'messages_in'; + const rows = db.prepare(`SELECT timestamp FROM ${table} WHERE timestamp > ?`).all(cutoff) as { timestamp: string }[]; + for (const row of rows) { + const key = row.timestamp.slice(0, 13); + if (buckets[key]) buckets[key][direction]++; + } + db.close(); + } catch { /* skip */ } + } + } + } + } catch { /* skip */ } + + return toBucketArray(buckets); +} + +function toBucketArray(buckets: Record) { + return Object.entries(buckets) + .map(([hour, counts]) => ({ hour, ...counts })) + .sort((a, b) => a.hour.localeCompare(b.hour)); +} + +function collectMessages() { + const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); + if (!fs.existsSync(sessionsDir)) return []; + + const results: Array<{ agentGroupId: string; sessionId: string; inbound: unknown[]; outbound: unknown[] }> = []; + const limit = 50; + + try { + for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { + const agPath = path.join(sessionsDir, agDir); + for (const sessDir of fs.readdirSync(agPath).filter((d) => d.startsWith('sess-'))) { + const inbound: unknown[] = []; + const outbound: unknown[] = []; + + const inDbPath = path.join(agPath, sessDir, 'inbound.db'); + if (fs.existsSync(inDbPath)) { + try { + const db = new Database(inDbPath, { readonly: true }); + const rows = db.prepare('SELECT * FROM messages_in ORDER BY seq DESC LIMIT ?').all(limit); + inbound.push(...(rows as unknown[]).reverse()); + db.close(); + } catch { /* skip */ } + } + + const outDbPath = path.join(agPath, sessDir, 'outbound.db'); + if (fs.existsSync(outDbPath)) { + try { + const db = new Database(outDbPath, { readonly: true }); + const rows = db.prepare('SELECT * FROM messages_out ORDER BY seq DESC LIMIT ?').all(limit); + outbound.push(...(rows as unknown[]).reverse()); + db.close(); + } catch { /* skip */ } + } + + if (inbound.length > 0 || outbound.length > 0) { + results.push({ agentGroupId: agDir, sessionId: sessDir, inbound, outbound }); + } + } + } + } catch { /* skip */ } + + return results; +} diff --git a/.claude/skills/add-deltachat/REMOVE.md b/.claude/skills/add-deltachat/REMOVE.md new file mode 100644 index 0000000..7cb2d31 --- /dev/null +++ b/.claude/skills/add-deltachat/REMOVE.md @@ -0,0 +1,62 @@ +# Remove DeltaChat + +## 1. Disable the adapter + +Comment out the import in `src/channels/index.ts`: + +```typescript +// import './deltachat.js'; +``` + +## 2. Remove credentials + +Remove the `DC_*` lines from `.env`: + +```bash +DC_EMAIL +DC_PASSWORD +DC_IMAP_HOST +DC_IMAP_PORT +DC_SMTP_HOST +DC_SMTP_PORT +``` + +## 3. Rebuild and restart + +```bash +pnpm run build + +# Linux +systemctl --user restart nanoclaw + +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +## 4. Remove account data (optional) + +To fully remove all account data including DeltaChat encryption keys: + +```bash +rm -rf dc-account/ +``` + +> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account. + +To keep the account for later reinstall, leave `dc-account/` intact. + +## 5. Remove the package (optional) + +```bash +pnpm remove @deltachat/stdio-rpc-server +``` + +## Verification + +After removal, confirm the adapter is no longer starting: + +```bash +grep "deltachat" logs/nanoclaw.log | tail -5 +``` + +Expected: no `Channel adapter started` entry after the last restart. diff --git a/.claude/skills/add-deltachat/SKILL.md b/.claude/skills/add-deltachat/SKILL.md new file mode 100644 index 0000000..45aa416 --- /dev/null +++ b/.claude/skills/add-deltachat/SKILL.md @@ -0,0 +1,254 @@ +--- +name: add-deltachat +description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption. +--- + +# Add DeltaChat Channel + +The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption. + +## Install + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/deltachat.ts` exists +- `src/channels/index.ts` contains `import './deltachat.js';` +- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if already present): + +```typescript +import './deltachat.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @deltachat/stdio-rpc-server@2.49.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Account Setup + +A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one. + +**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below. + +To find the correct hostnames for a domain: + +```bash +node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))" +``` + +Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access." + +## Credentials + +Add to `.env`: + +```bash +DC_EMAIL=bot@example.com +DC_PASSWORD=your-app-password +DC_IMAP_HOST=imap.example.com +DC_IMAP_PORT=993 +DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain +DC_SMTP_HOST=smtp.example.com +DC_SMTP_PORT=587 +DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain +``` + +Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account. + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Optional settings + +The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) | +| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat | +| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only | + +The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it. + +### Restart + +```bash +# Linux +systemctl --user restart nanoclaw + +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw +``` + +On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`). + +## Wiring + +### DMs + +**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake. + +#### Step 1 — Get the invite link + +After the service starts, the adapter logs the invite URL and writes a QR SVG: + +```bash +grep "invite link" logs/nanoclaw.log | tail -1 +# url field contains the https://i.delta.chat/... invite link +# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg) +``` + +The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts. + +#### Step 2 — Add the bot in DeltaChat + +Two options for the user to connect: + +- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt. +- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen. + +After accepting, DeltaChat exchanges keys and creates the chat automatically. + +#### Step 3 — Wire the chat to an agent + +Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID: + +```bash +sqlite3 data/v2.db \ + "SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5" +``` + +Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step: + +```bash +pnpm exec tsx scripts/init-first-agent.ts \ + --channel deltachat \ + --user-id deltachat:user@example.com \ + --platform-id \ + --display-name "Your Name" +``` + +### Groups + +Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group. + +## Channel Info + +- **type**: `deltachat` +- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups" +- **supports-threads**: no — DeltaChat has no thread model +- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier +- **user-id-format**: `deltachat:{email}` — the contact's email address +- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above +- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat +- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode + +### Features + +- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete +- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow +- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only) +- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks +- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle + +Not supported: DeltaChat reactions, message editing/deletion, read receipts. + +### Connectivity model + +`isConnected()` returns `true` when the internal connectivity value is ≥ 3000: + +| Range | Meaning | +|-------|---------| +| 1000–1999 | Not connected | +| 2000–2999 | Connecting | +| 3000–3999 | Working (IMAP fetching) | +| ≥ 4000 | Fully connected (IMAP IDLE) | + +## Troubleshooting + +### Adapter not starting — credentials missing + +```bash +grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat +``` + +All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`. + +### Account configure fails + +```bash +grep "DeltaChat" logs/nanoclaw.log | tail -20 +``` + +Common causes: +- Wrong IMAP/SMTP hostnames — double-check provider docs +- App password not generated — Gmail and some others require this when 2FA is enabled +- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env` + +### Provider uses SMTP port 465 (SSL/TLS) instead of 587 + +Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart. + +### Messages not arriving + +1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log` +2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log` +3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat +4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"` + +### Stale lock file after crash + +```bash +rm -f dc-account/accounts.lock +systemctl --user restart nanoclaw +``` + +### Bot not responding after restart + +The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors: + +```bash +grep "DeltaChat" logs/nanoclaw.error.log | tail -20 +``` + +### Messages received but agent not responding + +The messaging group exists but may not be wired to an agent group. Run: + +```bash +sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'" +``` + +If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`. diff --git a/.claude/skills/add-deltachat/VERIFY.md b/.claude/skills/add-deltachat/VERIFY.md new file mode 100644 index 0000000..839fa85 --- /dev/null +++ b/.claude/skills/add-deltachat/VERIFY.md @@ -0,0 +1,54 @@ +# Verify DeltaChat + +## 1. Check the adapter started + +```bash +grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1 +``` + +Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }` + +## 2. Check IMAP/SMTP connectivity + +Replace with your provider's hostnames from `.env`: + +```bash +DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2) +DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2) + +bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked" +bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked" +``` + +## 3. End-to-end message test + +1. Open DeltaChat on your device +2. Add the bot email address as a contact +3. Send a message +4. The bot should respond within a few seconds + +If nothing arrives, check: + +```bash +grep "DeltaChat" logs/nanoclaw.log | tail -20 +grep "DeltaChat" logs/nanoclaw.error.log | tail -10 +``` + +## 4. Check messaging group was created + +```bash +sqlite3 data/v2.db \ + "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5" +``` + +If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`. + +## 5. Verify user access + +If the message arrived but the agent didn't respond, the sender may not have access: + +```bash +sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'" +``` + +Grant access as shown in the SKILL.md "Grant user access" section. diff --git a/.claude/skills/add-discord/REMOVE.md b/.claude/skills/add-discord/REMOVE.md new file mode 100644 index 0000000..702e55d --- /dev/null +++ b/.claude/skills/add-discord/REMOVE.md @@ -0,0 +1,7 @@ +# Remove Discord + +1. Comment out `import './discord.js'` in `src/channels/index.ts` +2. Remove `DISCORD_BOT_TOKEN` from `.env` +3. Rebuild and restart + +No package to uninstall — Discord is built in. diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index e46bd3e..6d3ccc8 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -1,203 +1,98 @@ --- name: add-discord -description: Add Discord bot channel integration to NanoClaw. +description: Add Discord bot channel integration via Chat SDK. --- # Add Discord Channel -This skill adds Discord support to NanoClaw, then walks through interactive setup. +Adds Discord bot support via the Chat SDK bridge. -## Phase 1: Pre-flight +## Install -### Check if already applied +NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter in from the `channels` branch. -Check if `src/channels/discord.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. +### Pre-flight (idempotent) -### Ask the user +Skip to **Credentials** if all of these are already in place: -Use `AskUserQuestion` to collect configuration: +- `src/channels/discord.ts` exists +- `src/channels/index.ts` contains `import './discord.js';` +- `@chat-adapter/discord` is listed in `package.json` dependencies -AskUserQuestion: Do you have a Discord bot token, or do you need to create one? +Otherwise continue. Every step below is safe to re-run. -If they have one, collect it now. If not, we'll create one in Phase 3. - -## Phase 2: Apply Code Changes - -### Ensure channel remote +### 1. Fetch the channels branch ```bash -git remote -v +git fetch origin channels ``` -If `discord` is missing, add it: +### 2. Copy the adapter ```bash -git remote add discord https://github.com/qwibitai/nanoclaw-discord.git +git show origin/channels:src/channels/discord.ts > src/channels/discord.ts ``` -### Merge the skill branch +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './discord.js'; +``` + +### 4. Install the adapter package (pinned) ```bash -git fetch discord main -git merge discord/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} +pnpm install @chat-adapter/discord@4.26.0 ``` -This merges in: -- `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`) -- `src/channels/discord.test.ts` (unit tests with discord.js mock) -- `import './discord.js'` appended to the channel barrel file `src/channels/index.ts` -- `discord.js` npm dependency in `package.json` -- `DISCORD_BOT_TOKEN` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes +### 5. Build ```bash -npm install -npm run build -npx vitest run src/channels/discord.test.ts +pnpm run build ``` -All tests must pass (including the new Discord tests) and build must be clean before proceeding. +## Credentials -## Phase 3: Setup +### Create Discord Bot -### Create Discord Bot (if needed) - -If the user doesn't have a bot token, tell them: - -> I need you to create a Discord bot: -> -> 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) -> 2. Click **New Application** and give it a name (e.g., "Andy Assistant") -> 3. Go to the **Bot** tab on the left sidebar -> 4. Click **Reset Token** to generate a new bot token — copy it immediately (you can only see it once) -> 5. Under **Privileged Gateway Intents**, enable: -> - **Message Content Intent** (required to read message text) -> - **Server Members Intent** (optional, for member display names) -> 6. Go to **OAuth2** > **URL Generator**: -> - Scopes: select `bot` -> - Bot Permissions: select `Send Messages`, `Read Message History`, `View Channels` -> - Copy the generated URL and open it in your browser to invite the bot to your server - -Wait for the user to provide the token. +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) +2. Click **New Application** and give it a name (e.g., "NanoClaw Assistant") +3. From the **General Information** tab, copy the **Application ID** and **Public Key** +4. Go to the **Bot** tab and click **Add Bot** if needed +5. Copy the Bot Token (click **Reset Token** if you need a new one — you can only see it once) +6. Under **Privileged Gateway Intents**, enable **Message Content Intent** +7. Go to **OAuth2** > **URL Generator**: + - Scopes: select `bot` + - Bot Permissions: select `Send Messages`, `Read Message History`, `Add Reactions`, `Attach Files`, `Use Slash Commands` +8. Copy the generated URL and open it in your browser to invite the bot to your server ### Configure environment +All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`. + Add to `.env`: ```bash -DISCORD_BOT_TOKEN= +DISCORD_BOT_TOKEN=your-bot-token +DISCORD_APPLICATION_ID=your-application-id +DISCORD_PUBLIC_KEY=your-public-key ``` -Channels auto-enable when their credentials are present — no extra configuration needed. +Sync to container: `mkdir -p data/env && cp .env data/env/env` -Sync to container environment: +## Next Steps -```bash -mkdir -p data/env && cp .env data/env/env -``` +If you're in the middle of `/setup`, return to the setup flow now. -The container reads environment from `data/env/env`, not `.env` directly. +Otherwise, run `/manage-channels` to wire this channel to an agent group. -### Build and restart +## Channel Info -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -## Phase 4: Registration - -### Get Channel ID - -Tell the user: - -> To get the channel ID for registration: -> -> 1. In Discord, go to **User Settings** > **Advanced** > Enable **Developer Mode** -> 2. Right-click the text channel you want the bot to respond in -> 3. Click **Copy Channel ID** -> -> The channel ID will be a long number like `1234567890123456`. - -Wait for the user to provide the channel ID (format: `dc:1234567890123456`). - -### Register the channel - -The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. - -For a main channel (responds to all messages): - -```bash -npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main -``` - -For additional channels (trigger-only): - -```bash -npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_" --trigger "@${ASSISTANT_NAME}" --channel discord -``` - -## Phase 5: Verify - -### Test the connection - -Tell the user: - -> Send a message in your registered Discord channel: -> - For main channel: Any message works -> - For non-main: @mention the bot in Discord -> -> The bot should respond within a few seconds. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Troubleshooting - -### Bot not responding - -1. Check `DISCORD_BOT_TOKEN` is set in `.env` AND synced to `data/env/env` -2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'dc:%'"` -3. For non-main channels: message must include trigger pattern (@mention the bot) -4. Service is running: `launchctl list | grep nanoclaw` -5. Verify the bot has been invited to the server (check OAuth2 URL was used) - -### Bot only responds to @mentions - -This is the default behavior for non-main channels (`requiresTrigger: true`). To change: -- Update the registered group's `requiresTrigger` to `false` -- Or register the channel as the main channel - -### Message Content Intent not enabled - -If the bot connects but can't read messages, ensure: -1. Go to [Discord Developer Portal](https://discord.com/developers/applications) -2. Select your application > **Bot** tab -3. Under **Privileged Gateway Intents**, enable **Message Content Intent** -4. Restart NanoClaw - -### Getting Channel ID - -If you can't copy the channel ID: -- Ensure **Developer Mode** is enabled: User Settings > Advanced > Developer Mode -- Right-click the channel name in the server sidebar > Copy Channel ID - -## After Setup - -The Discord bot supports: -- Text messages in registered channels -- Attachment descriptions (images, videos, files shown as placeholders) -- Reply context (shows who the user is replying to) -- @mention translation (Discord `<@botId>` → NanoClaw trigger format) -- Message splitting for responses over 2000 characters -- Typing indicators while the agent processes +- **type**: `discord` +- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages. +- **how-to-find-id**: Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Then right-click a server and select "Copy Server ID" for the guild ID, and right-click the text channel and select "Copy Channel ID." The platform ID format used in registration is `discord:{guildId}:{channelId}` — both IDs are required. +- **supports-threads**: yes +- **typical-use**: Interactive chat — server channels or direct messages +- **default-isolation**: Same agent group for your personal server. Separate agent group for servers with different communities or where different members have different information boundaries. diff --git a/.claude/skills/add-discord/VERIFY.md b/.claude/skills/add-discord/VERIFY.md new file mode 100644 index 0000000..0db2e5a --- /dev/null +++ b/.claude/skills/add-discord/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Discord + +Send a message in a channel where the bot has access, or DM the bot directly. The bot should respond within a few seconds. diff --git a/.claude/skills/add-emacs/SKILL.md b/.claude/skills/add-emacs/SKILL.md index 09bdbdd..82a5098 100644 --- a/.claude/skills/add-emacs/SKILL.md +++ b/.claude/skills/add-emacs/SKILL.md @@ -1,12 +1,11 @@ --- name: add-emacs -description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed. +description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Local HTTP bridge — no bot token or external service needed. --- # Add Emacs Channel -This skill adds Emacs support to NanoClaw, then walks through interactive setup. -Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. +Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. ## What you can do with this @@ -15,95 +14,99 @@ Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. - **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node - **Draft writing** — send org prose; receive revisions or continuations in place - **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it -- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR") -## Phase 1: Pre-flight +## Install -### Check if already applied +NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and the Lisp client in from the `channels` branch. Native HTTP bridge — no Chat SDK, no adapter package. -Check if `src/channels/emacs.ts` exists: +### Pre-flight (idempotent) + +Skip to **Enable** if all of these are already in place: + +- `src/channels/emacs.ts` exists +- `emacs/nanoclaw.el` exists +- `src/channels/index.ts` contains `import './emacs.js';` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch ```bash -test -f src/channels/emacs.ts && echo "already applied" || echo "not applied" +git fetch origin channels ``` -If it exists, skip to Phase 3 (Setup). The code changes are already in place. - -## Phase 2: Apply Code Changes - -### Ensure the upstream remote +### 2. Copy the adapter and Lisp client ```bash -git remote -v +mkdir -p emacs +git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts +git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts +git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el ``` -If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing, -add it: +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './emacs.js'; +``` + +### 4. Build ```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git +pnpm run build ``` -### Merge the skill branch +No npm package to install — the adapter uses only Node builtins (`http`). + +## Enable + +The adapter is gated by `EMACS_ENABLED` so the HTTP port isn't opened on hosts that aren't running Emacs. Add to `.env`: ```bash -git fetch upstream skill/emacs -git merge upstream/skill/emacs +EMACS_ENABLED=true +EMACS_CHANNEL_PORT=8766 # optional — change only if 8766 is taken +EMACS_AUTH_TOKEN= # optional — set to a random string to lock the endpoint +EMACS_PLATFORM_ID=default # optional — only change if you want a non-default chat id ``` -If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming -version and continuing: +Generate an auth token (recommended even on single-user machines — prevents other local processes from poking the endpoint): ```bash -git checkout --theirs package-lock.json -git add package-lock.json -git merge --continue +node -e "console.log(require('crypto').randomBytes(16).toString('hex'))" ``` -For any other conflict, read the conflicted file and reconcile both sides manually. +## Wire the channel -This adds: -- `src/channels/emacs.ts` — `EmacsBridgeChannel` HTTP server (port 8766) -- `src/channels/emacs.test.ts` — unit tests -- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`) -- `import './emacs.js'` appended to `src/channels/index.ts` +Emacs is a single-user, single-chat channel. One host = one messaging group with `platform_id = "default"`. -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. +### If this is your first agent group -### Validate code changes +Run `/init-first-agent` — pick **Emacs** as the channel, use any short handle as the "user id" (e.g. your OS username), and the skill will create the agent group, wire the channel, and write a welcome message that the agent delivers back to your Emacs buffer. + +### Otherwise — wire to an existing agent group + +Run the `register` step directly. The `EMACS_PLATFORM_ID` (default `default`) becomes the messaging group's platform id: ```bash -npm run build -npx vitest run src/channels/emacs.test.ts +pnpm exec tsx setup/index.ts --step register -- \ + --platform-id "default" --name "Emacs" \ + --folder "" --channel "emacs" \ + --session-mode "agent-shared" \ + --assistant-name "" ``` -Build must be clean and tests must pass before proceeding. +`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent. -## Phase 3: Setup +## Configure Emacs -### Configure environment (optional) - -The channel works out of the box with defaults. Add to `.env` only if you need non-defaults: - -```bash -EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use -EMACS_AUTH_TOKEN= # optional — locks the endpoint to Emacs only -``` - -If you change or add values, sync to the container environment: - -```bash -mkdir -p data/env && cp .env data/env/env -``` - -### Configure Emacs - -The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed. +`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager. AskUserQuestion: Which Emacs distribution are you using? -- **Doom Emacs** - config.el with map! keybindings -- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs -- **Vanilla Emacs / other** - init.el with global-set-key +- **Doom Emacs** — `config.el` with `map!` keybindings +- **Spacemacs** — `dotspacemacs/user-config` in `~/.spacemacs` +- **Vanilla Emacs / other** — `init.el` with `global-set-key` **Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`): @@ -117,7 +120,7 @@ AskUserQuestion: Which Emacs distribution are you using? :desc "Send org" "o" #'nanoclaw-org-send) ``` -Then reload: `M-x doom/reload` +Reload: `M-x doom/reload` **Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`: @@ -129,9 +132,9 @@ Then reload: `M-x doom/reload` (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send) ``` -Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. +Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. -**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`): +**Vanilla Emacs** — add to `~/.emacs.d/init.el`: ```elisp ;; NanoClaw — personal AI assistant channel @@ -141,61 +144,75 @@ Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. (global-set-key (kbd "C-c n o") #'nanoclaw-org-send) ``` -Then reload: `M-x eval-buffer` or restart Emacs. +Reload: `M-x eval-buffer` or restart Emacs. -If `EMACS_AUTH_TOKEN` was set, also add (any distribution): +Replace `~/src/nanoclaw/emacs/nanoclaw.el` with your actual NanoClaw checkout path. + +If `EMACS_AUTH_TOKEN` is set, also add (any distribution): ```elisp (setq nanoclaw-auth-token "") ``` -If `EMACS_CHANNEL_PORT` was changed from the default, also add: +If you changed `EMACS_CHANNEL_PORT` from the default: ```elisp (setq nanoclaw-port ) ``` -### Restart NanoClaw +## Restart NanoClaw ```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw +pnpm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux ``` -## Phase 4: Verify +## Verify -### Test the HTTP endpoint +### HTTP endpoint ```bash -curl -s "http://localhost:8766/api/messages?since=0" +curl -s http://localhost:8766/api/messages?since=0 ``` -Expected: `{"messages":[]}` - -If you set `EMACS_AUTH_TOKEN`: +Expected: `{"messages":[]}`. With an auth token: ```bash -curl -s -H "Authorization: Bearer " "http://localhost:8766/api/messages?since=0" +curl -s -H "Authorization: Bearer " http://localhost:8766/api/messages?since=0 ``` -### Test from Emacs +### From Emacs Tell the user: > 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`) -> 2. Type a message and press `RET` -> 3. A response from Andy should appear within a few seconds +> 2. Type a message and press `C-c C-c` to send (RET inserts newlines) +> 3. A response should appear within a few seconds > > For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o` -### Check logs if needed +### Log line -```bash -tail -f logs/nanoclaw.log -``` +`tail -f logs/nanoclaw.log` should show `Emacs channel listening` at startup. -Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent. +## Channel Info + +- **type**: `emacs` +- **terminology**: Single local buffer. There are no "groups" or separate chats — one host = one chat, addressed by a `platform_id` string (default `default`). +- **how-to-find-id**: The platform id is whatever you set in `EMACS_PLATFORM_ID` (default `default`). User handles are arbitrary; your OS username or first name is fine (e.g. `emacs:`). +- **supports-threads**: no +- **typical-use**: Single developer talking to the assistant from within Emacs, alongside whatever other channel they use (Slack, Telegram, Discord). +- **default-isolation**: Same agent group as the primary DM, with `session-mode = agent-shared` so a conversation started elsewhere continues in Emacs. Pick a separate folder only if you specifically want an Emacs-only persona. + +### Features + +- Interactive chat buffer (`nanoclaw-chat`) with markdown → org-mode rendering +- Org integration (`nanoclaw-org-send`) — sends the current subtree or region; reply lands as a child heading +- Optional bearer-token auth for the local endpoint +- Single-user: the adapter exposes exactly one messaging group per host + +Not applicable (design): multi-user channels, threads, cold DM initiation, typing indicators, attachments. ## Troubleshooting @@ -205,66 +222,53 @@ Look for `Emacs channel listening` at startup and `Emacs message received` when Error: listen EADDRINUSE: address already in use :::8766 ``` -Either a stale NanoClaw process is running, or 8766 is taken by another app. - -Find and kill the stale process: +Either a stale NanoClaw is running or another app has the port. Kill stale process or change port: ```bash lsof -ti :8766 | xargs kill -9 +# or set EMACS_CHANNEL_PORT in .env and mirror in Emacs config (nanoclaw-port) ``` -Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config. +### Adapter not starting + +If `grep "Emacs channel listening" logs/nanoclaw.log` returns nothing, check that `EMACS_ENABLED=true` is in `.env` and that the adapter import is present: + +```bash +grep -q '^EMACS_ENABLED=true' .env && echo "enabled" || echo "not enabled" +grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "not imported" +``` ### No response from agent -Check: -1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) -2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"` -3. Logs show activity: `tail -50 logs/nanoclaw.log` +1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) +2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"` +3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20` -If the group is not registered, it will be created automatically on the next NanoClaw restart. +If no messaging group row exists, run the `register` command above. ### Auth token mismatch (401 Unauthorized) -Verify the token in Emacs matches `.env`: - ```elisp -;; M-x describe-variable RET nanoclaw-auth-token RET +M-x describe-variable RET nanoclaw-auth-token RET ``` -Must exactly match `EMACS_AUTH_TOKEN` in `.env`. +Must match `EMACS_AUTH_TOKEN` in `.env`. If you didn't set one server-side, clear it in Emacs too: + +```elisp +(setq nanoclaw-auth-token nil) +``` ### nanoclaw.el not loading -Check the path is correct: - ```bash ls ~/src/nanoclaw/emacs/nanoclaw.el ``` If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config. -## After Setup - -If running `npm run dev` while the service is active: - -```bash -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -npm run dev -# When done testing: -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist - -# Linux: -# systemctl --user stop nanoclaw -# npm run dev -# systemctl --user start nanoclaw -``` - ## Agent Formatting -The Emacs bridge converts markdown → org-mode automatically. Agents should -output standard markdown — **not** org-mode syntax. The conversion handles: +The Emacs bridge converts markdown → org-mode automatically. Agents should output standard markdown, **not** org-mode syntax: | Markdown | Org-mode | |----------|----------| @@ -274,16 +278,19 @@ output standard markdown — **not** org-mode syntax. The conversion handles: | `` `code` `` | `~code~` | | ` ```lang ` | `#+begin_src lang` | -If an agent outputs org-mode directly, bold/italic/etc. will be double-converted -and render incorrectly. +If an agent outputs org-mode directly, markers get double-converted and render incorrectly. ## Removal -To remove the Emacs channel: +```bash +rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el +# Remove the `import './emacs.js';` line from src/channels/index.ts +# Remove EMACS_* lines from .env +pnpm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux -1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el` -2. Remove `import './emacs.js'` from `src/channels/index.ts` -3. Remove the NanoClaw block from your Emacs config file -4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"` -5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set -6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) \ No newline at end of file +# Remove the NanoClaw block from your Emacs config +# Optionally clean up the messaging group: +sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';" +``` diff --git a/.claude/skills/add-gcal-tool/SKILL.md b/.claude/skills/add-gcal-tool/SKILL.md new file mode 100644 index 0000000..5751933 --- /dev/null +++ b/.claude/skills/add-gcal-tool/SKILL.md @@ -0,0 +1,210 @@ +--- +name: add-gcal-tool +description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time. +--- + +# Add Google Calendar Tool (OneCLI-native) + +This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault. + +**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained. + +Tools exposed (surfaced as `mcp__calendar__`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools. + +**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly. + +## Phase 1: Pre-flight + +### Verify OneCLI has Google Calendar connected + +```bash +onecli apps get --provider google-calendar +``` + +Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`. + +If not connected, tell the user: + +> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes. + +### Verify stub credentials exist + +The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead. + +```bash +ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1 +``` + +If both exist with `onecli-managed`: + +```bash +grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json +``` + +...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding. + +If absent, write them: + +```bash +mkdir -p ~/.calendar-mcp +cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF' +{ + "installed": { + "client_id": "onecli-managed.apps.googleusercontent.com", + "client_secret": "onecli-managed", + "redirect_uris": ["http://localhost:3000/oauth2callback"] + } +} +EOF +cat > ~/.calendar-mcp/credentials.json <<'EOF' +{ + "access_token": "onecli-managed", + "refresh_token": "onecli-managed", + "token_type": "Bearer", + "expiry_date": 99999999999999, + "scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events" +} +EOF +chmod 600 ~/.calendar-mcp/*.json +``` + +### Verify mount allowlist covers the path + +```bash +cat ~/.config/nanoclaw/mount-allowlist.json +``` + +`~/.calendar-mcp` must sit under an `allowedRoots` entry. + +### Check agent secret-mode + +For each target agent group, confirm OneCLI will inject the Google Calendar token: + +```bash +onecli agents list +``` + +`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret. + +## Phase 2: Apply Code Changes + +### Check if already applied + +```bash +grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \ +grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \ +echo "ALREADY APPLIED — skip to Phase 3" +``` + +### Add MCP server to Dockerfile + +Edit `container/Dockerfile`. Find the pinned-version ARG block and add: + +```dockerfile +ARG CALENDAR_MCP_VERSION=2.6.1 +``` + +If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g \ + "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ + "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \ + "zod-to-json-schema@3.22.5" +``` + +If `/add-gmail-tool` hasn't been applied, install Calendar standalone: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" +``` + +### Add tools to allowlist + +Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present). + +### Rebuild the container image + +```bash +./container/build.sh +``` + +## Phase 3: Wire Per-Agent-Group + +For each agent group, merge into `groups//container.json`: + +```jsonc +{ + "mcpServers": { + "calendar": { + "command": "google-calendar-mcp", + "args": [], + "env": { + "GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json", + "GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json" + } + } + }, + "additionalMounts": [ + { + "hostPath": "/home//.calendar-mcp", + "containerPath": ".calendar-mcp", + "readonly": false + } + ] +} +``` + +Substitute `` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/`). + +**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`. + +## Phase 4: Build and Restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +Kill any existing agent containers so they respawn with the new mcpServers config: + +```bash +docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill +``` + +## Phase 5: Verify + +### Test from a wired agent + +> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**. +> +> First call takes 2–3s while the MCP server starts and OneCLI does the token exchange. + +### Check logs if the tool isn't working + +```bash +tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp' +``` + +Common signals: +- `command not found: google-calendar-mcp` → image not rebuilt. +- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist. +- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected. +- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again). + +## Removal + +1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`. +2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`. +3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block. +4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`. +5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`. + +## Credits & references + +- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar. +- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it. +- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism. diff --git a/.claude/skills/add-gchat/REMOVE.md b/.claude/skills/add-gchat/REMOVE.md new file mode 100644 index 0000000..104ad2d --- /dev/null +++ b/.claude/skills/add-gchat/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Google Chat Channel + +1. Comment out `import './gchat.js'` in `src/channels/index.ts` +2. Remove `GCHAT_CREDENTIALS` from `.env` +3. `pnpm uninstall @chat-adapter/gchat` +4. Rebuild and restart diff --git a/.claude/skills/add-gchat/SKILL.md b/.claude/skills/add-gchat/SKILL.md new file mode 100644 index 0000000..c4d8dfd --- /dev/null +++ b/.claude/skills/add-gchat/SKILL.md @@ -0,0 +1,92 @@ +--- +name: add-gchat +description: Add Google Chat channel integration via Chat SDK. +--- + +# Add Google Chat Channel + +Adds Google Chat support via the Chat SDK bridge. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/gchat.ts` exists +- `src/channels/index.ts` contains `import './gchat.js';` +- `@chat-adapter/gchat` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './gchat.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/gchat@4.26.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +> 1. Go to [Google Cloud Console](https://console.cloud.google.com) +> 2. Create or select a project +> 3. Enable the **Google Chat API** +> 4. Go to **Google Chat API** > **Configuration**: +> - App name and description +> - Connection settings: select **HTTP endpoint URL** and set to `https://your-domain/webhook/gchat` +> 5. Create a **Service Account**: +> - Go to **IAM & Admin** > **Service Accounts** > **Create Service Account** +> - Grant the Chat Bot role +> - Create a JSON key and download it + +### Configure environment + +Add the service account JSON as a single-line string to `.env`: + +```bash +GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."} +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `gchat` +- **terminology**: Google Chat has "spaces." A space can be a group conversation or a direct message with the bot. +- **how-to-find-id**: Open the space in Google Chat, look at the URL — the space ID is the segment after `/space/` (e.g. `spaces/AAAA...`). Or use the Google Chat API to list spaces. +- **supports-threads**: yes +- **typical-use**: Interactive chat — team spaces or direct messages +- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive contexts. diff --git a/.claude/skills/add-gchat/VERIFY.md b/.claude/skills/add-gchat/VERIFY.md new file mode 100644 index 0000000..fc131a4 --- /dev/null +++ b/.claude/skills/add-gchat/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Google Chat Channel + +Add the bot to a Google Chat space, then send a message or @mention the bot. The bot should respond within a few seconds. diff --git a/.claude/skills/add-github/REMOVE.md b/.claude/skills/add-github/REMOVE.md new file mode 100644 index 0000000..c41df6f --- /dev/null +++ b/.claude/skills/add-github/REMOVE.md @@ -0,0 +1,6 @@ +# Remove GitHub Channel + +1. Comment out `import './github.js'` in `src/channels/index.ts` +2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env` +3. `pnpm uninstall @chat-adapter/github` +4. Rebuild and restart diff --git a/.claude/skills/add-github/SKILL.md b/.claude/skills/add-github/SKILL.md new file mode 100644 index 0000000..78366f3 --- /dev/null +++ b/.claude/skills/add-github/SKILL.md @@ -0,0 +1,148 @@ +--- +name: add-github +description: Add GitHub channel integration via Chat SDK. PR and issue comment threads as conversations. +--- + +# Add GitHub Channel + +Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads. + +## Prerequisites + +You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/github.ts` exists +- `src/channels/index.ts` contains `import './github.js';` +- `@chat-adapter/github` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/github.ts > src/channels/github.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './github.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/github@4.26.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +### 1. Create a Personal Access Token for the bot account + +Log in as your **bot account**, then: + +1. Go to [Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens) +2. Create a **Fine-grained token** with: + - Repository access: select the repos you want the bot to monitor + - Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write) +3. Copy the token + +### 2. Set up a webhook on each repo + +On each repo (logged in as the repo owner/admin): + +1. Go to **Settings** > **Webhooks** > **Add webhook** +2. Payload URL: `https://your-domain/webhook/github` (the shared webhook server, default port 3000) +3. Content type: `application/json` +4. Secret: generate a random string (e.g. `openssl rand -hex 20`) +5. Events: select **Issue comments** and **Pull request review comments** + +### 3. Configure environment + +Add to `.env`: + +```bash +GITHUB_TOKEN=github_pat_... +GITHUB_WEBHOOK_SECRET=your-webhook-secret +GITHUB_BOT_USERNAME=your-bot-username +``` + +`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment. + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Wiring + +Ask the user: **Is this a private or public repo?** + +- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through. +- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below). + +Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually: + +```sql +-- Create messaging group (one per repo) +INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at) +VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '', datetime('now')); + +-- Wire to agent group +INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) +VALUES ('mga-github-myrepo', 'mg-github-myrepo', '', '', 'all', 'per-thread', 10, datetime('now')); +``` + +Replace `` with `public` or `strict` based on the user's choice above. + +### Adding members (for strict mode) + +When using `strict`, add each GitHub user who should be able to trigger the agent: + +```sql +-- Add user (kind = 'github', id = 'github:') +INSERT OR IGNORE INTO users (id, kind, display_name, created_at) +VALUES ('github:', 'github', '', datetime('now')); + +-- Grant membership to the agent group +INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id) +VALUES ('github:', ''); +``` + +To find a GitHub user's numeric ID: `gh api users/ --jq .id` + +Use `per-thread` session mode so each PR/issue gets its own agent session. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel. + +## Channel Info + +- **type**: `github` +- **terminology**: GitHub has "repositories" containing "pull requests" and "issues." Each PR or issue comment thread is a separate conversation. +- **how-to-find-id**: The platform ID is `github:owner/repo` (e.g. `github:acme/backend`). Each PR/issue becomes its own thread automatically. +- **supports-threads**: yes (PR and issue comment threads are native conversations) +- **typical-use**: Webhook-driven — the agent receives PR and issue comment events and responds in comment threads when @-mentioned. After the first mention, the thread is subscribed and the agent responds to all follow-up comments. +- **default-isolation**: Use `per-thread` session mode. Each PR or issue gets its own isolated agent session. Typically wire to a dedicated agent group if the repo contains sensitive code. diff --git a/.claude/skills/add-github/VERIFY.md b/.claude/skills/add-github/VERIFY.md new file mode 100644 index 0000000..61840b7 --- /dev/null +++ b/.claude/skills/add-github/VERIFY.md @@ -0,0 +1,3 @@ +# Verify GitHub Channel + +@mention the bot in a PR comment or issue comment. The bot should respond within a few seconds. diff --git a/.claude/skills/add-gmail-tool/SKILL.md b/.claude/skills/add-gmail-tool/SKILL.md new file mode 100644 index 0000000..095c285 --- /dev/null +++ b/.claude/skills/add-gmail-tool/SKILL.md @@ -0,0 +1,229 @@ +--- +name: add-gmail-tool +description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form. +--- + +# Add Gmail Tool (OneCLI-native) + +This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault. + +Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`. + +**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight. + +## Phase 1: Pre-flight + +### Verify OneCLI has Gmail connected + +```bash +onecli apps get --provider gmail +``` + +Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`. + +If not connected, tell the user: + +> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as. + +### Verify stub credentials exist + +```bash +ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1 +``` + +If both exist and contain `"onecli-managed"`: + +```bash +grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json +``` + +...skip to Phase 2. + +If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong. + +If both files are absent, write them now: + +```bash +mkdir -p ~/.gmail-mcp +cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF' +{ + "installed": { + "client_id": "onecli-managed.apps.googleusercontent.com", + "client_secret": "onecli-managed", + "redirect_uris": ["http://localhost:3000/oauth2callback"] + } +} +EOF +cat > ~/.gmail-mcp/credentials.json <<'EOF' +{ + "access_token": "onecli-managed", + "refresh_token": "onecli-managed", + "token_type": "Bearer", + "expiry_date": 99999999999999, + "scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send" +} +EOF +chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json +``` + +### Verify mount allowlist covers the path + +```bash +cat ~/.config/nanoclaw/mount-allowlist.json +``` + +`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory. + +### Check agent secret-mode + +For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`: + +```bash +onecli agents list +``` + +If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets: + +```bash +onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app) +onecli agents set-secrets --id --secret-ids +``` + +## Phase 2: Apply Code Changes + +### Check if already applied + +```bash +grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \ +grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \ +echo "ALREADY APPLIED — skip to Phase 3" +``` + +### Add MCP server to Dockerfile + +Edit `container/Dockerfile`. Find the pinned-version ARG block: + +```dockerfile +ARG CLAUDE_CODE_VERSION=2.1.116 +ARG AGENT_BROWSER_VERSION=latest +ARG VERCEL_VERSION=latest +ARG BUN_VERSION=1.3.12 +``` + +Add a new line: + +```dockerfile +ARG GMAIL_MCP_VERSION=1.1.11 +``` + +Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`: + +```dockerfile +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g \ + "@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \ + "zod-to-json-schema@3.22.5" +``` + +Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image. + +**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`. + +### Add tools to allowlist + +Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it. + +### Rebuild the container image + +```bash +./container/build.sh +``` + +Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild). + +## Phase 3: Wire Per-Agent-Group + +For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups//container.json` to add the mount and MCP server. + +Merge these into the group's `container.json`: + +```jsonc +{ + "mcpServers": { + "gmail": { + "command": "gmail-mcp", + "args": [], + "env": { + "GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json", + "GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json" + } + } + }, + "additionalMounts": [ + { + "hostPath": "/home//.gmail-mcp", + "containerPath": ".gmail-mcp", + "readonly": false + } + ] +} +``` + +Substitute `` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes). + +**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container. + +## Phase 4: Build and Restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +## Phase 5: Verify + +### Test from the wired agent + +Tell the user: + +> In your `` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**. +> +> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange. + +### Check logs if the tool isn't working + +```bash +tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp' +# Per-container logs — session-scoped: +ls data/v2-sessions/*/stderr.log | head +``` + +Common signals: +- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile). +- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`. +- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id `) and that the Gmail app is connected (`onecli apps get --provider gmail`). +- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious). + +## Removal + +1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`. +2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`. +3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`. +4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`. +5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs. +6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`. + +## Notes + +- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes. +- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set. +- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0. + +## Credits & references + +- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed. +- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`. +- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md). +- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side. +- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version. diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md deleted file mode 100644 index 781a0eb..0000000 --- a/.claude/skills/add-gmail/SKILL.md +++ /dev/null @@ -1,220 +0,0 @@ ---- -name: add-gmail -description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration. ---- - -# Add Gmail Integration - -This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/channels/gmail.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. - -### Ask the user - -Use `AskUserQuestion`: - -AskUserQuestion: Should incoming emails be able to trigger the agent? - -- **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically -- **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added. - -## Phase 2: Apply Code Changes - -### Ensure channel remote - -```bash -git remote -v -``` - -If `gmail` is missing, add it: - -```bash -git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git -``` - -### Merge the skill branch - -```bash -git fetch gmail main -git merge gmail/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`) -- `src/channels/gmail.test.ts` (unit tests) -- `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts` -- Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts` -- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts` -- `googleapis` npm dependency in `package.json` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Add email handling instructions (Channel mode only) - -If the user chose channel mode, append the following to `groups/main/CLAUDE.md` (before the formatting section): - -```markdown -## Email Notifications - -When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email. -``` - -### Validate code changes - -```bash -npm install -npm run build -npx vitest run src/channels/gmail.test.ts -``` - -All tests must pass (including the new Gmail tests) and build must be clean before proceeding. - -## Phase 3: Setup - -### Check existing Gmail credentials - -```bash -ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found" -``` - -If `credentials.json` already exists, skip to "Build and restart" below. - -### GCP Project Setup - -Tell the user: - -> I need you to set up Google Cloud OAuth credentials: -> -> 1. Open https://console.cloud.google.com — create a new project or select existing -> 2. Go to **APIs & Services > Library**, search "Gmail API", click **Enable** -> 3. Go to **APIs & Services > Credentials**, click **+ CREATE CREDENTIALS > OAuth client ID** -> - If prompted for consent screen: choose "External", fill in app name and email, save -> - Application type: **Desktop app**, name: anything (e.g., "NanoClaw Gmail") -> 4. Click **DOWNLOAD JSON** and save as `gcp-oauth.keys.json` -> -> Where did you save the file? (Give me the full path, or paste the file contents here) - -If user provides a path, copy it: - -```bash -mkdir -p ~/.gmail-mcp -cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json -``` - -If user pastes JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`. - -### OAuth Authorization - -Tell the user: - -> I'm going to run Gmail authorization. A browser window will open — sign in and grant access. If you see an "app isn't verified" warning, click "Advanced" then "Go to [app name] (unsafe)" — this is normal for personal OAuth apps. - -Run the authorization: - -```bash -npx -y @gongrzhe/server-gmail-autoauth-mcp auth -``` - -If that fails (some versions don't have an auth subcommand), try `timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`. - -### Build and restart - -Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server): - -```bash -rm -r data/sessions/*/agent-runner-src 2>/dev/null || true -``` - -Rebuild the container (agent-runner changed): - -```bash -cd container && ./build.sh -``` - -Then compile and restart: - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Verify - -### Test tool access (both modes) - -Tell the user: - -> Gmail is connected! Send this in your main channel: -> -> `@Andy check my recent emails` or `@Andy list my Gmail labels` - -### Test channel mode (Channel mode only) - -Tell the user to send themselves a test email. The agent should pick it up within a minute. Monitor: `tail -f logs/nanoclaw.log | grep -iE "(gmail|email)"`. - -Once verified, offer filter customization via `AskUserQuestion` — by default, only emails in the Primary inbox trigger the agent (Promotions, Social, Updates, and Forums are excluded). The user can keep this default or narrow further by sender, label, or keywords. No code changes needed for filters. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Troubleshooting - -### Gmail connection not responding - -Test directly: - -```bash -npx -y @gongrzhe/server-gmail-autoauth-mcp -``` - -### OAuth token expired - -Re-authorize: - -```bash -rm ~/.gmail-mcp/credentials.json -npx -y @gongrzhe/server-gmail-autoauth-mcp -``` - -### Container can't access Gmail - -- Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount -- Check container logs: `cat groups/main/logs/container-*.log | tail -50` - -### Emails not being detected (Channel mode only) - -- By default, the channel polls unread Primary inbox emails (`is:unread category:primary`) -- Check logs for Gmail polling errors - -## Removal - -### Tool-only mode - -1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` -2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` -3. Rebuild and restart -4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` -5. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) - -### Channel mode - -1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts` -2. Remove `import './gmail.js'` from `src/channels/index.ts` -3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` -4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` -5. Uninstall: `npm uninstall googleapis` -6. Rebuild and restart -7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` -8. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md deleted file mode 100644 index 072bf7b..0000000 --- a/.claude/skills/add-image-vision/SKILL.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -name: add-image-vision -description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks. ---- - -# Image Vision Skill - -Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks. - -## Phase 1: Pre-flight - -1. Check if `src/image.ts` exists — skip to Phase 3 if already applied -2. Confirm `sharp` is installable (native bindings require build tools) - -**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/image-vision -git merge whatsapp/skill/image-vision || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `src/image.ts` (image download, resize via sharp, base64 encoding) -- `src/image.test.ts` (8 unit tests) -- Image attachment handling in `src/channels/whatsapp.ts` -- Image passing to agent in `src/index.ts` and `src/container-runner.ts` -- Image content block support in `container/agent-runner/src/index.ts` -- `sharp` npm dependency in `package.json` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -npm install -npm run build -npx vitest run src/image.test.ts -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Configure - -1. Rebuild the container (agent-runner changes need a rebuild): - ```bash - ./container/build.sh - ``` - -2. Sync agent-runner source to group caches: - ```bash - for dir in data/sessions/*/agent-runner-src/; do - cp container/agent-runner/src/*.ts "$dir" - done - ``` - -3. Restart the service: - ```bash - launchctl kickstart -k gui/$(id -u)/com.nanoclaw - ``` - -## Phase 4: Verify - -1. Send an image in a registered WhatsApp group -2. Check the agent responds with understanding of the image content -3. Check logs for "Processed image attachment": - ```bash - tail -50 groups/*/logs/container-*.log - ``` - -## Troubleshooting - -- **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections. -- **"Image - processing failed"**: Sharp may not be installed correctly. Run `npm ls sharp` to verify. -- **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches. diff --git a/.claude/skills/add-imessage/REMOVE.md b/.claude/skills/add-imessage/REMOVE.md new file mode 100644 index 0000000..684b2d1 --- /dev/null +++ b/.claude/skills/add-imessage/REMOVE.md @@ -0,0 +1,6 @@ +# Remove iMessage Channel + +1. Comment out `import './imessage.js'` in `src/channels/index.ts` +2. Remove iMessage env vars (`IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, `IMESSAGE_API_KEY`) from `.env` +3. `pnpm uninstall chat-adapter-imessage` +4. Rebuild and restart diff --git a/.claude/skills/add-imessage/SKILL.md b/.claude/skills/add-imessage/SKILL.md new file mode 100644 index 0000000..7ee87aa --- /dev/null +++ b/.claude/skills/add-imessage/SKILL.md @@ -0,0 +1,113 @@ +--- +name: add-imessage +description: Add iMessage channel integration via Chat SDK. Local (macOS) or remote (Photon API) mode. +--- + +# Add iMessage Channel + +Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API). + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/imessage.ts` exists +- `src/channels/index.ts` contains `import './imessage.js';` +- `chat-adapter-imessage` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './imessage.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install chat-adapter-imessage@0.1.1 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +### Local Mode (macOS) + +Requirements: macOS with Full Disk Access granted to the Node.js binary. + +The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`). To make it easy, open the folder in Finder so the user can drag the file into System Settings: + +```bash +open "$(dirname "$(which node)")" +``` + +Then tell the user: + +1. Open **System Settings** > **Privacy & Security** > **Full Disk Access** +2. Click **+**, then drag the `node` file from the Finder window that just opened +3. Toggle it on + +Stop and wait for the user to confirm before continuing. + +### Remote Mode (Photon API) + +1. Set up a [Photon](https://photon.im) account +2. Get your server URL and API key + +### Configure environment + +**Local mode** -- add to `.env`: + +```bash +IMESSAGE_ENABLED=true +IMESSAGE_LOCAL=true +``` + +**Remote mode** -- add to `.env`: + +```bash +IMESSAGE_LOCAL=false +IMESSAGE_SERVER_URL=https://your-photon-server.com +IMESSAGE_API_KEY=your-api-key +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `imessage` +- **terminology**: iMessage has "conversations." Each conversation is with a contact identified by phone number or email address. Group chats are also supported. +- **how-to-find-id**: The platform ID is the contact's phone number (e.g. `+15551234567`) or email address. For group chats, the ID is assigned by iMessage internally. +- **supports-threads**: no +- **typical-use**: Interactive 1:1 chat — personal messaging +- **default-isolation**: Same agent group if you're the only person messaging the bot across iMessage and other channels. Separate agent group if different contacts should have information isolation. diff --git a/.claude/skills/add-imessage/VERIFY.md b/.claude/skills/add-imessage/VERIFY.md new file mode 100644 index 0000000..4fa4755 --- /dev/null +++ b/.claude/skills/add-imessage/VERIFY.md @@ -0,0 +1,3 @@ +# Verify iMessage Channel + +Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds. diff --git a/.claude/skills/add-karpathy-llm-wiki/SKILL.md b/.claude/skills/add-karpathy-llm-wiki/SKILL.md new file mode 100644 index 0000000..12b9b37 --- /dev/null +++ b/.claude/skills/add-karpathy-llm-wiki/SKILL.md @@ -0,0 +1,110 @@ +--- +name: add-karpathy-llm-wiki +description: Add a persistent wiki knowledge base to a NanoClaw group. Based on Karpathy's LLM Wiki pattern. Triggers on "add wiki", "wiki", "knowledge base", "llm wiki", "karpathy wiki". +--- + +# Add Karpathy LLM Wiki + +Set up a persistent wiki knowledge base on NanoClaw, based on Karpathy's LLM Wiki pattern. + +## Step 1: Read the pattern + +Read `${CLAUDE_SKILL_DIR}/llm-wiki.md` — this is the full LLM Wiki idea as written by Karpathy. Understand it thoroughly before proceeding. Summarize the core idea to the user briefly, then discuss what they want to build. + +## Step 2: Choose a group + +AskUserQuestion: "Which group should have the wiki?" + +1. **Main group** — add to your existing main chat +2. **Dedicated group** — create a new group just for the wiki +3. **Other** — pick an existing group + +If dedicated: ask which channel and chat, then register with `pnpm exec tsx setup/index.ts --step register`. + +## Step 3: Design collaboratively + +Discuss with the user based on the pattern: +- What's the wiki's domain or topic? +- What kinds of sources will they add? (URLs, PDFs, images, voice notes, books, transcripts) +- Do they want the full three-layer architecture or a lighter version? +- Any specific conventions they care about? (The pattern intentionally leaves this open.) + +Based on this discussion, create three things: + +### 3a. Directory structure + +Create `wiki/` and `sources/` directories in the group folder. Create initial `index.md` and `log.md` per the pattern's Indexing and Logging section. Adapt to the user's domain. + +### 3b. Container skill + +Create a `container/skills/wiki/SKILL.md` tailored to this user's wiki. This is the schema layer from the pattern — it tells the agent how to maintain the wiki. Base it on the pattern's Operations section (ingest, query, lint) and the conventions you agreed on with the user. Don't over-prescribe — the pattern says "your LLM figures out the rest." + +### 3c. Group CLAUDE.md + +Edit the group's CLAUDE.md to add a wiki section. This is critical — it's what turns the agent into a wiki maintainer. It should: + +- Explain the wiki system concisely: what it is, the three layers (sources, wiki, schema), the three operations (ingest, query, lint) +- Index the key files and folders (`wiki/`, `sources/`, `wiki/index.md`, `wiki/log.md`) +- Point to the container skill for detailed workflow +- **Ingest discipline:** Be very explicit that when the user provides multiple files or points at a folder with many files, the agent MUST process them one at a time. For each file: read it, discuss takeaways, create/update all wiki pages (summary, entities, concepts, cross-references, index, log), and completely finish with that file before moving to the next. Never batch-read all files and then process them together — this produces shallow, generic pages instead of the deep integration the pattern requires. + +## Step 4: Source handling capabilities + +Based on the source types the user plans to ingest (discussed in Step 3), check whether the agent can already handle those formats — some are supported natively, others need a skill (e.g. `/add-image-vision`, `/add-pdf-reader`, `/add-voice-transcription`). If a needed capability isn't installed, check if there's an available skill for it and help the user get it set up. + +### URL handling note + +claude has built-in `WebFetch`, but it returns a summary, not the full document. For wiki ingestion of a URL where the full text matters, the container skill and CLAUDE.md should instruct claude to use bash commands to download full files instead. For example: + +```bash +curl -sLo sources/filename.pdf "" +``` + +If the document is a webpage, then claude can use fetch or `agent-browser` to open the page and extract full text if available. The container skill and CLAUDE.md should note this so claude gets full content for sources rather than summaries. + + +## Step 5: Optional lint schedule + +AskUserQuestion: "Want periodic wiki health checks?" + +1. **Weekly** +2. **Monthly** +3. **Skip** — lint manually + +If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database: + +```bash +pnpm exec tsx -e " +const Database = require('better-sqlite3'); +const { CronExpressionParser } = require('cron-parser'); +const db = new Database('store/messages.db'); +const interval = CronExpressionParser.parse('', { tz: process.env.TZ || 'UTC' }); +const nextRun = interval.next().toISOString(); +db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run( + 'wiki-lint', + '', + '', + 'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.', + 'cron', + '', + 'group', + nextRun, + 'active', + new Date().toISOString() +); +db.close(); +" +``` + +Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am). + +## Step 6: Build and restart + +```bash +pnpm run build +./container/build.sh +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# Linux: systemctl --user restart nanoclaw +``` + +Tell the user to test by sending a source to the wiki group. diff --git a/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md b/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md new file mode 100644 index 0000000..829d21c --- /dev/null +++ b/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md @@ -0,0 +1,75 @@ +# LLM Wiki + +> Source: [karpathy/llm-wiki.md](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) + +A pattern for building personal knowledge bases using LLMs. + +This is an idea file, designed to be copied to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, etc.). Its goal is to communicate the high-level idea, with your agent building out specifics through collaboration with you. + +## The Core Idea + +Most interactions with LLMs and documents follow RAG patterns: upload files, retrieve relevant chunks at query time, generate answers. The knowledge is re-derived on each question with no accumulation. + +The concept here differs fundamentally. Rather than just retrieving from raw documents, the LLM incrementally builds and maintains a persistent wiki — a structured, interlinked markdown collection sitting between you and raw sources. When adding new material, the LLM reads it, extracts key information, and integrates it into existing wiki pages—updating entities, revising summaries, flagging contradictions, strengthening synthesis. Knowledge compiles once and stays current rather than re-deriving on every query. + +The wiki becomes a persistent, compounding artifact. Cross-references already exist. Contradictions are flagged. Synthesis reflects everything read. The wiki enriches with every source added and question asked. + +You source material and ask questions; the LLM maintains everything—summarizing, cross-referencing, filing, and organizing. The LLM acts as programmer; Obsidian serves as IDE; the wiki functions as codebase. + +**Applications include:** +- Personal: tracking goals, health, self-improvement +- Research: deep dives over weeks/months +- Reading: building companion wikis while progressing through books +- Business/teams: internal wikis fed by Slack, transcripts, documents +- Analysis: competitive research, due diligence, trip planning, hobby deep-dives + +## Architecture + +Three layers comprise the system: + +**Raw sources** — immutable curated documents (articles, papers, images, data). The LLM reads but never modifies these. + +**The wiki** — LLM-generated markdown directories containing summaries, entity pages, concept pages, comparisons, syntheses. The LLM owns this entirely, creating and updating pages while maintaining cross-references and consistency. + +**The schema** — configuration document (e.g., CLAUDE.md) explaining wiki structure, conventions, and workflows for ingestion, querying, and maintenance. This key file transforms the LLM into disciplined wiki maintainer rather than generic chatbot. + +## Operations + +**Ingest:** Drop new sources into the raw collection; the LLM processes them. The agent reads sources, discusses takeaways, writes summaries, updates indexes, refreshes entity and concept pages, logs entries. Single sources might touch 10-15 wiki pages. Prefer ingesting individually while staying involved, though batch ingestion with less oversight is possible. + +**Query:** Ask questions against the wiki. The LLM searches relevant pages, synthesizes answers with citations. Answers take various forms—markdown pages, comparison tables, slide decks, charts, canvas. Good answers can be filed back into the wiki as new pages—explorations compound in the knowledge base rather than disappearing into chat history. + +**Lint:** Periodically health-check the wiki. Look for contradictions, stale claims superseded by newer sources, orphan pages lacking inbound links, important concepts lacking dedicated pages, missing cross-references, data gaps. The LLM suggests investigations and sources to pursue, keeping the wiki healthy as it grows. + +## Indexing and Logging + +Two special files help navigate the growing wiki: + +**index.md** — content-oriented catalog of everything (each page with link, one-line summary, optional metadata like dates or source counts), organized by category. The LLM updates it on every ingest. When answering queries, read the index first to locate relevant pages before drilling deeper. This approach works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) while avoiding embedding-based RAG infrastructure needs. + +**log.md** — append-only chronological record of what happened and when (ingests, queries, lint passes). Each entry beginning with consistent prefix (e.g., `## [2026-04-02] ingest | Article Title`) becomes parseable with simple tools—`grep "^## \[" log.md | tail -5` yields last 5 entries. The log shows wiki evolution timeline and helps the LLM understand recent activity. + +## Optional: CLI Tools + +At scale, small tools help the LLM operate more efficiently. Search engine over wiki pages is most obvious—at small scale the index suffices, but as the wiki grows, proper search becomes necessary. qmd (https://github.com/tobi/qmd) offers local search with hybrid BM25/vector search and LLM re-ranking, entirely on-device. It includes both CLI (so LLMs can shell out) and MCP server (native tool integration). Build simpler custom search scripts as needs arise. + +## Tips and Tricks + +- **Obsidian Web Clipper** converts web articles to markdown for quick source collection +- **Download images locally:** Set attachment folder in Obsidian Settings, bind download hotkey. All images store locally; LLM views and references directly instead of relying on potentially broken URLs +- **Obsidian's graph view** visualizes wiki connectivity—what connects to what, hub pages, orphans +- **Marp** provides markdown-based slide deck format with Obsidian plugin integration +- **Dataview** plugin queries page frontmatter, generating dynamic tables/lists when LLM adds YAML frontmatter +- The wiki is simply a git-backed markdown directory—version history, branching, collaboration included + +## Why This Works + +Knowledge base maintenance's tedious part is bookkeeping, not reading/thinking: updating cross-references, keeping summaries current, noting data contradictions, maintaining consistency across pages. Humans abandon wikis as maintenance burden outpaces value. LLMs don't bore, don't forget updates, can touch 15 files in one pass. Wiki maintenance becomes nearly free. + +Humans curate sources, direct analysis, ask good questions, think about meaning. LLMs handle everything else. + +This relates in spirit to Vannevar Bush's 1945 Memex—personal curated knowledge stores with associative document trails. Bush's vision resembled this more than what the web became: private, actively curated, with connections between documents as valuable as documents themselves. Bush couldn't solve maintenance; LLMs handle that. + +## Note + +This document intentionally remains abstract, describing the idea rather than specific implementation. Directory structure, schema conventions, page formats, tooling—all depend on domain, preferences, and LLM choice. Everything is optional and modular. Pick what's useful; ignore what isn't. Your sources might be text-only (no image handling needed). Your wiki might stay small enough that index files suffice (no search engine required). You might want different output formats entirely. Share this with your LLM agent and work collaboratively to instantiate a version fitting your needs. This document's sole purpose is communicating the pattern; your LLM figures out the rest. diff --git a/.claude/skills/add-linear/REMOVE.md b/.claude/skills/add-linear/REMOVE.md new file mode 100644 index 0000000..4b95024 --- /dev/null +++ b/.claude/skills/add-linear/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Linear Channel + +1. Comment out `import './linear.js'` in `src/channels/index.ts` +2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env` +3. `pnpm uninstall @chat-adapter/linear` +4. Rebuild and restart diff --git a/.claude/skills/add-linear/SKILL.md b/.claude/skills/add-linear/SKILL.md new file mode 100644 index 0000000..dc657af --- /dev/null +++ b/.claude/skills/add-linear/SKILL.md @@ -0,0 +1,168 @@ +--- +name: add-linear +description: Add Linear channel integration via Chat SDK. Issue comment threads as conversations. +--- + +# Add Linear Channel + +Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed. + +## Prerequisites + +**Recommended:** Create a Linear **OAuth application** so the agent posts as an app identity, not as you. This prevents the adapter from filtering your own comments as self-messages. + +1. Go to [Linear Settings > API > OAuth Applications](https://linear.app/settings/api/applications/new) +2. Create an app (e.g. "NanoClaw Bot") + - Developer URL: your repo URL (e.g. `https://github.com/your-org/nanoclaw`) + - Callback URL: `http://localhost` +3. After creating, click the app and enable **Client credentials** under grant types +4. Copy the **Client ID** and **Client Secret** + +**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work). + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned). + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/linear.ts` exists +- `src/channels/index.ts` contains `import './linear.js';` +- `@chat-adapter/linear` is listed in `package.json` dependencies +- `src/channels/chat-sdk-bridge.ts` contains `catchAll` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/linear.ts > src/channels/linear.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './linear.js'; +``` + +### 4. Patch the Chat SDK bridge for catch-all message forwarding + +Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`: + +**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface: + +```typescript + /** + * Forward ALL messages in unsubscribed threads, not just @-mentions. + * Use for platforms where the bot identity can't be @-mentioned (e.g. + * Linear OAuth apps). The thread is auto-subscribed on first message. + */ + catchAll?: boolean; +``` + +**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block): + +```typescript + // Catch-all for platforms where @-mention isn't possible (e.g. Linear + // OAuth apps). Forward every unsubscribed message and auto-subscribe. + if (config.catchAll) { + chat.onNewMessage(/.*/, async (thread, message) => { + const channelId = adapter.channelIdFromThreadId(thread.id); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + await thread.subscribe(); + }); + } +``` + +### 5. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/linear@4.26.0 +``` + +### 6. Build + +```bash +pnpm run build +``` + +## Credentials + +### 1. Set up a webhook + +1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook** +2. Label: `NanoClaw` +3. URL: `https://your-domain/webhook/linear` (the shared webhook server, default port 3000) +4. Team: select the team you want to monitor +5. Events: check **Comment** +6. Save — copy the **signing secret** + +Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal. + +### 2. Configure environment + +Add to `.env`: + +```bash +# OAuth app (recommended) +LINEAR_CLIENT_ID=your-client-id +LINEAR_CLIENT_SECRET=your-client-secret + +# OR Personal API key (simpler, but agent posts as you) +# LINEAR_API_KEY=lin_api_... + +LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret +LINEAR_BOT_USERNAME=NanoClaw Bot +LINEAR_TEAM_KEY=ENG +``` + +- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key) +- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group. + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Wiring + +Ask the user: **Is this a private or public Linear workspace?** + +- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment. +- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example). + +Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually: + +```sql +-- Create messaging group (one per team) +INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at) +VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now')); + +-- Wire to agent group +INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) +VALUES ('mga-linear-eng', 'mg-linear-eng', '', '', 'all', 'per-thread', 10, datetime('now')); +``` + +The `platform_id` must be `linear:` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel. + +## Channel Info + +- **type**: `linear` +- **terminology**: Linear has "teams" containing "issues." Each issue's comment thread is a separate conversation. +- **how-to-find-id**: The platform ID is `linear:` (e.g. `linear:ENG`). Find your team key in Linear under Settings > Teams. Each issue becomes its own thread automatically. +- **supports-threads**: yes (issue comment threads are native conversations) +- **typical-use**: Webhook-driven — the agent receives all issue comment events and responds automatically. No @-mention needed (Linear OAuth apps can't be @-mentioned). +- **default-isolation**: Use `per-thread` session mode. Each issue comment thread gets its own isolated agent session. diff --git a/.claude/skills/add-linear/VERIFY.md b/.claude/skills/add-linear/VERIFY.md new file mode 100644 index 0000000..8a2581a --- /dev/null +++ b/.claude/skills/add-linear/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Linear Channel + +@mention the bot in a Linear issue comment. The bot should respond within a few seconds. diff --git a/.claude/skills/add-matrix/REMOVE.md b/.claude/skills/add-matrix/REMOVE.md new file mode 100644 index 0000000..b64aa56 --- /dev/null +++ b/.claude/skills/add-matrix/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Matrix Channel + +1. Comment out `import './matrix.js'` in `src/channels/index.ts` +2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env` +3. `pnpm uninstall @beeper/chat-adapter-matrix` +4. Rebuild and restart diff --git a/.claude/skills/add-matrix/SKILL.md b/.claude/skills/add-matrix/SKILL.md new file mode 100644 index 0000000..cf6da75 --- /dev/null +++ b/.claude/skills/add-matrix/SKILL.md @@ -0,0 +1,148 @@ +--- +name: add-matrix +description: Add Matrix channel integration via Chat SDK. Works with any Matrix homeserver. +--- + +# Add Matrix Channel + +Adds Matrix support via the Chat SDK bridge. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/matrix.ts` exists +- `src/channels/index.ts` contains `import './matrix.js';` +- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './matrix.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @beeper/chat-adapter-matrix@0.2.0 +``` + +### 5. Patch matrix-js-sdk ESM imports + +The adapter's published dist references `matrix-js-sdk/lib/...` without `.js` +extensions, which fails under Node 22 strict ESM resolution. Add the missing +extensions (idempotent — safe to re-run): + +```bash +node -e ' + const fs = require("fs"), path = require("path"); + const root = "node_modules/.pnpm"; + const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@")); + if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); } + const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js"); + fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace( + /from "(matrix-js-sdk\/lib\/[^"]+?)(? **Help & About** > **Access Token** (under Advanced). Or via API: + +```bash +curl -XPOST 'https://matrix.org/_matrix/client/r0/login' \ + -d '{"type":"m.login.password","user":"andybot","password":"..."}' +``` + +```bash +MATRIX_BASE_URL=https://matrix.org +MATRIX_ACCESS_TOKEN=your-access-token +MATRIX_USER_ID=@andybot:matrix.org +MATRIX_BOT_USERNAME=Andy +``` + +### Optional settings + +```bash +MATRIX_INVITE_AUTOJOIN=true # Auto-accept room invites (default: true) +MATRIX_INVITE_AUTOJOIN_ALLOWLIST=@you:matrix.org # Only accept invites from these users +MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing +MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts +``` + +### Configure environment + +Add the chosen env vars to `.env`, then sync: + +```bash +mkdir -p data/env && cp .env data/env/env +``` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `matrix` +- **terminology**: Matrix has "rooms." A room can be a group chat or a direct message. Rooms have internal IDs (like `!abc123:matrix.org`) and optional aliases (like `#general:matrix.org`). +- **how-to-find-id**: For DMs, use the bot's `openDM` to resolve the room automatically. For group rooms, in Element click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`. +- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability) +- **typical-use**: Interactive chat — rooms or direct messages. Requires a separate bot account (the agent cannot DM users from their own account). +- **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts. diff --git a/.claude/skills/add-matrix/VERIFY.md b/.claude/skills/add-matrix/VERIFY.md new file mode 100644 index 0000000..f483abb --- /dev/null +++ b/.claude/skills/add-matrix/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Matrix Channel + +Invite the bot to a Matrix room and send a message. The bot should respond within a few seconds. diff --git a/.claude/skills/add-ollama-provider/SKILL.md b/.claude/skills/add-ollama-provider/SKILL.md new file mode 100644 index 0000000..83f7e5a --- /dev/null +++ b/.claude/skills/add-ollama-provider/SKILL.md @@ -0,0 +1,179 @@ +--- +name: add-ollama-provider +description: Route a NanoClaw agent group to a local Ollama model instead of the Anthropic API. Ollama speaks the Anthropic API natively (v1/messages), so no provider code changes are needed — just env var overrides and a model setting. Use when the user wants to run their agent locally, cut API costs, or experiment with open-weight models. See docs/ollama.md for background. +--- + +# Add Ollama Provider + +Routes an agent group to a local Ollama instance instead of the Anthropic API. +See `docs/ollama.md` for how this works and the tradeoffs involved. + +## Prerequisites + +1. **Ollama is installed and running** on the host — verify: `curl -s http://localhost:11434/api/tags` +2. **A model is pulled** — e.g. `ollama pull gemma4` or `ollama pull qwen3-coder` +3. **The agent group already exists** — run `/init-first-agent` first if needed + +## 1. Check source support + +The feature requires two fields in `ContainerConfig` (`env` and `blockedHosts`) and their +corresponding wiring in `container-runner.ts`. Check if already present: + +```bash +grep -c 'blockedHosts' src/container-config.ts src/container-runner.ts +``` + +If either count is 0, apply the changes in steps 1a and 1b. Otherwise skip to step 2. + +### 1a. Extend ContainerConfig + +In `src/container-config.ts`, add to the `ContainerConfig` interface: + +```typescript +env?: Record; +blockedHosts?: string[]; +``` + +And in `readContainerConfig`, add inside the returned object: + +```typescript +env: raw.env, +blockedHosts: raw.blockedHosts, +``` + +### 1b. Wire into container-runner + +In `src/container-runner.ts`, after the `NANOCLAW_MCP_SERVERS` block, add: + +```typescript +// Per-agent-group env overrides — applied last to win over OneCLI values. +if (containerConfig.env) { + for (const [key, value] of Object.entries(containerConfig.env)) { + args.push('-e', `${key}=${value}`); + } +} + +// Blocked hosts: resolve to 0.0.0.0 so they are unreachable inside the container. +if (containerConfig.blockedHosts) { + for (const host of containerConfig.blockedHosts) { + args.push('--add-host', `${host}:0.0.0.0`); + } +} +``` + +### 1c. Fix home directory permissions (if not already done) + +The container may run as your host uid (not uid 1000). Check the Dockerfile: + +```bash +grep 'chmod.*home/node' container/Dockerfile +``` + +If it shows `chmod 755`, change it to `chmod 777` so any uid can write there. +Then rebuild the container image: `./container/build.sh` + +## 2. Identify the setup + +Ask the user (plain text, not AskUserQuestion): + +1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"` +2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'` +3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts. + +Record as `FOLDER`, `MODEL`, and `BLOCK_ANTHROPIC`. + +## 3. Configure container.json + +Read `groups//container.json`. Add (or merge into) an `env` block and optionally `blockedHosts`: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://host.docker.internal:11434", + "ANTHROPIC_API_KEY": "ollama", + "NO_PROXY": "host.docker.internal", + "no_proxy": "host.docker.internal" + }, + "blockedHosts": ["api.anthropic.com"] +} +``` + +Omit `blockedHosts` if the user declined step 2. + +**Why these vars:** `ANTHROPIC_BASE_URL` redirects the Anthropic SDK to Ollama. +`ANTHROPIC_API_KEY=ollama` satisfies the SDK's key requirement (Ollama ignores it). +`NO_PROXY` bypasses the OneCLI HTTPS proxy for requests to `host.docker.internal` +so they reach Ollama directly instead of going through the credential gateway. + +## 4. Set the model + +Read the agent group's shared Claude settings: + +```bash +# Find the agent group ID +AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='';") +SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json +``` + +Add `"model": ""` to that settings file. Create the file if it doesn't exist: + +```json +{ + "model": "gemma4:latest" +} +``` + +If the file already has content, merge the `model` key in — don't overwrite existing keys. + +**Why here and not container.json:** Claude Code reads its model from its own settings +file, not from env vars. This file is bind-mounted into the container as `~/.claude/settings.json`. + +## 5. Build and restart + +```bash +export PATH="/opt/homebrew/bin:$PATH" +pnpm run build +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist +# Linux: systemctl --user restart nanoclaw +``` + +## 6. Verify + +Send a message to the agent. Then confirm: + +```bash +# Ollama shows the model as active +curl -s http://localhost:11434/api/ps | grep '"name"' + +# Container has the right env vars +CTR=$(docker ps --filter "name=nanoclaw-v2-" --format "{{.Names}}" | head -1) +docker inspect "$CTR" --format '{{json .HostConfig.ExtraHosts}}' +docker exec "$CTR" env | grep ANTHROPIC +``` + +Expected: `api.anthropic.com:0.0.0.0` in ExtraHosts, `ANTHROPIC_BASE_URL=http://host.docker.internal:11434`. + +## Reverting to Claude + +To switch back to the Anthropic API: + +1. Remove the `env` and `blockedHosts` keys from `groups//container.json` +2. Remove `"model"` from the shared settings file +3. Restart the service + +No rebuild needed — both files are read at container spawn time. + +## Troubleshooting + +**Agent hangs, no response:** Ollama may be loading the model cold (large models take 10–30s). +Watch `curl -s http://localhost:11434/api/ps` — the model appears once loaded. + +**"model not found" error in container logs:** The model name in settings.json doesn't match +what Ollama has. Run `ollama list` on the host and use the exact name shown. + +**Responses claim to be Claude:** The model was trained on data that includes Claude conversations. +Add a line to `groups//CLAUDE.md` telling it what model it runs on. + +**Agent responds but Ollama shows no activity:** `NO_PROXY` may not have taken effect for +`http_proxy` (lowercase). Add both `NO_PROXY` and `no_proxy` to the env block. diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md index a347b49..8c7abca 100644 --- a/.claude/skills/add-ollama-tool/SKILL.md +++ b/.claude/skills/add-ollama-tool/SKILL.md @@ -1,15 +1,21 @@ --- name: add-ollama-tool -description: Add Ollama MCP server so the container agent can call local models for cheaper/faster tasks like summarization, translation, or general queries. +description: Add Ollama MCP server so the container agent can call local models and optionally manage the Ollama model library. --- # Add Ollama Integration -This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models. +This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly. -Tools added: -- `ollama_list_models` — lists installed Ollama models -- `ollama_generate` — sends a prompt to a specified model and returns the response +Core tools (always available): +- `ollama_list_models` — list installed Ollama models with name, size, and family +- `ollama_generate` — send a prompt to a specified model and return the response + +Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`): +- `ollama_pull_model` — pull (download) a model from the Ollama registry +- `ollama_delete_model` — delete a locally installed model to free disk space +- `ollama_show_model` — show model details: modelfile, parameters, and architecture info +- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type ## Phase 1: Pre-flight @@ -81,7 +87,7 @@ done ### Validate code changes ```bash -npm run build +pnpm run build ./container/build.sh ``` @@ -89,6 +95,23 @@ Build must be clean before proceeding. ## Phase 3: Configure +### Enable model management tools (optional) + +Ask the user: + +> Would you like the agent to be able to **manage Ollama models** (pull, delete, inspect, list running)? +> +> - **Yes** — adds tools to pull new models, delete old ones, show model info, and check what's loaded in memory +> - **No** — the agent can only list installed models and generate responses (you manage models yourself on the host) + +If the user wants management tools, add to `.env`: + +```bash +OLLAMA_ADMIN_TOOLS=true +``` + +If they decline (or don't answer), do not add the variable — management tools will be disabled by default. + ### Set Ollama host (optional) By default, the MCP server connects to `http://host.docker.internal:11434` (Docker Desktop) with a fallback to `localhost`. To use a custom Ollama host, add to `.env`: @@ -106,7 +129,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS ## Phase 4: Verify -### Test via WhatsApp +### Test inference Tell the user: @@ -114,6 +137,14 @@ Tell the user: > > The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response. +### Test model management (if enabled) + +If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user: + +> Send a message like: "pull the gemma3:1b model" or "which ollama models are currently loaded in memory?" +> +> The agent should call `ollama_pull_model` or `ollama_list_running` respectively. + ### Monitor activity (optional) Run the watcher script for macOS notifications when Ollama is used: @@ -129,9 +160,10 @@ tail -f logs/nanoclaw.log | grep -i ollama ``` Look for: -- `Agent output: ... Ollama ...` — agent used Ollama successfully -- `[OLLAMA] >>> Generating` — generation started (if log surfacing works) +- `[OLLAMA] >>> Generating` — generation started - `[OLLAMA] <<< Done` — generation completed +- `[OLLAMA] Pulling model:` — pull in progress (management tools) +- `[OLLAMA] Deleted:` — model removed (management tools) ## Troubleshooting @@ -151,3 +183,11 @@ The agent is trying to run `ollama` CLI inside the container instead of using th ### Agent doesn't use Ollama tools The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..." + +### `ollama_pull_model` times out on large models + +Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull ` + +### Management tools not showing up + +Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it. diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md new file mode 100644 index 0000000..555f0fe --- /dev/null +++ b/.claude/skills/add-opencode/SKILL.md @@ -0,0 +1,229 @@ +--- +name: add-opencode +description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers. +--- + +# OpenCode agent provider + +NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`). + +Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image. + +## Install + +### Pre-flight + +If all of the following are already present, skip to **Configuration**: + +- `src/providers/opencode.ts` +- `container/agent-runner/src/providers/opencode.ts` +- `import './opencode.js';` line in `src/providers/index.ts` +- `import './opencode.js';` line in `container/agent-runner/src/providers/index.ts` +- `@opencode-ai/sdk` in `container/agent-runner/package.json` +- `opencode-ai@${OPENCODE_VERSION}` in the pnpm global-install block in `container/Dockerfile` + +Missing pieces — continue below. All steps are idempotent; re-running is safe. + +### 1. Fetch the providers branch + +```bash +git fetch origin providers +``` + +### 2. Copy the OpenCode source files + +Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed): + +```bash +git show origin/providers:src/providers/opencode.ts > src/providers/opencode.ts +git show origin/providers:container/agent-runner/src/providers/opencode.ts > container/agent-runner/src/providers/opencode.ts +git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.ts > container/agent-runner/src/providers/mcp-to-opencode.ts +git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.test.ts > container/agent-runner/src/providers/mcp-to-opencode.test.ts +git show origin/providers:container/agent-runner/src/providers/opencode.factory.test.ts > container/agent-runner/src/providers/opencode.factory.test.ts +``` + +### 3. Append the self-registration imports + +Each barrel gets one line appended at the end — skip if the line is already present. + +`src/providers/index.ts`: + +```typescript +import './opencode.js'; +``` + +`container/agent-runner/src/providers/index.ts`: + +```typescript +import './opencode.js'; +``` + +### 4. Add the agent-runner dependency + +Pinned. Bump deliberately, not with `bun update`. Use `1.4.17` — must match the `opencode-ai` CLI version pinned in step 5. The 1.14.x SDK has a completely different API and is **incompatible** with the current provider code. + +```bash +cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 && cd - +``` + +### 5. Add `opencode-ai` to the container Dockerfile + +Two edits to `container/Dockerfile`, both idempotent (skip if already present): + +**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG VERCEL_VERSION=latest`: + +```dockerfile +ARG OPENCODE_VERSION=1.4.17 +``` + +> **Do not use `latest`** — the CLI and SDK must be the same version. `latest` silently upgrades the CLI to 1.14.x which has a breaking session API change (UUID session IDs → `ses_` prefix) incompatible with SDK 1.4.x. + +**(b)** In the `pnpm install -g` block (around line 80), append `"opencode-ai@${OPENCODE_VERSION}"` to the list: + +```dockerfile + pnpm install -g \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + "agent-browser@${AGENT_BROWSER_VERSION}" \ + "vercel@${VERCEL_VERSION}" \ + "opencode-ai@${OPENCODE_VERSION}" +``` + +### 6. Build + +```bash +pnpm run build # host +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck +./container/build.sh # agent image +``` + +> **Build cache gotcha:** The container buildkit caches COPY steps aggressively. If provider files were already present in the build context before, the new files may not be picked up. If you see "Unknown provider: opencode" after the build, prune the builder and rebuild: +> ```bash +> docker builder prune -f && ./container/build.sh +> ``` + +### 7. Propagate to existing per-group overlays + +Each agent group has a live source overlay at `data/v2-sessions//agent-runner-src/providers/` that **overrides the image at runtime**. This overlay is created when the group is first wired and never auto-updated by image rebuilds. Any group that already existed before this skill ran needs the new files copied in manually. + +```bash +for overlay in data/v2-sessions/*/agent-runner-src/providers/; do + [ -d "$overlay" ] || continue + cp container/agent-runner/src/providers/opencode.ts "$overlay" + cp container/agent-runner/src/providers/mcp-to-opencode.ts "$overlay" + cp container/agent-runner/src/providers/index.ts "$overlay" + echo "Updated: $overlay" +done +``` + +## Configuration + +### Host `.env` (typical) + +Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs. + +These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below). + +- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`. +- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`. +- `OPENCODE_SMALL_MODEL` — optional second model for lighter tasks; defaults to `OPENCODE_MODEL` if unset. +- `ANTHROPIC_BASE_URL` — **required for non-`anthropic` providers.** The opencode container provider passes this as the `baseURL` for the upstream provider config so requests route through OneCLI's credential proxy or directly to the provider's API. Set it to the provider's API base URL (e.g. `https://api.deepseek.com/v1`, `https://openrouter.ai/api/v1`). + +Credentials: register provider API keys in OneCLI with the matching `--host-pattern` (e.g. `api.deepseek.com`, `openrouter.ai`). OneCLI injects them via `HTTPS_PROXY` in the container — the key never lives in `.env` or the container environment. + +After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned: + +```bash +# Find the agent id and secret id, then: +onecli agents set-secrets --id --secret-ids , +``` + +Always include existing secret IDs in the list — `set-secrets` replaces, not appends. + +#### Example: DeepSeek + +```env +OPENCODE_PROVIDER=deepseek +OPENCODE_MODEL=deepseek/deepseek-chat +OPENCODE_SMALL_MODEL=deepseek/deepseek-chat +ANTHROPIC_BASE_URL=https://api.deepseek.com/v1 +``` + +Register the key: +```bash +onecli secrets create --name "DeepSeek" --type generic \ + --value YOUR_KEY --host-pattern "api.deepseek.com" \ + --header-name "Authorization" --value-format "Bearer {value}" +``` + +#### Example: OpenRouter + +```env +OPENCODE_PROVIDER=openrouter +OPENCODE_MODEL=openrouter/anthropic/claude-sonnet-4 +OPENCODE_SMALL_MODEL=openrouter/anthropic/claude-haiku-4.5 +ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 +``` + +Register the key: +```bash +onecli secrets create --name "OpenRouter" --type generic \ + --value YOUR_KEY --host-pattern "openrouter.ai" \ + --header-name "Authorization" --value-format "Bearer {value}" +``` + +#### Example: Anthropic (no ANTHROPIC_BASE_URL needed) + +When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container — the proxy + placeholder key pattern is unchanged and `ANTHROPIC_BASE_URL` is not required. + +```env +OPENCODE_PROVIDER=anthropic +OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514 +OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001 +``` + +#### OpenCode Zen (`x-api-key`, not Bearer) + +Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working. + +**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)). + +**Host `.env` (typical Zen shape):** + +```env +OPENCODE_PROVIDER=opencode +OPENCODE_MODEL=opencode/big-pickle +OPENCODE_SMALL_MODEL=opencode/big-pickle +ANTHROPIC_BASE_URL=https://opencode.ai/zen/v1 +``` + +Use a real Zen model id from the docs; `big-pickle` is one example. + +**OneCLI:** register the Zen key with **`x-api-key`**, not Bearer: + +```bash +onecli secrets create --name "OpenCode Zen" --type generic \ + --value YOUR_ZEN_KEY --host-pattern opencode.ai \ + --header-name "x-api-key" --value-format "{value}" +``` + +### Per group / per session + +Set `"provider": "opencode"` in the group's **`container.json`** (`groups//container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`. + +Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers. + +## Operational notes + +- OpenCode keeps a local **`opencode serve`** process and SSE subscription; the provider tears down with **`stream.return`** and **SIGKILL** on the server process on **`abort()`** / shared runtime reset to avoid MCP/zombie hangs. +- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder. +- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI). + +## Verify + +```bash +grep -q "./opencode.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK" +grep -q "./opencode.js" src/providers/index.ts && echo "host barrel: OK" +grep -q "@opencode-ai/sdk" container/agent-runner/package.json && echo "agent-runner dep: OK" +grep -q "opencode-ai@" container/Dockerfile && echo "Dockerfile install: OK" +cd container/agent-runner && bun test src/providers/ && cd - +``` diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index f4c1982..a9dff8f 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -232,7 +232,7 @@ echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Containe Rebuild the main app and restart: ```bash -npm run build +pnpm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` @@ -286,5 +286,5 @@ To remove Parallel AI integration: 1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env` 2. Revert changes to container-runner.ts and agent-runner/src/index.ts 3. Remove Web Research Tools section from groups/main/CLAUDE.md -4. Rebuild: `./container/build.sh && npm run build` +4. Rebuild: `./container/build.sh && pnpm run build` 5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md deleted file mode 100644 index a01e530..0000000 --- a/.claude/skills/add-pdf-reader/SKILL.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -name: add-pdf-reader -description: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Handles WhatsApp attachments, URLs, and local files. ---- - -# Add PDF Reader - -Adds PDF reading capability to all container agents using poppler-utils (pdftotext/pdfinfo). PDFs sent as WhatsApp attachments are auto-downloaded to the group workspace. - -## Phase 1: Pre-flight - -1. Check if `container/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied -2. Confirm WhatsApp is installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/pdf-reader -git merge whatsapp/skill/pdf-reader || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `container/skills/pdf-reader/SKILL.md` (agent-facing documentation) -- `container/skills/pdf-reader/pdf-reader` (CLI script) -- `poppler-utils` in `container/Dockerfile` -- PDF attachment download in `src/channels/whatsapp.ts` -- PDF tests in `src/channels/whatsapp.test.ts` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate - -```bash -npm run build -npx vitest run src/channels/whatsapp.test.ts -``` - -### Rebuild container - -```bash -./container/build.sh -``` - -### Restart service - -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 3: Verify - -### Test PDF extraction - -Send a PDF file in any registered WhatsApp chat. The agent should: -1. Download the PDF to `attachments/` -2. Respond acknowledging the PDF -3. Be able to extract text when asked - -### Test URL fetching - -Ask the agent to read a PDF from a URL. It should use `pdf-reader fetch `. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log | grep -i pdf -``` - -Look for: -- `Downloaded PDF attachment` — successful download -- `Failed to download PDF attachment` — media download issue - -## Troubleshooting - -### Agent says pdf-reader command not found - -Container needs rebuilding. Run `./container/build.sh` and restart the service. - -### PDF text extraction is empty - -The PDF may be scanned (image-based). pdftotext only handles text-based PDFs. Consider using the agent-browser to open the PDF visually instead. - -### WhatsApp PDF not detected - -Verify the message has `documentMessage` with `mimetype: application/pdf`. Some file-sharing apps send PDFs as generic files without the correct mimetype. diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md deleted file mode 100644 index de86768..0000000 --- a/.claude/skills/add-reactions/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: add-reactions -description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions. ---- - -# Add Reactions - -This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/status-tracker.ts` exists: - -```bash -test -f src/status-tracker.ts && echo "Already applied" || echo "Not applied" -``` - -If already applied, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/reactions -git merge whatsapp/skill/reactions || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This adds: -- `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes) -- `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry) -- `src/status-tracker.test.ts` (unit tests for StatusTracker) -- `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool) -- Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `container/agent-runner/src/ipc-mcp-stdio.ts` - -### Run database migration - -```bash -npx tsx scripts/migrate-reactions.ts -``` - -### Validate code changes - -```bash -npm test -npm run build -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Verify - -### Build and restart - -```bash -npm run build -``` - -Linux: -```bash -systemctl --user restart nanoclaw -``` - -macOS: -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -### Test receiving reactions - -1. Send a message from your phone -2. React to it with an emoji on WhatsApp -3. Check the database: - -```bash -sqlite3 store/messages.db "SELECT * FROM reactions ORDER BY timestamp DESC LIMIT 5;" -``` - -### Test sending reactions - -Ask the agent to react to a message via the `react_to_message` MCP tool. Check your phone — the reaction should appear on the message. - -## Troubleshooting - -### Reactions not appearing in database - -- Check NanoClaw logs for `Failed to process reaction` errors -- Verify the chat is registered -- Confirm the service is running - -### Migration fails - -- Ensure `store/messages.db` exists and is accessible -- If "table reactions already exists", the migration already ran — skip it - -### Agent can't send reactions - -- Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat -- Verify WhatsApp is connected: check logs for connection status diff --git a/.claude/skills/add-resend/REMOVE.md b/.claude/skills/add-resend/REMOVE.md new file mode 100644 index 0000000..4c00004 --- /dev/null +++ b/.claude/skills/add-resend/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Resend Email Channel + +1. Comment out `import './resend.js'` in `src/channels/index.ts` +2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env` +3. `pnpm uninstall @resend/chat-sdk-adapter` +4. Rebuild and restart diff --git a/.claude/skills/add-resend/SKILL.md b/.claude/skills/add-resend/SKILL.md new file mode 100644 index 0000000..59ff577 --- /dev/null +++ b/.claude/skills/add-resend/SKILL.md @@ -0,0 +1,93 @@ +--- +name: add-resend +description: Add Resend (email) channel integration via Chat SDK. +--- + +# Add Resend Email Channel + +Connect NanoClaw to email via Resend for async email conversations. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/resend.ts` exists +- `src/channels/index.ts` contains `import './resend.js';` +- `@resend/chat-sdk-adapter` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/resend.ts > src/channels/resend.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './resend.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @resend/chat-sdk-adapter@0.1.1 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +1. Go to [resend.com](https://resend.com) and create an account. +2. Add and verify your sending domain. +3. Go to **API Keys** and create a new key. +4. Set up a webhook: + - Go to **Webhooks** > **Add webhook**. + - URL: `https://your-domain/webhook/resend`. + - Events: select **email.received**. + - Copy the signing secret. + +### Configure environment + +Add to `.env`: + +```bash +RESEND_API_KEY=re_... +RESEND_FROM_ADDRESS=bot@yourdomain.com +RESEND_FROM_NAME=NanoClaw +RESEND_WEBHOOK_SECRET=your-webhook-secret +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `resend` +- **terminology**: Resend handles email. Each email thread (identified by subject/In-Reply-To headers) is a separate conversation. The "from address" is the bot's identity. +- **how-to-find-id**: The platform ID is the from email address (e.g. `bot@yourdomain.com`). Each sender's email thread becomes its own conversation. +- **supports-threads**: yes (via email threading headers -- replies to the same thread stay together) +- **typical-use**: Async communication -- email conversations with longer response expectations +- **default-isolation**: Same agent group if you want your agent to handle email alongside other channels. Separate agent group if email contains sensitive correspondence that shouldn't be accessible from other channels. diff --git a/.claude/skills/add-resend/VERIFY.md b/.claude/skills/add-resend/VERIFY.md new file mode 100644 index 0000000..983197e --- /dev/null +++ b/.claude/skills/add-resend/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Resend Email Channel + +Send an email to the configured from address. The bot should respond via email within a few seconds. diff --git a/.claude/skills/add-signal/REMOVE.md b/.claude/skills/add-signal/REMOVE.md new file mode 100644 index 0000000..db37ade --- /dev/null +++ b/.claude/skills/add-signal/REMOVE.md @@ -0,0 +1,13 @@ +# Remove Signal + +1. Comment out `import './signal.js'` in `src/channels/index.ts` +2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env` +3. Rebuild and restart + +If you also want to unlink the Signal account from `signal-cli`: + +```bash +signal-cli -a +1YOURNUMBER removeDevice --deviceId +``` + +(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.) diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md new file mode 100644 index 0000000..7dcc8ad --- /dev/null +++ b/.claude/skills/add-signal/SKILL.md @@ -0,0 +1,318 @@ +--- +name: add-signal +description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge. +--- + +# Add Signal Channel + +Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`). + +Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number. + +## Prerequisites + +### Java + +signal-cli requires Java 17+: + +```bash +java -version +``` + +If missing: +- **macOS:** `brew install --cask temurin@17` +- **Debian/Ubuntu:** `sudo apt-get install -y default-jre` +- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk` + +Java 17–25 all work. + +### signal-cli + +- **macOS:** `brew install signal-cli` +- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases): + +```bash +SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])") +curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \ + | tar -xz -C ~/.local +ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli +signal-cli --version +``` + +> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH. + +## Registration + +Two paths. The new-number path is recommended and battle-tested. + +### Path A: Register a new number (recommended) + +Use a dedicated SIM or VoIP number. NanoClaw owns it entirely. + +> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM. + +**Step 1: Solve the CAPTCHA** + +Signal requires a CAPTCHA on first registration: + +1. Open `https://signalcaptchas.org/registration/generate.html` in a browser +2. Solve the captcha +3. Right-click the **"Open Signal"** button → **Copy Link** +4. The link starts with `signalcaptcha://` — the token is everything after that prefix + +**Step 2: Request SMS verification** + +```bash +signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE" +``` + +**Step 3: Voice call fallback (if your number can't receive SMS)** + +Wait ~60 seconds after the SMS request, then: + +```bash +signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN" +``` + +Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one. + +> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…` + +**Step 4: Verify** + +```bash +signal-cli -a +1YOURNUMBER verify CODE +``` + +No output = success. + +**Step 5: Set profile name (optional)** + +> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running. + +```bash +# macOS +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName" +# optionally: --avatar /path/to/avatar.jpg +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist + +# Linux +systemctl --user stop nanoclaw +signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName" +systemctl --user start nanoclaw +``` + +### Path B: Link as secondary device + +Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number. + +```bash +signal-cli -a +1YOURNUMBER link --name "NanoClaw" +``` + +This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires. + +## Install + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist +- `src/channels/index.ts` contains `import './signal.js';` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter and tests + +```bash +git show origin/channels:src/channels/signal.ts > src/channels/signal.ts +git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './signal.js'; +``` + +### 4. Build + +```bash +pnpm run build +``` + +No npm packages to install — the adapter uses only Node.js builtins. + +## Credentials + +Add to `.env`: + +```bash +SIGNAL_ACCOUNT=+1YOURNUMBER +``` + +### Optional settings + +```bash +# TCP daemon host and port (default: 127.0.0.1:7583) +SIGNAL_TCP_HOST=127.0.0.1 +SIGNAL_TCP_PORT=7583 + +# Path to the signal-cli binary (default: resolved on PATH) +SIGNAL_CLI_PATH=/usr/local/bin/signal-cli + +# Whether NanoClaw manages the daemon lifecycle (default: true). +# Set to false if you run signal-cli daemon externally. +SIGNAL_MANAGE_DAEMON=true + +# signal-cli data directory (default: ~/.local/share/signal-cli) +SIGNAL_DATA_DIR=~/.local/share/signal-cli +``` + +**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network. + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Restart + +```bash +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw + +# Linux +systemctl --user restart nanoclaw +``` + +## Wiring + +### DMs + +After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then: + +```bash +sqlite3 data/v2.db \ + "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" +``` + +Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group. + +### Groups + +Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions: + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR IGNORE INTO messaging_group_agents + (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) +VALUES + ('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW'); +" +``` + +### Grant user access + +New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`: + +```bash +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") +sqlite3 data/v2.db " +INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); +INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) + VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW'); +" +``` + +Find the UUID from `messaging_groups.platform_id` or the `users` table. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group. + +## Channel Info + +- **type**: `signal` +- **terminology**: Signal has "chats" (1:1 DMs) and "groups" +- **supports-threads**: no +- **platform-id-format**: + - DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number + - Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID +- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above +- **typical-use**: Personal assistant via Signal DMs or small group chats +- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode + +### Features + +- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles) +- Quoted replies — `replyTo*` fields populated from Signal quotes +- Typing indicators — DMs only (Signal doesn't support group typing) +- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops +- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true` +- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx + +Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions. + +## Troubleshooting + +### Daemon not reachable + +```bash +grep "Signal" logs/nanoclaw.log | tail +``` + +If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`: +- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`) +- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting + +If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`. + +### Bot not responding + +1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` +2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` +3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) + +### Lost connection mid-session + +If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish. + +### Messages dropped with `not_member` + +The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person. + +### Captcha required + +Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`. + +### `Invalid verification method: Before requesting voice verification…` + +You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token. + +### Config file in use / daemon lock + +signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward. + +### Group replies going to DM instead of group + +Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`. + +### Java not found + +Install Java 17+ — see the Prerequisites section above. + +### QR code expired (Path B) + +QR codes expire in ~30 seconds. Re-run the link command to generate a new one. diff --git a/.claude/skills/add-signal/VERIFY.md b/.claude/skills/add-signal/VERIFY.md new file mode 100644 index 0000000..b1ae851 --- /dev/null +++ b/.claude/skills/add-signal/VERIFY.md @@ -0,0 +1,5 @@ +# Verify Signal + +Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds. + +If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`. diff --git a/.claude/skills/add-slack/REMOVE.md b/.claude/skills/add-slack/REMOVE.md new file mode 100644 index 0000000..3ac402a --- /dev/null +++ b/.claude/skills/add-slack/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Slack + +1. Comment out `import './slack.js'` in `src/channels/index.ts` +2. Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env` +3. `pnpm uninstall @chat-adapter/slack` +4. Rebuild and restart diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 4c86e19..d09db61 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -1,80 +1,88 @@ --- name: add-slack -description: Add Slack as a channel. Can replace WhatsApp entirely or run alongside it. Uses Socket Mode (no public URL needed). +description: Add Slack channel integration via Chat SDK. --- # Add Slack Channel -This skill adds Slack support to NanoClaw, then walks through interactive setup. +Adds Slack support via the Chat SDK bridge. -## Phase 1: Pre-flight +## Install -### Check if already applied +NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in from the `channels` branch. -Check if `src/channels/slack.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. +### Pre-flight (idempotent) -### Ask the user +Skip to **Credentials** if all of these are already in place: -**Do they already have a Slack app configured?** If yes, collect the Bot Token and App Token now. If no, we'll create one in Phase 3. +- `src/channels/slack.ts` exists +- `src/channels/index.ts` contains `import './slack.js';` +- `@chat-adapter/slack` is listed in `package.json` dependencies -## Phase 2: Apply Code Changes +Otherwise continue. Every step below is safe to re-run. -### Ensure channel remote +### 1. Fetch the channels branch ```bash -git remote -v +git fetch origin channels ``` -If `slack` is missing, add it: +### 2. Copy the adapter ```bash -git remote add slack https://github.com/qwibitai/nanoclaw-slack.git +git show origin/channels:src/channels/slack.ts > src/channels/slack.ts ``` -### Merge the skill branch +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './slack.js'; +``` + +### 4. Install the adapter package (pinned) ```bash -git fetch slack main -git merge slack/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} +pnpm install @chat-adapter/slack@4.26.0 ``` -This merges in: -- `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`) -- `src/channels/slack.test.ts` (46 unit tests) -- `import './slack.js'` appended to the channel barrel file `src/channels/index.ts` -- `@slack/bolt` npm dependency in `package.json` -- `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes +### 5. Build ```bash -npm install -npm run build -npx vitest run src/channels/slack.test.ts +pnpm run build ``` -All tests must pass (including the new Slack tests) and build must be clean before proceeding. +## Credentials -## Phase 3: Setup +### Create Slack App -### Create Slack App (if needed) +1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch** +2. Name it (e.g., "NanoClaw") and select your workspace +3. Go to **OAuth & Permissions** and add Bot Token Scopes: + - `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` +4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`) +5. Go to **Basic Information** and copy the **Signing Secret** -If the user doesn't have a Slack app, share [SLACK_SETUP.md](SLACK_SETUP.md) which has step-by-step instructions with screenshots guidance, troubleshooting, and a token reference table. +### Enable DMs -Quick summary of what's needed: -1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) -2. Enable Socket Mode and generate an App-Level Token (`xapp-...`) -3. Subscribe to bot events: `message.channels`, `message.groups`, `message.im` -4. Add OAuth scopes: `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read` -5. Install to workspace and copy the Bot Token (`xoxb-...`) +6. Go to **App Home** and enable the **Messages Tab** +7. Check **"Allow users to send Slash commands and messages from the messages tab"** -Wait for the user to provide both tokens. +### Event Subscriptions + +8. Go to **Event Subscriptions** and toggle **Enable Events** +9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save +10. Under **Subscribe to bot events**, add: + - `message.channels`, `message.groups`, `message.im`, `app_mention` +11. Click **Save Changes** + +### Interactivity + +12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on +13. Set the **Request URL** to the same `https://your-domain/webhook/slack` +14. Click **Save Changes** +15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings ### Configure environment @@ -82,126 +90,29 @@ Add to `.env`: ```bash SLACK_BOT_TOKEN=xoxb-your-bot-token -SLACK_APP_TOKEN=xapp-your-app-token +SLACK_SIGNING_SECRET=your-signing-secret ``` -Channels auto-enable when their credentials are present — no extra configuration needed. +Sync to container: `mkdir -p data/env && cp .env data/env/env` -Sync to container environment: +### Webhook server -```bash -mkdir -p data/env && cp .env data/env/env -``` +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. -The container reads environment from `data/env/env`, not `.env` directly. +If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`. -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Registration +Otherwise, run `/manage-channels` to wire this channel to an agent group. -### Get Channel ID +## Channel Info -Tell the user: - -> 1. Add the bot to a Slack channel (right-click channel → **View channel details** → **Integrations** → **Add apps**) -> 2. In that channel, the channel ID is in the URL when you open it in a browser: `https://app.slack.com/client/T.../C0123456789` — the `C...` part is the channel ID -> 3. Alternatively, right-click the channel name → **Copy link** — the channel ID is the last path segment -> -> The JID format for NanoClaw is: `slack:C0123456789` - -Wait for the user to provide the channel ID. - -### Register the channel - -The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. - -For a main channel (responds to all messages): - -```bash -npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_main" --trigger "@${ASSISTANT_NAME}" --channel slack --no-trigger-required --is-main -``` - -For additional channels (trigger-only): - -```bash -npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_" --trigger "@${ASSISTANT_NAME}" --channel slack -``` - -## Phase 5: Verify - -### Test the connection - -Tell the user: - -> Send a message in your registered Slack channel: -> - For main channel: Any message works -> - For non-main: `@ hello` (using the configured trigger word) -> -> The bot should respond within a few seconds. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Troubleshooting - -### Bot not responding - -1. Check `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are set in `.env` AND synced to `data/env/env` -2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'slack:%'"` -3. For non-main channels: message must include trigger pattern -4. Service is running: `launchctl list | grep nanoclaw` - -### Bot connected but not receiving messages - -1. Verify Socket Mode is enabled in the Slack app settings -2. Verify the bot is subscribed to the correct events (`message.channels`, `message.groups`, `message.im`) -3. Verify the bot has been added to the channel -4. Check that the bot has the required OAuth scopes - -### Bot not seeing messages in channels - -By default, bots only see messages in channels they've been explicitly added to. Make sure to: -1. Add the bot to each channel you want it to monitor -2. Check the bot has `channels:history` and/or `groups:history` scopes - -### "missing_scope" errors - -If the bot logs `missing_scope` errors: -1. Go to **OAuth & Permissions** in your Slack app settings -2. Add the missing scope listed in the error message -3. **Reinstall the app** to your workspace — scope changes require reinstallation -4. Copy the new Bot Token (it changes on reinstall) and update `.env` -5. Sync: `mkdir -p data/env && cp .env data/env/env` -6. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` - -### Getting channel ID - -If the channel ID is hard to find: -- In Slack desktop: right-click channel → **Copy link** → extract the `C...` ID from the URL -- In Slack web: the URL shows `https://app.slack.com/client/TXXXXXXX/C0123456789` -- Via API: `curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" "https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'` - -## After Setup - -The Slack channel supports: -- **Public channels** — Bot must be added to the channel -- **Private channels** — Bot must be invited to the channel -- **Direct messages** — Users can DM the bot directly -- **Multi-channel** — Can run alongside WhatsApp or other channels (auto-enabled by credentials) - -## Known Limitations - -- **Threads are flattened** — Threaded replies are delivered to the agent as regular channel messages. The agent sees them but has no awareness they originated in a thread. Responses always go to the channel, not back into the thread. Users in a thread will need to check the main channel for the bot's reply. Full thread-aware routing (respond in-thread) requires pipeline-wide changes: database schema, `NewMessage` type, `Channel.sendMessage` interface, and routing logic. -- **No typing indicator** — Slack's Bot API does not expose a typing indicator endpoint. The `setTyping()` method is a no-op. Users won't see "bot is typing..." while the agent works. -- **Message splitting is naive** — Long messages are split at a fixed 4000-character boundary, which may break mid-word or mid-sentence. A smarter split (on paragraph or sentence boundaries) would improve readability. -- **No file/image handling** — The bot only processes text content. File uploads, images, and rich message blocks are not forwarded to the agent. -- **Channel metadata sync is unbounded** — `syncChannelMetadata()` paginates through all channels the bot is a member of, but has no upper bound or timeout. Workspaces with thousands of channels may experience slow startup. -- **Workspace admin policies not detected** — If the Slack workspace restricts bot app installation, the setup will fail at the "Install to Workspace" step with no programmatic detection or guidance. See SLACK_SETUP.md troubleshooting section. +- **type**: `slack` +- **terminology**: Slack has "workspaces" containing "channels." Channels can be public (#general) or private. The bot can also receive direct messages. +- **platform-id-format**: `slack:{channelId}` for channels (e.g., `slack:C0123ABC`), `slack:{dmId}` for DMs (e.g., `slack:D0ARWEBLV63`) +- **how-to-find-id**: Right-click a channel name > "View channel details" — the Channel ID is at the bottom (starts with C). For DMs, the ID starts with D. Or copy the channel link — the ID is the last segment of the URL. +- **supports-threads**: yes +- **typical-use**: Interactive chat — team channels or direct messages +- **default-isolation**: Same agent group for channels where you're the primary user. Separate agent group for channels with different teams or sensitive contexts. diff --git a/.claude/skills/add-slack/VERIFY.md b/.claude/skills/add-slack/VERIFY.md new file mode 100644 index 0000000..23eb994 --- /dev/null +++ b/.claude/skills/add-slack/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Slack + +Add the bot to a Slack channel, then send a message or @mention the bot. The bot should respond within a few seconds. diff --git a/.claude/skills/add-teams/REMOVE.md b/.claude/skills/add-teams/REMOVE.md new file mode 100644 index 0000000..caa87b8 --- /dev/null +++ b/.claude/skills/add-teams/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Microsoft Teams Channel + +1. Comment out `import './teams.js'` in `src/channels/index.ts` +2. Remove `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` from `.env` +3. `pnpm uninstall @chat-adapter/teams` +4. Rebuild and restart diff --git a/.claude/skills/add-teams/SKILL.md b/.claude/skills/add-teams/SKILL.md new file mode 100644 index 0000000..10bce29 --- /dev/null +++ b/.claude/skills/add-teams/SKILL.md @@ -0,0 +1,207 @@ +--- +name: add-teams +description: Add Microsoft Teams channel integration via Chat SDK. +--- + +# Add Microsoft Teams Channel + +Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/teams.ts` exists +- `src/channels/index.ts` contains `import './teams.js';` +- `@chat-adapter/teams` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/teams.ts > src/channels/teams.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './teams.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/teams@4.26.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +### Step 1: Create an Azure AD App Registration + +1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration** +2. Name it (e.g., "NanoClaw") +3. Supported account types: **Single tenant** (your org only) or **Multi tenant** (any org) +4. Click **Register** +5. Copy the **Application (client) ID** and **Directory (tenant) ID** from the Overview page + +### Step 2: Create a Client Secret + +1. In the App Registration, go to **Certificates & secrets** +2. Click **New client secret**, description "nanoclaw", expiry 180 days +3. Click **Add** and **copy the Value immediately** (shown only once) + +### Step 3: Create an Azure Bot + +1. Go to Azure Portal > search **Azure Bot** > **Create** +2. Fill in: + - **Bot handle**: unique name (e.g., "nanoclaw-bot") + - **Type of App**: match your app registration (Single or Multi Tenant) + - **Creation type**: **Use existing app registration** + - **App ID**: paste from Step 1 + - **App tenant ID**: paste from Step 1 (Single Tenant only) +3. Click **Review + create** > **Create** + +Or use Azure CLI: + +```bash +az group create --name nanoclaw-rg --location eastus +az bot create \ + --resource-group nanoclaw-rg \ + --name nanoclaw-bot \ + --app-type SingleTenant \ + --appid YOUR_APP_ID \ + --tenant-id YOUR_TENANT_ID \ + --endpoint "https://your-domain/api/webhooks/teams" +``` + +### Step 4: Configure Messaging Endpoint + +1. Go to your Azure Bot resource > **Configuration** +2. Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams` +3. Click **Apply** + +### Step 5: Enable Teams Channel + +1. In the Azure Bot resource, go to **Channels** +2. Click **Microsoft Teams** > Accept terms > **Apply** + +Or via CLI: + +```bash +az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot +``` + +### Step 6: Create and Sideload Teams App + +Create a `manifest.json`: + +```json +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", + "manifestVersion": "1.16", + "version": "1.0.0", + "id": "YOUR_APP_ID", + "packageName": "com.nanoclaw.bot", + "developer": { + "name": "NanoClaw", + "websiteUrl": "https://your-domain", + "privacyUrl": "https://your-domain", + "termsOfUseUrl": "https://your-domain" + }, + "name": { "short": "NanoClaw", "full": "NanoClaw Assistant" }, + "description": { + "short": "NanoClaw assistant bot", + "full": "NanoClaw personal assistant powered by Claude." + }, + "icons": { "outline": "outline.png", "color": "color.png" }, + "accentColor": "#4A90D9", + "bots": [{ + "botId": "YOUR_APP_ID", + "scopes": ["personal", "team", "groupchat"], + "supportsFiles": false, + "isNotificationOnly": false + }], + "permissions": ["identity", "messageTeamMembers"], + "validDomains": ["your-domain"] +} +``` + +Create two icon PNGs (32x32 `outline.png`, 192x192 `color.png`), zip all three files together. + +**Sideload in Teams:** +1. Open Teams > **Apps** > **Manage your apps** +2. Click **Upload an app** > **Upload a custom app** +3. Select the zip file + +Sideloading requires Teams admin access. Free personal Teams does NOT support sideloading. Use a Microsoft 365 Business account or developer tenant. + +### Step 7: Receive All Messages (Optional) + +By default, the bot only receives messages when @-mentioned. To receive all messages in a channel without @-mention, add RSC permissions to `manifest.json`: + +```json +{ + "authorization": { + "permissions": { + "resourceSpecific": [ + { "name": "ChannelMessage.Read.Group", "type": "Application" } + ] + } + } +} +``` + +### Configure environment + +Add to `.env`: + +```bash +TEAMS_APP_ID=your-app-id +TEAMS_APP_PASSWORD=your-client-secret +# For Single Tenant only: +TEAMS_APP_TENANT_ID=your-tenant-id +TEAMS_APP_TYPE=SingleTenant +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Webhook server + +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. + +For local development without a public URL, use a tunnel (e.g., `ngrok http 3000`) and update the messaging endpoint in Azure Bot Configuration. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `teams` +- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies. +- **platform-id-format**: `teams:{base64-encoded-conversation-id}:{base64-encoded-service-url}` — auto-generated by the adapter, not human-readable. Use the auto-created messaging group ID for wiring. +- **how-to-find-id**: Send a message to the bot in the channel. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group ID for wiring. +- **supports-threads**: yes (channels only; DMs and group chats are flat) +- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs +- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory. diff --git a/.claude/skills/add-teams/VERIFY.md b/.claude/skills/add-teams/VERIFY.md new file mode 100644 index 0000000..f0b9a9a --- /dev/null +++ b/.claude/skills/add-teams/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Microsoft Teams Channel + +Add the bot to a Teams channel or send it a direct message. The bot should respond within a few seconds. diff --git a/.claude/skills/add-telegram-swarm/SKILL.md b/.claude/skills/add-telegram-swarm/SKILL.md deleted file mode 100644 index ac4922c..0000000 --- a/.claude/skills/add-telegram-swarm/SKILL.md +++ /dev/null @@ -1,384 +0,0 @@ ---- -name: add-telegram-swarm -description: Add Agent Swarm (Teams) support to Telegram. Each subagent gets its own bot identity in the group. Requires Telegram channel to be set up first (use /add-telegram). Triggers on "agent swarm", "agent teams telegram", "telegram swarm", "bot pool". ---- - -# Add Agent Swarm to Telegram - -This skill adds Agent Teams (Swarm) support to an existing Telegram channel. Each subagent in a team gets its own bot identity in the Telegram group, so users can visually distinguish which agent is speaking. - -**Prerequisite**: Telegram must already be set up via the `/add-telegram` skill. If `src/telegram.ts` does not exist or `TELEGRAM_BOT_TOKEN` is not configured, tell the user to run `/add-telegram` first. - -## How It Works - -- The **main bot** receives messages and sends lead agent responses (already set up by `/add-telegram`) -- **Pool bots** are send-only — each gets a Grammy `Api` instance (no polling) -- When a subagent calls `send_message` with a `sender` parameter, the host assigns a pool bot and renames it to match the sender's role -- Messages appear in Telegram from different bot identities - -``` -Subagent calls send_message(text: "Found 3 results", sender: "Researcher") - → MCP writes IPC file with sender field - → Host IPC watcher picks it up - → Assigns pool bot #2 to "Researcher" (round-robin, stable per-group) - → Renames pool bot #2 to "Researcher" via setMyName - → Sends message via pool bot #2's Api instance - → Appears in Telegram from "Researcher" bot -``` - -## Prerequisites - -### 1. Create Pool Bots - -Tell the user: - -> I need you to create 3-5 Telegram bots to use as the agent pool. These will be renamed dynamically to match agent roles. -> -> 1. Open Telegram and search for `@BotFather` -> 2. Send `/newbot` for each bot: -> - Give them any placeholder name (e.g., "Bot 1", "Bot 2") -> - Usernames like `myproject_swarm_1_bot`, `myproject_swarm_2_bot`, etc. -> 3. Copy all the tokens -> 4. Add all bots to your Telegram group(s) where you want agent teams - -Wait for user to provide the tokens. - -### 2. Disable Group Privacy for Pool Bots - -Tell the user: - -> **Important**: Each pool bot needs Group Privacy disabled so it can send messages in groups. -> -> For each pool bot in `@BotFather`: -> 1. Send `/mybots` and select the bot -> 2. Go to **Bot Settings** > **Group Privacy** > **Turn off** -> -> Then add all pool bots to your Telegram group(s). - -## Implementation - -### Step 1: Update Configuration - -Read `src/config.ts` and add the bot pool config near the other Telegram exports: - -```typescript -export const TELEGRAM_BOT_POOL = (process.env.TELEGRAM_BOT_POOL || '') - .split(',') - .map((t) => t.trim()) - .filter(Boolean); -``` - -### Step 2: Add Bot Pool to Telegram Module - -Read `src/telegram.ts` and add the following: - -1. **Update imports** — add `Api` to the Grammy import: - -```typescript -import { Api, Bot } from 'grammy'; -``` - -2. **Add pool state** after the existing `let bot` declaration: - -```typescript -// Bot pool for agent teams: send-only Api instances (no polling) -const poolApis: Api[] = []; -// Maps "{groupFolder}:{senderName}" → pool Api index for stable assignment -const senderBotMap = new Map(); -let nextPoolIndex = 0; -``` - -3. **Add pool functions** — place these before the `isTelegramConnected` function: - -```typescript -/** - * Initialize send-only Api instances for the bot pool. - * Each pool bot can send messages but doesn't poll for updates. - */ -export async function initBotPool(tokens: string[]): Promise { - for (const token of tokens) { - try { - const api = new Api(token); - const me = await api.getMe(); - poolApis.push(api); - logger.info( - { username: me.username, id: me.id, poolSize: poolApis.length }, - 'Pool bot initialized', - ); - } catch (err) { - logger.error({ err }, 'Failed to initialize pool bot'); - } - } - if (poolApis.length > 0) { - logger.info({ count: poolApis.length }, 'Telegram bot pool ready'); - } -} - -/** - * Send a message via a pool bot assigned to the given sender name. - * Assigns bots round-robin on first use; subsequent messages from the - * same sender in the same group always use the same bot. - * On first assignment, renames the bot to match the sender's role. - */ -export async function sendPoolMessage( - chatId: string, - text: string, - sender: string, - groupFolder: string, -): Promise { - if (poolApis.length === 0) { - // No pool bots — fall back to main bot - await sendTelegramMessage(chatId, text); - return; - } - - const key = `${groupFolder}:${sender}`; - let idx = senderBotMap.get(key); - if (idx === undefined) { - idx = nextPoolIndex % poolApis.length; - nextPoolIndex++; - senderBotMap.set(key, idx); - // Rename the bot to match the sender's role, then wait for Telegram to propagate - try { - await poolApis[idx].setMyName(sender); - await new Promise((r) => setTimeout(r, 2000)); - logger.info({ sender, groupFolder, poolIndex: idx }, 'Assigned and renamed pool bot'); - } catch (err) { - logger.warn({ sender, err }, 'Failed to rename pool bot (sending anyway)'); - } - } - - const api = poolApis[idx]; - try { - const numericId = chatId.replace(/^tg:/, ''); - const MAX_LENGTH = 4096; - if (text.length <= MAX_LENGTH) { - await api.sendMessage(numericId, text); - } else { - for (let i = 0; i < text.length; i += MAX_LENGTH) { - await api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH)); - } - } - logger.info({ chatId, sender, poolIndex: idx, length: text.length }, 'Pool message sent'); - } catch (err) { - logger.error({ chatId, sender, err }, 'Failed to send pool message'); - } -} -``` - -### Step 3: Add sender Parameter to MCP Tool - -Read `container/agent-runner/src/ipc-mcp-stdio.ts` and update the `send_message` tool to accept an optional `sender` parameter: - -Change the tool's schema from: -```typescript -{ text: z.string().describe('The message text to send') }, -``` - -To: -```typescript -{ - text: z.string().describe('The message text to send'), - sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'), -}, -``` - -And update the handler to include `sender` in the IPC data: - -```typescript -async (args) => { - const data: Record = { - type: 'message', - chatJid, - text: args.text, - sender: args.sender || undefined, - groupFolder, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(MESSAGES_DIR, data); - - return { content: [{ type: 'text' as const, text: 'Message sent.' }] }; - }, -``` - -### Step 4: Update Host IPC Routing - -Read `src/ipc.ts` and make these changes: - -1. **Add imports** — add `sendPoolMessage` and `initBotPool` from the Telegram swarm module, and `TELEGRAM_BOT_POOL` from config. - -2. **Update IPC message routing** — in `src/ipc.ts`, find where the `sendMessage` dependency is called to deliver IPC messages (inside `processIpcFiles`). The `sendMessage` is passed in via the `IpcDeps` parameter. Wrap it to route Telegram swarm messages through the bot pool: - -```typescript -if (data.sender && data.chatJid.startsWith('tg:')) { - await sendPoolMessage( - data.chatJid, - data.text, - data.sender, - sourceGroup, - ); -} else { - await deps.sendMessage(data.chatJid, data.text); -} -``` - -Note: The assistant name prefix is handled by `formatOutbound()` in the router — Telegram channels have `prefixAssistantName = false` so no prefix is added for `tg:` JIDs. - -3. **Initialize pool in `main()` in `src/index.ts`** — after creating the Telegram channel, add: - -```typescript -if (TELEGRAM_BOT_POOL.length > 0) { - await initBotPool(TELEGRAM_BOT_POOL); -} -``` - -### Step 5: Update CLAUDE.md Files - -#### 5a. Add global message formatting rules - -Read `groups/global/CLAUDE.md` and add a Message Formatting section: - -```markdown -## Message Formatting - -NEVER use markdown. Only use WhatsApp/Telegram formatting: -- *single asterisks* for bold (NEVER **double asterisks**) -- _underscores_ for italic -- • bullet points -- ```triple backticks``` for code - -No ## headings. No [links](url). No **double stars**. -``` - -#### 5b. Update existing group CLAUDE.md headings - -In any group CLAUDE.md that has a "WhatsApp Formatting" section (e.g. `groups/main/CLAUDE.md`), rename the heading to reflect multi-channel support: - -``` -## WhatsApp Formatting (and other messaging apps) -``` - -#### 5c. Add Agent Teams instructions to Telegram groups - -For each Telegram group that will use agent teams, create or update its `groups/{folder}/CLAUDE.md` with these instructions. Read the existing CLAUDE.md first (or `groups/global/CLAUDE.md` as a base) and add the Agent Teams section: - -```markdown -## Agent Teams - -When creating a team to tackle a complex task, follow these rules: - -### CRITICAL: Follow the user's prompt exactly - -Create *exactly* the team the user asked for — same number of agents, same roles, same names. Do NOT add extra agents, rename roles, or use generic names like "Researcher 1". If the user says "a marine biologist, a physicist, and Alexander Hamilton", create exactly those three agents with those exact names. - -### Team member instructions - -Each team member MUST be instructed to: - -1. *Share progress in the group* via `mcp__nanoclaw__send_message` with a `sender` parameter matching their exact role/character name (e.g., `sender: "Marine Biologist"` or `sender: "Alexander Hamilton"`). This makes their messages appear from a dedicated bot in the Telegram group. -2. *Also communicate with teammates* via `SendMessage` as normal for coordination. -3. Keep group messages *short* — 2-4 sentences max per message. Break longer content into multiple `send_message` calls. No walls of text. -4. Use the `sender` parameter consistently — always the same name so the bot identity stays stable. -5. NEVER use markdown formatting. Use ONLY WhatsApp/Telegram formatting: single *asterisks* for bold (NOT **double**), _underscores_ for italic, • for bullets, ```backticks``` for code. No ## headings, no [links](url), no **double asterisks**. - -### Example team creation prompt - -When creating a teammate, include instructions like: - -\``` -You are the Marine Biologist. When you have findings or updates for the user, send them to the group using mcp__nanoclaw__send_message with sender set to "Marine Biologist". Keep each message short (2-4 sentences max). Use emojis for strong reactions. ONLY use single *asterisks* for bold (never **double**), _underscores_ for italic, • for bullets. No markdown. Also communicate with teammates via SendMessage. -\``` - -### Lead agent behavior - -As the lead agent who created the team: - -- You do NOT need to react to or relay every teammate message. The user sees those directly from the teammate bots. -- Send your own messages only to comment, share thoughts, synthesize, or direct the team. -- When processing an internal update from a teammate that doesn't need a user-facing response, wrap your *entire* output in `` tags. -- Focus on high-level coordination and the final synthesis. -``` - -### Step 6: Update Environment - -Add pool tokens to `.env`: - -```bash -TELEGRAM_BOT_POOL=TOKEN1,TOKEN2,TOKEN3,... -``` - -**Important**: Sync to all required locations: - -```bash -cp .env data/env/env -``` - -Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.nanoclaw.plist`) in the `EnvironmentVariables` dict if using launchd. - -### Step 7: Rebuild and Restart - -```bash -npm run build -./container/build.sh # Required — MCP tool changed -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -# Linux: -# systemctl --user restart nanoclaw -``` - -Must use `unload/load` (macOS) or `restart` (Linux) because the service env vars changed. - -### Step 8: Test - -Tell the user: - -> Send a message in your Telegram group asking for a multi-agent task, e.g.: -> "Assemble a team of a researcher and a coder to build me a hello world app" -> -> You should see: -> - The lead agent (main bot) acknowledging and creating the team -> - Each subagent messaging from a different bot, renamed to their role -> - Short, scannable messages from each agent -> -> Check logs: `tail -f logs/nanoclaw.log | grep -i pool` - -## Architecture Notes - -- Pool bots use Grammy's `Api` class — lightweight, no polling, just send -- Bot names are set via `setMyName` — changes are global to the bot, not per-chat -- A 2-second delay after `setMyName` allows Telegram to propagate the name change before the first message -- Sender→bot mapping is stable within a group (keyed as `{groupFolder}:{senderName}`) -- Mapping resets on service restart — pool bots get reassigned fresh -- If pool runs out, bots are reused (round-robin wraps) - -## Troubleshooting - -### Pool bots not sending messages - -1. Verify tokens: `curl -s "https://api.telegram.org/botTOKEN/getMe"` -2. Check pool initialized: `grep "Pool bot" logs/nanoclaw.log` -3. Ensure all pool bots are members of the Telegram group -4. Check Group Privacy is disabled for each pool bot - -### Bot names not updating - -Telegram caches bot names client-side. The 2-second delay after `setMyName` helps, but users may need to restart their Telegram client to see updated names immediately. - -### Subagents not using send_message - -Check the group's `CLAUDE.md` has the Agent Teams instructions. The lead agent reads this when creating teammates and must include the `send_message` + `sender` instructions in each teammate's prompt. - -## Removal - -To remove Agent Swarm support while keeping basic Telegram: - -1. Remove `TELEGRAM_BOT_POOL` from `src/config.ts` -2. Remove pool code from `src/telegram.ts` (`poolApis`, `senderBotMap`, `initBotPool`, `sendPoolMessage`) -3. Remove pool routing from IPC handler in `src/index.ts` (revert to plain `sendMessage`) -4. Remove `initBotPool` call from `main()` -5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts` -6. Remove Agent Teams section from group CLAUDE.md files -7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit -8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-telegram/REMOVE.md b/.claude/skills/add-telegram/REMOVE.md new file mode 100644 index 0000000..3d6d2b8 --- /dev/null +++ b/.claude/skills/add-telegram/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Telegram + +1. Comment out `import './telegram.js'` in `src/channels/index.ts` +2. Remove `TELEGRAM_BOT_TOKEN` from `.env` +3. `pnpm uninstall @chat-adapter/telegram` +4. Rebuild and restart diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md index 10f25ab..f605b41 100644 --- a/.claude/skills/add-telegram/SKILL.md +++ b/.claude/skills/add-telegram/SKILL.md @@ -1,222 +1,108 @@ --- name: add-telegram -description: Add Telegram as a channel. Can replace WhatsApp entirely or run alongside it. Also configurable as a control-only channel (triggers actions) or passive channel (receives notifications only). +description: Add Telegram channel integration via Chat SDK. --- # Add Telegram Channel -This skill adds Telegram support to NanoClaw, then walks through interactive setup. +Adds Telegram bot support via the Chat SDK bridge. -## Phase 1: Pre-flight +## Install -### Check if already applied +NanoClaw doesn't ship channels in trunk. This skill copies the Telegram adapter, its formatting/pairing helpers, their tests, and the `pair-telegram` setup step in from the `channels` branch. -Check if `src/channels/telegram.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. +### Pre-flight (idempotent) -### Ask the user +Skip to **Credentials** if all of these are already in place: -Use `AskUserQuestion` to collect configuration: +- `src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist +- `src/channels/index.ts` contains `import './telegram.js';` +- `setup/pair-telegram.ts` exists and `setup/index.ts`'s `STEPS` map contains `'pair-telegram':` +- `@chat-adapter/telegram` is listed in `package.json` dependencies -AskUserQuestion: Do you have a Telegram bot token, or do you need to create one? +Otherwise continue. Every step below is safe to re-run. -If they have one, collect it now. If not, we'll create one in Phase 3. - -## Phase 2: Apply Code Changes - -### Ensure channel remote +### 1. Fetch the channels branch ```bash -git remote -v +git fetch origin channels ``` -If `telegram` is missing, add it: +### 2. Copy the adapter, helpers, tests, and setup step ```bash -git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git +git show origin/channels:src/channels/telegram.ts > src/channels/telegram.ts +git show origin/channels:src/channels/telegram-pairing.ts > src/channels/telegram-pairing.ts +git show origin/channels:src/channels/telegram-pairing.test.ts > src/channels/telegram-pairing.test.ts +git show origin/channels:src/channels/telegram-markdown-sanitize.ts > src/channels/telegram-markdown-sanitize.ts +git show origin/channels:src/channels/telegram-markdown-sanitize.test.ts > src/channels/telegram-markdown-sanitize.test.ts +git show origin/channels:setup/pair-telegram.ts > setup/pair-telegram.ts ``` -### Merge the skill branch +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if already present): + +```typescript +import './telegram.js'; +``` + +### 4. Register the setup step + +In `setup/index.ts`, add this entry to the `STEPS` map (right after the `register` line is fine; skip if already present): + +```typescript +'pair-telegram': () => import('./pair-telegram.js'), +``` + +### 5. Install the adapter package (pinned) ```bash -git fetch telegram main -git merge telegram/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} +pnpm install @chat-adapter/telegram@4.26.0 ``` -This merges in: -- `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`) -- `src/channels/telegram.test.ts` (unit tests with grammy mock) -- `import './telegram.js'` appended to the channel barrel file `src/channels/index.ts` -- `grammy` npm dependency in `package.json` -- `TELEGRAM_BOT_TOKEN` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes +### 6. Build ```bash -npm install -npm run build -npx vitest run src/channels/telegram.test.ts +pnpm run build ``` -All tests must pass (including the new Telegram tests) and build must be clean before proceeding. +## Credentials -## Phase 3: Setup +### Create Telegram Bot -### Create Telegram Bot (if needed) +1. Open Telegram and search for `@BotFather` +2. Send `/newbot` and follow the prompts: + - Bot name: Something friendly (e.g., "NanoClaw Assistant") + - Bot username: Must end with "bot" (e.g., "nanoclaw_bot") +3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) -If the user doesn't have a bot token, tell them: +**Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages: -> I need you to create a Telegram bot: -> -> 1. Open Telegram and search for `@BotFather` -> 2. Send `/newbot` and follow prompts: -> - Bot name: Something friendly (e.g., "Andy Assistant") -> - Bot username: Must end with "bot" (e.g., "andy_ai_bot") -> 3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) - -Wait for the user to provide the token. +1. Open `@BotFather` > `/mybots` > select your bot +2. **Bot Settings** > **Group Privacy** > **Turn off** ### Configure environment Add to `.env`: ```bash -TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_TOKEN=your-bot-token ``` -Channels auto-enable when their credentials are present — no extra configuration needed. +Sync to container: `mkdir -p data/env && cp .env data/env/env` -Sync to container environment: +## Next Steps -```bash -mkdir -p data/env && cp .env data/env/env -``` +If you're in the middle of `/setup`, return to the setup flow now. -The container reads environment from `data/env/env`, not `.env` directly. +Otherwise, run `/manage-channels` to wire this channel to an agent group. -### Disable Group Privacy (for group chats) +## Channel Info -Tell the user: - -> **Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages: -> -> 1. Open Telegram and search for `@BotFather` -> 2. Send `/mybots` and select your bot -> 3. Go to **Bot Settings** > **Group Privacy** > **Turn off** -> -> This is optional if you only want trigger-based responses via @mentioning the bot. - -### Build and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Registration - -### Get Chat ID - -Tell the user: - -> 1. Open your bot in Telegram (search for its username) -> 2. Send `/chatid` — it will reply with the chat ID -> 3. For groups: add the bot to the group first, then send `/chatid` in the group - -Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234567890`). - -### Register the chat - -The chat ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. - -For a main chat (responds to all messages): - -```bash -npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_main" --trigger "@${ASSISTANT_NAME}" --channel telegram --no-trigger-required --is-main -``` - -For additional chats (trigger-only): - -```bash -npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_" --trigger "@${ASSISTANT_NAME}" --channel telegram -``` - -## Phase 5: Verify - -### Test the connection - -Tell the user: - -> Send a message to your registered Telegram chat: -> - For main chat: Any message works -> - For non-main: `@Andy hello` or @mention the bot -> -> The bot should respond within a few seconds. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Troubleshooting - -### Bot not responding - -Check: -1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env` -2. Chat is registered in SQLite (check with: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`) -3. For non-main chats: message includes trigger pattern -4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) - -### Bot only responds to @mentions in groups - -Group Privacy is enabled (default). Fix: -1. `@BotFather` > `/mybots` > select bot > **Bot Settings** > **Group Privacy** > **Turn off** -2. Remove and re-add the bot to the group (required for the change to take effect) - -### Getting chat ID - -If `/chatid` doesn't work: -- Verify token: `curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"` -- Check bot is started: `tail -f logs/nanoclaw.log` - -## After Setup - -If running `npm run dev` while the service is active: -```bash -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -npm run dev -# When done testing: -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -# Linux: -# systemctl --user stop nanoclaw -# npm run dev -# systemctl --user start nanoclaw -``` - -## Agent Swarms (Teams) - -After completing the Telegram setup, use `AskUserQuestion`: - -AskUserQuestion: Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions. - -If they say yes, invoke the `/add-telegram-swarm` skill. - -## Removal - -To remove Telegram integration: - -1. Delete `src/channels/telegram.ts` and `src/channels/telegram.test.ts` -2. Remove `import './telegram.js'` from `src/channels/index.ts` -3. Remove `TELEGRAM_BOT_TOKEN` from `.env` -4. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"` -5. Uninstall: `npm uninstall grammy` -6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) +- **type**: `telegram` +- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot. +- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent `, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block (follow the `REMINDER_TO_ASSISTANT` line in that block), and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@ CODE`. Wrong guesses invalidate the code — if a `PAIR_TELEGRAM_ATTEMPT` block arrives with a mismatched `RECEIVED_CODE`, a `PAIR_TELEGRAM_NEW_CODE` block will follow automatically (up to 5 regenerations); show the new code. On `PAIR_TELEGRAM STATUS=failed ERROR=max-regenerations-exceeded`, ask the user if they want to try again and re-invoke the step — each invocation starts a fresh 5-attempt batch. Success emits `PAIR_TELEGRAM STATUS=success` with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID`. The service must be running for this to work (the polling adapter is what observes the code). +- **supports-threads**: no +- **typical-use**: Interactive chat — direct messages or small groups +- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups. diff --git a/.claude/skills/add-telegram/VERIFY.md b/.claude/skills/add-telegram/VERIFY.md new file mode 100644 index 0000000..79c0f0d --- /dev/null +++ b/.claude/skills/add-telegram/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Telegram + +Send a message to your bot in Telegram (search for its username), or add the bot to a group and send a message there. The bot should respond within a few seconds. diff --git a/.claude/skills/add-vercel/SKILL.md b/.claude/skills/add-vercel/SKILL.md new file mode 100644 index 0000000..dbd9780 --- /dev/null +++ b/.claude/skills/add-vercel/SKILL.md @@ -0,0 +1,147 @@ +--- +name: add-vercel +description: Add Vercel deployment capability to NanoClaw agents. Installs the Vercel CLI in agent containers and sets up OneCLI credential injection for api.vercel.com. Use when the user wants agents to deploy web applications to Vercel. +--- + +# Add Vercel + +This skill gives NanoClaw agents the ability to deploy web applications to Vercel. It installs the Vercel CLI in agent containers and configures OneCLI to inject Vercel credentials automatically. + +**Principle:** Do the work — don't tell the user to do it. Only ask for their input when it genuinely requires manual action (pasting a token). + +## Phase 1: Pre-flight + +### Check if already applied + +Check if the container skill exists: + +```bash +test -d container/skills/vercel-cli && echo "INSTALLED" || echo "NOT_INSTALLED" +``` + +If `INSTALLED`, skip to Phase 3 (Configure Credentials). + +### Check prerequisites + +Verify OneCLI is working (required for credential injection): + +```bash +onecli version 2>/dev/null && echo "ONECLI_OK" || echo "ONECLI_MISSING" +``` + +If `ONECLI_MISSING`, tell the user to run `/init-onecli` first, then retry `/add-vercel`. Stop here. + +## Phase 2: Install Container Skill + +Copy the bundled container skill into the container skills directory: + +```bash +rsync -a .claude/skills/add-vercel/container-skills/ container/skills/ +``` + +Verify: + +```bash +head -5 container/skills/vercel-cli/SKILL.md +``` + +## Phase 3: Configure Credentials + +### Check if Vercel credential already exists + +```bash +onecli secrets list 2>/dev/null | grep -i vercel +``` + +If a Vercel credential already exists, skip to Phase 4. + +### Set up Vercel API credential + +The agent needs a Vercel personal access token. Tell the user: + +> I need your Vercel personal access token. Go to https://vercel.com/account/tokens and create one with these settings: +> +> - **Token name:** `nanoclaw` (or any name you'll recognize) +> - **Scope:** "Full Account" — the agent needs to create projects, deploy, and manage domains +> - **Expiration:** "No expiration" recommended (avoids credential rotation), or pick a date if your security policy requires it +> +> After creating the token, copy it — you'll only see it once. + +Once the user provides the token, add it to OneCLI: + +```bash +onecli secrets create \ + --name "Vercel API Token" \ + --type generic \ + --value "" \ + --host-pattern "api.vercel.com" \ + --header-name "Authorization" \ + --value-format "Bearer {value}" +``` + +Verify: + +```bash +onecli secrets list | grep -i vercel +``` + +### Assign the secret to all agents + +OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent: + +```bash +# For each agent, add the Vercel secret to its assigned secrets list. +# First get current assignments, then set them with the new secret appended. +VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//') +for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do + CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//') + onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID" +done +``` + +## Phase 4: Ensure Vercel CLI in Container Image + +Check if `vercel` is already in the Dockerfile: + +```bash +grep -q 'vercel' container/Dockerfile && echo "PRESENT" || echo "MISSING" +``` + +If `MISSING`, add `vercel` to the global npm install line in `container/Dockerfile`, then rebuild: + +```bash +./container/build.sh +``` + +If `PRESENT`, skip — no rebuild needed. + +## Phase 5: Sync Skills to Running Agent Groups + +Container skills are copied once at group creation and not auto-synced. After installing or updating a container skill, sync it to all existing agent groups: + +```bash +for session_dir in data/v2-sessions/ag-*; do + if [ -d "$session_dir/.claude-shared/skills" ]; then + rsync -a container/skills/ "$session_dir/.claude-shared/skills/" + echo "Synced skills to: $session_dir" + fi +done +``` + +## Phase 6: Restart Running Containers + +Stop all running agent containers so they pick up the new skills on next wake: + +```bash +docker ps --format "{{.ID}} {{.Names}}" | grep nanoclaw-v2 | awk '{print $1}' | xargs -r docker stop +``` + +## Done + +The agent can now deploy web applications to Vercel. Key commands: + +- `vercel deploy --yes --prod --token placeholder` — deploy to production +- `vercel ls --token placeholder` — list deployments +- `vercel whoami --token placeholder` — check auth + +For the full command reference, the agent has the `vercel-cli` container skill loaded automatically. diff --git a/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md b/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md new file mode 100644 index 0000000..7933e77 --- /dev/null +++ b/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md @@ -0,0 +1,103 @@ +--- +name: vercel-cli +description: Deploy apps to Vercel. Use when asked to deploy, ship, or publish a web application, or manage Vercel projects, domains, and environment variables. +--- + +# Vercel CLI + +You can deploy web applications to Vercel using the `vercel` CLI. + +## Auth + +Auth is handled by OneCLI — the HTTPS_PROXY injects the real token into API requests automatically. The Vercel CLI requires a token to be present to skip its local credential check, so **always pass `--token placeholder`** on every command. OneCLI replaces this with the real token at the proxy level. + +Before any Vercel operation, verify auth: + +```bash +vercel whoami --token placeholder +``` + +If this fails with an auth error, ask the user to add a Vercel token to OneCLI. They can create one at https://vercel.com/account/tokens and register it via `onecli secrets create` on the host. Once added, retry `vercel whoami`. + +## Deploying + +Always use `--yes` to skip interactive prompts and `--token placeholder` for auth (OneCLI replaces with real token). + +```bash +# Deploy to production +vercel deploy --yes --prod --token placeholder + +# Deploy from a specific directory +vercel deploy --yes --prod --token placeholder --cwd /path/to/project + +# Preview deployment (not production) +vercel deploy --yes --token placeholder +``` + +After deploying, verify the live URL: + +```bash +# Check deployment status +vercel inspect --token placeholder +``` + +## Pre-Send Checks (do this before sharing the URL) + +Don't send the deployment URL to the user until you've confirmed it's actually working. At minimum: + +1. **Local build passes** — run `npm run build` (or the project's build command) before `vercel deploy`. If the build fails locally, fix it first; don't deploy broken code. +2. **Deployment succeeded** — the `vercel deploy` output shows a "Production: https://..." URL and the status is READY (confirm with `vercel inspect`). +3. **Live URL responds** — `curl -sI | head -1` should return `HTTP/2 200` (or another 2xx/3xx). A 404/500 means something's broken even though Vercel reported success. +4. **Optional visual check** — if `agent-browser` is loaded, open the URL and eyeball it. Helpful for catching broken layouts that a 200 response wouldn't reveal. + +If any check fails, fix the issue and redeploy before reporting to the user. + +## Project Management + +```bash +# Link to an existing Vercel project (non-interactive) +vercel link --yes --token placeholder + +# List recent deployments +vercel ls --token placeholder + +# List all projects +vercel project ls --token placeholder +``` + +## Domains + +```bash +# List domains +vercel domains ls --token placeholder + +# Add a domain to the current project +vercel domains add example.com --token placeholder +``` + +## Environment Variables + +```bash +# Pull env vars from Vercel to local .env +vercel env pull --token placeholder + +# Add an env var (use echo to pipe the value — avoids interactive prompt) +echo "value" | vercel env add VAR_NAME production --token placeholder +``` + +## Common Errors + +| Error | Fix | +|-------|-----| +| `Error: No framework detected` | Ensure the project has a `package.json` with a `build` script, or set the framework in `vercel.json` | +| `Error: Rate limited` | Wait and retry. Don't loop — report to user | +| `Error: You have reached your project limit` | User needs to upgrade Vercel plan or delete unused projects | +| `ENOTFOUND api.vercel.com` | Network issue. Check proxy connectivity | +| Auth error after `vercel whoami` | Credential may be expired. Ask the user to refresh the Vercel token in OneCLI | + +## Best Practices + +- Run `npm run build` locally before deploying to catch build errors early +- Use `--cwd` instead of `cd` to keep your working directory stable +- For Next.js projects, `vercel deploy` auto-detects the framework — no extra config needed +- Use `vercel.json` only when you need custom build settings, rewrites, or headers diff --git a/.claude/skills/add-voice-transcription/SKILL.md b/.claude/skills/add-voice-transcription/SKILL.md deleted file mode 100644 index 8ccec32..0000000 --- a/.claude/skills/add-voice-transcription/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: add-voice-transcription -description: Add voice message transcription to NanoClaw using OpenAI's Whisper API. Automatically transcribes WhatsApp voice notes so the agent can read and respond to them. ---- - -# Add Voice Transcription - -This skill adds automatic voice message transcription to NanoClaw's WhatsApp channel using OpenAI's Whisper API. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice: ]`. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place. - -### Ask the user - -Use `AskUserQuestion` to collect information: - -AskUserQuestion: Do you have an OpenAI API key for Whisper transcription? - -If yes, collect it now. If no, direct them to create one at https://platform.openai.com/api-keys. - -## Phase 2: Apply Code Changes - -**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/voice-transcription -git merge whatsapp/skill/voice-transcription || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `src/transcription.ts` (voice transcription module using OpenAI Whisper) -- Voice handling in `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call) -- Transcription tests in `src/channels/whatsapp.test.ts` -- `openai` npm dependency in `package.json` -- `OPENAI_API_KEY` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -npm install --legacy-peer-deps -npm run build -npx vitest run src/channels/whatsapp.test.ts -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Configure - -### Get OpenAI API key (if needed) - -If the user doesn't have an API key: - -> I need you to create an OpenAI API key: -> -> 1. Go to https://platform.openai.com/api-keys -> 2. Click "Create new secret key" -> 3. Give it a name (e.g., "NanoClaw Transcription") -> 4. Copy the key (starts with `sk-`) -> -> Cost: ~$0.006 per minute of audio (~$0.003 per typical 30-second voice note) - -Wait for the user to provide the key. - -### Add to environment - -Add to `.env`: - -```bash -OPENAI_API_KEY= -``` - -Sync to container environment: - -```bash -mkdir -p data/env && cp .env data/env/env -``` - -The container reads environment from `data/env/env`, not `.env` directly. - -### Build and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Verify - -### Test with a voice note - -Tell the user: - -> Send a voice note in any registered WhatsApp chat. The agent should receive it as `[Voice: ]` and respond to its content. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log | grep -i voice -``` - -Look for: -- `Transcribed voice message` — successful transcription with character count -- `OPENAI_API_KEY not set` — key missing from `.env` -- `OpenAI transcription failed` — API error (check key validity, billing) -- `Failed to download audio message` — media download issue - -## Troubleshooting - -### Voice notes show "[Voice Message - transcription unavailable]" - -1. Check `OPENAI_API_KEY` is set in `.env` AND synced to `data/env/env` -2. Verify key works: `curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 200` -3. Check OpenAI billing — Whisper requires a funded account - -### Voice notes show "[Voice Message - transcription failed]" - -Check logs for the specific error. Common causes: -- Network timeout — transient, will work on next message -- Invalid API key — regenerate at https://platform.openai.com/api-keys -- Rate limiting — wait and retry - -### Agent doesn't respond to voice notes - -Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups. diff --git a/.claude/skills/add-webex/REMOVE.md b/.claude/skills/add-webex/REMOVE.md new file mode 100644 index 0000000..b778644 --- /dev/null +++ b/.claude/skills/add-webex/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Webex Channel + +1. Comment out `import './webex.js'` in `src/channels/index.ts` +2. Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env` +3. `pnpm uninstall @bitbasti/chat-adapter-webex` +4. Rebuild and restart diff --git a/.claude/skills/add-webex/SKILL.md b/.claude/skills/add-webex/SKILL.md new file mode 100644 index 0000000..0f52178 --- /dev/null +++ b/.claude/skills/add-webex/SKILL.md @@ -0,0 +1,88 @@ +--- +name: add-webex +description: Add Webex channel integration via Chat SDK. +--- + +# Add Webex Channel + +Adds Cisco Webex support via the Chat SDK bridge. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/webex.ts` exists +- `src/channels/index.ts` contains `import './webex.js';` +- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/webex.ts > src/channels/webex.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './webex.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @bitbasti/chat-adapter-webex@0.1.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot +2. Copy the **Bot Access Token** +3. Set up a webhook: + - Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex` + - Set a webhook secret for signature verification + +### Configure environment + +Add to `.env`: + +```bash +WEBEX_BOT_TOKEN=your-bot-token +WEBEX_WEBHOOK_SECRET=your-webhook-secret +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `webex` +- **terminology**: Webex has "spaces." A space can be a group conversation or a 1:1 direct message with the bot. +- **how-to-find-id**: Open the space in Webex, click the space name > Settings — the Space ID is listed there. Or use the Webex API (`GET /rooms`) to list spaces and their IDs. +- **supports-threads**: yes +- **typical-use**: Interactive chat — team spaces or direct messages +- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive information. diff --git a/.claude/skills/add-webex/VERIFY.md b/.claude/skills/add-webex/VERIFY.md new file mode 100644 index 0000000..3bd872b --- /dev/null +++ b/.claude/skills/add-webex/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Webex Channel + +Add the bot to a Webex space or send it a direct message. The bot should respond within a few seconds. diff --git a/.claude/skills/add-wechat/REMOVE.md b/.claude/skills/add-wechat/REMOVE.md new file mode 100644 index 0000000..366739e --- /dev/null +++ b/.claude/skills/add-wechat/REMOVE.md @@ -0,0 +1,49 @@ +# Remove WeChat Channel + +Undo `/add-wechat`. + +### 1. Remove credentials + +Delete WeChat lines from `.env`: + +```bash +sed -i.bak '/^WECHAT_ENABLED=/d' .env && rm -f .env.bak +cp .env data/env/env +``` + +### 2. Remove adapter and import + +```bash +rm -f src/channels/wechat.ts +sed -i.bak "/import '\.\/wechat\.js';/d" src/channels/index.ts && rm -f src/channels/index.ts.bak +``` + +### 3. Uninstall the package + +```bash +pnpm remove wechat-ilink-client +``` + +### 4. Remove saved auth + sync state + +```bash +rm -rf data/wechat +``` + +### 5. Remove DB wiring + +```sql +-- Remove any sessions first (foreign key) +DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat'); +DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat'); +DELETE FROM messaging_groups WHERE channel_type = 'wechat'; +``` + +### 6. Rebuild and restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# or +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` diff --git a/.claude/skills/add-wechat/SKILL.md b/.claude/skills/add-wechat/SKILL.md new file mode 100644 index 0000000..49746ce --- /dev/null +++ b/.claude/skills/add-wechat/SKILL.md @@ -0,0 +1,170 @@ +--- +name: add-wechat +description: Add WeChat (personal) channel integration via Tencent's official iLink Bot API. Uses long-polling and QR scan — no webhook, no ToS risk, no paid token. +--- + +# Add WeChat Channel + +Adds WeChat support via **iLink Bot API** — the first-party Tencent API for personal WeChat bots (different from WeCom / Official Account). + +**Why this is different from wechaty/PadLocal:** + +- Official Tencent API — no ToS violation, no ban risk +- Free — no PadLocal token required +- No public webhook URL needed — uses long-poll +- Works with any personal WeChat account + +## Prerequisites + +- A **personal WeChat account** with the mobile app installed +- A phone to scan the QR code for login +- Node.js >= 20 (already required by NanoClaw) + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the WeChat adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/wechat.ts` exists +- `src/channels/index.ts` contains `import './wechat.js';` +- `wechat-ilink-client` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './wechat.js'; +``` + +### 4. Install the library (pinned) + +```bash +pnpm install wechat-ilink-client@0.1.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +Unlike most channels, WeChat requires **no pre-configured API keys**. Auth happens via QR code scan from your phone. + +### 1. Enable the channel + +Add to `.env`: + +```bash +WECHAT_ENABLED=true +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### 2. Start the service and scan the QR + +Restart NanoClaw: + +```bash +systemctl --user restart nanoclaw # Linux +# or +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`: + +```bash +tail -f logs/nanoclaw.log | grep WeChat +# or +cat data/wechat/qr.txt +``` + +Open the URL in a browser (it renders a QR code), then: + +1. Open WeChat on your phone +2. Use its built-in QR scanner (top-right "+" → Scan) +3. Approve the authorization on your phone +4. Auth credentials are saved to `data/wechat/auth.json` — do not commit this file + +The bot is now connected as your WeChat account. + +## Wire your first DM + +A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond. + +### 1. Trigger the first inbound message + +Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`. + +### 2. Run the wire script + +```bash +pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts +``` + +Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`). + +With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent. + +Non-interactive: + +```bash +pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \ + --platform-id wechat:wxid_xxxxx \ + --agent-group ag-xxxxx \ + --non-interactive +``` + +Flags: + +- `--platform-id ` — wire a specific messaging group (default: most recent unwired) +- `--agent-group ` — target agent group (default: prompt; or solo admin group in non-interactive) +- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs) +- `--session-mode shared|per-thread` — default `shared` + +### 3. Test + +Have the sender message the bot again — the agent should respond. + +## Operational notes + +- **Only one instance can use a given token at a time.** Don't run multiple NanoClaw instances pointing to the same `data/wechat/auth.json`. +- **Re-login on session expiry:** if you see `WeChat: session expired` in logs, delete `data/wechat/auth.json` and restart — you'll be asked to re-scan. +- **Sync cursor persistence:** `data/wechat/sync-buf.txt` holds the long-poll cursor. Deleting it replays recent history on next start; don't delete it in normal operation. +- **Account safety:** this uses the official Tencent API, so account bans for bot automation aren't a risk. That said, don't spam — normal rate limits still apply. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, restart the service to pick up the new channel and wiring. + +## Channel Info + +- **type**: `wechat` +- **terminology**: WeChat has "contacts" (DMs) and "group chats" (rooms). Each DM or group is a separate messaging group. +- **how-to-find-id**: Send a message to the bot from the target account; the adapter auto-creates a messaging group and logs `WeChat inbound platformId=wechat:`. Use `wechat:` for DMs, `wechat:` for rooms. +- **admin-user-id**: The operator's WeChat user_id (for `init-first-agent.ts --admin-user-id`) is saved to `data/wechat/auth.json` as `operatorUserId` after the QR scan. Read it with `cat data/wechat/auth.json | jq -r .operatorUserId` and prefix with `wechat:` (i.e. `wechat:`). +- **supports-threads**: no (WeChat has no reply threads) +- **typical-use**: Long-poll — the adapter holds a persistent connection to Tencent's iLink API and receives messages in real time. No webhook URL needed. +- **default-isolation**: `shared` session mode per messaging group (DM or room). Use `strict` sender policy if you want only specific users to reach the agent; `public` opens it to anyone who messages the bot. +- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running as part of `bash nanoclaw.sh`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above. diff --git a/.claude/skills/add-wechat/scripts/wire-dm.ts b/.claude/skills/add-wechat/scripts/wire-dm.ts new file mode 100644 index 0000000..f94c88d --- /dev/null +++ b/.claude/skills/add-wechat/scripts/wire-dm.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env pnpm exec tsx +/** + * Wire a WeChat DM (or group) to an agent group. + * + * After /add-wechat installs the adapter and the user scans the QR login, + * the first inbound message from another WeChat account auto-creates a + * `messaging_groups` row. This script finds that row, asks the operator + * which agent group to wire it to, and inserts the `messaging_group_agents` + * join row with sensible defaults — the "post-login wiring" step /add-wechat + * otherwise requires manual SQL for. + * + * Usage: + * pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts + * + * Flags: + * --platform-id Wire a specific messaging group (default: most recent unwired) + * --agent-group Target agent group (default: interactive pick; or solo admin group) + * --sender-policy

public | strict (default: public) + * --session-mode shared | per-thread (default: shared) + * --non-interactive Fail instead of prompting + */ +import Database from 'better-sqlite3'; +import path from 'node:path'; +import readline from 'node:readline'; + +const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db'); + +type SenderPolicy = 'public' | 'strict' | 'request_approval'; + +interface Args { + platformId?: string; + agentGroupId?: string; + senderPolicy: SenderPolicy; + sessionMode: 'shared' | 'per-thread'; + interactive: boolean; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + // Default matches the router's auto-create (`request_approval`) so the + // admin gets an approval card on the next unknown-sender DM rather than + // a silent allow. Pass `--sender-policy public` to open the channel to + // anyone, or `strict` to require explicit membership. + senderPolicy: 'request_approval', + sessionMode: 'shared', + interactive: true, + }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const val = argv[i + 1]; + switch (flag) { + case '--platform-id': args.platformId = val; i++; break; + case '--agent-group': args.agentGroupId = val; i++; break; + case '--sender-policy': + if (val !== 'public' && val !== 'strict' && val !== 'request_approval') { + throw new Error(`bad --sender-policy: ${val} (use public | strict | request_approval)`); + } + args.senderPolicy = val; i++; break; + case '--session-mode': + if (val !== 'shared' && val !== 'per-thread') throw new Error(`bad --session-mode: ${val}`); + args.sessionMode = val; i++; break; + case '--non-interactive': args.interactive = false; break; + case '--help': case '-h': + console.log('See .claude/skills/add-wechat/scripts/wire-dm.ts header for usage.'); + process.exit(0); + } + } + return args; +} + +async function prompt(q: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); })); +} + +function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + + // 1. Pick the messaging group + let platformId = args.platformId; + if (!platformId) { + const rows = db.prepare(` + SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at + FROM messaging_groups mg + LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id + WHERE mg.channel_type = 'wechat' AND mga.id IS NULL + ORDER BY mg.created_at DESC + `).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>; + + if (rows.length === 0) { + console.error('No unwired WeChat messaging groups found.'); + console.error('Send a message to the bot first (from another WeChat account), then re-run.'); + process.exit(1); + } + + if (rows.length === 1 || !args.interactive) { + platformId = rows[0].platform_id; + console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`); + } else { + console.log('Unwired WeChat messaging groups:'); + rows.forEach((r, i) => { + console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`); + }); + const pick = await prompt('Pick one [1]: '); + const idx = pick === '' ? 0 : parseInt(pick, 10) - 1; + if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice'); + platformId = rows[idx].platform_id; + } + } + + const mg = db.prepare( + 'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?' + ).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined; + if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`); + + // 2. Pick the agent group + let agentGroupId = args.agentGroupId; + if (!agentGroupId) { + const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC') + .all() as Array<{ id: string; name: string; is_admin: number }>; + if (agents.length === 0) throw new Error('no agent groups exist — create one first'); + + const adminAgents = agents.filter((a) => a.is_admin === 1); + if (adminAgents.length === 1 && !args.interactive) { + agentGroupId = adminAgents[0].id; + console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`); + } else if (args.interactive) { + console.log('Agent groups:'); + agents.forEach((a, i) => { + console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`); + }); + const pick = await prompt('Pick one [1]: '); + const idx = pick === '' ? 0 : parseInt(pick, 10) - 1; + if (Number.isNaN(idx) || idx < 0 || idx >= agents.length) throw new Error('invalid choice'); + agentGroupId = agents[idx].id; + } else { + throw new Error('multiple agent groups exist; pass --agent-group '); + } + } + + const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as + { id: string; name: string } | undefined; + if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`); + + // 3. Update sender policy + wire + const tx = db.transaction(() => { + db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?') + .run(args.senderPolicy, mg.id); + + db.prepare(` + INSERT INTO messaging_group_agents + (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) + VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now')) + `).run(generateId('mga'), mg.id, ag.id, args.sessionMode); + }); + tx(); + + console.log(''); + console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`); + db.close(); +} + +main().catch((err) => { + console.error('FAILED:', err.message); + process.exit(1); +}); diff --git a/.claude/skills/add-whatsapp-cloud/REMOVE.md b/.claude/skills/add-whatsapp-cloud/REMOVE.md new file mode 100644 index 0000000..d10df0e --- /dev/null +++ b/.claude/skills/add-whatsapp-cloud/REMOVE.md @@ -0,0 +1,6 @@ +# Remove WhatsApp Cloud API Channel + +1. Comment out `import './whatsapp-cloud.js'` in `src/channels/index.ts` +2. Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN` from `.env` +3. `pnpm uninstall @chat-adapter/whatsapp` +4. Rebuild and restart diff --git a/.claude/skills/add-whatsapp-cloud/SKILL.md b/.claude/skills/add-whatsapp-cloud/SKILL.md new file mode 100644 index 0000000..d08f375 --- /dev/null +++ b/.claude/skills/add-whatsapp-cloud/SKILL.md @@ -0,0 +1,95 @@ +--- +name: add-whatsapp-cloud +description: Add WhatsApp Business Cloud API channel via Chat SDK. Official Meta API. +--- + +# Add WhatsApp Cloud API Channel + +Connect NanoClaw to WhatsApp via the official Meta WhatsApp Business Cloud API. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/whatsapp-cloud.ts` exists +- `src/channels/index.ts` contains `import './whatsapp-cloud.js';` +- `@chat-adapter/whatsapp` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './whatsapp-cloud.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/whatsapp@4.26.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business). +2. Add the **WhatsApp** product. +3. Go to **WhatsApp** > **API Setup**: + - Note the **Phone Number ID** (not the phone number itself). + - Generate a **permanent System User access token** with `whatsapp_business_messaging` permission. +4. Go to **WhatsApp** > **Configuration**: + - Set webhook URL: `https://your-domain/webhook/whatsapp`. + - Set a **Verify Token** (any random string you choose). + - Subscribe to webhook fields: `messages`. +5. Copy the **App Secret** from **Settings** > **Basic**. + +### Configure environment + +Add to `.env`: + +```bash +WHATSAPP_ACCESS_TOKEN=your-system-user-access-token +WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id +WHATSAPP_APP_SECRET=your-app-secret +WHATSAPP_VERIFY_TOKEN=your-verify-token +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `whatsapp-cloud` +- **terminology**: WhatsApp Cloud API supports 1:1 conversations only (no group chats). Each conversation is with a phone number. +- **how-to-find-id**: The platform ID is the Phone Number ID from the Meta Business dashboard (not the phone number itself). Find it under WhatsApp > API Setup. +- **supports-threads**: no +- **typical-use**: Interactive 1:1 chat -- direct messages only +- **default-isolation**: Same agent group if you're the only person messaging the bot. Each additional person who messages gets their own conversation automatically, but they share the agent's workspace and memory -- use a separate agent group if you need information isolation between different contacts. diff --git a/.claude/skills/add-whatsapp-cloud/VERIFY.md b/.claude/skills/add-whatsapp-cloud/VERIFY.md new file mode 100644 index 0000000..905f89f --- /dev/null +++ b/.claude/skills/add-whatsapp-cloud/VERIFY.md @@ -0,0 +1,3 @@ +# Verify WhatsApp Cloud API Channel + +Send a message to your WhatsApp Business number. The bot should respond within a few seconds. Note: WhatsApp Cloud API only supports 1:1 DMs, not group chats. diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index cbdf00b..3f10ce1 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -1,20 +1,81 @@ --- name: add-whatsapp -description: Add WhatsApp as a channel. Can replace other channels entirely or run alongside them. Uses QR code or pairing code for authentication. +description: Add WhatsApp channel via native Baileys adapter. Direct connection — no Chat SDK bridge. Uses QR code or pairing code for authentication. --- # Add WhatsApp Channel -This skill adds WhatsApp support to NanoClaw. It installs the WhatsApp channel code, dependencies, and guides through authentication, registration, and configuration. +Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge). -## Phase 1: Pre-flight +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/whatsapp.ts` exists +- `src/channels/index.ts` contains `import './whatsapp.js';` +- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist +- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:` +- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter and setup steps + +```bash +git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts +git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts +git show origin/channels:setup/groups.ts > setup/groups.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if already present): + +```typescript +import './whatsapp.js'; +``` + +### 4. Register the setup steps + +In `setup/index.ts`, add these entries to the `STEPS` map (skip lines already present): + +```typescript +groups: () => import('./groups.js'), +'whatsapp-auth': () => import('./whatsapp-auth.js'), +``` + +### 5. Install the adapter packages (pinned) + +```bash +pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +``` + +### 6. Build + +```bash +pnpm run build +``` + +## Credentials + +WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone. ### Check current state -Check if WhatsApp is already configured. If `store/auth/` exists with credential files, skip to Phase 4 (Registration) or Phase 5 (Verify). +Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number". ```bash -ls store/auth/creds.json 2>/dev/null && echo "WhatsApp auth exists" || echo "No WhatsApp auth" +test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth" ``` ### Detect environment @@ -42,57 +103,6 @@ If they chose pairing code: AskUserQuestion: What is your phone number? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.) -## Phase 2: Apply Code Changes - -Check if `src/channels/whatsapp.ts` already exists. If it does, skip to Phase 3 (Authentication). - -### Ensure channel remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp main -git merge whatsapp/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `src/channels/whatsapp.ts` (WhatsAppChannel class with self-registration via `registerChannel`) -- `src/channels/whatsapp.test.ts` (41 unit tests) -- `src/whatsapp-auth.ts` (standalone WhatsApp authentication script) -- `setup/whatsapp-auth.ts` (WhatsApp auth setup step) -- `import './whatsapp.js'` appended to the channel barrel file `src/channels/index.ts` -- `'whatsapp-auth'` step added to `setup/index.ts` -- `@whiskeysockets/baileys`, `qrcode`, `qrcode-terminal` npm dependencies in `package.json` -- `ASSISTANT_HAS_OWN_NUMBER` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -npm install -npm run build -npx vitest run src/channels/whatsapp.test.ts -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Authentication - ### Clean previous auth state (if re-authenticating) ```bash @@ -104,7 +114,7 @@ rm -rf store/auth/ For QR code in browser (recommended): ```bash -npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser +pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser ``` (Bash timeout: 150000ms) @@ -120,10 +130,12 @@ Tell the user: For QR code in terminal: ```bash -npx tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal +pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal ``` -Tell the user to run `npm run auth` in another terminal, then: +(Bash timeout: 150000ms) + +Tell the user: > 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** > 2. Scan the QR code displayed in the terminal @@ -135,7 +147,7 @@ Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Devi Run the auth process in the background and poll `store/pairing-code.txt` for the code: ```bash -rm -f store/pairing-code.txt && npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone > /tmp/wa-auth.log 2>&1 & +rm -f store/pairing-code.txt && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone > /tmp/wa-auth.log 2>&1 & ``` Then immediately poll for the code (do NOT wait for the background command to finish): @@ -155,10 +167,10 @@ Display the code to the user the moment it appears. Tell them: After the user enters the code, poll for authentication to complete: ```bash -for i in $(seq 1 60); do grep -q 'AUTH_STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'AUTH_STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done +for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done ``` -**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry. +**If failed:** logged_out → delete `store/auth/` and re-run. timeout → ask user, offer retry. ### Verify authentication succeeded @@ -166,128 +178,43 @@ for i in $(seq 1 60); do grep -q 'AUTH_STATUS: authenticated' /tmp/wa-auth.log 2 test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed" ``` -### Configure environment +### Shared vs dedicated number -Channels auto-enable when their credentials are present — WhatsApp activates when `store/auth/creds.json` exists. +AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number? +- **Shared number** — your personal WhatsApp (bot prefixes messages with its name) +- **Dedicated number** — a separate phone/SIM for the assistant -Sync to container environment: +If dedicated, add to `.env`: ```bash -mkdir -p data/env && cp .env data/env/env +ASSISTANT_HAS_OWN_NUMBER=true ``` -## Phase 4: Registration +## Next Steps -### Configure trigger and channel type +If you're in the middle of `/setup`, return to the setup flow now. -Get the bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"` +Otherwise, run `/manage-channels` to wire this channel to an agent group. -AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number (separate device)? -- **Shared number** - Your personal WhatsApp number (recommended: use self-chat or a solo group) -- **Dedicated number** - A separate phone/SIM for the assistant +## Channel Info -AskUserQuestion: What trigger word should activate the assistant? -- **@Andy** - Default trigger -- **@Claw** - Short and easy -- **@Claude** - Match the AI name +- **type**: `whatsapp` +- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members. +- **how-to-find-id**: DMs use `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`. +- **supports-threads**: no +- **typical-use**: Interactive chat — direct messages or small groups +- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups. -AskUserQuestion: What should the assistant call itself? -- **Andy** - Default name -- **Claw** - Short and easy -- **Claude** - Match the AI name +### Features -AskUserQuestion: Where do you want to chat with the assistant? +- Markdown formatting — `**bold**`→`*bold*`, `*italic*`→`_italic_`, headings→bold, code blocks preserved +- Approval questions — `ask_user_question` renders with `/approve`, `/reject` slash commands +- File attachments — send and receive images, video, audio, documents +- Reactions — send emoji reactions on messages +- Typing indicators — composing presence updates +- Credential requests — text fallback (WhatsApp has no modal support) -**Shared number options:** -- **Self-chat** (Recommended) - Chat in your own "Message Yourself" conversation -- **Solo group** - A group with just you and the linked device -- **Existing group** - An existing WhatsApp group - -**Dedicated number options:** -- **DM with bot** (Recommended) - Direct message the bot's number -- **Solo group** - A group with just you and the bot -- **Existing group** - An existing WhatsApp group - -### Get the JID - -**Self-chat:** JID = your phone number with `@s.whatsapp.net`. Extract from auth credentials: - -```bash -node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')" -``` - -**DM with bot:** Ask for the bot's phone number. JID = `NUMBER@s.whatsapp.net` - -**Group (solo, existing):** Run group sync and list available groups: - -```bash -npx tsx setup/index.ts --step groups -npx tsx setup/index.ts --step groups --list -``` - -The output shows `JID|GroupName` pairs. Present candidates as AskUserQuestion (names only, not JIDs). - -### Register the chat - -```bash -npx tsx setup/index.ts --step register \ - --jid "" \ - --name "" \ - --trigger "@" \ - --folder "whatsapp_main" \ - --channel whatsapp \ - --assistant-name "" \ - --is-main \ - --no-trigger-required # Only for main/self-chat -``` - -For additional groups (trigger-required): - -```bash -npx tsx setup/index.ts --step register \ - --jid "" \ - --name "" \ - --trigger "@" \ - --folder "whatsapp_" \ - --channel whatsapp -``` - -## Phase 5: Verify - -### Build and restart - -```bash -npm run build -``` - -Restart the service: - -```bash -# macOS (launchd) -launchctl kickstart -k gui/$(id -u)/com.nanoclaw - -# Linux (systemd) -systemctl --user restart nanoclaw - -# Linux (nohup fallback) -bash start-nanoclaw.sh -``` - -### Test the connection - -Tell the user: - -> Send a message to your registered WhatsApp chat: -> - For self-chat / main: Any message works -> - For groups: Use the trigger word (e.g., "@Andy hello") -> -> The assistant should respond within a few seconds. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` +Not supported (WhatsApp linked device limitation): edit messages, delete messages. ## Troubleshooting @@ -296,77 +223,42 @@ tail -f logs/nanoclaw.log QR codes expire after ~60 seconds. Re-run the auth command: ```bash -rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts +rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser ``` ### Pairing code not working -Codes expire in ~60 seconds. To retry: +Codes expire in ~60 seconds. Delete auth and retry: ```bash -rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone +rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone ``` -Enter the code **immediately** when it appears. Also ensure: -1. Phone number is digits only — country code + number, no `+` prefix (e.g., `14155551234` where `1` is country code, `4155551234` is the number) -2. Phone has internet access -3. WhatsApp is updated to the latest version +Ensure: digits only (no `+`), phone has internet, WhatsApp is updated. If pairing code keeps failing, switch to QR-browser auth instead: ```bash -rm -rf store/auth/ && npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser +rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser ``` -### "conflict" disconnection +### "waiting for this message" on reactions -This happens when two instances connect with the same credentials. Ensure only one NanoClaw process is running: +Signal sessions corrupted from rapid restarts. Clear sessions: ```bash -pkill -f "node dist/index.js" -# Then restart +systemctl --user stop nanoclaw +rm store/auth/session-*.json +systemctl --user start nanoclaw ``` ### Bot not responding -Check: -1. Auth credentials exist: `ls store/auth/creds.json` -3. Chat is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE '%whatsapp%' OR jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"` -4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) -5. Logs: `tail -50 logs/nanoclaw.log` +1. Auth exists: `test -f store/auth/creds.json` +2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1` +3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"` +4. Service running: `systemctl --user status nanoclaw` -### Group names not showing +### "conflict" disconnection -Run group metadata sync: - -```bash -npx tsx setup/index.ts --step groups -``` - -This fetches all group names from WhatsApp. Runs automatically every 24 hours. - -## After Setup - -If running `npm run dev` while the service is active: - -```bash -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -npm run dev -# When done testing: -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist - -# Linux: -# systemctl --user stop nanoclaw -# npm run dev -# systemctl --user start nanoclaw -``` - -## Removal - -To remove WhatsApp integration: - -1. Delete auth credentials: `rm -rf store/auth/` -2. Remove WhatsApp registrations: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"` -3. Sync env: `mkdir -p data/env && cp .env data/env/env` -4. Rebuild and restart: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) +Two instances connected with same credentials. Ensure only one NanoClaw process is running. diff --git a/.claude/skills/channel-formatting/SKILL.md b/.claude/skills/channel-formatting/SKILL.md deleted file mode 100644 index b995fb8..0000000 --- a/.claude/skills/channel-formatting/SKILL.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: channel-formatting -description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill. ---- - -# Channel Formatting - -This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's -responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or -Telegram. - -| Channel | Transformation | -|---------|---------------| -| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links flattened | -| Telegram | same as WhatsApp | -| Slack | same as WhatsApp, but links become `` | -| Discord | passthrough (Discord already renders Markdown) | -| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill | - -Code blocks (fenced and inline) are always protected — their content is never transformed. - -## Phase 1: Pre-flight - -### Check if already applied - -```bash -test -f src/text-styles.ts && echo "already applied" || echo "not yet applied" -``` - -If `already applied`, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -### Ensure the upstream remote - -```bash -git remote -v -``` - -If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing, -add it: - -```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git -``` - -### Merge the skill branch - -```bash -git fetch upstream skill/channel-formatting -git merge upstream/skill/channel-formatting -``` - -If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming -version and continuing: - -```bash -git checkout --theirs package-lock.json -git add package-lock.json -git merge --continue -``` - -For any other conflict, read the conflicted file and reconcile both sides manually. - -This merge adds: - -- `src/text-styles.ts` — `parseTextStyles(text, channel)` for marker substitution and - `parseSignalStyles(text)` for Signal native rich text -- `src/router.ts` — `formatOutbound` gains an optional `channel` parameter; when provided - it calls `parseTextStyles` after stripping `` tags -- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound` -- `src/formatting.test.ts` — test coverage for both functions across all channels - -### Validate - -```bash -npm install -npm run build -npx vitest run src/formatting.test.ts -``` - -All 73 tests should pass and the build should be clean before continuing. - -## Phase 3: Verify - -### Rebuild and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -### Spot-check formatting - -Send a message through any registered WhatsApp or Telegram chat that will trigger a -response from Claude. Ask something that will produce formatted output, such as: - -> Summarise the three main advantages of TypeScript using bullet points and **bold** headings. - -Confirm that the response arrives with native bold (`*text*`) rather than raw double -asterisks. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Signal Skill Integration - -If you have the Signal skill installed, `src/channels/signal.ts` can import -`parseSignalStyles` from the newly present `src/text-styles.ts`: - -```typescript -import { parseSignalStyles, SignalTextStyle } from '../text-styles.js'; -``` - -`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where -`textStyle` is an array of `{ style, start, length }` objects suitable for the -`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`). - -## Removal - -```bash -# Remove the new file -rm src/text-styles.ts - -# Revert router.ts to remove the channel param -git diff upstream/main src/router.ts # review changes -git checkout upstream/main -- src/router.ts - -# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText) -# (edit manually or: git checkout upstream/main -- src/index.ts) - -npm run build -``` \ No newline at end of file diff --git a/.claude/skills/convert-to-apple-container/SKILL.md b/.claude/skills/convert-to-apple-container/SKILL.md index caf9c22..af6f39e 100644 --- a/.claude/skills/convert-to-apple-container/SKILL.md +++ b/.claude/skills/convert-to-apple-container/SKILL.md @@ -45,7 +45,7 @@ Apple Container requires macOS. It does not work on Linux. grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts ``` -If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3. +If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 4. ## Phase 2: Apply Code Changes @@ -80,13 +80,50 @@ If the merge reports conflicts, resolve them by reading the conflicted files and ### Validate code changes ```bash -npm test -npm run build +pnpm test +pnpm run build ``` All tests must pass and build must be clean before proceeding. -## Phase 3: Verify +## Phase 3: Credential proxy network binding + +Apple Container uses a bridge network (bridge100) that only exists while containers are running. The credential proxy must start before any container, so it cannot bind to the bridge IP. It must bind to `0.0.0.0`, which exposes port 3001 on all network interfaces — anyone on your local network could route API requests through the proxy using your credentials. + +Use AskUserQuestion to ask the user: + +**"The credential proxy needs to bind to all interfaces (0.0.0.0). Is this Mac on a trusted private network?"** + +Options: +1. **Yes, private/home network** — description: "No firewall rule needed." +2. **No, shared/public network** — description: "Add a macOS firewall rule to block external access to port 3001." + +For both options, add `CREDENTIAL_PROXY_HOST=0.0.0.0` to `.env`: + +```bash +grep -q 'CREDENTIAL_PROXY_HOST' .env 2>/dev/null || echo 'CREDENTIAL_PROXY_HOST=0.0.0.0' >> .env +``` + +If they chose the public network option, set up and persist the firewall rule: + +```bash +echo "block in on en0 proto tcp to any port 3001" | sudo pfctl -ef - +``` + +```bash +grep -q 'nanoclaw proxy' /etc/pf.conf 2>/dev/null || echo '# nanoclaw proxy — block LAN access to credential proxy +block in on en0 proto tcp to any port 3001' | sudo tee -a /etc/pf.conf > /dev/null +``` + +Verify the rule is working: + +```bash +curl -sf http://$(ipconfig getifaddr en0):3001 && echo "EXPOSED — rule not working" || echo "BLOCKED — rule active" +``` + +If the verification shows "EXPOSED", warn the user and retry. If "BLOCKED", confirm success and continue. + +## Phase 4: Verify ### Ensure Apple Container runtime is running @@ -135,7 +172,7 @@ Expected: Both operations succeed. ### Full integration test ```bash -npm run build +pnpm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw ``` diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md index 614a979..c83bd26 100644 --- a/.claude/skills/customize/SKILL.md +++ b/.claude/skills/customize/SKILL.md @@ -91,7 +91,7 @@ Implementation: Always tell the user: ```bash # Rebuild and restart -npm run build +pnpm run build # macOS: launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 03c34de..128b8c3 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -41,7 +41,7 @@ Set `LOG_LEVEL=debug` for verbose output: ```bash # For development -LOG_LEVEL=debug npm run dev +LOG_LEVEL=debug pnpm run dev # For launchd service (macOS), add to plist EnvironmentVariables: LOG_LEVEL @@ -231,7 +231,7 @@ query({ ```bash # Rebuild main app -npm run build +pnpm run build # Rebuild container (use --no-cache for clean rebuild) ./container/build.sh diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md new file mode 100644 index 0000000..6b110d3 --- /dev/null +++ b/.claude/skills/init-first-agent/SKILL.md @@ -0,0 +1,120 @@ +--- +name: init-first-agent +description: Walk the operator through creating the first NanoClaw agent for a DM channel — resolve the operator's channel identity, wire the DM messaging group to a new agent, and trigger a welcome DM via the normal delivery path. Use after channel credentials are configured and the service is running. +--- + +# Init First Agent + +Stand up the first NanoClaw agent for a channel and verify end-to-end delivery by having the agent DM the operator. Everything the skill does is idempotent — rerunning is safe. + +## Prerequisites + +- **Service running.** Check: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first. +- **Target channel installed.** At least one `/add-` skill has run, credentials are in `.env`, and the adapter is uncommented in `src/channels/index.ts`. +- **Adapter connected.** Tail `logs/nanoclaw.log` — look for a recent `channel setup` / `adapter connected` line for the target channel. + +## 1. Pick the channel + +Read `src/channels/index.ts` to find enabled channels (uncommented imports). Cross-check `.env` for the relevant credentials. + +AskUserQuestion: "Which channel should host the welcome DM?" with one option per enabled channel (Discord, Slack, Telegram, WhatsApp, Webex, Teams, Google Chat, Matrix, iMessage, Resend, …). + +Record the choice as `CHANNEL` (lowercase, e.g. `discord`). + +## 2. Ask for the operator's identity + +Read the channel's own skill for its `## Channel Info > how-to-find-id` section (e.g. `.claude/skills/add-discord/SKILL.md`, `.claude/skills/add-telegram/SKILL.md`). Show those instructions to the user in plain text. + +Then ask in plain text (NOT `AskUserQuestion` — these are free-form): + +1. **Your user id on this channel** — e.g. a Discord user ID, Telegram user ID, Slack user ID. Record as `USER_HANDLE`. +2. **Your display name** — human name, used to name the agent group (`dm-with-`) and as the welcome-message addressee. Record as `DISPLAY_NAME`. +3. **Agent persona name** — the assistant's display name. Default: `DISPLAY_NAME`. Record as `AGENT_NAME`. + +## 3. Resolve the DM platform id + +This depends on whether the channel supports cold DM via `adapter.openDM`. + +**Channels without cold DM (direct-addressable): telegram, whatsapp, imessage, matrix, resend.** The user handle doubles as the DM chat id. Set: + +``` +PLATFORM_ID=${CHANNEL}:${USER_HANDLE} +``` + +Skip to step 4. + +**Channels with cold DM (resolution-required): discord, slack, teams, webex, gchat.** The bot can DM cold at runtime via Chat SDK, but this skill runs standalone — it can't call the adapter. Two resolutions: + +### 3a. User DMs the bot once (Discord / Slack / Teams / Webex / gChat) + +Tell the user: + +> Send any single message to the bot as a DM from your account on `${CHANNEL}`. The router will record the DM as a messaging group. Reply `done` here when you've sent the message. + +Wait for the user's confirmation. Then look up the most recent DM messaging groups: + +```bash +sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5" +``` + +Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues. + +### 3b. Telegram pair-code path (if the user prefers not to DM first) + +For Telegram only, there's an existing pair-code primitive. When you run this tool, take the output and extract the pairing code. Then show it to the user in plain text and ask the user to send the code in the Telegram chat to complete the pairing. + +```bash +npx tsx setup/index.ts --step pair-telegram -- --intent new-agent:dm-with- +``` + +Parse the `PAIR_TELEGRAM_ISSUED` status block for `CODE` and follow the `REMINDER_TO_ASSISTANT` line in that block. Then wait for the `PAIR_TELEGRAM` block — read `PLATFORM_ID` and `PAIRED_USER_ID` from it. telegram.ts's interceptor has already upserted the user and granted owner if none existed yet. Use `PLATFORM_ID` and `PAIRED_USER_ID` directly in step 4. + +## 4. Run the init script + +```bash +npx tsx scripts/init-first-agent.ts \ + --channel "${CHANNEL}" \ + --user-id "${CHANNEL}:${USER_HANDLE}" \ + --platform-id "${PLATFORM_ID}" \ + --display-name "${DISPLAY_NAME}" \ + --agent-name "${AGENT_NAME}" +``` + +Add `--welcome "System instruction: ..."` to override the default welcome prompt. + +The script: +1. Upserts the `users` row and grants `owner` role if no owner exists. +2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-/`. +3. Reuses or creates the DM `messaging_groups` row. +4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row). +5. Hands the welcome message to the running service via its CLI socket (`data/cli.sock`), targeting the DM messaging group. The service routes it into the DM session, which wakes the container synchronously. If the socket isn't reachable (service down), falls back to a direct `inbound.db` write that the next host sweep picks up. + +Show the script's output to the user. + +## 5. Verify + +The welcome DM is queued synchronously; the only wait is container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel. + +Do not tail the log or poll in a sleep loop. Ask the user in plain text: + +> The welcome DM should arrive shortly. Let me know when you've received it (or if it doesn't arrive within two minutes). + +Wait for the user's reply. If they confirm receipt, the skill is done. + +If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't): + +- `sqlite3 data/v2-sessions//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` with the values from the script's output. +- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes. +- `ls data/v2-sessions//sessions/*/outbound.db` — confirm the session exists. + +## Troubleshooting + +**"Missing required args"** — the script wants `--channel`, `--user-id`, `--platform-id`, `--display-name` at minimum. Re-check the command you assembled. + +**No `messaging_groups` row appears after the user DMs (step 3a)** — the router silently drops messages from unknown senders under `strict` policy but still creates the `messaging_groups` row. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit). + +**Owner already exists** — `hasAnyOwner()` returned true, so the grant is skipped silently. That's fine; the script still creates the agent and wiring. Reassigning ownership needs a separate flow (not this skill). + +**Wrong person got the welcome DM** — the `--platform-id` you passed is someone else's DM channel. Rerun with the correct one; the script is idempotent on user/messaging-group/agent-group but writes a new session welcome each run. + +**Agent group name collision** — if `dm-with-` already exists (e.g. rerunning with the same display name), the script reuses it. Pass a different `--display-name` to get a distinct folder. diff --git a/.claude/skills/init-onecli/SKILL.md b/.claude/skills/init-onecli/SKILL.md new file mode 100644 index 0000000..b3d441f --- /dev/null +++ b/.claude/skills/init-onecli/SKILL.md @@ -0,0 +1,270 @@ +--- +name: init-onecli +description: Install and initialize OneCLI Agent Vault. Migrates existing .env credentials to the vault. Use after /update-nanoclaw brings in OneCLI as a breaking change, or for first-time OneCLI setup. +--- + +# Initialize OneCLI Agent Vault + +This skill installs OneCLI, configures the Agent Vault gateway, and migrates any existing `.env` credentials into it. Run this after `/update-nanoclaw` introduces OneCLI as a breaking change, or any time OneCLI needs to be set up from scratch. + +**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. pasting a token). + +## Phase 1: Pre-flight + +### Check if OneCLI is already working + +```bash +onecli version 2>/dev/null +``` + +If the command succeeds, OneCLI is installed, check for an Anthropic secret: + +```bash +onecli secrets list +``` + +If an Anthropic secret exists, tell the user OneCLI is already configured and working. Use AskUserQuestion: + +1. **Keep current setup** — description: "OneCLI is installed and has credentials configured. Nothing to do." +2. **Reconfigure** — description: "Start fresh — reinstall OneCLI and re-register credentials." + +If they choose to keep, skip to Phase 5 (Verify). If they choose to reconfigure, continue. + +### Check for native credential proxy + +```bash +grep "credential-proxy" src/index.ts 2>/dev/null +``` + +If `startCredentialProxy` is imported, the native credential proxy skill is active. Tell the user: "You're currently using the native credential proxy (`.env`-based). This skill will switch you to OneCLI's Agent Vault, which adds per-agent policies and rate limits. Your `.env` credentials will be migrated to the vault." + +Use AskUserQuestion: +1. **Continue** — description: "Switch to OneCLI Agent Vault." +2. **Cancel** — description: "Keep the native credential proxy." + +If they cancel, stop. + +### Check the codebase expects OneCLI + +```bash +grep "@onecli-sh/sdk" package.json +``` + +If `@onecli-sh/sdk` is NOT in package.json, the codebase hasn't been updated to use OneCLI yet. Tell the user to run `/update-nanoclaw` first to get the OneCLI integration, then retry `/init-onecli`. Stop here. + +## Phase 2: Install OneCLI + +### Install the gateway and CLI + +```bash +curl -fsSL onecli.sh/install | sh +curl -fsSL onecli.sh/cli/install | sh +``` + +Verify: `onecli version` + +If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH: + +```bash +export PATH="$HOME/.local/bin:$PATH" +grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc +``` + +Re-verify with `onecli version`. + +### Configure the CLI + +Point the CLI at the local OneCLI instance, the ONECLI_URL was output from the install script above: + +```bash +onecli config set api-host ${ONECLI_URL} +``` + +### Set ONECLI_URL in .env + +```bash +grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=${ONECLI_URL}' >> .env +``` + +### Wait for gateway readiness + +The gateway may take a moment to start after installation. Poll for up to 15 seconds: + +```bash +for i in $(seq 1 15); do + curl -sf ${ONECLI_URL}/health && break + sleep 1 +done +``` + +If it never becomes healthy, check if the gateway process is running: + +```bash +ps aux | grep -i onecli | grep -v grep +``` + +If it's not running, try starting it manually: `onecli start`. If that fails, show the error and stop — the user needs to debug their OneCLI installation. + +## Phase 3: Migrate existing credentials + +### Scan .env for credentials to migrate + +Read the `.env` file and look for these credential variables: + +| .env variable | OneCLI secret type | Host pattern | +|---|---|---| +| `ANTHROPIC_API_KEY` | `anthropic` | `api.anthropic.com` | +| `CLAUDE_CODE_OAUTH_TOKEN` | `anthropic` | `api.anthropic.com` | +| `ANTHROPIC_AUTH_TOKEN` | `anthropic` | `api.anthropic.com` | + +Read `.env`: + +```bash +cat .env +``` + +Parse the file for any of the credential variables listed above. + +### If credentials found in .env + +For each credential found, migrate it to OneCLI: + +**Anthropic API key** (`ANTHROPIC_API_KEY=sk-ant-...`): +```bash +onecli secrets create --name Anthropic --type anthropic --value --host-pattern api.anthropic.com +``` + +**Claude OAuth token** (`CLAUDE_CODE_OAUTH_TOKEN=...` or `ANTHROPIC_AUTH_TOKEN=...`): +```bash +onecli secrets create --name Anthropic --type anthropic --value --host-pattern api.anthropic.com +``` + +After successful migration, remove the credential lines from `.env`. Use the Edit tool to remove only the credential variable lines (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`). Keep all other `.env` entries intact (e.g. `ONECLI_URL`, `TELEGRAM_BOT_TOKEN`, channel tokens). + +Verify the secret was registered: +```bash +onecli secrets list +``` + +Tell the user: "Migrated your Anthropic credentials from `.env` to the OneCLI Agent Vault. The raw keys have been removed from `.env` — they're now managed by OneCLI and will be injected at request time without entering containers." + +### Offer to migrate other container-facing credentials + +After handling Anthropic credentials (whether migrated or freshly registered), scan `.env` again for remaining credential variables that containers use for outbound API calls. + +**Important:** Only migrate credentials that containers use via outbound HTTPS. Channel tokens (`TELEGRAM_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `DISCORD_BOT_TOKEN`) are used by the NanoClaw host process to connect to messaging platforms — they must stay in `.env`. + +Known container-facing credentials: + +| .env variable | Secret name | Host pattern | +|---|---|---| +| `OPENAI_API_KEY` | `OpenAI` | `api.openai.com` | +| `PARALLEL_API_KEY` | `Parallel` | `api.parallel.ai` | + +If any of these are found with non-empty values, present them to the user: + +AskUserQuestion (multiSelect): "These credentials are used by container agents for outbound API calls. Moving them to the vault means agents never see the raw keys, and you can apply rate limits and policies." + +- One option per credential found (e.g., "OPENAI_API_KEY" — description: "Used by voice transcription and other OpenAI integrations inside containers") +- **Skip — keep them in .env** — description: "Leave these in .env for now. You can move them later." + +For each credential the user selects: + +```bash +onecli secrets create --name --type api_key --value --host-pattern +``` + +If there are credential variables not in the table above that look container-facing (i.e. not a channel token), ask the user: "Is `` used by agents inside containers? If so, what API host does it authenticate against? (e.g., `api.example.com`)" — then migrate accordingly. + +After migration, remove the migrated lines from `.env` using the Edit tool. Keep channel tokens and any credentials the user chose not to migrate. + +Verify all secrets were registered: +```bash +onecli secrets list +``` + +### If no credentials found in .env + +No migration needed. Proceed to register credentials fresh. + +Check if OneCLI already has an Anthropic secret: +```bash +onecli secrets list +``` + +If an Anthropic secret already exists, skip to Phase 4. + +Otherwise, register credentials using the same flow as `/setup`: + +AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**? + +1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token." +2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com." + +#### Subscription path + +Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat. + +Once they have the token, AskUserQuestion with two options: + +1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI. Use type 'anthropic' and paste your token as the value." +2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" + +#### API key path + +Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one. + +AskUserQuestion with two options: + +1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI." +2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`" + +#### After either path + +Ask them to let you know when done. + +**If the user's response happens to contain a token or key** (starts with `sk-ant-` or looks like a token): handle it gracefully — run the `onecli secrets create` command with that value on their behalf. + +**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again. + +## Phase 4: Build and restart + +```bash +pnpm run build +``` + +If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `pnpm install` first. + +Restart the service: +- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` +- Linux (systemd): `systemctl --user restart nanoclaw` +- WSL/manual: stop and re-run `bash start-nanoclaw.sh` + +## Phase 5: Verify + +Check logs for successful OneCLI integration: + +```bash +tail -30 logs/nanoclaw.log | grep -i "onecli\|gateway" +``` + +Expected: `OneCLI gateway config applied` messages when containers start. + +If the service is running and a channel is configured, tell the user to send a test message to verify the agent responds. + +Tell the user: +- OneCLI Agent Vault is now managing credentials +- Agents never see raw API keys — credentials are injected at the gateway level +- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL} +- To add rate limits or policies: `onecli rules create --help` + +## Troubleshooting + +**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed. + +**Container gets no credentials:** Verify `ONECLI_URL` is set in `.env` and the gateway has an Anthropic secret (`onecli secrets list`). + +**Old .env credentials still present:** This skill should have removed them. Double-check `.env` for `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, or `ANTHROPIC_AUTH_TOKEN` and remove them manually if still present. + +**Port 10254 already in use:** Another OneCLI instance may be running. Check with `lsof -i :10254` and kill the old process, or configure a different port. diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md new file mode 100644 index 0000000..9d84d3d --- /dev/null +++ b/.claude/skills/manage-channels/SKILL.md @@ -0,0 +1,87 @@ +--- +name: manage-channels +description: Wire channels to agent groups, manage isolation levels, add new channel groups. Use after adding a channel, during setup, or standalone to reconfigure. +--- + +# Manage Channels + +Wire messaging channels to agent groups. See `docs/isolation-model.md` for the full isolation model. + +Privilege is a **user-level** concept, not a channel-level one (see `src/db/user-roles.ts`, `src/access.ts`). There is no "main channel" / "main group" — any user can be granted `owner` or `admin` (global or scoped to an agent group) via `grantRole()`, and messages from unknown senders are gated per-messaging-group by `unknown_sender_policy` (`strict` | `request_approval` | `public`). + +## Assess Current State + +Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. + +Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**. + +If the instance has no owner yet (`SELECT COUNT(*) FROM user_roles WHERE role='owner' AND agent_group_id IS NULL` returns 0), tell the user they should run `/init-first-agent` first — it stands up the first agent group, promotes the operator to owner, and verifies delivery end-to-end by having the agent DM them. Then return here for any additional channels/groups. + +## First Channel (No Agent Groups Exist) + +**Delegate to `/init-first-agent`.** It handles: channel choice, operator identity lookup, DM platform id resolution (with cold-DM or pair-code fallback), agent group creation, wiring, and the welcome DM. Return here afterward for any additional channels. + +## Wire New Channel + +For each unwired channel: + +1. Read its SKILL.md `## Channel Info` for terminology, how-to-find-id, typical-use, and default-isolation +2. Ask for the platform ID using the platform's terminology +3. Ask the isolation question (see below) +4. Register with the appropriate flags + +### Isolation Question + +Present a multiple-choice with a contextual recommendation. The three options: + +- **Same conversation** (`--session-mode "agent-shared"` + existing folder) — all messages land in one session. Recommend for webhook + chat combos (GitHub + Slack). +- **Same agent, separate conversations** (`--session-mode "shared"` + existing folder) — shared workspace/memory, independent threads. Recommend for same user across platforms. +- **Separate agent** (new `--folder`) — full isolation. Recommend when different people are involved. + +Use the channel's `typical-use` and `default-isolation` fields to pick the recommendation. Offer to explain more if the user is unsure — reference `docs/isolation-model.md` for the detailed explanation. + +### Register Command + +```bash +pnpm exec tsx setup/index.ts --step register -- \ + --platform-id "" --name "" \ + --folder "" --channel "" \ + --session-mode "" \ + --assistant-name "" +``` + +The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name — no separate destination step needed. + +For separate agents, also ask for a folder name and optionally a different assistant name. + +## Add Channel Group + +When adding another group/chat on an already-configured platform (e.g. a second Telegram group): + +1. **Telegram:** ask the isolation question first to determine intent (`wire-to:` for an existing agent, `new-agent:` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent `, show the CODE (follow the `REMINDER_TO_ASSISTANT` line in the `PAIR_TELEGRAM_ISSUED` block) and tell the user to post `@ CODE` in the target group (or DM the bot for a private chat). Wait for the `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring: + + ```bash + pnpm exec tsx setup/index.ts --step register -- \ + --platform-id "" --name "" \ + --folder "" --channel "telegram" \ + --session-mode "" \ + --assistant-name "" + ``` + +2. **Other channels:** read the channel's SKILL.md `## Channel Info` for terminology and how-to-find-id. Ask for the new group/chat ID, ask the isolation question, then register. No package or credential changes needed. + +## Change Wiring + +1. Show current wiring (agent_groups × messaging_group_agents) +2. Ask which channel to move and to which agent group +3. Delete the old `messaging_group_agents` entry, create a new one +4. Note: existing sessions stay with the old agent group; new messages route to the new one. The `agent_destinations` row created for the old wiring is NOT automatically removed — if you want the old agent to stop seeing the channel as a named target, delete it from `agent_destinations` manually. + +## Show Configuration + +Display a readable summary showing: + +- **Agent groups** with their wired channels (from `messaging_group_agents`) +- **Configured-but-unwired** channels (credentials present, no DB entities) +- **Unconfigured** channels +- **Privileged users**: `SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC` diff --git a/.claude/skills/manage-mounts/SKILL.md b/.claude/skills/manage-mounts/SKILL.md new file mode 100644 index 0000000..ddfa28b --- /dev/null +++ b/.claude/skills/manage-mounts/SKILL.md @@ -0,0 +1,47 @@ +--- +name: manage-mounts +description: Configure which host directories agent containers can access. View, add, or remove mount allowlist entries. Triggers on "mounts", "mount allowlist", "agent access to directories", "container mounts". +--- + +# Manage Mounts + +Configure which host directories NanoClaw agent containers can access. The mount allowlist lives at `~/.config/nanoclaw/mount-allowlist.json`. + +## Show Current Config + +```bash +cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured" +``` + +Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only. + +## Add Directories + +Ask which directories the user wants agents to access. For each path: +- Validate the path exists +- Ask if it should be read-only for non-main agents (default: yes) + +Build the JSON config and write it: + +```bash +npx tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}' +``` + +Use `--force` to overwrite the existing config. + +## Remove Directories + +Read the current config, show it, ask which entry to remove, write the updated config. + +## Reset to Empty + +```bash +npx tsx setup/index.ts --step mounts --force -- --empty +``` + +## After Changes + +Restart the service so containers pick up the new config: + +- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` +- Linux: `systemctl --user restart nanoclaw` diff --git a/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md b/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md new file mode 100644 index 0000000..437c6f4 --- /dev/null +++ b/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md @@ -0,0 +1,100 @@ +# Migrating OpenClaw Cron Jobs to NanoClaw Scheduled Tasks + +This file is referenced by SKILL.md Phase 5 when cron jobs are detected. + +**Before inserting tasks:** Read `src/db.ts` and search for `scheduled_tasks` to verify the current table schema. The schema below is a reference — if columns have been added, removed, or renamed, use the current schema from the source code. + +Also verify the `createTask` function signature in `src/db.ts` — it may be simpler to call it via a script than raw SQL. + +## OpenClaw Cron Job Format + +Source: `/cron/jobs.json` (from `src/cron/types.ts`). If the file format doesn't match what's described below, read the actual file and adapt — OpenClaw may have changed the schema. + +The jobs file is `{ version: 1, jobs: CronJob[] }`. Each job has: +- `id`, `name`, `description`, `enabled`, `deleteAfterRun` +- `schedule`: `{ kind: "cron", expr: string, tz?: string }` | `{ kind: "every", everyMs: number }` | `{ kind: "at", at: string }` +- `payload`: `{ kind: "agentTurn", message: string, model?, thinking?, timeoutSeconds? }` | `{ kind: "systemEvent", text: string }` +- `sessionTarget`: `"main"` | `"isolated"` | `"current"` | `"session:"` +- `wakeMode`: `"next-heartbeat"` | `"now"` +- `delivery`: `{ mode: "none" | "announce" | "webhook", channel?, to?, threadId?, bestEffort? }` +- `failureAlert`: `{ after?: number, channel?, to?, cooldownMs? }` | `false` +- `state`: runtime state (nextRunAtMs, lastRunStatus, consecutiveErrors, etc.) + +## NanoClaw `scheduled_tasks` Table + +Source: `src/db.ts` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | TEXT PK | Unique task ID | +| `group_folder` | TEXT | Target group directory (e.g. `"main"`) | +| `chat_jid` | TEXT | Target chat JID | +| `prompt` | TEXT | Task instructions | +| `script` | TEXT | Optional bash pre-check script | +| `schedule_type` | TEXT | `"cron"`, `"interval"`, or `"once"` | +| `schedule_value` | TEXT | Cron expr, ms interval, or ISO timestamp | +| `context_mode` | TEXT | `"group"` or `"isolated"` (default) | +| `next_run` | TEXT | ISO timestamp — must be computed at insert time | +| `last_run` | TEXT | null initially | +| `last_result` | TEXT | null initially | +| `status` | TEXT | `"active"`, `"paused"`, or `"completed"` | +| `created_at` | TEXT | ISO timestamp | + +## Field Mapping + +- `schedule.kind:"cron"` + `schedule.expr` → `schedule_type:"cron"`, `schedule_value:` +- `schedule.kind:"every"` + `schedule.everyMs` → `schedule_type:"interval"`, `schedule_value:` +- `schedule.kind:"at"` + `schedule.at` → `schedule_type:"once"`, `schedule_value:` +- `payload.message` or `payload.text` → `prompt` +- `sessionTarget:"isolated"` → `context_mode:"isolated"`, `sessionTarget:"main"` or `"current"` → `context_mode:"group"` + +## What Doesn't Map + +- `delivery.mode:"webhook"` — NanoClaw has no webhook delivery. Discuss with the user: this could be implemented as a task `script` that runs `curl` to hit the webhook endpoint. +- `failureAlert` — NanoClaw has no failure alert system. Note this to the user. +- `wakeMode` — NanoClaw tasks always wake the agent immediately. +- `payload.model`, `payload.thinking`, `payload.timeoutSeconds` — NanoClaw doesn't support per-task model/thinking config. These are handled by the SDK. +- `deleteAfterRun` — NanoClaw `"once"` tasks are marked `"completed"` after running, not deleted. + +## For Each Enabled Job + +1. Show what it does: name, schedule, prompt, delivery mode +2. Explain any differences (no retry config, no webhook delivery, no failure alerts) +3. If `delivery.mode:"webhook"`: discuss with the user — a task `script` with `curl` often suffices +4. Ask if they want to keep this task + +## Inserting Tasks + +Insert directly into the SQLite database. This requires groups to be registered first (Phase 1). Use the registered group's `folder` and `chat_jid`: + +```bash +pnpm exec tsx -e " +const Database = require('better-sqlite3'); +const { CronExpressionParser } = require('cron-parser'); +const db = new Database('store/messages.db'); +// Compute next_run for cron tasks: +// const interval = CronExpressionParser.parse('', { tz: process.env.TZ || 'UTC' }); +// const nextRun = interval.next().toISOString(); +db.prepare(\`INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\`).run( + 'migrated-', + '', + '', + '', + null, + '', + '', + '', + '', + 'active', + new Date().toISOString() +); +db.close(); +" +``` + +**Computing `next_run`:** +- `cron` tasks: use `CronExpressionParser.parse(expr, { tz }).next().toISOString()` +- `interval` tasks: `new Date(Date.now() + ms).toISOString()` +- `once` tasks: `next_run` equals `schedule_value` + +If groups haven't been registered yet (database doesn't exist), save the task details to `groups/main/openclaw-migration-tasks.md` with the exact SQL payloads, and tell the user: "These tasks will be created after `/setup` registers your groups." diff --git a/.claude/skills/migrate-from-openclaw/SKILL.md b/.claude/skills/migrate-from-openclaw/SKILL.md new file mode 100644 index 0000000..46a18f9 --- /dev/null +++ b/.claude/skills/migrate-from-openclaw/SKILL.md @@ -0,0 +1,447 @@ +--- +name: migrate-from-openclaw +description: Migrate from OpenClaw to NanoClaw. Detects existing OpenClaw installation, extracts identity, channel credentials, scheduled tasks, and other config, then guides interactive migration. Triggers on "migrate from openclaw", "openclaw migration", "import from openclaw". +--- + +# Migrate from OpenClaw + +Guide the user through migrating their OpenClaw installation to NanoClaw. This is a conversation, not a batch job. Read OpenClaw state, discuss it with the user, make judgment calls together about what to bring over and how. + +**Principle:** Never silently copy data. Read it, explain it, discuss where it belongs in NanoClaw's architecture, show proposed changes before applying. Credentials must be masked when displayed (first 4 + `...` + last 4 characters). Make judgment calls about what's core vs. reference material. + +**UX:** Use `AskUserQuestion` for multiple-choice only. Use plain text for free-form input. Don't dump raw data — summarize and explain conversationally. + +## Migration State File + +Create `migration-state.md` in the project root at the start of Phase 0. Update it after each phase completes. This file is the single source of truth for the migration — if context is compacted or lost, re-read it to recover all decisions and progress. + +Before starting any phase, re-read `migration-state.md` to ensure you have current state. + +Sections to maintain (add data as each phase completes): + +- **Progress** — checkbox list of phases (Phase 0–7) +- **Discovery** — STATE_DIR, IDENTITY_NAME, channels, groups (with JID mappings), workspace files, cron job count, MCP servers +- **Decisions** — assistant_name, group_model (shared/separate/main-only), main_group (folder + jid) +- **Registered Groups** — table: folder, jid, channel, is_main +- **Settings Migrated** — timezone, anthropic_credential (masked), sender_allowlist (created/skipped) +- **Identity & Memory** — paths of files created, which CLAUDE.md was edited +- **Channel Credentials** — table: channel, status, env_var +- **Scheduled Tasks** — table: original_id, name, migrated/deferred +- **Deferred / Not Applicable** — unsupported channels, discussed customizations, OpenClaw-only features + +Keep it factual and terse — this is for machine recovery after compaction, not human reading. Delete the file at the end of Phase 7 (or offer to keep it as a record). + +## Phase 0: Discovery + +Run the discovery script to find and summarize the OpenClaw installation: + +```bash +pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/discover-openclaw.ts +``` + +If the user specifies a custom path, pass it: `--state-dir ` + +Parse the status block. Key fields: STATUS, STATE_DIR, CHANNELS, WORKSPACE_FILES, DAILY_MEMORY_FILES, SKILL_COUNT, SKILLS, CRON_JOBS, MCP_SERVERS, IDENTITY_NAME, AGENT_COUNT, AGENT_IDS. + +**Sanity-check the output:** The discovery script detects known structures but can silently miss data if OpenClaw's format has changed. Check `CONFIG_TOP_KEYS` and `CONFIG_CHANNEL_KEYS` — if you see keys the script didn't report on (e.g. a channel name not in CHANNELS, or a top-level section like `integrations` or `plugins`), read that section of the config directly with the Read tool. Also check `STATE_DIR_CONTENTS` for directories the script doesn't scan (e.g. unexpected folders alongside `workspace/`, `agents/`, `cron/`). + +**If STATUS=not_found:** Tell the user no OpenClaw installation was detected at the standard locations (`~/.openclaw`, `~/.clawdbot`). Ask if they have a custom path. If not, exit. + +**If STATUS=found:** Present a human-readable summary: + +- "I found your OpenClaw installation at ``." +- Identity: name from IDENTITY.md (if found) +- Workspace files: which of SOUL.md, USER.md, MEMORY.md, IDENTITY.md exist +- Channels: list each, note which NanoClaw supports (whatsapp, telegram, slack, discord) and which it doesn't +- Daily memory files: count (if any) +- Skills: count and names (from workspace, shared, personal, project locations) +- Cron jobs: count and names +- MCP servers: count and names +- Agents: count (relevant for Phase 1 groups discussion) + +Then explain the key architectural differences. Don't dump a table — paraphrase conversationally: + +- **Container isolation:** NanoClaw runs each agent in an isolated Linux container (Docker or Apple Container). OpenClaw runs everything in one process. This means stronger isolation but also means each group is its own sandbox. +- **Group-based memory:** In OpenClaw, all groups under one agent share the same SOUL.md, MEMORY.md, and IDENTITY.md. In NanoClaw, each group has its own filesystem and CLAUDE.md. Shared state goes in `groups/global/CLAUDE.md` (mounted read-only into all non-main containers). +- **Channel skills:** In OpenClaw, channels are configured in `openclaw.json`. In NanoClaw, channels are installed as code via skills (`/add-telegram`, `/add-whatsapp`, etc.) and configured through `.env` variables. +- **Simpler config:** NanoClaw has no config file — behavior is in the code and `CLAUDE.md` files. Credentials live in `.env` or the OneCLI vault. + +AskUserQuestion: "Ready to start migrating? I'll go through each area one at a time." +1. **Yes, let's go** — proceed to Phase 1 +2. **Tell me more** — explain more about any area they ask about +3. **Skip migration** — exit + +## Phase 1: Groups and Architecture + +**This discussion must happen before identity/memory, because the shared-vs-isolated decision determines where files go.** + +If GROUP_COUNT > 0 or AGENT_COUNT > 1, this is a critical conversation. Even with just one group, explain the model difference so the user understands what they're getting into. + +**OpenClaw model:** All groups routed to the same agent share one workspace — the same SOUL.md, MEMORY.md, IDENTITY.md, and tools. When you talk to the bot in your family chat or your work chat, it's the same agent with the same personality and memory. Only the session (conversation history) is separate per group. + +**NanoClaw model:** Each group is a completely separate agent running in its own Linux container. Separate filesystem, separate memory, separate CLAUDE.md. The bot in your family chat and your work chat are different agents that don't know about each other — unless you explicitly share state via `groups/global/CLAUDE.md`, which is mounted read-only into all non-main containers. + +Explain this conversationally. If the user only has one group, it's simple — just note the difference and move on. If they have multiple groups, discuss: + +AskUserQuestion: "In OpenClaw, your groups shared the same personality and memory. In NanoClaw, each group is a fully separate agent. How would you like to handle this?" + +1. **Shared personality (recommended if your groups had the same bot)** — "I'll put the shared personality, identity, and user context in `groups/global/CLAUDE.md`. Every group sees it. Each group can add its own customizations on top." +2. **Fully separate** — "Each group gets its own independent personality and memory. Complete isolation between groups." +3. **Just main group for now** — "Set up one group now. We can add others later." + +Remember this choice — it determines where identity and memory files go in the next phase. + +### Confirm assistant name + +Before registering groups, confirm the assistant name — it's used for trigger patterns and CLAUDE.md templates. + +IDENTITY_NAME from discovery gives the OpenClaw name. Ask the user: "Your OpenClaw assistant was named ``. Want to keep this name in NanoClaw?" If they want a different name, ask what it should be. If IDENTITY_NAME was empty, ask them to choose a name (default: "Andy"). + +The register step's `--assistant-name` flag writes `ASSISTANT_NAME` to `.env` and updates CLAUDE.md templates automatically — no manual `.env` write needed. + +### Registering groups + +The discovery script provides detected groups in the GROUPS field (format: `channel:id(name)=>nanoclaw_jid`). These are extracted from OpenClaw's session store and channel config. + +For each group the user wants to bring over, pre-register it: + +```bash +pnpm exec tsx setup/index.ts --step register -- --jid "" --name "" --folder "_" --trigger "@" --channel --assistant-name "" +``` + +Only pass `--assistant-name` on the first registration (it updates all CLAUDE.md templates globally). + +Folder naming: `_` (e.g. `whatsapp_family-chat`, `telegram_dev-team`). Ask the user to confirm each group's name and folder. + +For the first/primary group, add `--is-main --no-trigger-required`. Other groups default to requiring a trigger prefix. + +**Important:** Registration requires the database to exist. If the environment step hasn't been run yet, run it first: `pnpm exec tsx setup/index.ts --step environment`. Registration also creates the group folder under `groups/` and copies the CLAUDE.md template. + +Register groups from all channels — including channels NanoClaw doesn't yet support (signal, matrix, etc.). The registration stores the JID and metadata in the database, ready for when that channel is added later. Groups won't receive messages until their channel code is installed, but the registration, group folder, and CLAUDE.md will be ready. + +## Phase 2: Settings from Config + +Before identity/memory, extract settings from `openclaw.json` that map directly to NanoClaw setup. Read the config file with the Read tool (`/openclaw.json` or `clawdbot.json`). + +### Timezone + +Check `agents.defaults.userTimezone` in the config. If present and it's a valid IANA timezone (e.g. `America/New_York`, `Asia/Jerusalem`), write it to `.env` as `TZ=`. NanoClaw's setup step 2a reads `TZ` from `.env` (`src/config.ts:84-97`) and will skip the autodetection prompt. + +### Anthropic Credentials + +Check for Anthropic API keys or tokens in OpenClaw's auth system. OpenClaw stores credentials in `/auth-profiles.json` or `/agents/main/agent/auth-profiles.json` with this structure: + +```json +{ + "version": 1, + "profiles": { + "anthropic:default": { + "type": "api_key", // or "token" or "oauth" + "provider": "anthropic", + "key": "sk-ant-..." // for api_key type + } + } +} +``` + +Profile IDs follow `provider:identifier` format. Look for any profile where `provider` is `"anthropic"`. The credential field depends on the `type`: +- `type: "api_key"` → `key` field (or `keyRef` for SecretRef) +- `type: "token"` → `token` field (or `tokenRef` for SecretRef) +- `type: "oauth"` → `access` field (OAuth access token, may need refresh) + +Also check: +1. `/.env` — for `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` +2. Config `models.providers` — for Anthropic provider entries with `apiKey` + +If found, offer to save to `.env`. This pre-fills the NanoClaw setup credential step (step 4) so the user doesn't need to re-enter it. Use the same masking approach — show first 4 + last 4 characters, write the full value directly. + +**Important:** If the credential uses `keyRef`/`tokenRef` with `source:"exec"` or `source:"file"`, explain that it can't be auto-extracted and the user will need to enter it during setup. For `type: "oauth"` credentials with an expiry in the past, warn the user the token may need to be refreshed during setup. + +### Sender Allowlists + +Read the channel configs for access control settings. OpenClaw stores these per-channel: +- `channels..allowFrom` — array of allowed sender IDs (E.164 for WhatsApp, numeric IDs for Telegram) +- `channels..dmPolicy` — `"open"`, `"allowlist"`, `"disabled"` +- `channels..groupPolicy` — `"open"`, `"allowlist"`, `"disabled"` +- `channels..groupAllowFrom` — array of allowed group member IDs + +NanoClaw uses `~/.config/nanoclaw/sender-allowlist.json` with this format: +```json +{ + "default": { "allow": "*", "mode": "trigger" }, + "chats": { + "": { + "allow": ["sender-id-1", "sender-id-2"], + "mode": "trigger" + } + }, + "logDenied": true +} +``` + +Fields: +- `allow`: `"*"` (all senders) or `string[]` (specific sender IDs) +- `mode`: `"trigger"` (messages stored but trigger blocked for non-allowed senders) or `"drop"` (messages silently discarded before storage) +- `logDenied`: optional boolean (default `true`), logs denied messages + +If OpenClaw had allowlists configured, show the user what was set and offer to create the NanoClaw equivalent. Map: +- `dmPolicy:"allowlist"` + `allowFrom` → per-chat entry with `"allow"` array, `"mode": "trigger"` +- `groupPolicy:"allowlist"` + `groupAllowFrom` → per-group entry with `"allow"` array, `"mode": "trigger"` +- `dmPolicy:"open"` → `"allow": "*"` +- `dmPolicy:"disabled"` → per-chat entry with `"allow": []`, `"mode": "drop"` (or don't register that chat) + +Create the directory and file: +```bash +mkdir -p ~/.config/nanoclaw +``` + +Then write the JSON file. If no allowlists were configured, skip this. + +### Container Timeout + +Check `agents.defaults.timeoutSeconds` in the config. This is maximum total agent runtime (wall-clock). NanoClaw's equivalent is `CONTAINER_TIMEOUT` (env var, default 30 min), also configurable per-group via `containerConfig.timeout`. Note: NanoClaw also has a separate `IDLE_TIMEOUT` (max time without output) which resets on activity — OpenClaw has no equivalent. + +If the OpenClaw value differs significantly from 30 minutes, note it for the user. They can set `CONTAINER_TIMEOUT=` in `.env` after setup. + +## Phase 3: Identity and Memory + +This phase is fully conversational — read files directly and discuss with the user. No script needed. + +**Where files go depends on the Phase 1 (groups) decision:** +- **Shared personality:** Core identity goes in `groups/global/CLAUDE.md` (seen by all groups). Group-specific customizations go in each group's own CLAUDE.md. +- **Fully separate:** Everything goes in `groups/main/` (or each group's own folder). +- **Just main group:** Everything goes in `groups/main/`. + +### Find workspace files + +The STATE_DIR from discovery tells you where OpenClaw lives. Look for workspace files at `/workspace/`. If AGENT_COUNT > 1, also check `/agents/*/workspace/` and ask which agent to migrate. + +Use the Read tool to look at each file found. + +### IDENTITY.md + +Read `/workspace/IDENTITY.md` if it exists. It uses a key:value format (name, emoji, creature, vibe, etc.). + +The assistant name was already confirmed and written to `.env` in Phase 1. Here, focus on the rest of the identity — create an `identity.md` file with the full identity details (emoji, creature, vibe, personality traits, etc.). If shared personality was chosen in Phase 1, put it alongside `groups/global/CLAUDE.md`. Otherwise, put it in `groups/main/`. + +### SOUL.md + +Read `/workspace/SOUL.md` if it exists. Then read `groups/main/CLAUDE.md`. + +CLAUDE.md is always loaded into the agent's context — it's the agent's continuous instructions. Not everything from SOUL.md needs to be there. Discuss with the user what belongs where: + +- **In CLAUDE.md (always loaded):** Core personality traits, communication style, key behavioral rules. Weave these into the existing CLAUDE.md structure — adjust the opening description under the `# ` heading, modify the tone in the Communication section. +- **In a separate soul file:** Detailed personality backstory, extended guidelines, creative writing style, philosophical grounding — things the agent can reference when relevant but don't need to consume context tokens on every turn. + +**File placement depends on Phase 1 choice:** +- Shared personality → edit `groups/global/CLAUDE.md` for the core traits, create `groups/global/soul.md` for the extended content. All groups will see both. +- Separate / main only → edit `groups/main/CLAUDE.md`, create `groups/main/soul.md`. + +Add a reference in the relevant CLAUDE.md: "Your personality and extended behavioral guidelines are in `soul.md`. Refer to it for identity questions or when crafting responses that need your full character." + +Show proposed edits to the user before applying. This is a thoughtful merge, not a copy-paste. + +### USER.md + +Read `/workspace/USER.md` if it exists. + +Create `groups/main/user-context.md` with the user information. Add a reference in CLAUDE.md: "Information about your user is in `user-context.md`. Read it when you need context about who you're talking to." + +Ask if they want any critical user facts (name, timezone, key preferences) directly in CLAUDE.md for always-on awareness. + +### MEMORY.md + +Read `/workspace/MEMORY.md` if it exists. + +Show the contents and discuss what's worth keeping. Some memory entries may be stale or OpenClaw-specific. Create `groups/main/memories.md` for relevant items. Add a reference in CLAUDE.md. + +### Daily memory files (`workspace/memory/*.md`) + +If DAILY_MEMORY_FILES > 0 in the discovery output, OpenClaw accumulated dated memory files (e.g. `2024-01-01.md`). These contain observations, facts, and context gathered over time. + +AskUserQuestion: "You have N daily memory files from OpenClaw. How would you like to handle them?" + +1. **Copy as-is (recommended for many files)** — "I'll create a `daily-memories/` folder in your group directory and copy them over. Your agent can reference them when needed." + - Create the folder in the appropriate group directory (per Phase 1 decision) + - Copy all `.md` files: `cp -r /memory/*.md /daily-memories/` + - Add a reference in CLAUDE.md: "Historical daily memory files from your previous system are in `daily-memories/`. Refer to them when you need context about past events or observations." + +2. **Consolidate into memories** — "I'll read through them, extract the durable facts, and add them to your memories file. This reduces clutter but takes longer." + - Read each file, extract entries worth keeping (skip transient observations, focus on durable facts about the user, preferences, recurring topics) + - Consolidate into `memories.md` + - Use sub-agents for large volumes (>10 files) + +3. **Skip** — "Don't bring daily memories over." + +### OpenClaw Skills + +If SKILL_COUNT > 0 in discovery, OpenClaw had custom skills. The SKILL.md format is a shared standard — skills are directly portable. + +The discovery reports skill names and source locations. For each skill, read just the YAML front matter (name + description at the top of SKILL.md) and present a list to the user: skill name, description, source location. Let the user select which ones to bring over. + +For confirmed skills, copy the entire skill directory as-is: + +```bash +cp -r container/skills/ +``` + +After all skills are copied, a container rebuild is needed — note this for post-migration: `./container/build.sh`. + +### Config-registered plugins and skills + +If CONFIG_PLUGIN_COUNT > 0 in discovery, OpenClaw had installed plugins/skills with API keys (e.g. `plugins.entries.brave`, `skills.entries.openai-whisper-api`). These are functional tools the agent had access to. + +For each detected plugin, present the name to the user and discuss whether to set it up in NanoClaw. Read the OpenClaw config section to understand what it is, then: + +1. **If NanoClaw has a matching skill** — check the available NanoClaw skills list for an equivalent (e.g. `/add-voice-transcription` for whisper). If found, save the API key to `.env` and invoke that skill. + +2. **If the OpenClaw plugin was an MCP server** — read its config to find the exact package name and command. Install the same MCP server (e.g. `pnpm dlx `). Don't search for or guess at MCP packages — only install what was explicitly configured. + +3. **If the OpenClaw plugin was a CLI tool** — read the config to identify the exact tool. If it's an npm package, add it to the container's Dockerfile. Add a note to the group's CLAUDE.md that the tool is available and how to invoke it. + +4. **If the plugin wraps an API** — discuss with the user what it did and offer to implement the equivalent: save the API key to `.env`, write a container skill with instructions for using the API, or wire it into the message flow if it's something automatic (e.g. voice transcription). + +5. **If unclear** — discuss with the user what the plugin did and decide together. Don't install unknown packages or search for replacements — that's a supply chain risk. + +For API keys, read the config value directly (don't display raw keys) and write to `.env`. The discovery script reports which plugins have keys but never extracts them. + +### Other files (TOOLS.md, HEARTBEAT.md, BOOTSTRAP.md, AGENTS.md) + +If these exist, briefly mention them and explain: +- TOOLS.md: NanoClaw agents have their own tool discovery; this doesn't transfer +- HEARTBEAT.md: NanoClaw uses scheduled tasks instead +- BOOTSTRAP.md: NanoClaw uses CLAUDE.md and container skills instead +- AGENTS.md: Already covered in the Phase 1 groups discussion + +## Phase 4: Channel Credentials + +For each channel found in the discovery results, handle it based on NanoClaw support: + +### Supported channels (whatsapp, telegram, slack, discord) + +Run the credential extraction script with `--write-env .env` so it writes credentials directly to NanoClaw's `.env` file. The script never emits raw credential values to stdout — only masked versions. + +First, run without `--write-env` to preview: + +```bash +pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir --channel +``` + +Parse the status block. Key fields: HAS_CREDENTIAL, CREDENTIAL_MASKED, NANOCLAW_ENV_VAR. + +**If HAS_CREDENTIAL=false but the user expects a credential:** The extraction script may not recognize the config structure. Fall back to reading the channel section of `openclaw.json` directly with the Read tool and look for any field that contains a token or key value. Ask the user to confirm. + +If HAS_CREDENTIAL=true: Show the masked credential (`CREDENTIAL_MASKED`). AskUserQuestion: +1. **Use this credential** — run again with `--write-env .env` to save it +2. **Enter a new one** — ask in plain text, write to `.env` manually +3. **Skip this channel** — don't configure + +If using the credential: + +```bash +pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir --channel --write-env .env +``` + +The script writes the credential directly to `.env` using the correct NanoClaw variable name (e.g. `TELEGRAM_BOT_TOKEN`). Check the status block for `WRITTEN_TO` and `WRITTEN_COUNT` to confirm. + +**Credential destination note:** Credentials are saved to `.env` for now. During `/setup`, the credential step will either keep them in `.env` (Apple Container) or migrate them to the OneCLI vault (Docker). The user doesn't need to worry about this now. + +For Slack: there are two credentials (bot token + app token). The script handles both in one run — check `HAS_CREDENTIAL_2` and `NANOCLAW_ENV_VAR_2` in the status block. + +**WhatsApp special case:** WhatsApp uses QR/pairing-code authentication, not a token. Do not copy auth state from OpenClaw — encryption sessions become stale after copying and messages fail to decrypt. Authentication will be handled during `/setup` via the `/add-whatsapp` skill (takes about 60 seconds with a pairing code). Just note that WhatsApp was configured and move on. + +**Allowlist note:** If the channel had `allowFrom` or group policies, these were already handled in Phase 2 (sender allowlists). Mention that the allowlist file was created earlier. + +### Unsupported channels (signal, matrix, irc, msteams, feishu, etc.) + +Explain briefly: "NanoClaw doesn't have a `` integration yet, but channels are added over time via skills. Any groups from this channel were already registered in Phase 1 — they'll activate when the channel is added." + +If there are credentials (tokens, keys) for the unsupported channel, offer to save them to `.env` with a descriptive variable name (e.g. `SIGNAL_ACCOUNT`, `MATRIX_ACCESS_TOKEN`) so they're available when the channel is eventually supported. + +Don't invoke channel skills here — just prepare `.env` credentials. Channel code is installed during `/setup`. + +## Phase 5: Scheduled Tasks + +Read `/cron/jobs.json` with the Read tool. If the file doesn't exist or has no jobs, skip this phase. + +If jobs exist, read `${CLAUDE_SKILL_DIR}/MIGRATE_CRONS.md` for the full OpenClaw cron format, NanoClaw table schema, field mapping, and SQL insert template. Follow those instructions for each job. + +## Phase 6: Webhooks, MCP, and Other Config + +Read relevant sections from `/openclaw.json` directly with the Read tool. This phase is fully conversational. + +### MCP Servers + +If MCP_SERVERS was non-empty in discovery, these can be ported. Claude Code supports MCP servers natively. Read the OpenClaw config's `mcp.servers` section to get each server's details (`command`, `args`, `env`, `url`). + +MCP servers in NanoClaw are registered in the agent-runner source code. Before editing, grep for `mcpServers` in `container/agent-runner/src/` to find the current location — it's expected to be in `index.ts` in the `query()` options, but may have moved. For each OpenClaw MCP server the user wants to bring over: + +1. Read its config: command, args, env, url +2. **stdio servers** (have `command`): Add an entry to the `mcpServers` object in `container/agent-runner/src/index.ts`. The command runs inside the container, so it needs to be available there (Node.js/npx-based servers work; custom binaries would need to be added to the Dockerfile). +3. **HTTP/SSE servers** (have `url`): These work if the URL is accessible from inside the container. Add them the same way. +4. **Environment variables**: Any `env` values that reference secrets should be added to `.env` and passed through via `process.env.*` in the mcpServers entry. + +After adding all MCP servers, a container rebuild is needed: `./container/build.sh` + +Show the user each server and ask which to bring over. For servers that need custom binaries not available in the container, note them for manual setup. + +### Webhooks and Endpoints + +If the config has webhook sections (in `cron.webhook`, `cron.failureDestination`, or channel-specific webhooks): +- Explain what they were used for +- These don't map directly but NanoClaw can be customized to support them +- Discuss the use case with the user and propose a solution if it's important to them +- For simple webhook notifications: a task script with `curl` often suffices + +### Other Config + +Scan the config for notable sections and briefly mention anything that doesn't carry over: +- **Exec approvals / command allowlist:** NanoClaw uses container isolation instead — the agent runs with `--dangerously-skip-permissions` inside a sandboxed container +- **Human delay:** Not applicable in NanoClaw's container model +- **Compaction:** Handled by Claude Code SDK automatically +- **TTS:** Not built into NanoClaw +- **Model configuration:** NanoClaw uses whatever Anthropic model the credential provides access to + +Don't belabor these — just mention and move on. + +## Phase 7: Summary + +### Summary + +Print a comprehensive summary: + +**Migrated:** +- Assistant name → `.env` ASSISTANT_NAME + CLAUDE.md templates updated +- Groups → registered in database, folders created with CLAUDE.md templates +- Timezone → `.env` TZ +- Anthropic credential → `.env` (for setup to pick up) +- Sender allowlists → `~/.config/nanoclaw/sender-allowlist.json` +- Personality → CLAUDE.md (core) + `soul.md` (extended), placed per Phase 1 decision (global or per-group) +- User context → `user-context.md` +- Memories → `memories.md` + daily memory files (copied to `daily-memories/` or consolidated) +- OpenClaw skills → copied to `container/skills/` +- Channel credentials → `.env` (list which channels) +- Scheduled tasks → inserted into database or noted for post-setup +- MCP servers → registered in agent-runner + +**Noted for later:** +- Channel code installation (happens during `/setup`) +- Task creation (if deferred due to no registered group yet) +- Container rebuild needed (if skills or MCP servers were added): `./container/build.sh` + +**Not applicable:** +- Unsupported channels (list them — groups registered for future) +- OpenClaw-specific features (exec approvals, human delay, TTS, model config, session reset policies, etc.) + +**Discussed and deferred:** +- List any customizations agreed on but not yet implemented + +Remind: "Run `/setup` next to complete your NanoClaw installation. Channel credentials are already prepared in `.env`. When setup asks which channels to enable, select the ones we configured." + +## Troubleshooting + +**Config parse error:** If `openclaw.json` fails to parse, it may use JSON5 features the parser doesn't handle. Ask the user to check the file for unusual syntax. As a fallback, the agent can read the file directly and work with it manually. + +**Credential not found:** If a channel credential resolves to empty, it may use `source:"exec"` or `source:"file"` SecretRef. These can't be auto-extracted. Ask the user to provide the value directly. + +**Multi-agent complexity:** If the user had many agents with different configs, focus on the primary/default agent first. Additional agents can be set up as separate NanoClaw groups later. diff --git a/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts b/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts new file mode 100644 index 0000000..ec44dae --- /dev/null +++ b/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts @@ -0,0 +1,734 @@ +/** + * Discover an existing OpenClaw installation and emit a structured summary. + * + * Usage: pnpm exec tsx .claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts [--state-dir ] + * + * Checks (in order): --state-dir arg, $OPENCLAW_STATE_DIR, ~/.openclaw, ~/.clawdbot + * Parses openclaw.json (JSON5-tolerant), scans workspace for identity/memory files, + * checks cron jobs, MCP servers, and channel credentials. + * + * Emits a status block on stdout: + * === NANOCLAW MIGRATE: DISCOVERY === + * ... + * === END === + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// JSON5-tolerant parser (no dependency) +// --------------------------------------------------------------------------- + +function parseJson5(text: string): unknown { + // Strip single-line comments (// ...) that aren't inside strings + let cleaned = text.replace( + /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, + (match, str) => (str ? str : ''), + ); + // Strip block comments (/* ... */) + cleaned = cleaned.replace( + /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, + (match, str) => (str ? str : ''), + ); + // Strip trailing commas before } or ] + cleaned = cleaned.replace(/,\s*([}\]])/g, '$1'); + return JSON.parse(cleaned); +} + +// --------------------------------------------------------------------------- +// Status block emitter (mirrors setup/status.ts convention) +// --------------------------------------------------------------------------- + +function emitStatus(fields: Record): void { + const lines = ['=== NANOCLAW MIGRATE: DISCOVERY ===']; + for (const [key, value] of Object.entries(fields)) { + lines.push(`${key}: ${value}`); + } + lines.push('=== END ==='); + console.log(lines.join('\n')); +} + +// --------------------------------------------------------------------------- +// CLI arg parsing +// --------------------------------------------------------------------------- + +function parseArgs(): { stateDir?: string } { + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + if (args[i] === '--state-dir' && args[i + 1]) { + return { stateDir: args[i + 1] }; + } + } + return {}; +} + +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +function resolveStateDir(explicit?: string): string | null { + const home = os.homedir(); + const candidates: string[] = []; + + if (explicit) { + // Expand ~ prefix + const expanded = explicit.startsWith('~') + ? path.join(home, explicit.slice(1)) + : explicit; + candidates.push(expanded); + } + + if (process.env.OPENCLAW_STATE_DIR) { + candidates.push(process.env.OPENCLAW_STATE_DIR); + } + + candidates.push(path.join(home, '.openclaw')); + candidates.push(path.join(home, '.clawdbot')); + + for (const dir of candidates) { + if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) { + return dir; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Config loading +// --------------------------------------------------------------------------- + +function loadConfig( + stateDir: string, +): Record | null { + for (const name of ['openclaw.json', 'clawdbot.json']) { + const configPath = path.join(stateDir, name); + if (fs.existsSync(configPath)) { + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + return parseJson5(raw) as Record; + } catch { + // Try next name + } + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Channel detection +// --------------------------------------------------------------------------- + +interface ChannelInfo { + name: string; + hasCreds: boolean; +} + +const SUPPORTED_CHANNELS = new Set([ + 'whatsapp', + 'telegram', + 'slack', + 'discord', +]); + +// Fields that indicate a credential is present for each channel +const CREDENTIAL_FIELDS: Record = { + telegram: ['botToken'], + discord: ['token'], + slack: ['botToken', 'appToken'], + whatsapp: [], // Auth-state based, no token + signal: ['account'], + imessage: [], + matrix: ['homeserverUrl', 'accessToken'], + irc: ['server'], + msteams: ['appId'], + feishu: ['appId'], + googlechat: [], + mattermost: ['token', 'url'], + zalo: [], + bluebubbles: ['url'], +}; + +const ALL_KNOWN_CHANNELS = new Set([ + 'whatsapp', 'telegram', 'slack', 'discord', 'signal', + 'imessage', 'matrix', 'irc', 'msteams', 'feishu', + 'googlechat', 'mattermost', 'zalo', 'bluebubbles', +]); + +function detectChannels( + config: Record, +): ChannelInfo[] { + // Check both config.channels.* (newer) and top-level config.* (older/legacy) + const channelsSections: Record = {}; + + // Source 1: channels.* (standard location) + const nested = config.channels as Record | undefined; + if (nested) { + for (const [k, v] of Object.entries(nested)) { + if (v && typeof v === 'object') channelsSections[k] = v; + } + } + + // Source 2: top-level keys matching known channel names (legacy format) + for (const key of Object.keys(config)) { + if (ALL_KNOWN_CHANNELS.has(key) && !channelsSections[key]) { + const v = config[key]; + if (v && typeof v === 'object') channelsSections[key] = v; + } + } + + const results: ChannelInfo[] = []; + + for (const [name, section] of Object.entries(channelsSections)) { + if (!section || typeof section !== 'object') continue; + const ch = section as Record; + + // Check if any credential field is present and non-empty + const credFields = CREDENTIAL_FIELDS[name] ?? []; + let hasCreds = false; + + for (const field of credFields) { + const val = ch[field]; + if (val && (typeof val === 'string' || typeof val === 'object')) { + hasCreds = true; + break; + } + } + + // Also check accounts for multi-account setups + if (!hasCreds && ch.accounts && typeof ch.accounts === 'object') { + for (const acct of Object.values( + ch.accounts as Record, + )) { + if (!acct || typeof acct !== 'object') continue; + const a = acct as Record; + for (const field of credFields) { + if ( + a[field] && + (typeof a[field] === 'string' || typeof a[field] === 'object') + ) { + hasCreds = true; + break; + } + } + if (hasCreds) break; + } + } + + // WhatsApp: check for auth state directory instead of token + if (name === 'whatsapp' && !hasCreds) { + // Will be checked separately via agents directory + hasCreds = false; + } + + results.push({ name, hasCreds }); + } + + return results; +} + +// --------------------------------------------------------------------------- +// Workspace scanning +// --------------------------------------------------------------------------- + +const WORKSPACE_FILES = [ + 'SOUL.md', + 'USER.md', + 'MEMORY.md', + 'IDENTITY.md', + 'TOOLS.md', + 'HEARTBEAT.md', + 'BOOTSTRAP.md', + 'AGENTS.md', +]; + +function findWorkspace(stateDir: string, config: Record | null): { + dir: string | null; + files: string[]; +} { + // Check config-specified workspace path first (agent.workspace or agents.defaults.workspace) + const configPaths: string[] = []; + if (config) { + const agentWs = (config.agent as Record | undefined)?.workspace as string | undefined; + if (agentWs) configPaths.push(agentWs.startsWith('~') ? path.join(os.homedir(), agentWs.slice(1)) : agentWs); + const defaultsWs = ((config.agents as Record | undefined)?.defaults as Record | undefined)?.workspace as string | undefined; + if (defaultsWs) configPaths.push(defaultsWs.startsWith('~') ? path.join(os.homedir(), defaultsWs.slice(1)) : defaultsWs); + } + + // Check config-specified paths, then default locations + const candidates = [ + ...configPaths, + ...['workspace', 'workspace.default'].map((n) => path.join(stateDir, n)), + ]; + + for (const ws of candidates) { + if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) { + const found = WORKSPACE_FILES.filter((f) => + fs.existsSync(path.join(ws, f)), + ); + if (found.length > 0) { + return { dir: ws, files: found }; + } + } + } + + // Check agent-specific workspaces + const agentsDir = path.join(stateDir, 'agents'); + if (fs.existsSync(agentsDir)) { + for (const agentId of fs.readdirSync(agentsDir)) { + for (const wsName of ['workspace', 'workspace.default']) { + const ws = path.join(agentsDir, agentId, wsName); + if (fs.existsSync(ws) && fs.statSync(ws).isDirectory()) { + const found = WORKSPACE_FILES.filter((f) => + fs.existsSync(path.join(ws, f)), + ); + if (found.length > 0) { + return { dir: ws, files: found }; + } + } + } + } + } + + return { dir: null, files: [] }; +} + +// --------------------------------------------------------------------------- +// Daily memory file detection +// --------------------------------------------------------------------------- + +function countDailyMemoryFiles(workspaceDir: string | null): number { + if (!workspaceDir) return 0; + const memoryDir = path.join(workspaceDir, 'memory'); + if (!fs.existsSync(memoryDir) || !fs.statSync(memoryDir).isDirectory()) { + return 0; + } + try { + return fs + .readdirSync(memoryDir) + .filter((f) => f.endsWith('.md')) + .length; + } catch { + return 0; + } +} + +// --------------------------------------------------------------------------- +// Skills detection +// --------------------------------------------------------------------------- + +interface SkillInfo { + name: string; + source: string; // 'workspace' | 'shared' | 'personal' | 'project' + path: string; +} + +function detectSkills( + stateDir: string, + workspaceDir: string | null, +): SkillInfo[] { + const skills: SkillInfo[] = []; + const seen = new Set(); + + const scanDir = (dir: string, source: string) => { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return; + try { + for (const entry of fs.readdirSync(dir)) { + const skillDir = path.join(dir, entry); + if (!fs.statSync(skillDir).isDirectory()) continue; + // A directory is a skill if it contains SKILL.md + if (fs.existsSync(path.join(skillDir, 'SKILL.md'))) { + if (seen.has(entry)) continue; + seen.add(entry); + skills.push({ name: entry, source, path: skillDir }); + } + } + } catch { + // ignore read errors + } + }; + + // 1. Workspace skills + if (workspaceDir) { + scanDir(path.join(workspaceDir, 'skills'), 'workspace'); + // 4. Project-level shared skills + scanDir(path.join(workspaceDir, '.agents', 'skills'), 'project'); + } + + // 2. Managed/shared skills + scanDir(path.join(stateDir, 'skills'), 'shared'); + + // 3. Personal cross-project skills + const personalSkills = path.join(os.homedir(), '.agents', 'skills'); + scanDir(personalSkills, 'personal'); + + return skills; +} + +// --------------------------------------------------------------------------- +// Identity extraction +// --------------------------------------------------------------------------- + +function extractIdentityName(stateDir: string, workspaceDir: string | null): string { + if (!workspaceDir) return ''; + + const identityPath = path.join(workspaceDir, 'IDENTITY.md'); + if (!fs.existsSync(identityPath)) return ''; + + try { + const content = fs.readFileSync(identityPath, 'utf-8'); + // IDENTITY.md uses key:value format, e.g. "name: Claw" + const match = content.match(/^name:\s*(.+)/im); + return match ? match[1].trim() : ''; + } catch { + return ''; + } +} + +// --------------------------------------------------------------------------- +// Agent detection +// --------------------------------------------------------------------------- + +function detectAgents(stateDir: string): string[] { + const agentsDir = path.join(stateDir, 'agents'); + if (!fs.existsSync(agentsDir)) return []; + + try { + return fs + .readdirSync(agentsDir) + .filter((f) => { + const p = path.join(agentsDir, f); + return fs.statSync(p).isDirectory() && !f.startsWith('.'); + }); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Group detection — from session store and channel config +// --------------------------------------------------------------------------- + +interface GroupInfo { + channel: string; + id: string; // Platform-specific ID (WhatsApp JID, Telegram chat ID, etc.) + name: string; + source: 'session' | 'config'; +} + +/** + * Map OpenClaw session key channel:kind:id to NanoClaw JID format. + * OpenClaw keys: "whatsapp:group:120...@g.us", "telegram:group:-10012345" + * NanoClaw JIDs: "120...@g.us", "tg:-10012345", "dc:12345", "slack:C12345" + */ +function toNanoClawJid(channel: string, id: string): string { + switch (channel) { + case 'whatsapp': + return id; // Already in JID format (120...@g.us) + case 'telegram': + return `tg:${id}`; + case 'discord': + return `dc:${id}`; + case 'slack': + return `slack:${id}`; + default: + return `${channel}:${id}`; + } +} + +function detectGroups( + stateDir: string, + config: Record | null, + agents: string[], +): GroupInfo[] { + const groups: GroupInfo[] = []; + const seen = new Set(); + + // Source 1: Session store — scan for group session keys + for (const agentId of agents) { + const sessionsPath = path.join( + stateDir, + 'agents', + agentId, + 'sessions', + 'sessions.json', + ); + if (!fs.existsSync(sessionsPath)) continue; + + try { + const raw = fs.readFileSync(sessionsPath, 'utf-8'); + const data = JSON.parse(raw) as Record; + + // Sessions can be stored as an object with session keys, or as + // { sessions: { key: entry } } or { entries: [...] } + const entries = + (data.sessions as Record) ?? + (data.entries as Record) ?? + data; + + for (const [key, value] of Object.entries(entries)) { + // Match session keys like "whatsapp:group:120...@g.us" + // or prefixed "agent:main:whatsapp:group:120...@g.us" + // Also match DM sessions: "whatsapp:dm:number@s.whatsapp.net" + const match = key.match(/(\w+):(group|dm|channel):(.+)$/i); + if (!match) continue; + + const [, channel, kind, id] = match; + // Skip DM sessions for group detection — they're individual chats + if (kind === 'dm') continue; + const dedupKey = `${channel}:${id}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + + // Try to extract display name from session entry + let name = ''; + if (value && typeof value === 'object') { + const entry = value as Record; + name = + (entry.displayName as string) ?? + (entry.label as string) ?? + (entry.subject as string) ?? + ''; + } + + groups.push({ + channel, + id, + name: name || id, + source: 'session', + }); + } + } catch { + // Ignore parse errors + } + } + + // Source 2: Channel config — groups explicitly configured + if (config) { + const channels = + (config.channels as Record | undefined) ?? {}; + for (const [channelName, channelSection] of Object.entries(channels)) { + if (!channelSection || typeof channelSection !== 'object') continue; + const ch = channelSection as Record; + + // WhatsApp/Telegram: channels..groups. + const configGroups = ch.groups as Record | undefined; + if (configGroups) { + for (const groupId of Object.keys(configGroups)) { + const dedupKey = `${channelName}:${groupId}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + groups.push({ + channel: channelName, + id: groupId, + name: groupId, + source: 'config', + }); + } + } + + // Discord: channels.discord.guilds. + if (channelName === 'discord') { + const guilds = ch.guilds as Record | undefined; + if (guilds) { + for (const guildId of Object.keys(guilds)) { + const dedupKey = `discord:${guildId}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + groups.push({ + channel: 'discord', + id: guildId, + name: guildId, + source: 'config', + }); + } + } + } + } + } + + return groups; +} + +// --------------------------------------------------------------------------- +// Cron job counting +// --------------------------------------------------------------------------- + +function countCronJobs(stateDir: string): { + count: number; + summaries: string[]; +} { + const jobsPath = path.join(stateDir, 'cron', 'jobs.json'); + if (!fs.existsSync(jobsPath)) return { count: 0, summaries: [] }; + + try { + const raw = fs.readFileSync(jobsPath, 'utf-8'); + const data = JSON.parse(raw) as { + jobs?: Array<{ name?: string; enabled?: boolean }>; + }; + const jobs = data.jobs ?? []; + const summaries = jobs + .filter((j) => j.enabled !== false) + .map((j) => j.name || 'unnamed') + .slice(0, 10); + return { count: jobs.length, summaries }; + } catch { + return { count: 0, summaries: [] }; + } +} + +// --------------------------------------------------------------------------- +// Config-registered plugins and skills (with API keys) +// --------------------------------------------------------------------------- + +interface ConfigPlugin { + name: string; + source: 'skills.entries' | 'plugins.entries'; + hasApiKey: boolean; +} + +function detectConfigPlugins( + config: Record, +): ConfigPlugin[] { + const results: ConfigPlugin[] = []; + + // Check skills.entries (e.g. openai-whisper-api with apiKey) + const skills = config.skills as Record | undefined; + const skillEntries = skills?.entries as Record | undefined; + if (skillEntries) { + for (const [name, entry] of Object.entries(skillEntries)) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + const hasKey = !!(e.apiKey || e.token || e.key); + results.push({ name, source: 'skills.entries', hasApiKey: hasKey }); + } + } + + // Check plugins.entries (e.g. brave with config.webSearch.apiKey) + const plugins = config.plugins as Record | undefined; + const pluginEntries = plugins?.entries as Record | undefined; + if (pluginEntries) { + for (const [name, entry] of Object.entries(pluginEntries)) { + if (!entry || typeof entry !== 'object') continue; + // Deep-search for apiKey in nested config + const hasKey = JSON.stringify(entry).includes('apiKey'); + results.push({ name, source: 'plugins.entries', hasApiKey: hasKey }); + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// MCP server detection +// --------------------------------------------------------------------------- + +function detectMcpServers( + config: Record, +): string[] { + const mcp = config.mcp as Record | undefined; + if (!mcp) return []; + const servers = mcp.servers as Record | undefined; + if (!servers) return []; + return Object.keys(servers); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const { stateDir: explicitDir } = parseArgs(); + const stateDir = resolveStateDir(explicitDir); + + if (!stateDir) { + emitStatus({ STATUS: 'not_found' }); + return; + } + + const config = loadConfig(stateDir); + const channels = config ? detectChannels(config) : []; + const { dir: workspaceDir, files: workspaceFiles } = + findWorkspace(stateDir, config); + const identityName = extractIdentityName(stateDir, workspaceDir); + const agents = detectAgents(stateDir); + const groups = detectGroups(stateDir, config, agents); + const { count: cronCount, summaries: cronSummaries } = + countCronJobs(stateDir); + const mcpServers = config ? detectMcpServers(config) : []; + const dailyMemoryFiles = countDailyMemoryFiles(workspaceDir); + const skills = detectSkills(stateDir, workspaceDir); + const configPlugins = config ? detectConfigPlugins(config) : []; + + // Format channels as "name(has_creds)" or "name(no_creds)" + const channelList = channels + .map((c) => `${c.name}(${c.hasCreds ? 'has_creds' : 'no_creds'})`) + .join(','); + + // Separate supported vs unsupported + const unsupported = channels + .filter((c) => !SUPPORTED_CHANNELS.has(c.name)) + .map((c) => c.name) + .join(','); + + // Format groups as "channel:id(name)" — also include NanoClaw JID mapping + const groupList = groups + .map( + (g) => + `${g.channel}:${g.id}(${g.name})=>${toNanoClawJid(g.channel, g.id)}`, + ) + .join('|'); + + // Format skills as "name(source)" list + const skillList = skills + .map((s) => `${s.name}(${s.source})`) + .join(','); + + // Dump raw top-level config keys so Claude can see what exists + // beyond what this script specifically detects + const configTopKeys = config ? Object.keys(config).sort().join(',') : 'none'; + const configChannelKeys = config?.channels + ? Object.keys(config.channels as Record).sort().join(',') + : 'none'; + + // List files/dirs at the state dir root for manual inspection + let stateDirContents = 'unknown'; + try { + stateDirContents = fs + .readdirSync(stateDir) + .filter((f) => !f.startsWith('.')) + .sort() + .join(','); + } catch { + // ignore + } + + emitStatus({ + STATUS: 'found', + STATE_DIR: stateDir, + CONFIG_FOUND: config !== null, + CONFIG_TOP_KEYS: configTopKeys, + CONFIG_CHANNEL_KEYS: configChannelKeys, + STATE_DIR_CONTENTS: stateDirContents, + CHANNELS: channelList || 'none', + UNSUPPORTED_CHANNELS: unsupported || 'none', + WORKSPACE_DIR: workspaceDir || 'not_found', + WORKSPACE_FILES: workspaceFiles.join(',') || 'none', + IDENTITY_NAME: identityName || 'unknown', + AGENT_COUNT: agents.length, + AGENT_IDS: agents.join(',') || 'none', + GROUPS: groupList || 'none', + GROUP_COUNT: groups.length, + DAILY_MEMORY_FILES: dailyMemoryFiles, + SKILL_COUNT: skills.length, + SKILLS: skillList || 'none', + CONFIG_PLUGINS: configPlugins.map((p) => `${p.name}(${p.source}${p.hasApiKey ? ',has_key' : ''})`).join(',') || 'none', + CONFIG_PLUGIN_COUNT: configPlugins.length, + CRON_JOBS: cronCount, + CRON_SUMMARIES: cronSummaries.join('|') || 'none', + MCP_SERVERS: mcpServers.join(',') || 'none', + }); +} + +main(); diff --git a/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts b/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts new file mode 100644 index 0000000..b1de03f --- /dev/null +++ b/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts @@ -0,0 +1,476 @@ +/** + * Extract a channel credential from an OpenClaw configuration and write it + * directly to the NanoClaw .env file. + * + * Usage: pnpm exec tsx .claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts \ + * --channel telegram --state-dir ~/.openclaw --write-env .env + * + * Handles OpenClaw SecretRef formats: + * - Plain string: "bot-token-value" + * - Env template: "${TELEGRAM_BOT_TOKEN}" + * - SecretRef object: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" } + * + * Also reads /.env for env-based secrets. + * + * Credential values are NEVER emitted to stdout — only masked versions. + * When --write-env is provided, the script writes credentials directly to + * the target .env file so the agent never sees raw secrets. + * + * Emits a status block on stdout: + * === NANOCLAW MIGRATE: CREDENTIAL === + * ... + * === END === + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// JSON5-tolerant parser (same as discover script) +// --------------------------------------------------------------------------- + +function parseJson5(text: string): unknown { + let cleaned = text.replace( + /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, + (match, str) => (str ? str : ''), + ); + cleaned = cleaned.replace( + /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, + (match, str) => (str ? str : ''), + ); + cleaned = cleaned.replace(/,\s*([}\]])/g, '$1'); + return JSON.parse(cleaned); +} + +// --------------------------------------------------------------------------- +// Inline dotenv parser (reads key=value, skips comments) +// --------------------------------------------------------------------------- + +function parseDotenv(filePath: string): Record { + const env: Record = {}; + if (!fs.existsSync(filePath)) return env; + + const lines = fs.readFileSync(filePath, 'utf-8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + // Strip surrounding quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +// --------------------------------------------------------------------------- +// Status block emitter +// --------------------------------------------------------------------------- + +function emitStatus(fields: Record): void { + const lines = ['=== NANOCLAW MIGRATE: CREDENTIAL ===']; + for (const [key, value] of Object.entries(fields)) { + lines.push(`${key}: ${value}`); + } + lines.push('=== END ==='); + console.log(lines.join('\n')); +} + +// --------------------------------------------------------------------------- +// Credential masking +// --------------------------------------------------------------------------- + +function maskCredential(value: string): string { + if (value.length < 10) return '****'; + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +// --------------------------------------------------------------------------- +// SecretRef resolution +// --------------------------------------------------------------------------- + +interface SecretRef { + source: string; + provider?: string; + id: string; +} + +function resolveSecretInput( + value: unknown, + dotenvVars: Record, +): { resolved: string | null; source: string; note?: string } { + if (!value) return { resolved: null, source: 'missing' }; + + // Plain string + if (typeof value === 'string') { + // Check for env template: "${VAR_NAME}" + const envMatch = value.match(/^\$\{([^}]+)\}$/); + if (envMatch) { + const envKey = envMatch[1]; + const envVal = + dotenvVars[envKey] ?? process.env[envKey] ?? null; + if (envVal) { + return { resolved: envVal, source: 'env_template' }; + } + return { + resolved: null, + source: 'env_template', + note: `Environment variable ${envKey} not found`, + }; + } + + // Plain literal value + return { resolved: value, source: 'plain' }; + } + + // SecretRef object + if (typeof value === 'object' && value !== null) { + const ref = value as SecretRef; + if (ref.source === 'env') { + const envVal = + dotenvVars[ref.id] ?? process.env[ref.id] ?? null; + if (envVal) { + return { resolved: envVal, source: 'env_ref' }; + } + return { + resolved: null, + source: 'env_ref', + note: `Environment variable ${ref.id} not found`, + }; + } + if (ref.source === 'file') { + return { + resolved: null, + source: 'file_ref', + note: `File-based secret (${ref.id}) — cannot auto-extract, add manually`, + }; + } + if (ref.source === 'exec') { + return { + resolved: null, + source: 'exec_ref', + note: `Exec-based secret (${ref.id}) — cannot auto-extract, add manually`, + }; + } + } + + return { resolved: null, source: 'unknown' }; +} + +// --------------------------------------------------------------------------- +// Channel credential mapping +// --------------------------------------------------------------------------- + +interface ChannelCredentialSpec { + // Fields to look for in the channel config + fields: string[]; + // Corresponding NanoClaw env var names + envVars: string[]; +} + +const CHANNEL_SPECS: Record = { + telegram: { + fields: ['botToken'], + envVars: ['TELEGRAM_BOT_TOKEN'], + }, + discord: { + fields: ['token'], + envVars: ['DISCORD_BOT_TOKEN'], + }, + slack: { + fields: ['botToken', 'appToken'], + envVars: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'], + }, + whatsapp: { + fields: [], // Auth-state based, no token field + envVars: [], + }, +}; + +// --------------------------------------------------------------------------- +// CLI arg parsing +// --------------------------------------------------------------------------- + +function parseArgs(): { channel: string; stateDir: string; writeEnv: string } { + const args = process.argv.slice(2); + let channel = ''; + let stateDir = ''; + let writeEnv = ''; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--channel' && args[i + 1]) { + channel = args[++i].toLowerCase(); + } + if (args[i] === '--state-dir' && args[i + 1]) { + stateDir = args[++i]; + } + if (args[i] === '--write-env' && args[i + 1]) { + writeEnv = args[++i]; + } + } + + if (!channel) { + console.error('Usage: --channel --state-dir [--write-env ]'); + process.exit(1); + } + + // Expand ~ prefix + if (stateDir.startsWith('~')) { + stateDir = path.join(os.homedir(), stateDir.slice(1)); + } + + // Default state dir + if (!stateDir) { + const home = os.homedir(); + if (fs.existsSync(path.join(home, '.openclaw'))) { + stateDir = path.join(home, '.openclaw'); + } else if (fs.existsSync(path.join(home, '.clawdbot'))) { + stateDir = path.join(home, '.clawdbot'); + } else { + console.error( + 'No OpenClaw directory found. Use --state-dir to specify.', + ); + process.exit(1); + } + } + + return { channel, stateDir, writeEnv }; +} + +// --------------------------------------------------------------------------- +// .env writer — appends or replaces a KEY=VALUE line +// --------------------------------------------------------------------------- + +function writeEnvVar(envPath: string, key: string, value: string): void { + let content = ''; + if (fs.existsSync(envPath)) { + content = fs.readFileSync(envPath, 'utf-8'); + } + + const pattern = new RegExp(`^${key}=.*$`, 'm'); + const line = `${key}="${value}"`; + + if (pattern.test(content)) { + content = content.replace(pattern, line); + } else { + content = content.trimEnd() + (content ? '\n' : '') + line + '\n'; + } + + fs.writeFileSync(envPath, content); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const { channel, stateDir, writeEnv } = parseArgs(); + const spec = CHANNEL_SPECS[channel]; + + // Load dotenv from state dir + const dotenvVars = parseDotenv(path.join(stateDir, '.env')); + + // Also check auth-profiles.json for API keys + const authProfilesPath = path.join(stateDir, 'auth-profiles.json'); + let authProfiles: Record = {}; + if (fs.existsSync(authProfilesPath)) { + try { + authProfiles = JSON.parse( + fs.readFileSync(authProfilesPath, 'utf-8'), + ) as Record; + } catch { + // Ignore parse errors + } + } + + // WhatsApp special case: no token, auth-state based. + // OpenClaw stores Baileys auth at /credentials/whatsapp// + // using useMultiFileAuthState (same as NanoClaw). The files are directly compatible. + if (channel === 'whatsapp') { + const authPaths = [ + path.join(stateDir, 'credentials', 'whatsapp', 'default'), + path.join(stateDir, 'credentials', 'whatsapp'), + path.join(stateDir, 'wa-auth'), + ]; + + // Also scan credentials/whatsapp/ for any account subdirectory + const waCredsDir = path.join(stateDir, 'credentials', 'whatsapp'); + if (fs.existsSync(waCredsDir)) { + try { + for (const entry of fs.readdirSync(waCredsDir)) { + const candidate = path.join(waCredsDir, entry); + if (fs.statSync(candidate).isDirectory()) { + authPaths.push(candidate); + } + } + } catch { + // ignore + } + } + let authStatePath = ''; + for (const p of authPaths) { + // Look for creds.json inside the directory — that confirms valid Baileys auth state + if (fs.existsSync(path.join(p, 'creds.json'))) { + authStatePath = p; + break; + } + } + + emitStatus({ + CHANNEL: 'whatsapp', + HAS_CREDENTIAL: false, + CREDENTIAL_SOURCE: 'auth_state', + NOTE: authStatePath + ? `Baileys auth state found at ${authStatePath}. May not be portable across versions — recommend re-authenticating.` + : 'No WhatsApp auth state found. Will need to authenticate during setup.', + AUTH_STATE_PATH: authStatePath || 'not_found', + }); + return; + } + + // Unknown channel + if (!spec) { + emitStatus({ + CHANNEL: channel, + HAS_CREDENTIAL: false, + NOTE: `Channel "${channel}" is not supported by NanoClaw. Supported: telegram, discord, slack, whatsapp.`, + }); + return; + } + + // Load OpenClaw config + let config: Record | null = null; + for (const name of ['openclaw.json', 'clawdbot.json']) { + const configPath = path.join(stateDir, name); + if (fs.existsSync(configPath)) { + try { + config = parseJson5( + fs.readFileSync(configPath, 'utf-8'), + ) as Record; + break; + } catch { + // Try next + } + } + } + + if (!config) { + emitStatus({ + CHANNEL: channel, + HAS_CREDENTIAL: false, + NOTE: 'Could not load openclaw.json', + }); + return; + } + + const channels = + (config.channels as Record | undefined) ?? {}; + const channelConfig = + (channels[channel] as Record | undefined) ?? {}; + + // Try to resolve each credential field + const results: Array<{ + envVar: string; + resolved: string | null; + masked: string; + source: string; + note?: string; + }> = []; + + for (let i = 0; i < spec.fields.length; i++) { + const field = spec.fields[i]; + const envVar = spec.envVars[i]; + + // Check top-level channel config first + let rawValue = channelConfig[field]; + + // If not found, check first account + if (!rawValue && channelConfig.accounts) { + const accounts = channelConfig.accounts as Record; + const firstAccount = Object.values(accounts)[0] as + | Record + | undefined; + if (firstAccount) { + rawValue = firstAccount[field]; + } + } + + const { resolved, source, note } = resolveSecretInput( + rawValue, + dotenvVars, + ); + results.push({ + envVar, + resolved, + masked: resolved ? maskCredential(resolved) : '', + source, + note, + }); + } + + // Emit results for the primary credential + const primary = results[0]; + if (!primary) { + emitStatus({ + CHANNEL: channel, + HAS_CREDENTIAL: false, + NOTE: `No credential fields defined for ${channel}`, + }); + return; + } + + // If --write-env is set and credentials were resolved, write directly to .env. + // Credential values never appear in stdout. + let written = 0; + if (writeEnv) { + for (const r of results) { + if (r.resolved) { + writeEnvVar(writeEnv, r.envVar, r.resolved); + written++; + } + } + } + + const fields: Record = { + CHANNEL: channel, + HAS_CREDENTIAL: !!primary.resolved, + CREDENTIAL_SOURCE: primary.source, + CREDENTIAL_MASKED: primary.masked || 'none', + NANOCLAW_ENV_VAR: primary.envVar, + }; + + if (writeEnv && written > 0) { + fields.WRITTEN_TO = writeEnv; + fields.WRITTEN_COUNT = written; + } + if (primary.note) { + fields.NOTE = primary.note; + } + + // Additional credentials (e.g. Slack has botToken + appToken) + if (results.length > 1) { + for (let i = 1; i < results.length; i++) { + const extra = results[i]; + const suffix = `_${i + 1}`; + fields[`HAS_CREDENTIAL${suffix}`] = !!extra.resolved; + fields[`CREDENTIAL_SOURCE${suffix}`] = extra.source; + fields[`CREDENTIAL_MASKED${suffix}`] = extra.masked || 'none'; + fields[`NANOCLAW_ENV_VAR${suffix}`] = extra.envVar; + if (extra.note) { + fields[`NOTE${suffix}`] = extra.note; + } + } + } + + emitStatus(fields); +} + +main(); diff --git a/.claude/skills/migrate-from-v1/SKILL.md b/.claude/skills/migrate-from-v1/SKILL.md new file mode 100644 index 0000000..e36a005 --- /dev/null +++ b/.claude/skills/migrate-from-v1/SKILL.md @@ -0,0 +1,232 @@ +--- +name: migrate-from-v1 +description: Finish migrating a NanoClaw v1 install into v2. Run after `bash migrate-v2.sh` completes. Seeds the owner, cleans up CLAUDE.local.md files, reconciles container configs, and helps port custom v1 code. Triggers on "migrate from v1", "finish migration", "v1 migration". +--- + +# Finish v1 → v2 migration + +`bash migrate-v2.sh` already ran the deterministic migration. It handled: + +- .env keys merged +- v2 DB seeded (agent_groups, messaging_groups, wiring) +- Group folders copied (v1 CLAUDE.md → v2 CLAUDE.local.md) +- Session data copied with conversation continuity (incl. Claude Code memory + JSONL transcripts) +- Scheduled tasks ported +- Channel code installed and auth state copied (incl. WhatsApp Baileys keystore) +- WhatsApp LIDs resolved from `store/auth` and aliased into `messaging_groups` +- Container skills copied +- Container image built + +Your job is the parts that need human judgment: triage any failed steps, seed the owner, clean up CLAUDE.local.md files, reconcile configs, and port any fork customizations. + +Read `logs/setup-migration/handoff.json` first — it has `overall_status`, per-step results in `steps`, and a `followups` list. + +## Preflight: was the script run? + +Before anything else, check that `logs/setup-migration/handoff.json` exists. If it doesn't, the user is invoking this skill before `migrate-v2.sh` ran. Stop and tell them, verbatim: + +> This skill finishes a migration that `migrate-v2.sh` started. Run that first, in your terminal — not from inside Claude: +> +> ```bash +> bash migrate-v2.sh +> ``` +> +> It needs interactive prompts (channel selection, service switchover) and runs Node/pnpm bootstrap, Docker, OneCLI setup, and a container build that don't fit inside a Claude session. When it finishes, it'll hand control back to Claude automatically — at which point this skill picks up. + +Do not attempt to run the script yourself, simulate its effects, or pick up the migration mid-stream. The deterministic side has dependencies on a real interactive shell. + +Once `handoff.json` exists, proceed to Phase 0. + +## Phase 0: Get v2 routing real messages + +Before any deeper migration work, prove v2 actually answers messages on the user's real channels. v1 is paused, not touched — flipping back is a service restart. + +### 0a — Fix blockers only + +Walk `handoff.steps`. Fix only the failures that would stop the bot from routing one message; defer the rest to its later phase. + +### 0b — Smoke test, then continue + +Tell the user the switch is non-destructive (v1 is paused, not modified; reverting is one command). Help them stop v1's service unit and start v2's, tail the host log for a clean boot, and have them send a real test message. Use `AskUserQuestion` to confirm the bot responded. + +If yes, continue to Phase 1. If no, diagnose from `logs/nanoclaw.log` and re-test — don't proceed to deeper work on a broken router. + +### Deferred failures + +Re-visit anything you skipped in 0a before declaring the migration done. Most surface naturally in later phases (`1c-groups` ↔ Phase 2, `1e-tasks` ↔ task verification). + +## Phase 1: Owner and access + +v2 auto-creates a `users` row for every sender it sees (via `extractAndUpsertUser` in `src/modules/permissions/index.ts`). By the time this skill runs, the owner's row likely already exists — it just needs the `owner` role granted. + +**User ID format**: always `:`. Each channel populates this differently: +- **Telegram**: `telegram:` (e.g. `telegram:6037840640`) +- **Discord**: `discord:` (e.g. `discord:123456789012345678`) +- **WhatsApp**: `whatsapp:@s.whatsapp.net` (e.g. `whatsapp:14155551234@s.whatsapp.net`) +- **Slack**: `slack:` (e.g. `slack:U04ABCDEF`) +- **Others**: `:` + +**Steps:** + +1. Query `users` table: `SELECT id, kind, display_name FROM users`. +2. If exactly one user exists, confirm: `AskUserQuestion`: "Is `` (``) you?" — Yes / No, let me type it. +3. If multiple users exist, present them as options in `AskUserQuestion`. +4. If no users exist yet (service hasn't received a message), ask the user to send a test message first, then re-query. +5. Once confirmed, check `user_roles` — if the owner role already exists, skip. Otherwise insert: + ```sql + INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES ('', 'owner', NULL, NULL, datetime('now')) + ``` + +Use the DB helpers in `src/db/user-roles.ts` — they keep indexes correct. Init the DB first: + +```ts +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; +import { DATA_DIR } from '../src/config.js'; +import path from 'path'; +const db = initDb(path.join(DATA_DIR, 'v2.db')); +runMigrations(db); +``` + +### Access policy + +After seeding the owner, discuss the access policy. v2's `messaging_groups.unknown_sender_policy` controls who can interact with the bot. `migrate-v2.sh` set it to `public` so the bot would respond during the switchover test, but the user may want to tighten it. + +Present the options via `AskUserQuestion`: + +1. **Public** (current) — anyone can message the bot. Good for personal DM bots. +2. **Known users only** — only users in `agent_group_members` can trigger the bot. Others are silently dropped. +3. **Approval required** — unknown senders trigger an approval request to the owner. Good for group chats where you want to vet new members. + +If the user picks option 2 or 3, seed the known users from v1's message history. The v1 database is at `/store/messages.db`. It has a `messages` table with `sender` and `sender_name` columns. For each group: + +```sql +-- v1: unique senders per chat (excluding bot messages) +SELECT DISTINCT sender, sender_name +FROM messages +WHERE chat_jid = '' AND is_from_me = 0 AND sender IS NOT NULL +``` + +The `sender` value is a platform handle (e.g. `6037840640` for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use `parseJid` from `setup/migrate-v2/shared.ts`) and combining: `:`. + +For each sender: +1. Upsert into `users(id, kind, display_name)` if not already present. +2. Insert into `agent_group_members(user_id, agent_group_id)` for each agent group wired to that messaging group. + +Show the user the list of senders being imported and let them deselect any they don't want. + +Then update the messaging groups: +```sql +UPDATE messaging_groups SET unknown_sender_policy = '' +WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN ()) +``` + +## Phase 2: Clean up CLAUDE.local.md + +The migration copied v1's entire CLAUDE.md into CLAUDE.local.md for each group. This file now contains v1 boilerplate that v2 handles through its own composed fragments (`container/CLAUDE.md` + `.claude-fragments/module-*.md`). The user's customizations are buried inside. + +For each group that has a `CLAUDE.local.md`: + +1. Read the file. +2. Read the v1 template it was based on. Determine which template by checking the v1 install: + - If the group had `is_main=1` in v1's `registered_groups`, the template was `groups/main/CLAUDE.md` + - Otherwise, the template was `groups/global/CLAUDE.md` + - The v1 path is in `handoff.json` → `v1_path` +3. Diff the file against the template. Identify sections that are: + - **Stock boilerplate** (identical to template) — remove. v2's fragments cover this. + - **User customizations** (added sections, modified sections) — keep. +4. The following v1 sections are now handled by v2 fragments and should be removed even if slightly modified: + - "What You Can Do" → v2 runtime system prompt + - "Communication" / "Internal thoughts" / "Sub-agents" → `container/CLAUDE.md` + `module-core.md` + - "Your Workspace" / workspace path references → `container/CLAUDE.md` + - "Memory" (the stock version) → `container/CLAUDE.md` + - "Message Formatting" → `container/CLAUDE.md` + - "Admin Context" → v2 uses `user_roles`, not is_main + - "Authentication" → v2 uses OneCLI + - "Container Mounts" → v2 mounts are different + - "Managing Groups" / "Finding Available Groups" / "Registered Groups Config" → v2 entity model, no IPC + - "Global Memory" → v2 has `.claude-shared.md` symlink + - "Scheduling for Other Groups" → `module-scheduling.md` + - "Task Scripts" → `module-scheduling.md` + - "Sender Allowlist" → v2 uses `unknown_sender_policy` + `user_roles` +5. Fix path references in kept sections: + - `/workspace/group/` → `/workspace/agent/` + - `/workspace/project/` → these paths don't exist in v2; discuss with the user + - `/workspace/ipc/` → gone; remove references + - `/workspace/extra/` → v2 uses `container.json` `additionalMounts`; keep but note the path may change +6. Keep the `# Name` heading and first paragraph (identity) — this is the user's agent personality. +7. Show the user the proposed new CLAUDE.local.md before writing it. Use `AskUserQuestion`: "Here's what I'd keep — look right?" with options to approve, edit, or keep the original. + +If a CLAUDE.local.md has no user customizations (pure template copy), write a minimal file with just the identity heading. + +## Phase 3: Container config + +`migrate-v2.sh` writes `container.json` directly from v1's `container_config` (the `additionalMounts` shape is identical). If the v1 config was unparseable, it falls back to a `.v1-container-config.json` sidecar. + +For each group, check: + +1. If `container.json` exists, read it and verify the `additionalMounts` host paths are still valid on this machine. Flag any that don't exist. +2. If `.v1-container-config.json` exists (parse failure fallback), read it, discuss with the user, and write a proper `container.json`. Then delete the sidecar. +3. Check for `env` or `packages` fields — `env` may overlap with OneCLI vault, `packages` (apt/npm) are portable. + +## Phase 4: Fork customizations + +Check whether the user's v1 install was a customized fork. + +```bash +cd +git remote -v +git log --oneline /main..HEAD 2>/dev/null +``` + +If no commits ahead of upstream: stock v1, skip this phase. + +If there are commits: + +1. Show the commit list to the user. +2. `AskUserQuestion`: "How do you want to handle your v1 customizations?" + - **Copy portable items** (recommended) — copy `container/skills/*`, `.claude/skills/*`, `docs/*`. Scan each with `scanForV1Patterns` from `setup/migrate-v2/shared.ts`. + - **Full walkthrough** — go commit by commit, decide together. + - **Reference only** — stash to `docs/v1-fork-reference/` for later. +3. Source code (`src/*`, `container/agent-runner/src/*`) is NOT portable — v2's architecture is fundamentally different. Stash to `docs/v1-fork-reference/` with a README explaining what each file did. Don't translate. + +## Principles + +- **v1 checkout is read-only.** Never modify files under `handoff.v1_path`. +- **Show before writing.** Show diffs/proposed content before modifying CLAUDE.local.md or container.json. +- **Mask credentials** when displaying (first 4 + `...` + last 4 characters). +- **`handoff.json` is the recovery point.** If context gets compacted, re-read it and `git status` to recover state. + +## Setup steps you can run + +The setup flow at `setup/index.ts` has individual steps you can invoke if something is missing or failed: + +```bash +pnpm exec tsx setup/index.ts --step +``` + +| Step | When to use | +|------|-------------| +| `onecli` | OneCLI not installed or not healthy | +| `auth` | No Anthropic credential in vault | +| `container` | Container image needs rebuild | +| `service` | Service not installed or not running | +| `mounts` | Mount allowlist missing | +| `verify` | End-to-end health check (run after everything else) | +| `environment` | System check (Node, dirs) | + +## When done + +1. Run the verify step to confirm everything works: + ```bash + pnpm exec tsx setup/index.ts --step verify + ``` +2. Delete `logs/setup-migration/handoff.json` — offer to save as `docs/migration-.md` first. +3. Restart the service if running so changes take effect: + ```bash + # Linux + systemctl --user restart nanoclaw-v2-* + # macOS + launchctl kickstart -k gui/$(id -u)/com.nanoclaw-v2-* + ``` diff --git a/.claude/skills/migrate-nanoclaw/SKILL.md b/.claude/skills/migrate-nanoclaw/SKILL.md new file mode 100644 index 0000000..82e1009 --- /dev/null +++ b/.claude/skills/migrate-nanoclaw/SKILL.md @@ -0,0 +1,484 @@ +--- +name: migrate-nanoclaw +description: Extracts user customizations from a fork, generates a replayable migration guide, and upgrades to upstream by reapplying customizations on a clean base. Replaces merge-based upgrades with intent-based migration. +--- + +# Context + +NanoClaw users fork the repo and customize it — changing config values, editing source files, modifying personas, adding skills. When upstream ships updates or refactors, `git merge` produces painful conflicts because the same core files were changed on both sides. + +This skill extracts the user's customizations into a migration guide — capturing both the intent (what they want) and the implementation details (how they did it, with code snippets, API calls, and specific configurations). On upgrade, it checks out clean upstream in a worktree, then reapplies customizations using the guide. No merge conflicts because there's nothing to merge. + +The migration guide is markdown, not structured data. It needs to capture the full range of what a user might customize, with enough implementation detail that a fresh Claude session can reapply it without having seen the original code. Standard changes (config values, simple logic) can be described briefly. Non-standard changes (specific APIs, custom integrations, unusual patterns) need code snippets and precise instructions. + +Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If a guide already exists, offer to skip to Upgrade. + +# Principles + +- Never proceed with a dirty working tree. +- Always create a rollback point (backup branch + tag) before touching anything. +- The migration guide is the source of truth, not diffs. +- Use a worktree to validate before affecting the live install. +- Data directories (`groups/`, `store/`, `data/`, `.env`) are never touched — only code. +- Be helpful: offer to do things (stash, commit, stop services) rather than telling the user to do them. +- **Use sub-agents for exploration.** Spawn haiku sub-agents to explore the codebase, trace skill merges, diff files, and identify customizations. This keeps the main context focused on the user conversation and decision-making. +- **Always use absolute paths in worktrees.** The Bash tool resets the working directory between calls. Never use relative `cd .upgrade-worktree` — always use the full absolute path: `cd /absolute/path/.upgrade-worktree && `. Store the worktree absolute path in a variable at creation time and reference it throughout. +- **Balance exploration and asking.** Don't bombard the user with questions when you can figure things out from the code. Don't burn endless tokens exploring when the user could clarify in one sentence. Use sub-agents to explore first, then ask the user targeted questions about things that are ambiguous or where intent isn't clear from the code alone. +- **Scale effort to complexity.** Not every migration needs the full process. Assess the scope early and take the lightest path that fits. + +--- + +# Phase 1: Extract + +## 1.0 Preflight + +Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`. + +Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`. + +Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH. + +## 1.1 Assess scope and determine path + +Quickly assess the scale of divergence, check for an existing guide, and determine the right approach — all before asking the user anything. + +```bash +BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH) +# Divergence stats +git rev-list --count $BASE..upstream/$UPSTREAM_BRANCH # upstream commits +git rev-list --count $BASE..HEAD # user commits +git diff --name-only $BASE..HEAD | wc -l # user changed files +git diff --stat $BASE..HEAD | tail -1 # insertions/deletions +git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH | wc -l # upstream changed files +``` + +Check for existing guide: `.nanoclaw-migrations/guide.md` or `.nanoclaw-migrations/index.md`. + +**Determine the tier based on the total diff from base:** + +### Tier 1: Lightweight — suggest `/update-nanoclaw` instead + +Conditions (any of): +- Very few upstream changes (< ~5 commits) AND few user changes (< ~3 changed files) +- User recently updated/migrated (merge-base is close to upstream HEAD) + +Tell the user the scope is small and suggest `/update-nanoclaw` might be simpler. Let them choose. + +### Tier 2: Standard + +Conditions: +- Moderate total diff (3-15 changed files, no large number of new files) +- Manageable scope that fits in a single guide file + +### Tier 3: Complex + +Conditions (any of): +- Many new files added (indicates many skills applied) — discount files that come purely from skill merges when assessing complexity; a fork with 3 skills and no other changes is simpler than it looks by file count alone +- Deep source changes to core files (`src/index.ts`, `src/container-runner.ts`, etc.) beyond what skills introduced +- Lots of insertions/deletions in user-authored code (not skill-merged code) +- Many skills applied (3+) AND the user confirms or sub-agents find customizations on top of them + +Use the full process: multiple sub-agents in parallel, directory-based guide, migration plan. + +**Now combine the scope assessment with initial user input in one interaction.** Present the scope summary (how many commits, files, which tier) and ask (AskUserQuestion): + +For Tier 1: +- **Use /update-nanoclaw** — simpler merge-based approach +- **Proceed with full migration** — continue + +For Tier 2/3 (with or without existing guide): +- If guide exists and is current: **Skip to upgrade** / **Update guide** (add new changes) / **Re-extract from scratch** +- If guide exists but is stale: **Update guide** (recommended) / **Re-extract from scratch** / **Skip to upgrade anyway** +- If no guide: **Yes, let me describe my customizations first** / **Just figure it out** / **A bit of both** + +This single interaction replaces what were previously separate steps for scope assessment, user input, and existing guide check. + +## 1.2 Update existing guide (if applicable) + +If the user chose to update an existing guide rather than re-extract: + +1. Read the existing guide +2. Find commits made since the guide was generated (compare guide's recorded base hash against current HEAD) +3. Spawn a haiku sub-agent to analyze only the new changes: + > Diff HEAD against ``. For each changed file, summarize what changed and why. +4. Present the new changes to the user for confirmation +5. Append new customizations to the existing guide, update the header hashes +6. Skip to Phase 2 + +## 1.3 Explore the codebase + +Spawn a haiku sub-agent (Agent tool, model: haiku) for initial exploration: + +> Explore this NanoClaw fork to identify all changes from the upstream base. Run these commands and report back: +> +> 1. `git diff --name-only $BASE..HEAD` — all changed files +> 2. `git log --oneline $BASE..HEAD` — all commits (look for skill branch merges like `Merge branch 'skill/*'`) +> 3. `git branch -r --list 'upstream/skill/*'` — available upstream skill branches +> 4. `ls .claude/skills/` — installed skills +> 5. For each skill merge found, record the merge commit hash +> +> Report: (a) list of applied skills with their merge commit hashes, (b) list of all changed files, (c) any custom skill directories that don't match upstream branches. + +From the sub-agent results, identify: +- **Which files came purely from skill merges** — these will be reapplied by re-merging skill branches in Phase 2 +- **Everything else** — all remaining changes are customizations to analyze (whether they're on skill-touched files or not) + +Don't try to distinguish "user modified a skill file" from "user made their own change" at this stage. The sub-agents in 1.4 will look at all non-skill changes together and surface what matters. + +## 1.4 Analyze customizations + +For each applied skill, ask the user in a single batched question (AskUserQuestion, multiSelect): + +> "I found these applied skills. Select any you customized further after applying:" + +Options: one per skill, plus "None — all used as-is". + +Then spawn sub-agents to analyze all non-skill changes. For Tier 2, one or two agents. For Tier 3, run in parallel by area: + +- **Config + build files** — one sub-agent +- **Source files** (`src/*.ts`) — one sub-agent +- **Skills the user flagged as modified** (or all of them for Tier 3) — one sub-agent per skill, comparing the user's current files against the skill merge commit version: + ``` + git diff ..HEAD -- + ``` +- **Container files** — one sub-agent (if changes exist) + +Each sub-agent task: + +> Read these diffs and the current file contents. For each change: +> 1. `git diff $BASE..HEAD -- ` (or `git diff ..HEAD -- ` for skill-modified files) +> 2. Read the full current file for context +> 3. Summarize: what changed, what the likely intent is +> 4. Assess detail level: could a fresh Claude session reproduce this from intent alone, or does it need specific code snippets, API details, import paths? +> 5. For non-standard changes, extract the key code, imports, API calls, and configurations verbatim. + +**Inter-skill conflicts:** If multiple skills are applied, spawn an additional sub-agent to check for interactions between them. Look for: +- Duplicate declarations (same variable/constant defined by two skill branches) +- Conflicting approaches (one skill throws on missing env var, another provides a fallback) +- Shared files modified by multiple skills + +Document any findings in the "Skill Interactions" section of the migration guide so they can be resolved after skill branches are re-merged during upgrade. + +## 1.5 Confirm with user + +After sub-agents report back, compile the findings and present to the user. + +For customizations where the intent is clear (config values, simple modifications): present as a batch for confirmation. Use AskUserQuestion with multiSelect to let the user flag any entries that need correction. + +For customizations where the intent is ambiguous: ask specific questions. Don't ask "what did you do?" — instead ask "I see you added X in this file. Was this for Y or something else?" + +The user can select "Other" on any question to provide their own description. + +## 1.6 Migration plan (Tier 3 only) + +For complex migrations, before writing the guide, create a migration plan: + +- **Order of operations**: which customizations depend on others, which skills must be applied first +- **Staging**: whether the migration should happen in stages (e.g. apply skills first, validate, then apply source customizations) +- **Risk areas**: customizations that touch files heavily changed by upstream — these may need manual review +- **Interactions**: customizations that interact with each other (e.g. a source change that depends on a skill, or two customizations that touch the same file) + +Present the plan to the user for review before proceeding to the guide. + +## 1.7 Write the migration guide + +**Storage:** `.nanoclaw-migrations/guide.md` for Tier 2. `.nanoclaw-migrations/` directory with `index.md` and section files for Tier 3. + +**Verification:** After writing the guide, read it back and verify: +- Every referenced file path exists in the current codebase +- Code snippets match what's actually in the files +- No customizations from the analysis were accidentally omitted + +The guide is structured markdown that a fresh Claude session can follow to reproduce this user's exact setup on a clean upstream checkout. + +Structure: + +```markdown +# NanoClaw Migration Guide + +Generated: +Base: +HEAD at generation: +Upstream: + +## Migration Plan + +(Tier 3 only — big-picture overview of order, staging, risks) + +## Applied Skills + +List each skill with its branch name. These are reapplied by merging the upstream skill branch. + +- `add-telegram` — branch `skill/telegram` +- `add-voice-transcription` — branch `skill/voice-transcription` + +Custom skills (user-created, not from upstream): `.claude/skills/my-custom-skill/` — copy as-is from main tree. + +## Skill Interactions + +(Document known conflicts or interactions between applied skills. +When two or more skills modify the same file or depend on shared +config, describe the conflict and how to resolve it after merging. +Example: skill A and skill B both add a PROXY_BIND_HOST declaration — +after merging both, deduplicate. Or: skill A throws if ENV_VAR is +missing, but skill B provides a fallback — use the fallback version.) + +## Modifications to Applied Skills + +### : + +**Intent:** ... + +**Files:** ... + +**How to apply:** (after the skill branch has been merged) + +... + +## Customizations + +### + +**Intent:** What the user wants and why. + +**Files:** Which files to modify. + +**How to apply:** + + + + + +### +``` + +**Judging detail level:** For each customization, assess whether a fresh Claude session could reproduce it from intent alone: +- **Standard changes** (config values, simple logic, well-known patterns): describe the intent and the target. Example: "Change `POLL_INTERVAL` in `src/config.ts` from 2000 to 1000." +- **Non-standard changes** (specific API usage, custom integrations, unusual patterns, library-specific configurations): include the actual code snippets, import paths, API endpoints, configuration objects — everything needed to reproduce it without guessing. + +Example entries at different detail levels: + +**Standard (brief):** +```markdown +### Custom trigger word + +**Intent:** Use `@Bob` instead of the default `@Andy`. + +**Files:** `src/config.ts` + +**How to apply:** Change the default value of `ASSISTANT_NAME` from `'Andy'` to `'Bob'`. +``` + +**Non-standard (detailed):** +```markdown +### Spanish translation for outbound messages + +**Intent:** All outbound messages are translated to Spanish before sending. Uses the DeepL API via the `deepl-node` package. + +**Files:** `src/router.ts`, `package.json` + +**How to apply:** + +1. Add dependency: `npm install deepl-node` + +2. In `src/router.ts`, add import at top: + ```typescript + import * as deepl from 'deepl-node'; + const translator = new deepl.Translator(process.env.DEEPL_API_KEY!); + ``` + +3. In the `formatOutbound` function, before the return statement, add: + ```typescript + const result = await translator.translateText(text, null, 'es'); + text = result.text; + ``` + Note: the function needs to be made async if it isn't already. +``` + +After writing, offer to commit for the user: +```bash +git add .nanoclaw-migrations/ +git commit -m "chore: save migration guide" +``` + +Ask (AskUserQuestion): "Migration guide saved. Want to upgrade now or later?" +- **Upgrade now** — continue to Phase 2 +- **Later** — stop here + +--- + +# Phase 2: Upgrade + +## 2.0 Preflight + +Same checks as 1.0 — clean tree (offer to stash/commit if dirty), upstream configured, fetch latest. + +Read the migration guide. If missing, tell the user you need to extract customizations first and ask if they want to do that now. + +**New-changes guard:** Compare the guide's "HEAD at generation" hash against current HEAD. If there are commits since the guide was generated, warn the user: + +> "You've made changes since the migration guide was generated. These changes won't be included in the upgrade." + +AskUserQuestion: +- **Update the guide first** — go to step 1.2 to incorporate new changes +- **Proceed anyway** — user accepts that recent changes will be lost +- **Abort** — stop + +## 2.1 Safety net + +```bash +HASH=$(git rev-parse --short HEAD) +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +git branch backup/pre-migrate-$HASH-$TIMESTAMP +git tag pre-migrate-$HASH-$TIMESTAMP +``` + +Save the tag name for rollback instructions at the end. + +## 2.2 Preview upstream changes + +```bash +BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH) +git log --oneline $BASE..upstream/$UPSTREAM_BRANCH +git diff $BASE..upstream/$UPSTREAM_BRANCH -- CHANGELOG.md +``` + +If there are `[BREAKING]` entries, show them and explain how they interact with the user's customizations from the migration guide. + +Ask (AskUserQuestion) to proceed or abort. + +## 2.3 Create upgrade worktree + +```bash +PROJECT_ROOT=$(pwd) +git worktree add .upgrade-worktree upstream/$UPSTREAM_BRANCH --detach +WORKTREE="$PROJECT_ROOT/.upgrade-worktree" +``` + +Store `$PROJECT_ROOT` and `$WORKTREE` as absolute paths. Use `$WORKTREE` in all subsequent commands — never `cd .upgrade-worktree` with a relative path. + +## 2.4 Reapply skills in worktree + +For each skill listed in the migration guide's "Applied Skills" section: + +1. Check if branch exists: `git branch -r --list "upstream/$branch"` +2. If yes, merge it in the worktree: + ```bash + cd "$WORKTREE" && git merge upstream/skill/ --no-edit + ``` +3. If missing, warn the user (skill may have been removed or renamed upstream). +4. If any skill merge conflicts, stop and tell the user — the skill needs updating for the new upstream. + +Copy any custom skills mentioned in the guide from the main tree into the worktree. + +## 2.5 Reapply customizations in worktree + +Work in `.upgrade-worktree/`. Follow each customization section in the migration guide, including "Modifications to Applied Skills." + +For Tier 3 migrations with a migration plan, follow the plan's ordering and staging. If the plan calls for staged validation (e.g. validate after skills, then validate after source changes), do so. + +For each customization: +1. Read the "How to apply" instructions from the guide +2. Read the target file(s) in the worktree to understand the current upstream version +3. Apply the changes as described — use the code snippets and specific instructions from the guide +4. If the target file has changed significantly from what the guide expects (function removed, file restructured, API changed), flag it and ask the user what to do +5. Verify the file has no syntax errors or broken imports after each change + +For behavior customizations (CLAUDE.md files): copy from the main tree. These are user content, not code. + +## 2.6 Validate in worktree + +```bash +cd "$WORKTREE" && pnpm install && pnpm run build && pnpm test +``` + +If build fails, show the error. Fix only issues caused by the migration. If unclear, ask the user. + +## 2.7 Live test (optional) + +Ask (AskUserQuestion): +- **Test live** — stop service, run from worktree against real data, send a test message +- **Skip** — trust the build, proceed to swap + +If testing live: + +1. Stop the service (do this directly): + ```bash + launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist 2>/dev/null || true + ``` + +2. Symlink data into the worktree: + ```bash + ln -s "$PROJECT_ROOT/store" "$WORKTREE/store" + ln -s "$PROJECT_ROOT/data" "$WORKTREE/data" + ln -s "$PROJECT_ROOT/groups" "$WORKTREE/groups" + ln -s "$PROJECT_ROOT/.env" "$WORKTREE/.env" + ``` + +3. Start from worktree: `cd "$WORKTREE" && pnpm run dev` + +4. Ask the user to send a test message from their phone. Wait for them to confirm it works. + +5. After confirmation, stop the dev server. + +6. Clean up symlinks: + ```bash + rm "$WORKTREE/store" "$WORKTREE/data" "$WORKTREE/groups" "$WORKTREE/.env" + ``` + +## 2.8 Swap into main tree + +The swap must be done carefully — the worktree has the upgraded code, but main needs to point to it cleanly. Use absolute paths throughout. + +```bash +# 1. Capture the worktree HEAD before removing it +WORKTREE_PATH=$(cd "$PROJECT_ROOT/.upgrade-worktree" && pwd) +UPGRADE_COMMIT=$(git -C "$WORKTREE_PATH" rev-parse HEAD) + +# 2. Copy the migration guide out of the worktree before removing it +cp -r "$WORKTREE_PATH/.nanoclaw-migrations" /tmp/nanoclaw-migrations-backup 2>/dev/null || true + +# 3. Remove the worktree +git worktree remove "$WORKTREE_PATH" --force + +# 4. Point the current branch at the upgraded commit +git reset --hard $UPGRADE_COMMIT + +# 5. Restore the migration guide and update its hashes +cp -r /tmp/nanoclaw-migrations-backup/* .nanoclaw-migrations/ 2>/dev/null || true +rm -rf /tmp/nanoclaw-migrations-backup +``` + +Update the guide's header hashes to reflect the new state. Offer to commit: +```bash +git add .nanoclaw-migrations/ +git commit -m "chore: upgrade to upstream $(git rev-parse --short upstream/$UPSTREAM_BRANCH)" +``` + +Do NOT use `git checkout -B` to create an intermediate branch — this caused issues in practice. The `git reset --hard` to the upgrade commit is the cleanest path since the backup tag already preserves the pre-upgrade state. + +## 2.9 Post-upgrade + +Run `npm install && pnpm run build` in the main tree to confirm. + +Restart the service: +```bash +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist +``` + +Show summary: +- Previous version (backup tag) +- New HEAD +- Customizations reapplied (list from guide) +- Skills reapplied +- Rollback: `git reset --hard ` +- Any customizations that needed manual adjustment + +Offer to pop the stash if one was created in preflight: `git stash pop` + +## Diagnostics + +1. Use the Read tool to read `.claude/skills/migrate-nanoclaw/diagnostics.md`. +2. Follow every step in that file before finishing. diff --git a/.claude/skills/setup/diagnostics.md b/.claude/skills/migrate-nanoclaw/diagnostics.md similarity index 62% rename from .claude/skills/setup/diagnostics.md rename to .claude/skills/migrate-nanoclaw/diagnostics.md index c6a42db..170fd7a 100644 --- a/.claude/skills/setup/diagnostics.md +++ b/.claude/skills/migrate-nanoclaw/diagnostics.md @@ -14,17 +14,22 @@ Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP ad ```json { "api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP", - "event": "setup_complete", + "event": "migrate_complete", "distinct_id": "", "properties": { "success": true, - "nanoclaw_version": "1.2.21", + "nanoclaw_version": "1.2.43", "os_platform": "darwin", "arch": "arm64", "node_major_version": 22, - "channels_selected": ["telegram", "whatsapp"], - "error_count": 0, - "failed_step": null + "migration_phase": "extract|upgrade|both", + "tier": 2, + "customization_count": 3, + "skills_applied_count": 2, + "skill_interaction_count": 0, + "live_test": false, + "breaking_changes_found": false, + "error_count": 0 } } ``` @@ -40,7 +45,7 @@ rm /tmp/nanoclaw-diagnostics.json **No**: `rm /tmp/nanoclaw-diagnostics.json` **Never ask again**: -1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out` -2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` -3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md` +1. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` +2. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` +3. Remove the diagnostics sections from each corresponding SKILL.md 4. `rm /tmp/nanoclaw-diagnostics.json` diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 54c3d2d..5ce96e3 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -1,291 +1,10 @@ --- name: setup -description: Run initial NanoClaw setup. Use when user wants to install dependencies, authenticate messaging channels, register their main channel, or start the background services. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests. +description: Run initial NanoClaw setup. Use when user wants to install NanoClaw, configure it, or go through first-time setup. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests. --- # NanoClaw Setup -Run setup steps automatically. Only pause when user action is required (channel authentication, configuration choices). Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step ` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`. +Tell the user to run `bash nanoclaw.sh` in their terminal. That script handles the full end-to-end setup — dependencies, container image, OneCLI vault, Anthropic credential, service, first agent, and optional channel wiring. -**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work. - -**UX Note:** Use `AskUserQuestion` for all user-facing questions. - -## 0. Git & Fork Setup - -Check the git remote configuration to ensure the user has a fork and upstream is configured. - -Run: -- `git remote -v` - -**Case A — `origin` points to `qwibitai/nanoclaw` (user cloned directly):** - -The user cloned instead of forking. AskUserQuestion: "You cloned NanoClaw directly. We recommend forking so you can push your customizations. Would you like to set up a fork?" -- Fork now (recommended) — walk them through it -- Continue without fork — they'll only have local changes - -If fork: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask them for their GitHub username. Run: -```bash -git remote rename origin upstream -git remote add origin https://github.com//nanoclaw.git -git push --force origin main -``` -Verify with `git remote -v`. - -If continue without fork: add upstream so they can still pull updates: -```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git -``` - -**Case B — `origin` points to user's fork, no `upstream` remote:** - -Add upstream: -```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git -``` - -**Case C — both `origin` (user's fork) and `upstream` (qwibitai) exist:** - -Already configured. Continue. - -**Verify:** `git remote -v` should show `origin` → user's repo, `upstream` → `qwibitai/nanoclaw.git`. - -## 1. Bootstrap (Node.js + Dependencies + OneCLI) - -Run `bash setup.sh` and parse the status block. - -- If NODE_OK=false → Node.js is missing or too old. Use `AskUserQuestion: Would you like me to install Node.js 22?` If confirmed: - - macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22` - - Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm - - After installing Node, re-run `bash setup.sh` -- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry. -- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run. -- Record PLATFORM and IS_WSL for later steps. - -After bootstrap succeeds, install OneCLI and its CLI tool: - -```bash -curl -fsSL onecli.sh/install | sh -curl -fsSL onecli.sh/cli/install | sh -``` - -Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it: - -```bash -export PATH="$HOME/.local/bin:$PATH" -# Persist for future sessions (append to shell profile if not already present) -grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc -grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc -``` - -Then re-verify with `onecli version`. - -Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise): -```bash -onecli config set api-host http://127.0.0.1:10254 -``` - -Ensure `.env` has the OneCLI URL (create the file if it doesn't exist): -```bash -grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env -``` - -## 2. Check Environment - -Run `npx tsx setup/index.ts --step environment` and parse the status block. - -- If HAS_AUTH=true → WhatsApp is already configured, note for step 5 -- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure -- Record APPLE_CONTAINER and DOCKER values for step 3 - -## 2a. Timezone - -Run `npx tsx setup/index.ts --step timezone` and parse the status block. - -- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `npx tsx setup/index.ts --step timezone -- --tz `. -- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference. - -## 3. Container Runtime - -### 3a. Choose runtime - -Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1. - -- PLATFORM=linux → Docker (only option) -- PLATFORM=macos + APPLE_CONTAINER=installed → Use `AskUserQuestion: Docker (cross-platform) or Apple Container (native macOS)?` If Apple Container, run `/convert-to-apple-container` now, then skip to 3c. -- PLATFORM=macos + APPLE_CONTAINER=not_found → Docker - -### 3a-docker. Install Docker - -- DOCKER=running → continue to 4b -- DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`. -- DOCKER=not_found → Use `AskUserQuestion: Docker is required for running agents. Would you like me to install it?` If confirmed: - - macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop - - Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership. - -### 3b. Apple Container conversion gate (if needed) - -**If the chosen runtime is Apple Container**, you MUST check whether the source code has already been converted from Docker to Apple Container. Do NOT skip this step. Run: - -```bash -grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "ALREADY_CONVERTED" || echo "NEEDS_CONVERSION" -``` - -**If NEEDS_CONVERSION**, the source code still uses Docker as the runtime. You MUST run the `/convert-to-apple-container` skill NOW, before proceeding to the build step. - -**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c. - -**If the chosen runtime is Docker**, no conversion is needed. Continue to 3c. - -### 3c. Build and test - -Run `npx tsx setup/index.ts --step container -- --runtime ` and parse the status block. - -**If BUILD_OK=false:** Read `logs/setup.log` tail for the build error. -- Cache issue (stale layers): `docker builder prune -f` (Docker) or `container builder stop && container builder rm && container builder start` (Apple Container). Retry. -- Dockerfile syntax or missing files: diagnose from the log and fix, then retry. - -**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test. - -## 4. Anthropic Credentials via OneCLI - -NanoClaw uses OneCLI to manage credentials — API keys are never stored in `.env` or exposed to containers. The OneCLI gateway injects them at request time. - -Check if a secret already exists: -```bash -onecli secrets list -``` - -If an Anthropic secret is listed, confirm with user: keep or reconfigure? If keeping, skip to step 5. - -AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**? - -1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token." -2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com." - -### Subscription path - -Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat. - -Once they have the token, they register it with OneCLI. AskUserQuestion with two options: - -1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value." -2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" - -### API key path - -Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one. - -Then AskUserQuestion with two options: - -1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI." -2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`" - -### After either path - -Ask them to let you know when done. - -**If the user's response happens to contain a token or key** (starts with `sk-ant-`): handle it gracefully — run the `onecli secrets create` command with that value on their behalf. - -**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again. - -## 5. Set Up Channels - -AskUserQuestion (multiSelect): Which messaging channels do you want to enable? -- WhatsApp (authenticates via QR code or pairing code) -- Telegram (authenticates via bot token from @BotFather) -- Slack (authenticates via Slack app with Socket Mode) -- Discord (authenticates via Discord bot token) - -**Delegate to each selected channel's own skill.** Each channel skill handles its own code installation, authentication, registration, and JID resolution. This avoids duplicating channel-specific logic and ensures JIDs are always correct. - -For each selected channel, invoke its skill: - -- **WhatsApp:** Invoke `/add-whatsapp` -- **Telegram:** Invoke `/add-telegram` -- **Slack:** Invoke `/add-slack` -- **Discord:** Invoke `/add-discord` - -Each skill will: -1. Install the channel code (via `git merge` of the skill branch) -2. Collect credentials/tokens and write to `.env` -3. Authenticate (WhatsApp QR/pairing, or verify token-based connection) -4. Register the chat with the correct JID format -5. Build and verify - -**After all channel skills complete**, install dependencies and rebuild — channel merges may introduce new packages: - -```bash -npm install && npm run build -``` - -If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 6. - -## 6. Mount Allowlist - -AskUserQuestion: Agent access to external directories? - -**No:** `npx tsx setup/index.ts --step mounts -- --empty` -**Yes:** Collect paths/permissions. `npx tsx setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'` - -## 7. Start Service - -If service already running: unload first. -- macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` -- Linux: `systemctl --user stop nanoclaw` (or `systemctl stop nanoclaw` if root) - -Run `npx tsx setup/index.ts --step service` and parse the status block. - -**If FALLBACK=wsl_no_systemd:** WSL without systemd detected. Tell user they can either enable systemd in WSL (`echo -e "[boot]\nsystemd=true" | sudo tee /etc/wsl.conf` then restart WSL) or use the generated `start-nanoclaw.sh` wrapper. - -**If DOCKER_GROUP_STALE=true:** The user was added to the docker group after their session started — the systemd service can't reach the Docker socket. Ask user to run these two commands: - -1. Immediate fix: `sudo setfacl -m u:$(whoami):rw /var/run/docker.sock` -2. Persistent fix (re-applies after every Docker restart): -```bash -sudo mkdir -p /etc/systemd/system/docker.service.d -sudo tee /etc/systemd/system/docker.service.d/socket-acl.conf << 'EOF' -[Service] -ExecStartPost=/usr/bin/setfacl -m u:USERNAME:rw /var/run/docker.sock -EOF -sudo systemctl daemon-reload -``` -Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` commands separately — the `tee` heredoc first, then `daemon-reload`. After user confirms setfacl ran, re-run the service step. - -**If SERVICE_LOADED=false:** -- Read `logs/setup.log` for the error. -- macOS: check `launchctl list | grep nanoclaw`. If PID=`-` and status non-zero, read `logs/nanoclaw.error.log`. -- Linux: check `systemctl --user status nanoclaw`. -- Re-run the service step after fixing. - -## 8. Verify - -Run `npx tsx setup/index.ts --step verify` and parse the status block. - -**If STATUS=failed, fix each:** -- SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup) -- SERVICE=not_found → re-run step 7 -- CREDENTIALS=missing → re-run step 4 (check `onecli secrets list` for Anthropic secret) -- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`) -- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5 -- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` - -Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log` - -## Troubleshooting - -**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), OneCLI not running (check `curl http://127.0.0.1:10254/api/health`), missing channel credentials (re-invoke channel skill). - -**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. - -**No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `npx tsx setup/index.ts --step verify`. Check `logs/nanoclaw.log`. - -**Channel not connecting:** Verify the channel's credentials are set in `.env`. Channels auto-enable when their credentials are present. For WhatsApp: check `store/auth/creds.json` exists. For token-based channels: check token values in `.env`. Restart the service after any `.env` change. - -**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw` - - -## 9. Diagnostics - -1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`. -2. Follow every step in that file before completing setup. +If they hit an error partway through, it will offer Claude-assisted recovery inline — no need to come back here. diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index 496d409..aebe96e 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -30,7 +30,7 @@ Run `/update-nanoclaw` in Claude Code. **Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact. -**Validation**: runs `npm run build` and `npm test`. +**Validation**: runs `pnpm run build` and `pnpm test`. **Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate. @@ -109,9 +109,11 @@ Show file-level impact from upstream: Bucket the upstream changed files: - **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill - **Source** (`src/`): may conflict if user modified the same files -- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed +- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`, `container/`, `launchd/`): review needed - **Other**: docs, tests, misc +**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push. + Present these buckets to the user and ask them to choose one path using AskUserQuestion: - A) **Full update**: merge all upstream changes - B) **Selective update**: cherry-pick specific upstream commits @@ -173,8 +175,8 @@ If it gets messy (more than 3 rounds of conflicts): # Step 5: Validation Run: -- `npm run build` -- `npm test` (do not fail the flow if tests are not configured) +- `pnpm run build` +- `pnpm test` (do not fail the flow if tests are not configured) If build fails: - Show the error. @@ -188,7 +190,7 @@ After validation succeeds, check if the update introduced any breaking changes. Determine which CHANGELOG entries are new by diffing against the backup tag: - `git diff ..HEAD -- CHANGELOG.md` -Parse the diff output for lines starting with `+[BREAKING]`. Each such line is one breaking change entry. The format is: +Parse the diff output for lines that contain `[BREAKING]` anywhere in the line. Each such line is one breaking change entry. The format is: ``` [BREAKING] . Run `/` to . ``` @@ -232,7 +234,7 @@ Tell the user: - Backup branch also exists: `backup/pre-update--` - Restart the service to apply changes: - If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` - - If running manually: restart `npm run dev` + - If running manually: restart `pnpm run dev` ## Diagnostics diff --git a/.claude/skills/update-nanoclaw/diagnostics.md b/.claude/skills/update-nanoclaw/diagnostics.md index 8b06aa4..551842e 100644 --- a/.claude/skills/update-nanoclaw/diagnostics.md +++ b/.claude/skills/update-nanoclaw/diagnostics.md @@ -43,7 +43,6 @@ rm /tmp/nanoclaw-diagnostics.json **No**: `rm /tmp/nanoclaw-diagnostics.json` **Never ask again**: -1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out` -2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` -3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md` -4. `rm /tmp/nanoclaw-diagnostics.json` +1. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out` +2. Remove the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md` +3. `rm /tmp/nanoclaw-diagnostics.json` diff --git a/.claude/skills/update-skills/SKILL.md b/.claude/skills/update-skills/SKILL.md index cbbff39..ca497ad 100644 --- a/.claude/skills/update-skills/SKILL.md +++ b/.claude/skills/update-skills/SKILL.md @@ -110,8 +110,8 @@ If a merge fails badly (e.g., cannot resolve conflicts): # Step 4: Validation After all selected skills are merged: -- `npm run build` -- `npm test` (do not fail the flow if tests are not configured) +- `pnpm run build` +- `pnpm test` (do not fail the flow if tests are not configured) If build fails: - Show the error. diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md deleted file mode 100644 index ec18a09..0000000 --- a/.claude/skills/use-local-whisper/SKILL.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: use-local-whisper -description: Use when the user wants local voice transcription instead of OpenAI Whisper API. Switches to whisper.cpp running on Apple Silicon. WhatsApp only for now. Requires voice-transcription skill to be applied first. ---- - -# Use Local Whisper - -Switches voice transcription from OpenAI's Whisper API to local whisper.cpp. Runs entirely on-device — no API key, no network, no cost. - -**Channel support:** Currently WhatsApp only. The transcription module (`src/transcription.ts`) uses Baileys types for audio download. Other channels (Telegram, Discord, etc.) would need their own audio-download logic before this skill can serve them. - -**Note:** The Homebrew package is `whisper-cpp`, but the CLI binary it installs is `whisper-cli`. - -## Prerequisites - -- `voice-transcription` skill must be applied first (WhatsApp channel) -- macOS with Apple Silicon (M1+) recommended -- `whisper-cpp` installed: `brew install whisper-cpp` (provides the `whisper-cli` binary) -- `ffmpeg` installed: `brew install ffmpeg` -- A GGML model file downloaded to `data/models/` - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/transcription.ts` already uses `whisper-cli`: - -```bash -grep 'whisper-cli' src/transcription.ts && echo "Already applied" || echo "Not applied" -``` - -If already applied, skip to Phase 3 (Verify). - -### Check dependencies are installed - -```bash -whisper-cli --help >/dev/null 2>&1 && echo "WHISPER_OK" || echo "WHISPER_MISSING" -ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING" -``` - -If missing, install via Homebrew: -```bash -brew install whisper-cpp ffmpeg -``` - -### Check for model file - -```bash -ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL" -``` - -If no model exists, download the base model (148MB, good balance of speed and accuracy): -```bash -mkdir -p data/models -curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" -``` - -For better accuracy at the cost of speed, use `ggml-small.bin` (466MB) or `ggml-medium.bin` (1.5GB). - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/local-whisper -git merge whatsapp/skill/local-whisper || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API. - -### Validate - -```bash -npm run build -``` - -## Phase 3: Verify - -### Ensure launchd PATH includes Homebrew - -The NanoClaw launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH. - -Check the current PATH: -```bash -grep -A1 'PATH' ~/Library/LaunchAgents/com.nanoclaw.plist -``` - -If `/opt/homebrew/bin` is missing, add it to the `` value inside the `PATH` key in the plist. Then reload: -```bash -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -``` - -### Build and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -### Test - -Send a voice note in any registered group. The agent should receive it as `[Voice: ]`. - -### Check logs - -```bash -tail -f logs/nanoclaw.log | grep -i -E "voice|transcri|whisper" -``` - -Look for: -- `Transcribed voice message` — successful transcription -- `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH - -## Configuration - -Environment variables (optional, set in `.env`): - -| Variable | Default | Description | -|----------|---------|-------------| -| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | -| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | - -## Troubleshooting - -**"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually: -```bash -ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y -whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt -``` - -**Transcription works in dev but not as service**: The launchd plist PATH likely doesn't include `/opt/homebrew/bin`. See "Ensure launchd PATH includes Homebrew" in Phase 3. - -**Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing. - -**Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`. diff --git a/.claude/skills/use-native-credential-proxy/SKILL.md b/.claude/skills/use-native-credential-proxy/SKILL.md index 4cdda4c..3b94822 100644 --- a/.claude/skills/use-native-credential-proxy/SKILL.md +++ b/.claude/skills/use-native-credential-proxy/SKILL.md @@ -48,8 +48,8 @@ git remote add upstream https://github.com/qwibitai/nanoclaw.git ```bash git fetch upstream skill/native-credential-proxy git merge upstream/skill/native-credential-proxy || { - git checkout --theirs package-lock.json - git add package-lock.json + git checkout --theirs pnpm-lock.yaml + git add pnpm-lock.yaml git merge --continue } ``` @@ -62,14 +62,24 @@ This merges in: - Restored platform-aware proxy bind address detection - Reverted setup skill to `.env`-based credential instructions -If the merge reports conflicts beyond `package-lock.json`, resolve them by reading the conflicted files and understanding the intent of both sides. +If the merge reports conflicts beyond `pnpm-lock.yaml`, resolve them by reading the conflicted files and understanding the intent of both sides. + +### Update main group CLAUDE.md + +Replace the OneCLI auth reference with the native proxy: + +In `groups/main/CLAUDE.md`, replace: +> OneCLI manages credentials (including Anthropic auth) — run `onecli --help`. + +with: +> The native credential proxy manages credentials (including Anthropic auth) via `.env` — see `src/credential-proxy.ts`. ### Validate code changes ```bash -npm install -npm run build -npx vitest run src/credential-proxy.test.ts src/container-runner.test.ts +pnpm install +pnpm run build +pnpm exec vitest run src/credential-proxy.test.ts src/container-runner.test.ts ``` All tests must pass and build must be clean before proceeding. @@ -115,7 +125,7 @@ echo 'ANTHROPIC_API_KEY=' >> .env 1. Rebuild and restart: ```bash -npm run build +pnpm run build ``` Then restart the service: @@ -151,7 +161,7 @@ To revert to OneCLI gateway: 1. Find the merge commit: `git log --oneline --merges -5` 2. Revert it: `git revert -m 1` (undoes the skill branch merge, keeps your other changes) -3. `npm install` (re-adds `@onecli-sh/sdk`) -4. `npm run build` +3. `pnpm install` (re-adds `@onecli-sh/sdk`) +4. `pnpm run build` 5. Follow `/setup` step 4 to configure OneCLI credentials 6. Remove `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` from `.env` diff --git a/.claude/skills/x-integration/SKILL.md b/.claude/skills/x-integration/SKILL.md index 29a7be6..5dc859e 100644 --- a/.claude/skills/x-integration/SKILL.md +++ b/.claude/skills/x-integration/SKILL.md @@ -26,7 +26,7 @@ Before using this skill, ensure: 1. **NanoClaw is installed and running** - WhatsApp connected, service active 2. **Dependencies installed**: ```bash - npm ls playwright dotenv-cli || npm install playwright dotenv-cli + pnpm ls playwright dotenv-cli || pnpm install playwright dotenv-cli ``` 3. **CHROME_PATH configured** in `.env` (if Chrome is not at default location): ```bash @@ -40,7 +40,7 @@ Before using this skill, ensure: ```bash # 1. Setup authentication (interactive) -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts # Verify: data/x-auth.json should exist after successful login # 2. Rebuild container to include skill @@ -48,7 +48,7 @@ npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts # Verify: Output shows "COPY .claude/skills/x-integration/agent.ts" # 3. Rebuild host and restart service -npm run build +pnpm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw # Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux) @@ -225,7 +225,7 @@ COPY container/agent-runner/package*.json ./ COPY container/agent-runner/ ./ ``` -Then add COPY line after `COPY container/agent-runner/ ./` and before `RUN npm run build`: +Then add COPY line after `COPY container/agent-runner/ ./` and before `RUN pnpm run build`: ```dockerfile # Copy skill MCP tools COPY .claude/skills/x-integration/agent.ts ./src/skills/x-integration/ @@ -247,7 +247,7 @@ echo "Chrome not found - update CHROME_PATH in .env" ### 2. Run Authentication ```bash -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts ``` This opens Chrome for manual X login. Session saved to `data/x-browser-profile/`. @@ -271,7 +271,7 @@ cat data/x-auth.json # Should show {"authenticated": true, ...} ### 4. Restart Service ```bash -npm run build +pnpm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` @@ -317,26 +317,26 @@ ls -la data/x-browser-profile/ 2>/dev/null | head -5 ### Re-authenticate (if expired) ```bash -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts ``` ### Test Post (will actually post) ```bash -echo '{"content":"Test tweet - please ignore"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/post.ts +echo '{"content":"Test tweet - please ignore"}' | pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/post.ts ``` ### Test Like ```bash -echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/like.ts +echo '{"tweetUrl":"https://x.com/user/status/123"}' | pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/like.ts ``` Or export `CHROME_PATH` manually before running: ```bash export CHROME_PATH="/path/to/chrome" -echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts +echo '{"content":"Test"}' | pnpm exec tsx .claude/skills/x-integration/scripts/post.ts ``` ## Troubleshooting @@ -344,7 +344,7 @@ echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts ### Authentication Expired ```bash -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` diff --git a/.claude/skills/x-integration/host.ts b/.claude/skills/x-integration/host.ts index a56269d..55a2b3a 100644 --- a/.claude/skills/x-integration/host.ts +++ b/.claude/skills/x-integration/host.ts @@ -8,12 +8,8 @@ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; -import pino from 'pino'; -const logger = pino({ - level: process.env.LOG_LEVEL || 'info', - transport: { target: 'pino-pretty', options: { colorize: true } } -}); +import { logger } from '../../../src/logger.js'; interface SkillResult { success: boolean; @@ -26,7 +22,7 @@ async function runScript(script: string, args: object): Promise { const scriptPath = path.join(process.cwd(), '.claude', 'skills', 'x-integration', 'scripts', `${script}.ts`); return new Promise((resolve) => { - const proc = spawn('npx', ['tsx', scriptPath], { + const proc = spawn('pnpm', ['exec', 'tsx', scriptPath], { cwd: process.cwd(), env: { ...process.env, NANOCLAW_ROOT: process.cwd() }, stdio: ['pipe', 'pipe', 'pipe'] diff --git a/.claude/skills/x-integration/scripts/like.ts b/.claude/skills/x-integration/scripts/like.ts index c55b8b4..b0c60f5 100644 --- a/.claude/skills/x-integration/scripts/like.ts +++ b/.claude/skills/x-integration/scripts/like.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Like Tweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx like.ts + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | pnpm exec tsx like.ts */ import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/post.ts b/.claude/skills/x-integration/scripts/post.ts index f7b47dc..66b1090 100644 --- a/.claude/skills/x-integration/scripts/post.ts +++ b/.claude/skills/x-integration/scripts/post.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Post Tweet - * Usage: echo '{"content":"Hello world"}' | npx tsx post.ts + * Usage: echo '{"content":"Hello world"}' | pnpm exec tsx post.ts */ import { getBrowserContext, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/quote.ts b/.claude/skills/x-integration/scripts/quote.ts index e0d2c33..6c779e7 100644 --- a/.claude/skills/x-integration/scripts/quote.ts +++ b/.claude/skills/x-integration/scripts/quote.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Quote Tweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","comment":"My thoughts"}' | npx tsx quote.ts + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","comment":"My thoughts"}' | pnpm exec tsx quote.ts */ import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/reply.ts b/.claude/skills/x-integration/scripts/reply.ts index e981cab..e4e345d 100644 --- a/.claude/skills/x-integration/scripts/reply.ts +++ b/.claude/skills/x-integration/scripts/reply.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Reply to Tweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","content":"Great post!"}' | npx tsx reply.ts + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","content":"Great post!"}' | pnpm exec tsx reply.ts */ import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/retweet.ts b/.claude/skills/x-integration/scripts/retweet.ts index 05b7437..9c51424 100644 --- a/.claude/skills/x-integration/scripts/retweet.ts +++ b/.claude/skills/x-integration/scripts/retweet.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Retweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx retweet.ts + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | pnpm exec tsx retweet.ts */ import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/setup.ts b/.claude/skills/x-integration/scripts/setup.ts index 94e5c03..36a7c53 100644 --- a/.claude/skills/x-integration/scripts/setup.ts +++ b/.claude/skills/x-integration/scripts/setup.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Authentication Setup - * Usage: npx tsx setup.ts + * Usage: pnpm exec tsx setup.ts * * Interactive script - opens browser for manual login */ diff --git a/.env.example b/.env.example index b90e6c9..e69de29 100644 --- a/.env.example +++ b/.env.example @@ -1 +0,0 @@ -TELEGRAM_BOT_TOKEN= diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 8191085..f1da58c 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -20,10 +20,12 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} + - uses: pnpm/action-setup@v4 + - name: Bump patch version run: | - npm version patch --no-git-tag-version - git add package.json package-lock.json + pnpm version patch --no-git-tag-version + git add package.json git diff --cached --quiet && exit 0 git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e11c2f4..41a3a5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,17 +9,31 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 - cache: npm - - run: npm ci + cache: pnpm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.12 + - run: pnpm install --frozen-lockfile + - name: Install agent-runner deps (Bun) + working-directory: container/agent-runner + run: bun install --frozen-lockfile - name: Format check - run: npm run format:check + run: pnpm run format:check - - name: Typecheck - run: npx tsc --noEmit + - name: Typecheck host + run: pnpm exec tsc --noEmit - - name: Tests - run: npx vitest run + - name: Typecheck container + run: pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit + + - name: Host tests + run: pnpm exec vitest run + + - name: Container tests + working-directory: container/agent-runner + run: bun test diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index bec9d3e..ebfe3f3 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -1,7 +1,12 @@ name: Label PR +# SECURITY: this workflow runs with write access to the base repo on fork PRs, +# because `pull_request_target` executes in the context of the base branch. +# Keep it metadata-only — do NOT add actions/checkout or any step that +# executes PR-supplied content (install scripts, build commands, etc.). +# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ on: - pull_request: + pull_request_target: types: [opened, edited] jobs: diff --git a/.gitignore b/.gitignore index e259fbf..8a57c51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Dependencies node_modules/ .npm-cache/ +# pnpm content-addressable store (created when running in sandbox mode) +.pnpm-store/ # Build output dist/ @@ -9,18 +11,19 @@ store/ data/ logs/ -# Groups - only track base structure and specific CLAUDE.md files +# Groups - per-installation state, not tracked groups/* -!groups/main/ -!groups/global/ -groups/main/* -groups/global/* -!groups/main/CLAUDE.md -!groups/global/CLAUDE.md + +# Composer-managed CLAUDE.md artifacts (regenerated every spawn) and +# per-group memory (CLAUDE.local.md) must never be committed. +**/CLAUDE.local.md +**/.claude-shared.md +**/.claude-fragments/ # Secrets *.keys.json .env +.env* # Temp files .tmp-* diff --git a/.husky/pre-commit b/.husky/pre-commit index 73c726d..799cd8f 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npm run format:fix +pnpm run format:fix diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7e54394 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# Safety net — pnpm-workspace.yaml has the authoritative minimumReleaseAge (4320 min = 3 days) +# This .npmrc value is a fallback if npm is ever invoked directly +minReleaseAge=3d diff --git a/.prettierrc b/.prettierrc index 544138b..0981b7c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,4 @@ { - "singleQuote": true + "singleQuote": true, + "printWidth": 120 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 323c0e1..2ec9fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to NanoClaw will be documented in this file. For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). +## [Unreleased] + +- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). +- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default. + +## [2.0.0] - 2026-04-22 + +Major version. NanoClaw v2 is a substantial architectural rewrite. Existing forks should run `/migrate-nanoclaw` (clean-base replay of customizations) or `/update-nanoclaw` (selective cherry-pick) before resuming work. + +- [BREAKING] **New entity model.** Users, roles (owner/admin), messaging groups, and agent groups are now tracked as separate entities, wired via `messaging_group_agents`. Privilege is user-level instead of channel-level, so the old "main channel = admin" concept is retired. See [docs/architecture.md](docs/architecture.md) and [docs/isolation-model.md](docs/isolation-model.md). +- [BREAKING] **Two-DB session split.** Each session now has `inbound.db` (host writes, container reads) and `outbound.db` (container writes, host reads) with exactly one writer each. Replaces the single shared session DB and eliminates cross-mount SQLite contention. See [docs/db-session.md](docs/db-session.md). +- [BREAKING] **Install flow replaced.** `bash nanoclaw.sh` is the new default: a scripted installer that hands off to Claude Code for error recovery and guided decisions. The `/setup` Claude-guided skill still works as an alternative. +- [BREAKING] **Channels moved to the `channels` branch.** Trunk no longer ships Discord, Slack, Telegram, WhatsApp, iMessage, Teams, Linear, GitHub, WeChat, Matrix, Google Chat, Webex, Resend, or WhatsApp Cloud. Install them per fork via `/add-` skills, which copy from the `channels` branch. `/update-nanoclaw` will re-install the channels your fork had. +- [BREAKING] **Alternative providers moved to the `providers` branch.** OpenCode, Codex, and Ollama install via `/add-opencode`, `/add-codex`, `/add-ollama-provider`. Claude remains the default provider baked into trunk. +- [BREAKING] **Three-level channel isolation.** Wire channels to their own agent (separate agent groups), share an agent with independent conversations (`session_mode: 'shared'`), or merge channels into one shared session (`session_mode: 'agent-shared'`). Chosen per channel via `/manage-channels`. +- [BREAKING] **Apple Container removed from default setup.** Still available as an opt-in via `/convert-to-apple-container`. +- **Shared-source agent-runner.** Per-group `agent-runner-src/` overlays are gone; all groups mount the same agent-runner read-only. Per-group customization flows through composed `CLAUDE.md` (shared base + per-group fragments). +- **Agent-runner runtime moved from Node to Bun.** Container image is self-contained; no host-side impact. Host remains on Node + pnpm. +- **OneCLI Agent Vault is the sole credential path.** Containers never receive raw API keys; credentials are injected at request time. + +## [1.2.36] - 2026-03-26 + +- [BREAKING] Replaced pino logger with built-in logger. WhatsApp users must re-merge the WhatsApp fork to pick up the Baileys logger compatibility fix: `git fetch whatsapp main && git merge whatsapp/main`. If the `whatsapp` remote is not configured: `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git`. + +## [1.2.35] - 2026-03-26 + +- [BREAKING] OneCLI Agent Vault replaces the built-in credential proxy. Check your runtime: `grep CONTAINER_RUNTIME_BIN src/container-runtime.ts` — if it shows `'container'` you are on Apple Container, if `'docker'` you are on Docker. Docker users: run `/init-onecli` to install OneCLI and migrate `.env` credentials to the vault. Apple Container users: re-merge the skill branch (`git fetch upstream skill/apple-container && git merge upstream/skill/apple-container`) then run `/convert-to-apple-container` and follow all instructions (configures credential proxy networking) — do NOT run `/init-onecli`, it requires Docker. + ## [1.2.21] - 2026-03-22 - Added opt-in diagnostics via PostHog with explicit user consent (Yes / No / Never ask again) diff --git a/CLAUDE.md b/CLAUDE.md index 2084578..e65515a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,79 +1,271 @@ +# ⚠️ STOP — READ THIS FIRST IF YOU ARE CLAUDE ⚠️ + +**If you are reading this because you just ran `git pull`, `git merge`, `git fetch && git merge`, or any equivalent to bring in upstream changes — and you see merge conflicts or a large diff involving this file — HALT IMMEDIATELY.** + +This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It cannot be merged into an existing v1 install. Attempting to resolve the conflicts by hand, run builds, or "fix" anything will corrupt the user's install and burn tokens for no result. + +**Do this instead:** +1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed). +2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `bash migrate-v2.sh` to migrate instead."* +3. Wait for the user to confirm before doing anything else. Do not attempt the migration yourself. + +If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below. + +--- + # NanoClaw -Personal Claude assistant. See [README.md](README.md) for philosophy and setup. See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) for architecture decisions. +Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`. ## Quick Context -Single Node.js process with skill-based channel system. Channels (WhatsApp, Telegram, Slack, Discord, Gmail) are skills that self-register at startup. Messages route to Claude Agent SDK running in containers (Linux VMs). Each group has isolated filesystem and memory. +The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls Claude, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter. + +**Everything is a message.** There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface. + +## Entity Model + +``` +users (id ":", kind, display_name) +user_roles (user_id, role, agent_group_id) — owner | admin (global or scoped) +agent_group_members (user_id, agent_group_id) — unprivileged access gate +user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache + +agent_groups (workspace, memory, CLAUDE.md, personality, container config) + ↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority) +messaging_groups (one chat/channel on one platform; unknown_sender_policy) + +sessions (agent_group_id + messaging_group_id + thread_id → per-session container) +``` + +Privilege is user-level (owner/admin), not agent-group-level. See [docs/isolation-model.md](docs/isolation-model.md) for the three isolation levels (`agent-shared`, `shared`, separate agents). + +## Two-DB Session Split + +Each session has **two** SQLite files under `data/v2-sessions//`: + +- `inbound.db` — host writes, container reads. `messages_in`, routing, destinations, pending_questions, processing_ack. +- `outbound.db` — container writes, host reads. `messages_out`, session_state. + +Exactly one writer per file — no cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd. + +## Central DB + +`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`. ## Key Files | File | Purpose | |------|---------| -| `src/index.ts` | Orchestrator: state, message loop, agent invocation | -| `src/channels/registry.ts` | Channel registry (self-registration at startup) | -| `src/ipc.ts` | IPC watcher and task processing | -| `src/router.ts` | Message formatting and outbound routing | -| `src/config.ts` | Trigger pattern, paths, intervals | -| `src/container-runner.ts` | Spawns agent containers with mounts | -| `src/task-scheduler.ts` | Runs scheduled tasks | -| `src/db.ts` | SQLite operations | -| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | -| `container/skills/` | Skills loaded inside agent containers (browser, status, formatting) | +| `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown | +| `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake | +| `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions (schedule, approvals, etc.) | +| `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence | +| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | +| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | +| `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup | +| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | +| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | +| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | +| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge | +| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache | +| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) | +| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations | +| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch | +| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) | +| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations | +| `container/skills/` | Container skills mounted into every agent session | +| `groups//` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) | +| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) | +| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). | -## Secrets / Credentials / Proxy (OneCLI) +## Channels and Providers (skill-installed) -API keys, secret keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway — which handles secret injection into containers at request time, so no keys or tokens are ever passed to containers directly. Run `onecli --help`. +Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills: + +- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud (+ helpers, tests, channel-specific setup steps). Installed via `/add-` skills. +- **`providers` branch** — OpenCode (and any future non-default agent providers). Installed via `/add-opencode`. + +Each `/add-` skill is idempotent: `git fetch origin ` → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → `pnpm install @` → build. + +## Self-Modification + +One tier of agent self-modification today: + +1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`. + +A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented. + +## Secrets / Credentials / OneCLI + +API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`. + +### Gotcha: auto-created agents start in `selective` secret mode + +When the host first spawns a session for a new agent group, `container-runner.ts:385` calls `onecli.ensureAgent({ name, identifier })`. The OneCLI `POST /api/agents` endpoint creates the agent in **`selective`** secret mode — meaning **no secrets are assigned to it by default**, even if the secrets exist in the vault and have host patterns that would otherwise match. + +Symptom: container starts, the proxy + CA cert are wired correctly, but the agent gets `401 Unauthorized` (or similar) from APIs whose credentials *are* in the vault. The credential just isn't in this agent's allow-list. + +The SDK does not expose `setSecretMode` — the only fix is the CLI (or the web UI at `http://127.0.0.1:10254`). + +```bash +# Find the agent (identifier is the agent group id) +onecli agents list + +# Flip to "all" so every vault secret with a matching host pattern gets injected +onecli agents set-secret-mode --id --mode all + +# Or, stay selective and assign specific secrets +onecli secrets list # find secret ids +onecli agents set-secrets --id --secret-ids , + +# Inspect what an agent currently has +onecli agents secrets --id # secrets assigned to this agent +onecli secrets list # all vault secrets (with host patterns) +``` + +If you've just enabled `mode all`, no container restart is needed — the gateway looks up secrets per request, so the next API call from the running container will see the new credentials. + +### Requiring approval for credential use + +Approval-gating credentialed actions is a **two-sided** flow: + +- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating. +- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only. + +If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired. ## Skills -Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy and guidelines. +Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy. -- **Feature skills** — merge a `skill/*` branch to add capabilities (e.g. `/add-telegram`, `/add-slack`) -- **Utility skills** — ship code files alongside SKILL.md (e.g. `/claw`) -- **Operational skills** — instruction-only workflows, always on `main` (e.g. `/setup`, `/debug`) -- **Container skills** — loaded inside agent containers at runtime (`container/skills/`) +- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`). +- **Utility skills** — ship code files alongside `SKILL.md` (e.g. `/claw`). +- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`). +- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `welcome`, `self-customize`, `agent-browser`, `slack-formatting`). | Skill | When to Use | |-------|-------------| -| `/setup` | First-time installation, authentication, service configuration | -| `/customize` | Adding channels, integrations, changing behavior | +| `/setup` | First-time install, auth, service config | +| `/init-first-agent` | Bootstrap the first DM-wired agent (channel pick → identity → wire → welcome DM) | +| `/manage-channels` | Wire channels to agent groups with isolation level decisions | +| `/customize` | Adding channels, integrations, behavior changes | | `/debug` | Container issues, logs, troubleshooting | -| `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install | -| `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch | -| `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks | +| `/update-nanoclaw` | Bring upstream updates into a customized install | +| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials | ## Contributing -Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, SKILL.md format rules, PR requirements, and the pre-submission checklist (searching for existing PRs/issues, testing, description format). +Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist. + +## PR Hygiene + +Before creating a PR, run these checks: + +```bash +git diff upstream/main --stat HEAD +git log upstream/main..HEAD --oneline +``` + +Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included. ## Development -Run commands directly—don't tell the user to run them. +Run commands directly — don't tell the user to run them. ```bash -npm run dev # Run with hot reload -npm run build # Compile TypeScript -./container/build.sh # Rebuild agent container +# Host (Node + pnpm) +pnpm run dev # Host with hot reload +pnpm run build # Compile host TypeScript (src/) +./container/build.sh # Rebuild agent container image (nanoclaw-agent:latest) +pnpm test # Host tests (vitest) + +# Agent-runner (Bun — separate package tree under container/agent-runner/) +cd container/agent-runner && bun install # After editing agent-runner deps +cd container/agent-runner && bun test # Container tests (bun:test) ``` +Container typecheck is a separate tsconfig — if you edit `container/agent-runner/src/`, run `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit` from root (or `bun run typecheck` from `container/agent-runner/`). + Service management: ```bash # macOS (launchd) -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart # Linux (systemd) -systemctl --user start nanoclaw -systemctl --user stop nanoclaw -systemctl --user restart nanoclaw +systemctl --user start|stop|restart nanoclaw ``` ## Troubleshooting -**WhatsApp not connecting after upgrade:** WhatsApp is now a separate skill, not bundled in core. Run `/add-whatsapp` (or `npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp && npm run build`) to install it. Existing auth credentials and groups are preserved. +Check these first when something goes wrong: + +| What | Where | +|------|-------| +| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain | +| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) | +| Session DBs | `data/v2-sessions///` — `inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) | + +Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect. + +## Supply Chain Security (pnpm) + +This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml`. New package versions must exist on the npm registry for 3 days before pnpm will resolve them. + +**Rules — do not bypass without explicit human approval:** +- **`minimumReleaseAgeExclude`**: Never add entries without human sign-off. If a package must bypass the release age gate, the human must approve and the entry must pin the exact version being excluded (e.g. `package@1.2.3`), never a range. +- **`onlyBuiltDependencies`**: Never add packages to this list without human approval — build scripts execute arbitrary code during install. +- **`pnpm install --frozen-lockfile`** should be used in CI, automation, and container builds. Never run bare `pnpm install` in those contexts. + +## Docs Index + +| Doc | Purpose | +|-----|---------| +| [docs/architecture.md](docs/architecture.md) | Full architecture writeup | +| [docs/api-details.md](docs/api-details.md) | Host API + DB schema details | +| [docs/db.md](docs/db.md) | DB architecture overview: three-DB model, cross-mount rules, readers/writers map | +| [docs/db-central.md](docs/db-central.md) | Central DB (`data/v2.db`) — every table + migration system | +| [docs/db-session.md](docs/db-session.md) | Per-session `inbound.db` + `outbound.db` schemas + seq parity | +| [docs/agent-runner-details.md](docs/agent-runner-details.md) | Agent-runner internals + MCP tool interface | +| [docs/isolation-model.md](docs/isolation-model.md) | Three-level channel isolation model | +| [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow | +| [docs/architecture-diagram.md](docs/architecture-diagram.md) | Diagram version of the architecture | +| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants | +| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved | +| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop | ## Container Build Cache The container buildkit caches the build context aggressively. `--no-cache` alone does NOT invalidate COPY steps — the builder's volume retains stale files. To force a truly clean rebuild, prune the builder then re-run `./container/build.sh`. + +## Container Runtime (Bun) + +The agent container runs on **Bun**; the host runs on **Node** (pnpm). They communicate only via session DBs — no shared modules. Details and rationale: [docs/build-and-runtime.md](docs/build-and-runtime.md). + +**Gotchas — trigger + action:** + +- **Adding or bumping a runtime dep in `container/agent-runner/`** → edit `package.json`, then `cd container/agent-runner && bun install` and commit the updated `bun.lock`. Do not run `pnpm install` there — agent-runner is not a pnpm workspace. +- **Bumping `@anthropic-ai/claude-agent-sdk`, `@modelcontextprotocol/sdk`, or any agent-runner runtime dep** → no `minimumReleaseAge` policy applies to this tree. Check the release date on npm, pin deliberately, never `bun update` blindly. +- **Writing a new named-param SQL insert/update in the container** → use `$name` in both SQL and JS keys: `.run({ $id: msg.id })`. `bun:sqlite` does not auto-strip the prefix the way `better-sqlite3` does on the host. Positional `?` params work normally. +- **Adding a test in `container/agent-runner/src/`** → import from `bun:test`, not `vitest`. Vitest runs on Node and can't load `bun:sqlite`. `vitest.config.ts` excludes this tree. +- **Adding a Node CLI the agent invokes at runtime** (like `agent-browser`, `claude-code`, `vercel`) → put it in the Dockerfile's pnpm global-install block, pinned to an exact version via a new `ARG`. Don't use `bun install -g` — that bypasses the pnpm supply-chain policy. +- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~301) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step. +- **Changing session-DB pragmas** (`container/agent-runner/src/db/connection.ts`) → `journal_mode=DELETE` is load-bearing for cross-mount visibility. Read the comment block at the top of the file first. + +## CJK font support + +Agent containers ship without CJK fonts by default (~200MB saved). If you notice signals the user works with Chinese/Japanese/Korean content — conversing in CJK, CJK timezone (e.g., `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Seoul`, `Asia/Taipei`, `Asia/Hong_Kong`), system locale hint, or mentions of needing to render CJK in screenshots/PDFs/scraped pages — offer to enable it: + +```bash +# Ensure .env has INSTALL_CJK_FONTS=true (overwrite or append) +grep -q '^INSTALL_CJK_FONTS=' .env && sed -i.bak 's/^INSTALL_CJK_FONTS=.*/INSTALL_CJK_FONTS=true/' .env && rm -f .env.bak || echo 'INSTALL_CJK_FONTS=true' >> .env + +# Rebuild and restart so new sessions pick up the new image +./container/build.sh +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +`container/build.sh` reads `INSTALL_CJK_FONTS` from `.env` and passes it through as a Docker build-arg. Without CJK fonts, Chromium-rendered screenshots and PDFs containing CJK text show tofu (empty rectangles) instead of characters. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..87fce6f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +community@nanoclaw.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a7816a..413e542 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s 1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge. 2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone. -3. **Check the right box** in the PR template. Labels are auto-applied based on your selection: +3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md). +4. **Check the right box** in the PR template. Labels are auto-applied based on your selection: | Checkbox | Label | |----------|-------| diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ab29b55..c765b12 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -17,3 +17,11 @@ Thanks to everyone who has contributed to NanoClaw! - [edwinwzhe](https://github.com/edwinwzhe) — Edwin He - [scottgl9](https://github.com/scottgl9) — Scott Glover - [ingyukoh](https://github.com/ingyukoh) — Ingyu Koh +- [cschmidt](https://github.com/cschmidt) — Carl Schmidt +- [leonalfredbot-ship-it](https://github.com/leonalfredbot-ship-it) — Alfred-the-buttler +- [moktamd](https://github.com/moktamd) +- [gurixs-carson](https://github.com/gurixs-carson) +- [MrBlaise](https://github.com/MrBlaise) — Balázs Rostás +- [lbsnrs](https://github.com/lbsnrs) — Andreas Liebschner +- [spencer-whitman](https://github.com/spencer-whitman) +- [lazure-ocean](https://github.com/lazure-ocean) — Cyril Ionov diff --git a/README.md b/README.md index 8d1eb37..69f9ea2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ 中文  •   日本語  •   Discord  •   - 34.9k tokens, 17% of context window + repo tokens

--- @@ -26,54 +26,61 @@ NanoClaw provides that same core functionality, but in a codebase small enough t ## Quick Start ```bash -gh repo fork qwibitai/nanoclaw --clone -cd nanoclaw -claude +git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 +cd nanoclaw-v2 +bash nanoclaw.sh ``` -
-Without GitHub CLI +`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke. -1. Fork [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw) on GitHub (click the Fork button) -2. `git clone https://github.com//nanoclaw.git` -3. `cd nanoclaw` -4. `claude` +
+Migrating from NanoClaw v1? + +Run from a fresh v2 checkout next to your v1 install: + +```bash +git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 +cd nanoclaw-v2 +bash migrate-v2.sh +``` + +`migrate-v2.sh` finds your v1 install (sibling directory, or `NANOCLAW_V1_PATH=/path/to/nanoclaw`), migrates state into the v2 checkout, then `exec`s into Claude Code to finish the parts that need judgment (owner seeding, CLAUDE.local.md cleanup, fork-customisation replay). + +Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build. + +**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container. + +**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched. + +See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different and [docs/migration-dev.md](docs/migration-dev.md) for development notes.
-Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup and service configuration. - -> **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal. If you don't have Claude Code installed, get it at [claude.com/product/claude-code](https://claude.com/product/claude-code). - ## Philosophy **Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it. -**Secure by isolation.** Agents run in Linux containers (Apple Container on macOS, or Docker) and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host. +**Secure by isolation.** Agents run in Linux containers and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host. **Built for the individual user.** NanoClaw isn't a monolithic framework; it's software that fits each user's exact needs. Instead of becoming bloatware, NanoClaw is designed to be bespoke. You make your own fork and have Claude Code modify it to match your needs. **Customization = code changes.** No configuration sprawl. Want different behavior? Modify the code. The codebase is small enough that it's safe to make changes. -**AI-native.** -- No installation wizard; Claude Code guides setup. -- No monitoring dashboard; ask Claude what's happening. -- No debugging tools; describe the problem and Claude fixes it. +**AI-native, hybrid by design.** The install and onboarding flow is an optimized scripted path, fast and deterministic. When a step needs judgment, whether a failed install, a guided decision, or a customization, control hands off to Claude Code seamlessly. Beyond setup there's no monitoring dashboard or debugging UI either: describe the problem in chat and Claude Code handles it. -**Skills over features.** Instead of adding features (e.g. support for Telegram) to the codebase, contributors submit [claude code skills](https://code.claude.com/docs/en/skills) like `/add-telegram` that transform your fork. You end up with clean code that does exactly what you need. +**Skills over features.** Trunk ships the registry and infrastructure, not specific channel adapters or alternative agent providers. Channels (Discord, Slack, Telegram, WhatsApp, …) live on a long-lived `channels` branch; alternative providers (OpenCode, Ollama) live on `providers`. You run `/add-telegram`, `/add-opencode`, etc. and the skill copies exactly the module(s) you need into your fork. No feature you didn't ask for. -**Best harness, best model.** NanoClaw runs on the Claude Agent SDK, which means you're running Claude Code directly. Claude Code is highly capable and its coding and problem-solving capabilities allow it to modify and expand NanoClaw and tailor it to each user. +**Best harness, best model.** NanoClaw natively uses Claude Code via Anthropic's official Claude Agent SDK, so you get the latest Claude models and Claude Code's full toolset, including the ability to modify and expand your own NanoClaw fork. Other providers are drop-in options: `/add-codex` for OpenAI's Codex (ChatGPT subscription or API key), `/add-opencode` for OpenRouter, Google, DeepSeek and more via OpenCode, and `/add-ollama-provider` for local open-weight models. Provider is configurable per agent group. ## What It Supports -- **Multi-channel messaging** - Talk to your assistant from WhatsApp, Telegram, Discord, Slack, or Gmail. Add channels with skills like `/add-whatsapp` or `/add-telegram`. Run one or many at the same time. -- **Isolated group context** - Each group has its own `CLAUDE.md` memory, isolated filesystem, and runs in its own container sandbox with only that filesystem mounted to it. -- **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated -- **Scheduled tasks** - Recurring jobs that run Claude and can message you back -- **Web access** - Search and fetch content from the Web -- **Container isolation** - Agents are sandboxed in Docker (macOS/Linux), [Docker Sandboxes](docs/docker-sandboxes.md) (micro VM isolation), or Apple Container (macOS) -- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks -- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills +- **Multi-channel messaging** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-` skills. Run one or many at the same time. +- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md). +- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to. +- **Scheduled tasks** — recurring jobs that run Claude and can message you back +- **Web access** — search and fetch content from the web +- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in +- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits. ## Usage @@ -85,7 +92,7 @@ Talk to your assistant with the trigger word (default: `@Andy`): @Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing ``` -From the main channel (your self-chat), you can manage groups and tasks: +From a channel you own or administer, you can manage groups and tasks: ``` @Andy list all scheduled tasks across groups @Andy pause the Monday briefing task @@ -109,58 +116,62 @@ The codebase is small enough that Claude can safely modify it. **Don't add features. Add skills.** -If you want to add Telegram support, don't create a PR that adds Telegram to the core codebase. Instead, fork NanoClaw, make the code changes on a branch, and open a PR. We'll create a `skill/telegram` branch from your PR that other users can merge into their fork. +If you want to add a new channel or agent provider, don't add it to trunk. New channel adapters land on the `channels` branch; new agent providers land on `providers`. Users install them in their own fork with `/add-` skills, which copy the relevant module(s) into the standard paths, wire the registration, and pin dependencies. -Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case. +This keeps trunk as pure registry and infra, and every fork stays lean — users get the channels and providers they asked for and nothing else. ### RFS (Request for Skills) Skills we'd like to see: **Communication Channels** -- `/add-signal` - Add Signal as a channel +- `/add-signal` — Add Signal as a channel ## Requirements -- macOS, Linux, or Windows (via WSL2) -- Node.js 20+ -- [Claude Code](https://claude.ai/download) -- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) +- macOS or Linux (Windows via WSL2) +- Node.js 20+ and pnpm 10+ (the installer will install both if missing) +- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) or Docker Engine (Linux) +- [Claude Code](https://claude.ai/download) for `/customize`, `/debug`, error recovery during setup, and all `/add-` skills ## Architecture ``` -Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response +messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps ``` -Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem. +A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs Claude, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter. -For the full architecture details, see the [documentation site](https://docs.nanoclaw.dev/concepts/architecture). +Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork. + +For the full architecture writeup see [docs/architecture.md](docs/architecture.md); for the three-level isolation model see [docs/isolation-model.md](docs/isolation-model.md). Key files: -- `src/index.ts` - Orchestrator: state, message loop, agent invocation -- `src/channels/registry.ts` - Channel registry (self-registration at startup) -- `src/ipc.ts` - IPC watcher and task processing -- `src/router.ts` - Message formatting and outbound routing -- `src/group-queue.ts` - Per-group queue with global concurrency limit -- `src/container-runner.ts` - Spawns streaming agent containers -- `src/task-scheduler.ts` - Runs scheduled tasks -- `src/db.ts` - SQLite operations (messages, groups, sessions, state) -- `groups/*/CLAUDE.md` - Per-group memory +- `src/index.ts` — entry point: DB init, channel adapters, delivery polls, sweep +- `src/router.ts` — inbound routing: messaging group → agent group → session → `inbound.db` +- `src/delivery.ts` — polls `outbound.db`, delivers via adapter, handles system actions +- `src/host-sweep.ts` — 60s sweep: stale detection, due-message wake, recurrence +- `src/session-manager.ts` — resolves sessions, opens `inbound.db` / `outbound.db` +- `src/container-runner.ts` — spawns per-agent-group containers, OneCLI credential injection +- `src/db/` — central DB (users, roles, agent groups, messaging groups, wiring, migrations) +- `src/channels/` — channel adapter infra (adapters installed via `/add-` skills) +- `src/providers/` — host-side provider config (`claude` baked in; others via skills) +- `container/agent-runner/` — Bun agent-runner: poll loop, MCP tools, provider abstraction +- `groups//` — per-agent-group filesystem (`CLAUDE.md`, skills, container config) ## FAQ **Why Docker?** -Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM. +Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM. **Can I run this on Linux or Windows?** -Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `/setup`. +Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `bash nanoclaw.sh`. **Is this secure?** -Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model. +Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. Credentials never enter the container — outbound API requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects authentication at the proxy level and supports rate limits and access policies. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model. **Why no configuration files?** @@ -168,33 +179,28 @@ We don't want configuration sprawl. Every user should customize NanoClaw so that **Can I use third-party or open-source models?** -Yes. NanoClaw supports any Claude API-compatible model endpoint. Set these environment variables in your `.env` file: +Yes. The supported path is `/add-opencode` (OpenRouter, OpenAI, Google, DeepSeek, and more via OpenCode config) or `/add-ollama-provider` (local open-weight models via Ollama). Both are configurable per agent group, so different agents can run on different backends in the same install. + +For one-off experiments, any Claude API-compatible endpoint also works via `.env`: ```bash ANTHROPIC_BASE_URL=https://your-api-endpoint.com ANTHROPIC_AUTH_TOKEN=your-token-here ``` -This allows you to use: -- Local models via [Ollama](https://ollama.ai) with an API proxy -- Open-source models hosted on [Together AI](https://together.ai), [Fireworks](https://fireworks.ai), etc. -- Custom model deployments with Anthropic-compatible APIs - -Note: The model must support the Anthropic API format for best compatibility. - **How do I debug issues?** Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach that underlies NanoClaw. **Why isn't the setup working for me?** -If you have issues, during setup, Claude will try to dynamically fix them. If that doesn't work, run `claude`, then run `/debug`. If Claude finds an issue that is likely affecting other users, open a PR to modify the setup SKILL.md. +If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill. **What changes will be accepted into the codebase?** Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all. -Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills. +Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills on the `channels` or `providers` branch. This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want. diff --git a/README_ja.md b/README_ja.md index 5c3f648..947db95 100644 --- a/README_ja.md +++ b/README_ja.md @@ -8,90 +8,56 @@

nanoclaw.dev  •   + ドキュメント  •   English  •   中文  •   Discord  •   - 34.9k tokens, 17% of context window + repo tokens

--- -

🐳 Dockerサンドボックスで動作

-

各エージェントはマイクロVM内の独立したコンテナで実行されます。
ハイパーバイザーレベルの分離。ミリ秒で起動。複雑なセットアップ不要。

- -**macOS (Apple Silicon)** -```bash -curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash -``` - -**Windows (WSL)** -```bash -curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash -``` - -> 現在、macOS(Apple Silicon)とWindows(x86)に対応しています。Linux対応は近日公開予定。 - -

発表記事を読む →  ·  手動セットアップガイド →

- ---- - ## NanoClawを作った理由 -[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、理解しきれない複雑なソフトウェアに自分の生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOS レベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。 +[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、自分が理解しきれない複雑なソフトウェアに生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOSレベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。 -NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています:1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。 +NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています。1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。 ## クイックスタート ```bash -gh repo fork qwibitai/nanoclaw --clone -cd nanoclaw -claude +git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 +cd nanoclaw-v2 +bash nanoclaw.sh ``` -
-GitHub CLIなしの場合 - -1. GitHub上で[qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)をフォーク(Forkボタンをクリック) -2. `git clone https://github.com/<あなたのユーザー名>/nanoclaw.git` -3. `cd nanoclaw` -4. `claude` - -
- -その後、`/setup`を実行します。Claude Codeがすべてを処理します:依存関係、認証、コンテナセットアップ、サービス設定。 - -> **注意:** `/`で始まるコマンド(`/setup`、`/add-whatsapp`など)は[Claude Codeスキル](https://code.claude.com/docs/en/skills)です。通常のターミナルではなく、`claude` CLIプロンプト内で入力してください。Claude Codeをインストールしていない場合は、[claude.com/product/claude-code](https://claude.com/product/claude-code)から入手してください。 +`nanoclaw.sh`は、まっさらなマシンから、メッセージを送れる名前付きエージェントが動く状態までを一気通貫で案内します。NodeやpnpmやDockerが無ければインストールし、AnthropicクレデンシャルをOneCLIに登録し、エージェントコンテナをビルドし、最初のチャネル(Telegram、Discord、WhatsApp、またはローカルCLI)とペアリングします。途中でステップが失敗すれば自動的にClaude Codeが呼び出され、原因を診断して中断箇所から再開します。 ## 設計思想 -**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を理解したい場合は、Claude Codeに説明を求めるだけです。 +**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を把握したいなら、Claude Codeに説明を求めれば十分です。 -**分離によるセキュリティ。** エージェントはLinuxコンテナ(macOSではApple Container、またはDocker)で実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスは安全です。 +**分離によるセキュリティ。** エージェントはLinuxコンテナで実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスも安全です。 -**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドになるよう設計されています。自分のフォークを作成し、Claude Codeにニーズに合わせて変更させます。 +**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドであるよう設計されています。自分のフォークを作り、Claude Codeにニーズに合わせて変更させます。 -**カスタマイズ=コード変更。** 設定ファイルの肥大化なし。動作を変えたい?コードを変更するだけ。コードベースは変更しても安全な規模です。 +**カスタマイズ=コード変更。** 設定の肥大化はありません。動作を変えたいならコードを変える。コードベースは変更しても安全な規模です。 -**AIネイティブ。** -- インストールウィザードなし — Claude Codeがセットアップを案内。 -- モニタリングダッシュボードなし — Claudeに状況を聞くだけ。 -- デバッグツールなし — 問題を説明すればClaudeが修正。 +**AIネイティブ、設計としてハイブリッド。** インストールとオンボーディングは最適化されたスクリプトのパスで、速く決定的です。判断が必要なところ(インストール失敗、対話的な決定、カスタマイズ)では、制御はシームレスにClaude Codeへ渡されます。セットアップ以降も、監視ダッシュボードやデバッグUIは用意しません。問題をチャットで説明すれば、Claude Codeが処理します。 -**機能追加ではなくスキル。** コードベースに機能(例:Telegram対応)を追加する代わりに、コントリビューターは`/add-telegram`のような[Claude Codeスキル](https://code.claude.com/docs/en/skills)を提出し、あなたのフォークを変換します。あなたが必要なものだけを正確に実行するクリーンなコードが手に入ります。 +**機能ではなくスキル。** トランクにはレジストリとインフラのみを同梱し、個別のチャネルアダプターや代替プロバイダーは含めません。チャネル(Discord、Slack、Telegram、WhatsAppなど)は長期運用される`channels`ブランチに、代替プロバイダー(OpenCode、Ollama)は`providers`ブランチに置かれます。`/add-telegram`や`/add-opencode`などを実行すると、スキルが必要なモジュールだけを正確にフォークへコピーします。要求していない機能は一切入りません。 -**最高のハーネス、最高のモデル。** NanoClawはClaude Agent SDK上で動作します。つまり、Claude Codeを直接実行しているということです。Claude Codeは高い能力を持ち、そのコーディングと問題解決能力によってNanoClawを変更・拡張し、各ユーザーに合わせてカスタマイズできます。 +**最高のハーネス、最高のモデル。** NanoClawはAnthropic公式のClaude Agent SDK経由でネイティブにClaude Codeを使用します。最新のClaudeモデルとClaude Codeの全ツールセット(自分のNanoClawフォークを変更・拡張する能力を含む)が手に入ります。他プロバイダーはドロップイン・オプションです。OpenAIのCodex(ChatGPTサブスクリプションまたはAPIキー)向けには`/add-codex`、OpenCode経由のOpenRouter、Google、DeepSeekなどには`/add-opencode`、ローカルのオープンウェイトモデルには`/add-ollama-provider`。プロバイダーはエージェントグループごとに設定可能です。 ## サポート機能 -- **マルチチャネルメッセージング** - WhatsApp、Telegram、Discord、Slack、Gmailからアシスタントと会話。`/add-whatsapp`や`/add-telegram`などのスキルでチャネルを追加。1つでも複数でも同時に実行可能。 -- **グループごとの分離コンテキスト** - 各グループは独自の`CLAUDE.md`メモリ、分離されたファイルシステムを持ち、そのファイルシステムのみがマウントされた専用コンテナサンドボックスで実行。 -- **メインチャネル** - 管理制御用のプライベートチャネル(セルフチャット)。各グループは完全に分離。 -- **スケジュールタスク** - Claudeを実行し、メッセージを返せる定期ジョブ。 -- **Webアクセス** - Webからのコンテンツ検索・取得。 -- **コンテナ分離** - エージェントは[Dockerサンドボックス](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes)(マイクロVM分離)、Apple Container(macOS)、またはDocker(macOS/Linux)でサンドボックス化。 -- **エージェントスウォーム** - 複雑なタスクで協力する専門エージェントチームを起動。 -- **オプション連携** - Gmail(`/add-gmail`)などをスキルで追加。 +- **マルチチャネルメッセージング** — WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat、Resend経由のメール。`/add-`スキルでオンデマンドにインストール。1つでも複数でも同時に実行可能。 +- **柔軟な分離モデル** — チャネルごとに専用エージェントを割り当てて完全プライバシーを確保することも、複数チャネルで1つのエージェントを共有して会話は分離しつつメモリを統一することも、複数チャネルを1つの共有セッションにまとめて会話を横断させることもできます。`/manage-channels`でチャネル単位に選択。[docs/isolation-model.md](docs/isolation-model.md)参照。 +- **エージェントごとのワークスペース** — 各エージェントグループは独自の`CLAUDE.md`、独自のメモリ、独自のコンテナ、そしてあなたが許可したマウントのみを持ちます。明示的に配線しない限り、境界を越えるものはありません。 +- **スケジュールタスク** — Claudeを実行し、結果を返信できる定期ジョブ。 +- **Webアクセス** — Webからの検索とコンテンツ取得。 +- **コンテナ分離** — エージェントはDockerでサンドボックス化されます(macOS/Linux/WSL2)。[Docker Sandboxes](docs/docker-sandboxes.md)によるマイクロVM分離や、macOSネイティブのオプトインとしてApple Containerも選択可能です。 +- **クレデンシャルのセキュリティ** — エージェントは生のAPIキーを保持しません。アウトバウンドリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、リクエスト時に認証情報を注入して、エージェントごとのポリシーとレート制限を適用します。 ## 使い方 @@ -103,7 +69,7 @@ claude @Andy 毎週月曜の朝8時に、Hacker NewsとTechCrunchからAI関連のニュースをまとめてブリーフィングを送って ``` -メインチャネル(セルフチャット)から、グループやタスクを管理できます: +所有または管理しているチャネルからは、グループやタスクを管理できます: ``` @Andy 全グループのスケジュールタスクを一覧表示して @Andy 月曜のブリーフィングタスクを一時停止して @@ -112,14 +78,14 @@ claude ## カスタマイズ -NanoClawは設定ファイルを使いません。変更するには、Claude Codeに伝えるだけです: +NanoClawは設定ファイルを使いません。変更したいときは、Claude Codeにやりたいことを伝えるだけです: - 「トリガーワードを@Bobに変更して」 - 「今後はレスポンスをもっと短く直接的にして」 - 「おはようと言ったらカスタム挨拶を追加して」 - 「会話の要約を毎週保存して」 -または`/customize`を実行してガイド付きの変更を行えます。 +または`/customize`を実行すればガイド付きで変更できます。 コードベースは十分に小さいため、Claudeが安全に変更できます。 @@ -127,105 +93,101 @@ NanoClawは設定ファイルを使いません。変更するには、Claude Co **機能を追加するのではなく、スキルを追加してください。** -Telegram対応を追加したい場合、コアコードベースにTelegramを追加するPRを作成しないでください。代わりに、NanoClawをフォークし、ブランチでコード変更を行い、PRを開いてください。あなたのPRから`skill/telegram`ブランチを作成し、他のユーザーが自分のフォークにマージできるようにします。 +新しいチャネルやエージェントプロバイダーを追加したい場合、トランクには追加しないでください。新しいチャネルアダプターは`channels`ブランチに、新しいエージェントプロバイダーは`providers`ブランチに追加します。ユーザーはそれぞれのフォークで`/add-`スキルを実行し、スキルが必要なモジュールを標準パスへコピーし、登録を配線し、依存関係をピン留めします。 -ユーザーは自分のフォークで`/add-telegram`を実行するだけで、あらゆるユースケースに対応しようとする肥大化したシステムではなく、必要なものだけを正確に実行するクリーンなコードが手に入ります。 +こうすることでトランクは純粋なレジストリ/インフラのまま保たれ、どのフォークもスリムなままです。ユーザーは求めたチャネルとプロバイダーだけを受け取り、それ以外は入りません。 ### RFS(スキル募集) -私たちが求めているスキル: +私たちが見たいスキル: **コミュニケーションチャネル** -- `/add-signal` - Signalをチャネルとして追加 - -**セッション管理** -- `/clear` - 会話をコンパクト化する`/clear`コマンドの追加(同一セッション内で重要な情報を保持しながらコンテキストを要約)。Claude Agent SDKを通じてプログラム的にコンパクト化をトリガーする方法の解明が必要。 +- `/add-signal` — Signalをチャネルとして追加 ## 必要条件 -- macOSまたはLinux -- Node.js 20以上 -- [Claude Code](https://claude.ai/download) -- [Apple Container](https://github.com/apple/container)(macOS)または[Docker](https://docker.com/products/docker-desktop)(macOS/Linux) +- macOSまたはLinux(WindowsはWSL2経由) +- Node.js 20以上とpnpm 10以上(インストーラーが未インストールなら両方をインストールします) +- [Docker Desktop](https://docker.com/products/docker-desktop)(macOS/Windows)または Docker Engine(Linux) +- [Claude Code](https://claude.ai/download)(`/customize`、`/debug`、セットアップ時のエラー復旧、全ての`/add-`スキルで使用) ## アーキテクチャ ``` -チャネル --> SQLite --> ポーリングループ --> コンテナ(Claude Agent SDK) --> レスポンス +メッセージングアプリ → ホストプロセス(ルーター) → inbound.db → コンテナ(Bun、Claude Agent SDK) → outbound.db → ホストプロセス(配信) → メッセージングアプリ ``` -単一のNode.jsプロセス。チャネルはスキルで追加され、起動時に自己登録します — オーケストレーターは認証情報が存在するチャネルを接続します。エージェントはファイルシステム分離された独立したLinuxコンテナで実行されます。マウントされたディレクトリのみアクセス可能。グループごとのメッセージキューと同時実行制御。ファイルシステム経由のIPC。 +単一のNodeホストがセッションごとのエージェントコンテナをオーケストレーションします。メッセージが到着すると、ホストはエンティティモデル(ユーザー → メッセージンググループ → エージェントグループ → セッション)に沿ってルーティングし、セッションの`inbound.db`に書き込み、コンテナを起こします。コンテナ内部のagent-runnerは`inbound.db`をポーリングしてClaudeを実行し、レスポンスを`outbound.db`に書き込みます。ホストは`outbound.db`をポーリングし、チャネルアダプターを通じて配信します。 -詳細なアーキテクチャについては、[docs/SPEC.md](docs/SPEC.md)を参照してください。 +セッションごとに2つのSQLiteファイル、各ファイルにライターは1つだけ — クロスマウントの競合なし、IPCなし、stdinパイプなし。チャネルと代替プロバイダーは起動時に自己登録します。トランクはレジストリとChat SDKブリッジを同梱し、アダプター本体はフォークごとにスキルでインストールされます。 + +詳しいアーキテクチャ説明は[docs/architecture.md](docs/architecture.md)を、3階層の分離モデルについては[docs/isolation-model.md](docs/isolation-model.md)を参照してください。 主要ファイル: -- `src/index.ts` - オーケストレーター:状態、メッセージループ、エージェント呼び出し -- `src/channels/registry.ts` - チャネルレジストリ(起動時の自己登録) -- `src/ipc.ts` - IPCウォッチャーとタスク処理 -- `src/router.ts` - メッセージフォーマットとアウトバウンドルーティング -- `src/group-queue.ts` - グローバル同時実行制限付きのグループごとのキュー -- `src/container-runner.ts` - ストリーミングエージェントコンテナの起動 -- `src/task-scheduler.ts` - スケジュールタスクの実行 -- `src/db.ts` - SQLite操作(メッセージ、グループ、セッション、状態) -- `groups/*/CLAUDE.md` - グループごとのメモリ +- `src/index.ts` — エントリーポイント:DB初期化、チャネルアダプター、配信ポーリング、sweep +- `src/router.ts` — インバウンドルーティング:メッセージンググループ → エージェントグループ → セッション → `inbound.db` +- `src/delivery.ts` — `outbound.db`をポーリングし、アダプター経由で配信、システムアクションを処理 +- `src/host-sweep.ts` — 60秒ごとのsweep:ストール検出、期限到来メッセージの起動、繰り返し +- `src/session-manager.ts` — セッションの解決、`inbound.db`と`outbound.db`のオープン +- `src/container-runner.ts` — エージェントグループごとのコンテナ起動、OneCLIによるクレデンシャル注入 +- `src/db/` — セントラルDB(ユーザー、ロール、エージェントグループ、メッセージンググループ、配線、マイグレーション) +- `src/channels/` — チャネルアダプターのインフラ(アダプターは`/add-`スキルでインストール) +- `src/providers/` — ホスト側プロバイダー設定(`claude`はバンドル、その他はスキル経由) +- `container/agent-runner/` — Bun製agent-runner:ポーリングループ、MCPツール、プロバイダー抽象化 +- `groups//` — エージェントグループごとのファイルシステム(`CLAUDE.md`、スキル、コンテナ設定) ## FAQ **なぜDockerなのか?** -Dockerはクロスプラットフォーム対応(macOS、Linux、さらにWSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使用できます。 +Dockerはクロスプラットフォーム対応(macOS、Linux、WSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使えます。さらに強い分離が必要なら、[Docker Sandboxes](docs/docker-sandboxes.md)が各コンテナをマイクロVM内で動作させます。 -**Linuxで実行できますか?** +**LinuxやWindowsで実行できますか?** -はい。DockerがデフォルトのランタイムでmacOSとLinuxの両方で動作します。`/setup`を実行するだけです。 +はい。Dockerがデフォルトのランタイムで、macOS、Linux、Windows(WSL2経由)で動作します。`bash nanoclaw.sh`を実行するだけです。 **セキュリティは大丈夫ですか?** -エージェントはアプリケーションレベルのパーミッションチェックの背後ではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセスできます。実行するものをレビューすべきですが、コードベースは十分に小さいため実際にレビュー可能です。完全なセキュリティモデルについては[docs/SECURITY.md](docs/SECURITY.md)を参照してください。 +エージェントはアプリケーションレベルのパーミッションチェックではなく、コンテナ内で実行されます。明示的にマウントされたディレクトリのみアクセス可能です。クレデンシャルはコンテナに渡されず、アウトバウンドAPIリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、プロキシレベルで認証を注入し、レートリミットやアクセスポリシーをサポートします。実行するものはレビューすべきですが、コードベースは実際にレビュー可能な規模です。完全なセキュリティモデルについては[セキュリティドキュメント](https://docs.nanoclaw.dev/concepts/security)を参照してください。 **なぜ設定ファイルがないのか?** -設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなく、コードが必要なことを正確に実行するようにすべきです。設定ファイルが欲しい場合は、Claudeに追加するよう伝えることができます。 +設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなくコードが自分の望み通りに動くようにすべきです。設定ファイルが欲しければClaudeに追加するよう伝えれば実現できます。 **サードパーティやオープンソースモデルを使えますか?** -はい。NanoClawはClaude API互換のモデルエンドポイントに対応しています。`.env`ファイルで以下の環境変数を設定してください: +はい。推奨される方法は`/add-opencode`(OpenCode設定経由でOpenRouter、OpenAI、Google、DeepSeekなど)か`/add-ollama-provider`(Ollama経由でローカルのオープンウェイトモデル)です。どちらもエージェントグループごとに設定可能なので、同じインストール内で異なるエージェントが異なるバックエンドで動作できます。 + +一時的な実験用には、Claude API互換のエンドポイントも`.env`で利用できます: ```bash ANTHROPIC_BASE_URL=https://your-api-endpoint.com ANTHROPIC_AUTH_TOKEN=your-token-here ``` -以下が使用可能です: -- [Ollama](https://ollama.ai)とAPIプロキシ経由のローカルモデル -- [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai)等でホストされたオープンソースモデル -- Anthropic互換APIのカスタムモデルデプロイメント - -注意:最高の互換性のため、モデルはAnthropic APIフォーマットに対応している必要があります。 - **問題のデバッグ方法は?** Claude Codeに聞いてください。「スケジューラーが動いていないのはなぜ?」「最近のログには何がある?」「このメッセージに返信がなかったのはなぜ?」これがNanoClawの基盤となるAIネイティブなアプローチです。 **セットアップがうまくいかない場合は?** -問題がある場合、セットアップ中にClaudeが動的に修正を試みます。それでもうまくいかない場合は、`claude`を実行してから`/debug`を実行してください。Claudeが他のユーザーにも影響する可能性のある問題を見つけた場合は、セットアップのSKILL.mdを修正するPRを開いてください。 +ステップが失敗した場合、`nanoclaw.sh`は診断と再開のためにClaude Codeへ制御を渡します。それでも解決しなければ、`claude`を実行して`/debug`を呼び出してください。他のユーザーにも影響しそうな問題をClaudeが特定した場合は、該当のセットアップステップまたはスキルにPRを送ってください。 **どのような変更がコードベースに受け入れられますか?** -セキュリティ修正、バグ修正、明確な改善のみが基本設定に受け入れられます。それだけです。 +ベース設定に受け入れられるのは、セキュリティ修正、バグ修正、明確な改善のみです。それだけです。 -それ以外のすべて(新機能、OS互換性、ハードウェアサポート、機能拡張)はスキルとしてコントリビューションすべきです。 +それ以外(新機能、OS互換性、ハードウェアサポート、拡張など)は、`channels`または`providers`ブランチのスキルとしてコントリビュートしてください。 -これにより、基本システムを最小限に保ち、すべてのユーザーが不要な機能を継承することなく、自分のインストールをカスタマイズできます。 +これにより、ベースシステムを最小限に保ち、全ユーザーが不要な機能を継承することなく自分のインストールをカスタマイズできます。 ## コミュニティ -質問やアイデアは?[Discordに参加](https://discord.gg/VDdww8qS42)してください。 +質問やアイデアがありますか?[Discordに参加](https://discord.gg/VDdww8qS42)してください。 ## 変更履歴 -破壊的変更と移行ノートについては[CHANGELOG.md](CHANGELOG.md)を参照してください。 +破壊的変更については[CHANGELOG.md](CHANGELOG.md)を、完全なリリース履歴はドキュメントサイトの[full release history](https://docs.nanoclaw.dev/changelog)を参照してください。 ## ライセンス diff --git a/README_zh.md b/README_zh.md index 714bd87..9db5c28 100644 --- a/README_zh.md +++ b/README_zh.md @@ -3,90 +3,87 @@

- NanoClaw —— 您的专属 Claude 助手,在容器中安全运行。它轻巧易懂,并能根据您的个人需求灵活定制。 + 一个将智能体安全运行在独立容器中的 AI 助手。轻量、易于理解,并可根据您的需求完全定制。

nanoclaw.dev  •   + 文档  •   English  •   日本語  •   Discord  •   - 34.9k tokens, 17% of context window + repo tokens

-通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。 -**新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。 +--- -## 我为什么创建这个项目 +## 我为什么创建 NanoClaw -[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。 +[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解、却能访问我个人隐私的复杂软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(白名单、配对码),而非真正的操作系统级隔离。所有东西都在一个共享内存的 Node 进程中运行。 -NanoClaw 用一个您能快速理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。 +NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能:一个进程,少数几个文件。Claude 智能体运行在具有文件系统隔离的独立 Linux 容器中,而不是仅靠权限检查。 ## 快速开始 ```bash -git clone https://github.com/qwibitai/nanoclaw.git -cd nanoclaw -claude +git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2 +cd nanoclaw-v2 +bash nanoclaw.sh ``` -然后运行 `/setup`。Claude Code 会处理一切:依赖安装、身份验证、容器设置、服务配置。 - -> **注意:** 以 `/` 开头的命令(如 `/setup`、`/add-whatsapp`)是 [Claude Code 技能](https://code.claude.com/docs/en/skills)。请在 `claude` CLI 提示符中输入,而非在普通终端中。 +`nanoclaw.sh` 会把您从一台全新机器一直带到一个可以直接发消息的命名智能体。它会在缺失时安装 Node、pnpm 和 Docker,向 OneCLI 注册您的 Anthropic 凭据,构建智能体容器,并配对您的第一个渠道(Telegram、Discord、WhatsApp 或本地 CLI)。如果某一步失败,会自动调用 Claude Code 进行诊断并从中断处继续。 ## 设计哲学 -**小巧易懂:** 单一进程,少量源文件。无微服务、无消息队列、无复杂抽象层。让 Claude Code 引导您轻松上手。 +**小到可以理解。** 单一进程,少量源文件,无微服务。如果您想了解完整的 NanoClaw 代码库,直接让 Claude Code 给您讲一遍就行。 -**通过隔离保障安全:** 智能体运行在 Linux 容器(在 macOS 上是 Apple Container,或 Docker)中。它们只能看到被明确挂载的内容。即便通过 Bash 访问也十分安全,因为所有命令都在容器内执行,不会直接操作您的宿主机。 +**通过隔离实现安全。** 智能体运行在 Linux 容器中,只能看到明确挂载的内容。Bash 访问是安全的,因为命令在容器内执行,而不是在您的宿主机上。 -**为单一用户打造:** 这不是一个框架,是一个完全符合您个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配。 +**为个人用户打造。** NanoClaw 不是一个单体框架,而是能精确匹配每个用户需求的软件。它被设计成量身定制的,而不是臃肿膨胀。您创建自己的 fork,让 Claude Code 按您的需求修改它。 -**定制即代码修改:** 没有繁杂的配置文件。想要不同的行为?直接修改代码。代码库足够小,这样做是安全的。 +**定制 = 修改代码。** 没有配置膨胀。想要不同的行为?改代码。代码库小到改动是安全的。 -**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)。 +**AI 原生,混合式设计。** 安装与上手流程走的是经过优化的脚本路径,快速且确定。当某一步需要判断(安装失败、引导决策、定制化)时,控制权会无缝地交给 Claude Code。安装之后也不提供监控仪表盘或调试 UI:您在聊天中描述问题,Claude Code 来处理。 -**技能(Skills)优于功能(Features):** 贡献者不应该向代码库添加新功能(例如支持 Telegram)。相反,他们应该贡献像 `/add-telegram` 这样的 [Claude Code 技能](https://code.claude.com/docs/en/skills),这些技能可以改造您的 fork。最终,您得到的是只做您需要事情的整洁代码。 +**技能优于功能。** 主干只发布注册表和基础设施,不包含具体的渠道适配器或替代智能体提供者。各个渠道(Discord、Slack、Telegram、WhatsApp……)放在长期存在的 `channels` 分支上;替代提供者(OpenCode、Ollama)放在 `providers` 分支上。您运行 `/add-telegram`、`/add-opencode` 等,技能会把您所需要的模块精确地复制到您的 fork 里。不会出现您没要求的功能。 -**最好的工具套件,最好的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。Claude Code 高度强大,其编码和问题解决能力使其能够修改和扩展 NanoClaw,为每个用户量身定制。 +**最强的 harness,最强的模型。** NanoClaw 通过 Anthropic 官方的 Claude Agent SDK 原生使用 Claude Code,所以您能用上最新的 Claude 模型以及 Claude Code 的完整工具集——包括修改和扩展自己的 NanoClaw fork 的能力。其他提供者是可插拔选项:`/add-codex` 对应 OpenAI 的 Codex(ChatGPT 订阅或 API key),`/add-opencode` 通过 OpenCode 接入 OpenRouter、Google、DeepSeek 等,`/add-ollama-provider` 用于本地开源权重模型。提供者可按智能体组单独配置。 ## 功能支持 -- **多渠道消息** - 通过 WhatsApp、Telegram、Discord、Slack 或 Gmail 与您的助手对话。使用 `/add-whatsapp` 或 `/add-telegram` 等技能添加渠道,可同时运行一个或多个。 -- **隔离的群组上下文** - 每个群组都拥有独立的 `CLAUDE.md` 记忆和隔离的文件系统。它们在各自的容器沙箱中运行,且仅挂载所需的文件系统。 -- **主频道** - 您的私有频道(self-chat),用于管理控制;其他所有群组都完全隔离 -- **计划任务** - 运行 Claude 的周期性作业,并可以给您回发消息 -- **网络访问** - 搜索和抓取网页内容 -- **容器隔离** - 智能体在 Apple Container (macOS) 或 Docker (macOS/Linux) 的沙箱中运行 -- **智能体集群(Agent Swarms)** - 启动多个专业智能体团队,协作完成复杂任务(首个支持此功能的个人 AI 助手) -- **可选集成** - 通过技能添加 Gmail (`/add-gmail`) 等更多功能 +- **多渠道消息** — WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat,以及通过 Resend 的邮件。按需通过 `/add-` 技能安装。可同时运行一个或多个。 +- **灵活的隔离模式** — 可为每个渠道配一个独立智能体以获得完全隐私,也可让一个智能体在多个渠道上共享、统一记忆但会话独立,或者把多个渠道合并到一个共享会话里,让一场对话横跨多个入口。通过 `/manage-channels` 按渠道选择。详见 [docs/isolation-model.md](docs/isolation-model.md)。 +- **每个智能体的独立工作区** — 每个智能体组都有自己的 `CLAUDE.md`、自己的记忆、自己的容器,以及您允许的挂载点。除非您明确接线,否则不会有东西越过边界。 +- **计划任务** — 运行 Claude 的周期性作业,可以给您回发消息。 +- **网络访问** — 搜索和抓取网页内容。 +- **容器隔离** — 智能体在 Docker(macOS/Linux/WSL2)中沙箱化运行,可选 [Docker Sandboxes](docs/docker-sandboxes.md) 的微虚拟机隔离,或在 macOS 上选用 Apple Container 作为原生运行时。 +- **凭据安全** — 智能体不持有原始 API key。出站请求经由 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli),在请求时注入凭据,并按每个智能体执行策略和速率限制。 ## 使用方法 -使用触发词(默认为 `@Andy`)与您的助手对话: +用触发词(默认为 `@Andy`)与您的助手对话: ``` -@Andy 每周一到周五早上9点,给我发一份销售渠道的概览(需要访问我的 Obsidian vault 文件夹) -@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入,就更新它 -@Andy 每周一早上8点,从 Hacker News 和 TechCrunch 收集关于 AI 发展的资讯,然后发给我一份简报 +@Andy 每个工作日早上 9 点给我发一份销售渠道概览(可以访问我的 Obsidian vault 文件夹) +@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入就更新它 +@Andy 每周一早上 8 点,从 Hacker News 和 TechCrunch 收集 AI 相关资讯,给我发一份简报 ``` -在主频道(您的self-chat)中,可以管理群组和任务: +在您拥有或管理的渠道里,还可以管理群组和任务: ``` -@Andy 列出所有群组的计划任务 +@Andy 列出所有群组里的计划任务 @Andy 暂停周一简报任务 @Andy 加入"家庭聊天"群组 ``` ## 定制 -没有需要学习的配置文件。直接告诉 Claude Code 您想要什么: +NanoClaw 不用配置文件。想改就直接告诉 Claude Code: - "把触发词改成 @Bob" -- "记住以后回答要更简短直接" -- "当我说早上好的时候,加一个自定义的问候" -- "每周存储一次对话摘要" +- "以后回答请更简短、更直接" +- "我说早上好的时候加一个自定义问候" +- "每周保存一次会话摘要" 或者运行 `/customize` 进行引导式修改。 @@ -94,107 +91,103 @@ claude ## 贡献 -**不要添加功能,而是添加技能。** +**不要加功能,要加技能。** -如果您想添加 Telegram 支持,不要创建一个 PR 同时添加 Telegram 和 WhatsApp。而是贡献一个技能文件 (`.claude/skills/add-telegram/SKILL.md`),教 Claude Code 如何改造一个 NanoClaw 安装以使用 Telegram。 +如果您想添加新的渠道或智能体提供者,不要把它加到主干上。新的渠道适配器进入 `channels` 分支;新的智能体提供者进入 `providers` 分支。用户在自己的 fork 上运行 `/add-` 技能,由技能把相关模块复制到标准路径、接好注册、固定依赖版本。 -然后用户在自己的 fork 上运行 `/add-telegram`,就能得到只做他们需要事情的整洁代码,而不是一个试图支持所有用例的臃肿系统。 +这样主干始终保持为纯粹的注册表和基础设施,每个 fork 也都保持精简——用户只获得他们要求的渠道和提供者,其它什么也不会混进来。 -### RFS (技能征集) +### RFS(技能征集) 我们希望看到的技能: **通信渠道** -- `/add-signal` - 添加 Signal 作为渠道 - -**会话管理** -- `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。 +- `/add-signal` — 添加 Signal 作为渠道 ## 系统要求 -- macOS 或 Linux -- Node.js 20+ -- [Claude Code](https://claude.ai/download) -- [Apple Container](https://github.com/apple/container) (macOS) 或 [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) +- macOS 或 Linux(Windows 通过 WSL2) +- Node.js 20+ 和 pnpm 10+(安装脚本会在缺失时自动安装) +- [Docker Desktop](https://docker.com/products/docker-desktop)(macOS/Windows)或 Docker Engine(Linux) +- [Claude Code](https://claude.ai/download),用于 `/customize`、`/debug`、安装过程中的错误恢复以及所有 `/add-` 技能 ## 架构 ``` -渠道 --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应 +消息应用 → 主机进程(路由器) → inbound.db → 容器(Bun、Claude Agent SDK) → outbound.db → 主机进程(投递) → 消息应用 ``` -单一 Node.js 进程。渠道通过技能添加,启动时自注册 — 编排器连接具有凭据的渠道。智能体在具有文件系统隔离的 Linux 容器中执行。每个群组的消息队列带有并发控制。通过文件系统进行 IPC。 +单一 Node 主机编排每个会话的智能体容器。当一条消息到来时,主机按实体模型(用户 → 消息组 → 智能体组 → 会话)进行路由,写入该会话的 `inbound.db`,并唤醒容器。容器内部的 agent-runner 轮询 `inbound.db`,调用 Claude,并把响应写入 `outbound.db`。主机轮询 `outbound.db`,通过渠道适配器投递回去。 -完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)。 +每个会话两个 SQLite 文件,每个文件只有一个写入者——没有跨挂载的锁争用,没有 IPC,没有 stdin 管道。渠道和替代提供者在启动时自注册;主干提供注册表和 Chat SDK 桥接,而适配器本身在每个 fork 里通过技能安装。 + +完整架构说明见 [docs/architecture.md](docs/architecture.md);三级隔离模型见 [docs/isolation-model.md](docs/isolation-model.md)。 关键文件: -- `src/index.ts` - 编排器:状态管理、消息循环、智能体调用 -- `src/channels/registry.ts` - 渠道注册表(启动时自注册) -- `src/ipc.ts` - IPC 监听与任务处理 -- `src/router.ts` - 消息格式化与出站路由 -- `src/group-queue.ts` - 带全局并发限制的群组队列 -- `src/container-runner.ts` - 生成流式智能体容器 -- `src/task-scheduler.ts` - 运行计划任务 -- `src/db.ts` - SQLite 操作(消息、群组、会话、状态) -- `groups/*/CLAUDE.md` - 各群组的记忆 +- `src/index.ts` — 入口:数据库初始化、渠道适配器、投递轮询、sweep +- `src/router.ts` — 入站路由:消息组 → 智能体组 → 会话 → `inbound.db` +- `src/delivery.ts` — 轮询 `outbound.db`,通过适配器投递,处理系统动作 +- `src/host-sweep.ts` — 60 秒 sweep:失效检测、到期消息唤醒、循环任务 +- `src/session-manager.ts` — 解析会话,打开 `inbound.db` / `outbound.db` +- `src/container-runner.ts` — 为每个智能体组启动容器,OneCLI 凭据注入 +- `src/db/` — 中心数据库(用户、角色、智能体组、消息组、接线、迁移) +- `src/channels/` — 渠道适配器基础设施(适配器通过 `/add-` 技能安装) +- `src/providers/` — 主机侧提供者配置(`claude` 内置,其他通过技能安装) +- `container/agent-runner/` — Bun 版 agent-runner:轮询循环、MCP 工具、提供者抽象 +- `groups//` — 每个智能体组的文件系统(`CLAUDE.md`、技能、容器配置) ## FAQ -**为什么是 Docker?** +**为什么用 Docker?** -Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量级的原生运行时体验。 +Docker 提供跨平台支持(macOS、Linux、Windows via WSL2)和成熟的生态。在 macOS 上,您可以选择通过 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量的原生运行时。如需更强隔离,[Docker Sandboxes](docs/docker-sandboxes.md) 会把每个容器放到一台微虚拟机里运行。 -**我可以在 Linux 上运行吗?** +**我可以在 Linux 或 Windows 上运行吗?** -可以。Docker 是默认的容器运行时,在 macOS 和 Linux 上都可以使用。只需运行 `/setup`。 +可以。Docker 是默认运行时,可在 macOS、Linux 以及 Windows(通过 WSL2)上工作。运行 `bash nanoclaw.sh` 就行。 **这个项目安全吗?** -智能体在容器中运行,而不是在应用级别的权限检查之后。它们只能访问被明确挂载的目录。您仍然应该审查您运行的代码,但这个代码库小到您真的可以做到。完整的安全模型请见 [docs/SECURITY.md](docs/SECURITY.md)。 +智能体运行在容器里,而不是躲在应用级权限检查之后。它们只能访问明确挂载的目录。凭据不会进入容器——出站 API 请求通过 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli) 在代理层注入认证,并支持速率限制和访问策略。您仍然应该审查自己要运行的代码,但代码库小到您真的能做到。完整的安全模型见 [安全文档](https://docs.nanoclaw.dev/concepts/security)。 **为什么没有配置文件?** -我们不希望配置泛滥。每个用户都应该定制它,让代码完全符合他们的需求,而不是去配置一个通用的系统。如果您喜欢用配置文件,告诉 Claude 让它加上。 +我们不想让配置泛滥。每位用户都应该定制 NanoClaw,让代码精确地做他们想要的事,而不是去配置一个通用系统。如果您更喜欢有配置文件,可以让 Claude 给您加。 **我可以使用第三方或开源模型吗?** -可以。NanoClaw 支持任何 API 兼容的模型端点。在 `.env` 文件中设置以下环境变量: +可以。推荐做法是 `/add-opencode`(通过 OpenCode 配置接入 OpenRouter、OpenAI、Google、DeepSeek 等)或 `/add-ollama-provider`(通过 Ollama 使用本地开源权重模型)。两者都可以按智能体组单独配置,所以同一套安装里不同的智能体可以运行在不同的后端上。 + +对于一次性实验,任何 Claude API 兼容的端点也可以通过 `.env` 使用: ```bash ANTHROPIC_BASE_URL=https://your-api-endpoint.com ANTHROPIC_AUTH_TOKEN=your-token-here ``` -这使您能够使用: -- 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型 -- 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型 -- 兼容 Anthropic API 格式的自定义模型部署 - -注意:为获得最佳兼容性,模型需支持 Anthropic API 格式。 - **我该如何调试问题?** -问 Claude Code。"为什么计划任务没有运行?" "最近的日志里有什么?" "为什么这条消息没有得到回应?" 这就是 AI 原生的方法。 +问 Claude Code。"为什么计划任务没运行?""最近的日志里有什么?""为什么这条消息没有得到回复?"这就是 NanoClaw 底层的 AI 原生方式。 -**为什么我的安装不成功?** +**为什么安装对我不成功?** -如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md。 +如果某一步失败,`nanoclaw.sh` 会把控制权交给 Claude Code 进行诊断并从中断处继续。如果还是没解决,运行 `claude`,然后 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请对相关的安装步骤或技能提 PR。 -**什么样的代码更改会被接受?** +**什么样的更改会被接受进代码库?** -安全修复、bug 修复,以及对基础配置的明确改进。仅此而已。 +进入基础配置的只会是:安全修复、bug 修复、明显的改进。仅此而已。 -其他一切(新功能、操作系统兼容性、硬件支持、增强功能)都应该作为技能来贡献。 +其他一切(新能力、操作系统兼容、硬件支持、增强)都应作为技能贡献到 `channels` 或 `providers` 分支。 -这使得基础系统保持最小化,并让每个用户可以定制他们的安装,而无需继承他们不想要的功能。 +这样基础系统保持最小化,每位用户都可以定制自己的安装,而不必继承他们不想要的功能。 ## 社区 -有任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流。 +有问题或想法?欢迎[加入 Discord](https://discord.gg/VDdww8qS42)。 ## 更新日志 -破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。 +破坏性变更见 [CHANGELOG.md](CHANGELOG.md),完整发布历史见文档站的 [full release history](https://docs.nanoclaw.dev/changelog)。 ## 许可证 diff --git a/assets/setup-splash.txt b/assets/setup-splash.txt new file mode 100644 index 0000000..e4b77ec --- /dev/null +++ b/assets/setup-splash.txt @@ -0,0 +1,30 @@ + + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ° + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀ + ⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧ + o ⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿ + ⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇ + ⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀ o + ⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀ + ° ⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀ + ⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀ + ⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ O + ⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀ + ⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀ + ⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀ + o ⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀ + + _ _  ___ _  +| \| |__ _ _ _ ___  / __| |__ ___ __ __ +| .` / _` | ' \/ _ \| (__| / _` \ V V / +|_|\_\__,_|_||_\___/ \___|_\__,_|\_/\_/  + + Small. + Runs on your machine. + Yours to modify. + +════════════════════════════════════════ diff --git a/container/.dockerignore b/container/.dockerignore new file mode 100644 index 0000000..598ce19 --- /dev/null +++ b/container/.dockerignore @@ -0,0 +1,2 @@ +agent-runner/node_modules +agent-runner/dist diff --git a/container/CLAUDE.md b/container/CLAUDE.md new file mode 100644 index 0000000..baf911a --- /dev/null +++ b/container/CLAUDE.md @@ -0,0 +1,21 @@ +You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn. + +## Communication + +Be concise — every message costs the reader's attention. Prefer outcomes over play-by-play; when the work is done, the final message should be about the result, not a transcript of what you did. + +## Workspace + +Files you create are saved in `/workspace/agent/`. Use this for notes, research, or anything that should persist across turns in this group. + +The file `CLAUDE.local.md` in your workspace is your per-group memory. Record things there that you'll want to remember in future sessions — user preferences, project context, recurring facts. Keep entries short and structured. + +## Memory + +When the user shares any substantive information with you, it must be stored somewhere you can retrieve it when relevant. If it's information that is pertinent to every single conversation turn it should be put into CLAUDE.local.md. Otherwise, create a system for storing the information depending on its type - e.g. create a file of people that the user mentions so you can keep track or a file of projects. For every file you create, add a concise reference in your CLAUDE.local.md so you'll be able to find it in future conversations. + +A core part of your job and the main thing that defines how useful you are to the user is how well you do in creating these systems for organizing information. These are your systems that help you do your job well. Evolve them over time as needed. + +## Conversation history + +The `conversations/` folder in your workspace holds searchable transcripts of past sessions with this group. Use it to recall prior context when a request references something that happened before. For structured long-lived data, prefer dedicated files (`customers.md`, `preferences.md`, etc.); split any file over ~500 lines into a folder with an index. diff --git a/container/Dockerfile b/container/Dockerfile index e8537c3..efa58b6 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,70 +1,121 @@ +# syntax=docker/dockerfile:1.7 # NanoClaw Agent Container -# Runs Claude Agent SDK in isolated Linux VM with browser automation +# Runs Claude Agent SDK in isolated Linux VM with browser automation. +# +# Runtime split: +# - agent-runner (our TypeScript code): Bun, mounted RO at /app/src by host +# - globally-installed Node CLIs (claude-code, agent-browser, vercel): pnpm + Node +# +# Source is never baked in — /app/src is provided by a shared read-only +# bind mount at runtime (see src/container-runner.ts). Source-only changes +# never require an image rebuild. FROM node:22-slim -# Install system dependencies for Chromium -RUN apt-get update && apt-get install -y \ - chromium \ - fonts-liberation \ - fonts-noto-cjk \ - fonts-noto-color-emoji \ - libgbm1 \ - libnss3 \ - libatk-bridge2.0-0 \ - libgtk-3-0 \ - libx11-xcb1 \ - libxcomposite1 \ - libxdamage1 \ - libxrandr2 \ - libasound2 \ - libpangocairo-1.0-0 \ - libcups2 \ - libdrm2 \ - libxshmfence1 \ - curl \ - git \ +# ---- Build-time arguments ---------------------------------------------------- +# CJK fonts add ~200MB. Opt in only if you render Chinese/Japanese/Korean text. +ARG INSTALL_CJK_FONTS=false + +# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs +# mean every rebuild silently picks up the latest and can break in lockstep +# across all users. +ARG CLAUDE_CODE_VERSION=2.1.116 +ARG AGENT_BROWSER_VERSION=latest +ARG VERCEL_VERSION=52.2.1 +ARG BUN_VERSION=1.3.12 + +# ---- System dependencies ----------------------------------------------------- +# tini: correct PID 1 / signal forwarding so outbound.db writes finalize on +# SIGTERM instead of being orphaned by the shell entrypoint. +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + fonts-liberation \ + fonts-noto-color-emoji \ + libgbm1 \ + libnss3 \ + libatk-bridge2.0-0 \ + libgtk-3-0 \ + libx11-xcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libasound2 \ + libpangocairo-1.0-0 \ + libcups2 \ + libdrm2 \ + libxshmfence1 \ + ca-certificates \ + curl \ + git \ + tini \ + unzip \ + && if [ "$INSTALL_CJK_FONTS" = "true" ]; then \ + apt-get install -y --no-install-recommends fonts-noto-cjk; \ + fi \ && rm -rf /var/lib/apt/lists/* -# Set Chromium path for agent-browser +# Chromium path for agent-browser / Playwright consumers ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium +# Belt-and-braces: prevent Playwright's postinstall from downloading its own +# ~300MB Chromium. We've already installed the system one above. +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 -# Install agent-browser and claude-code globally -RUN npm install -g agent-browser @anthropic-ai/claude-code +# ---- Bun runtime ------------------------------------------------------------- +# Install via the official script (handles multi-arch detection), then move +# the binary to /usr/local/bin so the non-root `node` user can execute it. +RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \ + install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun && \ + rm -rf /root/.bun -# Create app directory +# ---- agent-runner deps ------------------------------------------------------- +# Deps are cached independently of CLI versions. Source is NOT baked in — +# it's provided by the shared RO mount at runtime. WORKDIR /app -# Copy package files first for better caching -COPY agent-runner/package*.json ./ +COPY agent-runner/package.json agent-runner/bun.lock ./ -# Install dependencies -RUN npm install +RUN --mount=type=cache,target=/root/.bun/install/cache \ + bun install --frozen-lockfile -# Copy source code -COPY agent-runner/ ./ +# ---- pnpm + global Node CLIs ------------------------------------------------- +# Most stable first, most frequently bumped last. Bumping claude-code +# (the most common change) only invalidates one layer. +# +# only-built-dependencies gates pnpm's supply-chain policy: +# - agent-browser has a postinstall build step. +# - @anthropic-ai/claude-code's postinstall downloads the native Claude +# binary (linux-arm64 variant on our image). Without the allowlist +# the SDK fails at spawn time with "native binary not found". +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable -# Build TypeScript -RUN npm run build +RUN --mount=type=cache,target=/root/.cache/pnpm \ + echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ + echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \ + pnpm install -g "vercel@${VERCEL_VERSION}" -# Create workspace directories -RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" -# Create entrypoint script -# Container input (prompt, group info) is passed via stdin JSON. -# Credentials are injected by the host's credential proxy — never passed here. -# Follow-up messages arrive via IPC files in /workspace/ipc/input/ -RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" -# Set ownership to node user (non-root) for writable directories -RUN chown -R node:node /workspace && chmod 777 /home/node +# ---- Entrypoint -------------------------------------------------------------- +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# ---- Workspace + permissions ------------------------------------------------- +RUN mkdir -p /workspace/group /workspace/extra && \ + chown -R node:node /workspace && \ + chmod 777 /home/node -# Switch to non-root user (required for --dangerously-skip-permissions) USER node - -# Set working directory to group workspace WORKDIR /workspace/group -# Entry point reads JSON from stdin, outputs JSON to stdout -ENTRYPOINT ["/app/entrypoint.sh"] +# tini is PID 1, reaps zombies, forwards signals cleanly. entrypoint.sh does +# `exec bun ...` so bun runs as tini's direct child. +ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] diff --git a/container/agent-runner/bun.lock b/container/agent-runner/bun.lock new file mode 100644 index 0000000..3c08828 --- /dev/null +++ b/container/agent-runner/bun.lock @@ -0,0 +1,243 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "nanoclaw-agent-runner", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.116", + "@modelcontextprotocol/sdk": "^1.12.1", + "cron-parser": "^5.0.0", + "zod": "^4.0.0", + }, + "devDependencies": { + "@types/bun": "^1.1.0", + "@types/node": "^22.10.7", + "typescript": "^5.7.3", + }, + }, + }, + "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cron-parser": ["cron-parser@5.5.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + } +} diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json deleted file mode 100644 index 9ae119b..0000000 --- a/container/agent-runner/package-lock.json +++ /dev/null @@ -1,1524 +0,0 @@ -{ - "name": "nanoclaw-agent-runner", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "nanoclaw-agent-runner", - "version": "1.0.0", - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.76", - "@modelcontextprotocol/sdk": "^1.12.1", - "cron-parser": "^5.0.0", - "zod": "^4.0.0" - }, - "devDependencies": { - "@types/node": "^22.10.7", - "typescript": "^5.7.3" - } - }, - "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.76", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz", - "integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.34.2", - "@img/sharp-darwin-x64": "^0.34.2", - "@img/sharp-linux-arm": "^0.34.2", - "@img/sharp-linux-arm64": "^0.34.2", - "@img/sharp-linux-x64": "^0.34.2", - "@img/sharp-linuxmusl-arm64": "^0.34.2", - "@img/sharp-linuxmusl-x64": "^0.34.2", - "@img/sharp-win32-arm64": "^0.34.2", - "@img/sharp-win32-x64": "^0.34.2" - }, - "peerDependencies": { - "zod": "^4.0.0" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cron-parser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", - "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", - "license": "MIT", - "dependencies": { - "luxon": "^3.7.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", - "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.11.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz", - "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - } - } -} diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index 42a994e..e9af0b1 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -3,18 +3,19 @@ "version": "1.0.0", "type": "module", "description": "Container-side agent runner for NanoClaw", - "main": "dist/index.js", "scripts": { - "build": "tsc", - "start": "node dist/index.js" + "start": "bun src/index.ts", + "typecheck": "tsc --noEmit", + "test": "bun test" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.76", + "@anthropic-ai/claude-agent-sdk": "^0.2.116", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" }, "devDependencies": { + "@types/bun": "^1.1.0", "@types/node": "^22.10.7", "typescript": "^5.7.3" } diff --git a/container/agent-runner/scripts/sdk-signal-probe.ts b/container/agent-runner/scripts/sdk-signal-probe.ts new file mode 100644 index 0000000..a4a3c98 --- /dev/null +++ b/container/agent-runner/scripts/sdk-signal-probe.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env bun +/** + * SDK signal probe: run a prompt, log every signal the Agent SDK emits — + * async-iterator events + hook callbacks + CLI stderr — with absolute + * and relative timing. + * + * Usage: + * bun run scripts/sdk-signal-probe.ts "" # simple string mode + * bun run scripts/sdk-signal-probe.ts --stream "" # streaming-input mode + * bun run scripts/sdk-signal-probe.ts --stream "

" \ + * --push "5000:" --push "15000:" --timeout 60000 # multi-push + * + * Streaming mode (`--stream`) passes an AsyncIterable prompt to `query()`, + * which keeps the CLI subprocess alive past the first result (per SDK + * deep dive). Required for post-result pushes, agent teams, background + * task notifications. + */ +import { query } from '@anthropic-ai/claude-agent-sdk'; + +const args = process.argv.slice(2); +const prompts: string[] = []; +const pushes: Array<{ atMs: number; text: string }> = []; +let streamMode = false; +let timeoutMs: number | undefined; + +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--stream') streamMode = true; + else if (a === '--push') { + const val = args[++i] ?? ''; + const ix = val.indexOf(':'); + if (ix === -1) throw new Error(`bad --push (want MS:text): ${val}`); + pushes.push({ atMs: parseInt(val.slice(0, ix), 10), text: val.slice(ix + 1) }); + } else if (a === '--timeout') timeoutMs = parseInt(args[++i] ?? '0', 10); + else if (a === '--prompt') prompts.push(args[++i] ?? ''); + else prompts.push(a); +} + +const prompt = prompts.join(' '); +if (!prompt) { + console.error('usage: sdk-signal-probe.ts [--stream] "" [--push MS:]... [--timeout MS]'); + process.exit(1); +} + +const T0 = Date.now(); +let LAST = T0; + +function log(source: string, type: string, payload: unknown = {}): void { + const now = Date.now(); + const entry = { t_ms: now - T0, d_ms: now - LAST, source, type, payload }; + LAST = now; + console.log(JSON.stringify(entry)); +} + +function hookLogger(eventName: string) { + return async (input: unknown, toolUseID: string | undefined): Promise => { + log('hook', eventName, { toolUseID, input }); + // Stuck-tool simulation: if env flag is set and this is a PreToolUse for Bash, + // never resolve — simulates a tool that hangs forever. + if (process.env.PROBE_HANG === 'true' && eventName === 'PreToolUse') { + const toolName = (input as any)?.tool_name ?? (input as any)?.name; + if (toolName === 'Bash') { + log('meta', 'pre_tool_use_hanging', { toolUseID, toolName }); + await new Promise(() => { + /* never resolves */ + }); + } + } + return { continue: true }; + }; +} + +const HOOK_EVENTS = [ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'Notification', + 'UserPromptSubmit', + 'SessionStart', + 'SessionEnd', + 'Stop', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PermissionRequest', +] as const; + +const hooks: Record = {}; +for (const ev of HOOK_EVENTS) hooks[ev] = [{ hooks: [hookLogger(ev)] }]; + +// Build prompt — string (single-turn) or AsyncIterable (streaming-input) +let promptInput: any; + +if (streamMode) { + const sessionId = `probe-${Date.now()}`; + async function* streamPrompt() { + // Initial user message + yield { + type: 'user' as const, + message: { role: 'user' as const, content: prompt }, + parent_tool_use_id: null, + session_id: sessionId, + }; + // Schedule subsequent pushes + const startT = Date.now(); + const sorted = [...pushes].sort((a, b) => a.atMs - b.atMs); + for (const p of sorted) { + const waitMs = Math.max(0, p.atMs - (Date.now() - startT)); + if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs)); + log('meta', 'push_message', { atMs: p.atMs, text: p.text }); + yield { + type: 'user' as const, + message: { role: 'user' as const, content: p.text }, + parent_tool_use_id: null, + session_id: sessionId, + }; + } + // Keep stream open for tail events; iterator ends when we return + // (no more work expected). For post-result-idle scenarios, wait here. + await new Promise((r) => setTimeout(r, 5000)); + } + promptInput = streamPrompt(); +} else { + promptInput = prompt; +} + +log('meta', 'probe_start', { prompt, streamMode, pushes, timeoutMs }); + +const q = query({ + prompt: promptInput, + options: { + includePartialMessages: true, + hooks: hooks as any, + stderr: (data: string) => log('stderr', 'chunk', { data }), + settingSources: [], + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + }, +}); + +// Absolute time cap — exit cleanly so the log flushes +if (timeoutMs) { + setTimeout(() => { + log('meta', 'timeout_hit', { timeoutMs }); + setTimeout(() => process.exit(0), 250); + }, timeoutMs); +} + +try { + for await (const event of q) { + const snapshot: any = { ...event }; + try { + const raw = JSON.stringify(snapshot); + if (raw.length > 2000) { + snapshot._truncated_bytes = raw.length; + if (snapshot.message?.content) { + const c = JSON.stringify(snapshot.message.content); + snapshot.message = { ...snapshot.message, content: c.slice(0, 500) + `…<+${c.length - 500}b>` }; + } + } + } catch { + /* best-effort */ + } + log('event', snapshot.type ?? 'unknown', { subtype: snapshot.subtype, event: snapshot }); + } + log('meta', 'iterator_done'); +} catch (err: any) { + log('meta', 'iterator_error', { message: err?.message, stack: err?.stack?.split('\n').slice(0, 5) }); +} diff --git a/container/agent-runner/src/config.ts b/container/agent-runner/src/config.ts new file mode 100644 index 0000000..3a022ab --- /dev/null +++ b/container/agent-runner/src/config.ts @@ -0,0 +1,55 @@ +/** + * Runner config — reads /workspace/agent/container.json at startup. + * + * This file is mounted read-only inside the container. The host writes it; + * the runner only reads. All NanoClaw-specific configuration lives here + * instead of environment variables. + */ +import fs from 'fs'; + +const CONFIG_PATH = '/workspace/agent/container.json'; + +export interface RunnerConfig { + provider: string; + assistantName: string; + groupName: string; + agentGroupId: string; + maxMessagesPerPrompt: number; + mcpServers: Record }>; +} + +const DEFAULT_MAX_MESSAGES = 10; + +let _config: RunnerConfig | null = null; + +/** + * Load config from container.json. Called once at startup. + * Falls back to sensible defaults for any missing field. + */ +export function loadConfig(): RunnerConfig { + if (_config) return _config; + + let raw: Record = {}; + try { + raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + } catch { + console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`); + } + + _config = { + provider: (raw.provider as string) || 'claude', + assistantName: (raw.assistantName as string) || '', + groupName: (raw.groupName as string) || '', + agentGroupId: (raw.agentGroupId as string) || '', + maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES, + mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {}, + }; + + return _config; +} + +/** Get the loaded config. Throws if loadConfig() hasn't been called. */ +export function getConfig(): RunnerConfig { + if (!_config) throw new Error('Config not loaded — call loadConfig() first'); + return _config; +} diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts new file mode 100644 index 0000000..3ca44a8 --- /dev/null +++ b/container/agent-runner/src/db/connection.ts @@ -0,0 +1,259 @@ +/** + * Two-DB connection layer. + * + * The session uses two SQLite files to eliminate write contention across + * the host-container mount boundary: + * + * inbound.db — host writes new messages here; container opens READ-ONLY + * outbound.db — container writes responses + acks here; host opens read-only + * + * Each file has exactly one writer, so no cross-process lock contention. + * + * ⚠ Cross-mount visibility: inbound.db MUST be journal_mode=DELETE (set by + * the host when the file is created). WAL's `-shm` is memory-mapped and + * VirtioFS does not propagate mmap coherency from host to guest, so a + * WAL-mode inbound.db would leave this reader frozen on an early snapshot + * and it would silently never see new host messages. See + * src/session-manager.ts for the full set of cross-mount invariants and + * scripts/sanity-live-poll.ts for the empirical validation. + */ +import { Database } from 'bun:sqlite'; +import fs from 'fs'; + +const DEFAULT_INBOUND_PATH = '/workspace/inbound.db'; +const DEFAULT_OUTBOUND_PATH = '/workspace/outbound.db'; +const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat'; + +let _inbound: Database | null = null; +let _outbound: Database | null = null; +let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; + +/** + * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. + * + * Use this (not getInboundDb) for readers that need to see host-written rows + * promptly — e.g. messages_in polling. Caller must .close() the returned + * connection (try/finally). + * + * Needed for mounts where host writes don't reliably invalidate + * SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple + * Container), NFS. + * + * Cost is microseconds per query, so safe for universal use. + */ +export function openInboundDb(): Database { + const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA mmap_size = 0'); + return db; +} + +/** + * Inbound DB — long-lived singleton, OK for tables the host writes once + * at spawn and never again (destinations, session_routing). For + * messages_in polling — where the host writes continuously and a stale + * view causes the pollHandle hang — use `openInboundDb()` instead. + */ +export function getInboundDb(): Database { + if (!_inbound) { + _inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); + _inbound.exec('PRAGMA busy_timeout = 5000'); + _inbound.exec('PRAGMA mmap_size = 0'); + } + return _inbound; +} + +/** Outbound DB — container owns this file (sole writer). */ +export function getOutboundDb(): Database { + if (!_outbound) { + _outbound = new Database(DEFAULT_OUTBOUND_PATH); + _outbound.exec('PRAGMA journal_mode = DELETE'); + _outbound.exec('PRAGMA busy_timeout = 5000'); + _outbound.exec('PRAGMA foreign_keys = ON'); + // Lightweight forward-compat: session_state was added after the initial + // v2 schema, so older session DBs don't have it. Create it on demand + // instead of requiring a formal migration pass. Also handle the case + // where an earlier revision of this table existed without updated_at — + // ALTER TABLE to add any missing columns. + _outbound.exec(` + CREATE TABLE IF NOT EXISTS session_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + const cols = new Set( + (_outbound.prepare("PRAGMA table_info('session_state')").all() as Array<{ name: string }>).map((c) => c.name), + ); + if (!cols.has('updated_at')) { + _outbound.exec(`ALTER TABLE session_state ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''`); + } + // container_state: tracks the current tool in flight (if any) so the host + // sweep can widen its stuck tolerance when Bash is running with a user- + // declared long timeout. Forward-compat for older outbound.db files. + _outbound.exec(` + CREATE TABLE IF NOT EXISTS container_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_tool TEXT, + tool_declared_timeout_ms INTEGER, + tool_started_at TEXT, + updated_at TEXT NOT NULL + ); + `); + } + return _outbound; +} + +/** + * Record that a tool is starting. `declaredTimeoutMs` is the tool's own + * timeout hint when one is available (Bash exposes it in the tool_use input); + * omit for tools with no declared timeout. + */ +export function setContainerToolInFlight(tool: string, declaredTimeoutMs: number | null): void { + const now = new Date().toISOString(); + getOutboundDb() + .prepare( + `INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at) + VALUES (1, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + current_tool = excluded.current_tool, + tool_declared_timeout_ms = excluded.tool_declared_timeout_ms, + tool_started_at = excluded.tool_started_at, + updated_at = excluded.updated_at`, + ) + .run(tool, declaredTimeoutMs, now, now); +} + +/** Clear the in-flight tool — called on PostToolUse / PostToolUseFailure. */ +export function clearContainerToolInFlight(): void { + const now = new Date().toISOString(); + getOutboundDb() + .prepare( + `INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at) + VALUES (1, NULL, NULL, NULL, ?) + ON CONFLICT(id) DO UPDATE SET + current_tool = NULL, + tool_declared_timeout_ms = NULL, + tool_started_at = NULL, + updated_at = excluded.updated_at`, + ) + .run(now); +} + +/** + * Touch the heartbeat file — replaces the old touchProcessing() DB writes. + * The host checks this file's mtime for stale container detection. + * A file touch is cheaper and avoids cross-boundary DB write contention. + */ +export function touchHeartbeat(): void { + const p = _heartbeatPath; + const now = new Date(); + try { + fs.utimesSync(p, now, now); + } catch { + try { + fs.writeFileSync(p, ''); + } catch { + // Silently ignore — parent dir may not exist (e.g., in-memory test DBs) + } + } +} + +/** + * Clear stale processing_ack entries on container startup. + * If the previous container crashed, 'processing' entries are leftover. + * Clearing them lets the new container re-process those messages. + */ +export function clearStaleProcessingAcks(): void { + getOutboundDb().prepare("DELETE FROM processing_ack WHERE status = 'processing'").run(); +} + +/** For tests — creates in-memory DBs with the session schemas. */ +export function initTestSessionDb(): { inbound: Database; outbound: Database } { + _inbound = new Database(':memory:'); + _inbound.exec('PRAGMA foreign_keys = ON'); + _inbound.exec(` + CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', + process_after TEXT, + recurrence TEXT, + series_id TEXT, + tries INTEGER DEFAULT 0, + trigger INTEGER NOT NULL DEFAULT 1, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL + ); + CREATE TABLE delivered ( + message_out_id TEXT PRIMARY KEY, + platform_message_id TEXT, + status TEXT NOT NULL DEFAULT 'delivered', + delivered_at TEXT NOT NULL + ); + CREATE TABLE destinations ( + name TEXT PRIMARY KEY, + display_name TEXT, + type TEXT NOT NULL, + channel_type TEXT, + platform_id TEXT, + agent_group_id TEXT + ); + `); + + _outbound = new Database(':memory:'); + _outbound.exec('PRAGMA foreign_keys = ON'); + _outbound.exec(` + CREATE TABLE messages_out ( + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, + in_reply_to TEXT, + timestamp TEXT NOT NULL, + deliver_after TEXT, + recurrence TEXT, + kind TEXT NOT NULL, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL + ); + CREATE TABLE processing_ack ( + message_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + status_changed TEXT NOT NULL + ); + CREATE TABLE session_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE container_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_tool TEXT, + tool_declared_timeout_ms INTEGER, + tool_started_at TEXT, + updated_at TEXT NOT NULL + ); + `); + + return { inbound: _inbound, outbound: _outbound }; +} + +export function closeSessionDb(): void { + _inbound?.close(); + _inbound = null; + _outbound?.close(); + _outbound = null; +} + +/** + * @deprecated Use getInboundDb() / getOutboundDb() instead. + * Kept for backward compatibility during migration. + */ +export function getSessionDb(): Database { + return getInboundDb(); +} diff --git a/container/agent-runner/src/db/index.ts b/container/agent-runner/src/db/index.ts new file mode 100644 index 0000000..d1b97fe --- /dev/null +++ b/container/agent-runner/src/db/index.ts @@ -0,0 +1,20 @@ +export { + getInboundDb, + getOutboundDb, + getSessionDb, + initTestSessionDb, + closeSessionDb, + touchHeartbeat, + clearStaleProcessingAcks, +} from './connection.js'; +export { + getPendingMessages, + markProcessing, + markCompleted, + markFailed, + getMessageIn, + findQuestionResponse, +} from './messages-in.js'; +export type { MessageInRow } from './messages-in.js'; +export { writeMessageOut, getUndeliveredMessages } from './messages-out.js'; +export type { MessageOutRow, WriteMessageOut } from './messages-out.js'; diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts new file mode 100644 index 0000000..88906ed --- /dev/null +++ b/container/agent-runner/src/db/messages-in.ts @@ -0,0 +1,151 @@ +/** + * Inbound message operations (container side). + * + * Reads from inbound.db (host-owned, opened read-only). + * Writes processing status to processing_ack in outbound.db (container-owned). + * + * The container never writes to inbound.db — all status tracking goes through + * processing_ack. The host reads processing_ack to sync message lifecycle. + */ +import { getConfig } from '../config.js'; +import { openInboundDb, getOutboundDb } from './connection.js'; + +export interface MessageInRow { + id: string; + seq: number | null; + kind: string; + timestamp: string; + status: string; + process_after: string | null; + recurrence: string | null; + tries: number; + /** 1 = wake-eligible (default); 0 = accumulated context only */ + trigger: number; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; +} + +// Cap on how many messages reach the agent in one prompt. Read from +// container.json; falls back to 10. +function getMaxMessagesPerPrompt(): number { + try { + return getConfig().maxMessagesPerPrompt; + } catch { + // Config not loaded yet (e.g. test harness) — use default + return 10; + } +} + +/** + * Fetch pending messages that are due for processing. + * Reads from inbound.db (read-only), filters against processing_ack in outbound.db + * to skip messages already picked up by this or a previous container run. + * + * Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in + * chronological order, regardless of their `trigger` flag: accumulated + * context (trigger=0) rides along with the wake-eligible rows so the agent + * sees the prior context it missed. Host's countDueMessages gates waking on + * trigger=1 separately (see src/db/session-db.ts). + */ +export function getPendingMessages(): MessageInRow[] { + const inbound = openInboundDb(); + const outbound = getOutboundDb(); + + try { + const pending = inbound + .prepare( + `SELECT * FROM messages_in + WHERE status = 'pending' + AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) + ORDER BY seq DESC + LIMIT ?`, + ) + .all(getMaxMessagesPerPrompt()) as MessageInRow[]; + + if (pending.length === 0) return []; + + // Filter out messages already acknowledged in outbound.db + const ackedIds = new Set( + (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map( + (r) => r.message_id, + ), + ); + + // Reverse: we fetched DESC to take the most recent N, but the agent + // should see them in chronological order (oldest first). + return pending.filter((m) => !ackedIds.has(m.id)).reverse(); + } finally { + inbound.close(); + } +} + +/** Mark messages as processing — writes to processing_ack in outbound.db. */ +export function markProcessing(ids: string[]): void { + if (ids.length === 0) return; + const db = getOutboundDb(); + const stmt = db.prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', datetime('now'))", + ); + db.transaction(() => { + for (const id of ids) stmt.run(id); + })(); +} + +/** Mark messages as completed — updates processing_ack in outbound.db. */ +export function markCompleted(ids: string[]): void { + if (ids.length === 0) return; + const db = getOutboundDb(); + const stmt = db.prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))", + ); + db.transaction(() => { + for (const id of ids) stmt.run(id); + })(); +} + +/** Mark a single message as failed — writes to processing_ack in outbound.db. */ +export function markFailed(id: string): void { + getOutboundDb() + .prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', datetime('now'))", + ) + .run(id); +} + +/** Get a message by ID (read from inbound.db). */ +export function getMessageIn(id: string): MessageInRow | undefined { + const inbound = openInboundDb(); + try { + return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; + } finally { + inbound.close(); + } +} + +/** + * Find a pending response to a question (by questionId in content). + * Reads from inbound.db, checks processing_ack to skip already-handled responses. + */ +export function findQuestionResponse(questionId: string): MessageInRow | undefined { + const inbound = openInboundDb(); + const outbound = getOutboundDb(); + + try { + const response = inbound + .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined; + + if (!response) return undefined; + + // Check it hasn't been acked already + const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); + if (acked) return undefined; + + return response; + } finally { + inbound.close(); + } +} + diff --git a/container/agent-runner/src/db/messages-out.ts b/container/agent-runner/src/db/messages-out.ts new file mode 100644 index 0000000..85b0467 --- /dev/null +++ b/container/agent-runner/src/db/messages-out.ts @@ -0,0 +1,143 @@ +/** + * Outbound message operations (container side). + * + * Writes to outbound.db (container-owned). + * The host polls this DB (read-only) for undelivered messages. + */ +import { getInboundDb, getOutboundDb } from './connection.js'; + +export interface MessageOutRow { + id: string; + seq: number | null; + in_reply_to: string | null; + timestamp: string; + deliver_after: string | null; + recurrence: string | null; + kind: string; + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + content: string; +} + +export interface WriteMessageOut { + id: string; + in_reply_to?: string | null; + deliver_after?: string | null; + recurrence?: string | null; + kind: string; + platform_id?: string | null; + channel_type?: string | null; + thread_id?: string | null; + content: string; +} + +/** + * Write a new outbound message, auto-assigning an odd seq number. + * Container uses odd seq (1, 3, 5...), host uses even (2, 4, 6...). + * + * The disjoint namespace is load-bearing, not just collision avoidance: + * seq is the agent-facing message ID returned by send_message and accepted + * by edit_message / add_reaction, and getMessageIdBySeq() below looks up + * by seq across BOTH tables. If inbound and outbound could share a seq, + * the agent's "edit message #5" could resolve to the wrong row. + */ +export function writeMessageOut(msg: WriteMessageOut): number { + const outbound = getOutboundDb(); + const inbound = getInboundDb(); + + // Read max seq from both DBs to maintain global ordering. + // Safe: each side only reads the other DB, never writes to it. + const maxOut = (outbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m; + const maxIn = (inbound.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; + const max = Math.max(maxOut, maxIn); + const nextSeq = max % 2 === 0 ? max + 1 : max + 2; // next odd + + // bun:sqlite requires named parameters to be passed with the prefix character + // in the JS object keys (better-sqlite3 auto-stripped it, bun:sqlite does not). + outbound + .prepare( + `INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content) + VALUES ($id, $seq, $in_reply_to, datetime('now'), $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`, + ) + .run({ + $id: msg.id, + $seq: nextSeq, + $in_reply_to: msg.in_reply_to ?? null, + $deliver_after: msg.deliver_after ?? null, + $recurrence: msg.recurrence ?? null, + $kind: msg.kind, + $platform_id: msg.platform_id ?? null, + $channel_type: msg.channel_type ?? null, + $thread_id: msg.thread_id ?? null, + $content: msg.content, + }); + + return nextSeq; +} + +/** + * Look up a message's platform ID by seq number. + * Searches both inbound and outbound DBs since seq spans both. + * + * For inbound messages, the Chat SDK message ID is already the platform message ID + * (e.g., "6037840640:42" for Telegram). + * + * For outbound messages, the internal ID (msg-xxx) won't work for edits/reactions. + * Instead, look up the platform_message_id from the delivered table (host writes this + * after successful delivery). + */ +export function getMessageIdBySeq(seq: number): string | null { + const inbound = getInboundDb(); + + // Inbound messages: ID is already the platform message ID + const inRow = inbound.prepare('SELECT id FROM messages_in WHERE seq = ?').get(seq) as + | { id: string } + | undefined; + if (inRow) return inRow.id; + + // Outbound messages: look up platform message ID from delivered table + const outRow = getOutboundDb().prepare('SELECT id FROM messages_out WHERE seq = ?').get(seq) as + | { id: string } + | undefined; + if (!outRow) return null; + + // Check if host has stored the platform message ID after delivery + const deliveredRow = inbound + .prepare('SELECT platform_message_id FROM delivered WHERE message_out_id = ?') + .get(outRow.id) as { platform_message_id: string | null } | undefined; + if (deliveredRow?.platform_message_id) return deliveredRow.platform_message_id; + + // Fallback to internal ID (edits/reactions on undelivered messages won't work) + return outRow.id; +} + +/** + * Look up the routing fields for a message by seq (for edit/reaction targeting). + * Returns the channel_type, platform_id, thread_id of the referenced message. + */ +export function getRoutingBySeq( + seq: number, +): { channel_type: string | null; platform_id: string | null; thread_id: string | null } | null { + const inbound = getInboundDb(); + const inRow = inbound + .prepare('SELECT channel_type, platform_id, thread_id FROM messages_in WHERE seq = ?') + .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined; + if (inRow) return inRow; + + const outRow = getOutboundDb() + .prepare('SELECT channel_type, platform_id, thread_id FROM messages_out WHERE seq = ?') + .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined; + return outRow ?? null; +} + +/** Get undelivered messages (for host polling — reads from outbound.db). */ +export function getUndeliveredMessages(): MessageOutRow[] { + return getOutboundDb() + .prepare( + `SELECT * FROM messages_out + WHERE (deliver_after IS NULL OR deliver_after <= datetime('now')) + ORDER BY timestamp ASC`, + ) + .all() as MessageOutRow[]; +} diff --git a/container/agent-runner/src/db/session-routing.ts b/container/agent-runner/src/db/session-routing.ts new file mode 100644 index 0000000..94abca6 --- /dev/null +++ b/container/agent-runner/src/db/session-routing.ts @@ -0,0 +1,30 @@ +/** + * Default reply routing for this session — written by the host on every + * container wake (see src/session-manager.ts `writeSessionRouting`). + * + * Read by the MCP tools as the default destination for outbound messages + * when the agent doesn't specify an explicit `to`. This is what makes + * "agent replies in the thread it's currently in" work: the router strips + * or preserves thread_id based on the adapter's thread support, and we + * just read the fixed routing the host committed for this session. + */ +import { getInboundDb } from './connection.js'; + +export interface SessionRouting { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; +} + +export function getSessionRouting(): SessionRouting { + const db = getInboundDb(); + try { + const row = db + .prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1') + .get() as SessionRouting | undefined; + if (row) return row; + } catch { + // Table may not exist on an older session DB — fall through to defaults + } + return { channel_type: null, platform_id: null, thread_id: null }; +} diff --git a/container/agent-runner/src/db/session-state.test.ts b/container/agent-runner/src/db/session-state.test.ts new file mode 100644 index 0000000..b5aa269 --- /dev/null +++ b/container/agent-runner/src/db/session-state.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { getOutboundDb, initTestSessionDb } from './connection.js'; +import { + clearContinuation, + getContinuation, + migrateLegacyContinuation, + setContinuation, +} from './session-state.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +function seedLegacy(value: string): void { + getOutboundDb() + .prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)') + .run('sdk_session_id', value, new Date().toISOString()); +} + +describe('session-state — per-provider continuations', () => { + test('set/get round-trip, case-insensitive provider key', () => { + setContinuation('claude', 'claude-conv-1'); + expect(getContinuation('claude')).toBe('claude-conv-1'); + expect(getContinuation('Claude')).toBe('claude-conv-1'); + expect(getContinuation('CLAUDE')).toBe('claude-conv-1'); + }); + + test('providers are isolated — switching reads the right slot', () => { + setContinuation('claude', 'claude-conv-1'); + setContinuation('codex', 'codex-thread-xyz'); + + expect(getContinuation('claude')).toBe('claude-conv-1'); + expect(getContinuation('codex')).toBe('codex-thread-xyz'); + }); + + test('clearContinuation only affects the specified provider', () => { + setContinuation('claude', 'keep-me'); + setContinuation('codex', 'drop-me'); + + clearContinuation('codex'); + + expect(getContinuation('claude')).toBe('keep-me'); + expect(getContinuation('codex')).toBeUndefined(); + }); + + test('unknown provider returns undefined', () => { + expect(getContinuation('never-used')).toBeUndefined(); + }); +}); + +describe('session-state — legacy migration', () => { + test('adopts legacy value into current provider when current is empty', () => { + seedLegacy('old-session-id'); + + const adopted = migrateLegacyContinuation('claude'); + + expect(adopted).toBe('old-session-id'); + expect(getContinuation('claude')).toBe('old-session-id'); + }); + + test('always deletes legacy row regardless of migration outcome', () => { + seedLegacy('old-session-id'); + setContinuation('claude', 'existing'); + + migrateLegacyContinuation('claude'); + + // After migration the legacy key must be gone, whether or not it was adopted. + // A subsequent migration for a different provider must not see it. + const resultAfterSecondCall = migrateLegacyContinuation('codex'); + expect(resultAfterSecondCall).toBeUndefined(); + }); + + test('prefers existing current-provider slot over legacy', () => { + seedLegacy('legacy-value'); + setContinuation('claude', 'claude-value'); + + const result = migrateLegacyContinuation('claude'); + + expect(result).toBe('claude-value'); + expect(getContinuation('claude')).toBe('claude-value'); + }); + + test('no legacy row — returns current provider value (possibly undefined)', () => { + expect(migrateLegacyContinuation('claude')).toBeUndefined(); + + setContinuation('codex', 'codex-value'); + expect(migrateLegacyContinuation('codex')).toBe('codex-value'); + }); + + test('migration is idempotent on a second call (legacy already gone)', () => { + seedLegacy('once'); + + const first = migrateLegacyContinuation('claude'); + expect(first).toBe('once'); + + const second = migrateLegacyContinuation('claude'); + expect(second).toBe('once'); + }); +}); diff --git a/container/agent-runner/src/db/session-state.ts b/container/agent-runner/src/db/session-state.ts new file mode 100644 index 0000000..9e12309 --- /dev/null +++ b/container/agent-runner/src/db/session-state.ts @@ -0,0 +1,79 @@ +/** + * Persistent key/value state for the container. Lives in outbound.db + * (container-owned, already scoped per channel/thread). + * + * Primary use: remember each provider's opaque continuation id so the + * agent's conversation resumes across container restarts. Keyed per + * provider because continuations are provider-private — a Claude + * conversation id means nothing to Codex and vice versa. Switching + * providers is therefore lossless: each provider's last thread stays + * on file and resumes cleanly if the user flips back. + */ +import { getOutboundDb } from './connection.js'; + +const LEGACY_KEY = 'sdk_session_id'; + +function continuationKey(providerName: string): string { + return `continuation:${providerName.toLowerCase()}`; +} + +function getValue(key: string): string | undefined { + const row = getOutboundDb() + .prepare('SELECT value FROM session_state WHERE key = ?') + .get(key) as { value: string } | undefined; + return row?.value; +} + +function setValue(key: string, value: string): void { + getOutboundDb() + .prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)') + .run(key, value, new Date().toISOString()); +} + +function deleteValue(key: string): void { + getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key); +} + +/** + * One-time migration of the pre-per-provider continuation row. + * + * Before this was keyed per provider, continuations lived under the + * single key `sdk_session_id`. On container start, if that legacy row + * exists and the current provider has no continuation of its own, adopt + * the legacy value into the current provider's slot (best-guess — the + * legacy row was written by whatever provider ran last). The legacy row + * is always deleted so future provider flips never re-read a stale id + * through the wrong lens. + * + * Returns the continuation the caller should use at startup (either the + * current provider's existing value, the adopted legacy value, or + * undefined). + */ +export function migrateLegacyContinuation(providerName: string): string | undefined { + const legacy = getValue(LEGACY_KEY); + const currentKey = continuationKey(providerName); + const current = getValue(currentKey); + + if (legacy === undefined) return current; + + // Always drop the legacy row so no future provider reads it. + deleteValue(LEGACY_KEY); + + // Prefer the current provider's own slot if one already exists. + if (current !== undefined) return current; + + setValue(currentKey, legacy); + return legacy; +} + +export function getContinuation(providerName: string): string | undefined { + return getValue(continuationKey(providerName)); +} + +export function setContinuation(providerName: string, id: string): void { + setValue(continuationKey(providerName), id); +} + +export function clearContinuation(providerName: string): void { + deleteValue(continuationKey(providerName)); +} diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts new file mode 100644 index 0000000..013bd3b --- /dev/null +++ b/container/agent-runner/src/destinations.ts @@ -0,0 +1,135 @@ +/** + * Destination map — lives in inbound.db's `destinations` table. + * + * The host writes this table before every container wake AND on demand + * (e.g. when a new child agent is created mid-session). The container + * queries the table live on every lookup, so admin changes take effect + * immediately — no restart required. + * + * This table is BOTH the routing map and the container-visible ACL. + * The host re-validates on the delivery side against the central DB, + * so even if this table is stale the host's enforcement is authoritative. + */ +import { getInboundDb } from './db/connection.js'; + +export interface DestinationEntry { + name: string; + displayName: string; + type: 'channel' | 'agent'; + channelType?: string; + platformId?: string; + agentGroupId?: string; +} + +interface DestRow { + name: string; + display_name: string | null; + type: 'channel' | 'agent'; + channel_type: string | null; + platform_id: string | null; + agent_group_id: string | null; +} + +function rowToEntry(row: DestRow): DestinationEntry { + return { + name: row.name, + displayName: row.display_name ?? row.name, + type: row.type, + channelType: row.channel_type ?? undefined, + platformId: row.platform_id ?? undefined, + agentGroupId: row.agent_group_id ?? undefined, + }; +} + +export function getAllDestinations(): DestinationEntry[] { + const rows = getInboundDb().prepare('SELECT * FROM destinations ORDER BY name').all() as DestRow[]; + return rows.map(rowToEntry); +} + +export function findByName(name: string): DestinationEntry | undefined { + const row = getInboundDb().prepare('SELECT * FROM destinations WHERE name = ?').get(name) as DestRow | undefined; + return row ? rowToEntry(row) : undefined; +} + +/** + * Reverse lookup: given routing fields from an inbound message, find + * which destination they correspond to (what does this agent call the sender?). + */ +export function findByRouting( + channelType: string | null | undefined, + platformId: string | null | undefined, +): DestinationEntry | undefined { + if (!channelType || !platformId) return undefined; + const db = getInboundDb(); + const row = + channelType === 'agent' + ? (db + .prepare("SELECT * FROM destinations WHERE type = 'agent' AND agent_group_id = ?") + .get(platformId) as DestRow | undefined) + : (db + .prepare("SELECT * FROM destinations WHERE type = 'channel' AND channel_type = ? AND platform_id = ?") + .get(channelType, platformId) as DestRow | undefined); + return row ? rowToEntry(row) : undefined; +} + +/** + * Generate the system-prompt addendum: agent identity + destination map. + * + * Identity is injected here (not in the shared CLAUDE.md) because it's + * per-agent-group and changes when the operator renames an agent, while + * the shared base is identical across all agents. + */ +export function buildSystemPromptAddendum(assistantName?: string): string { + const sections: string[] = []; + + if (assistantName) { + sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n')); + } + + sections.push(buildDestinationsSection()); + + return sections.join('\n\n'); +} + +function buildDestinationsSection(): string { + const all = getAllDestinations(); + + if (all.length === 0) { + return [ + '## Sending messages', + '', + 'You currently have no configured destinations. You cannot send messages until an admin wires one up.', + ].join('\n'); + } + + // Single-destination shortcut: the agent just writes its response normally. + if (all.length === 1) { + const d = all[0]; + const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; + return [ + '## Sending messages', + '', + `Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`, + '', + 'To mark something as scratchpad (logged but not sent), wrap it in `...`.', + '', + 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.', + ].join('\n'); + } + + const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', '']; + for (const d of all) { + const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; + lines.push(`- \`${d.name}\`${label}`); + } + lines.push(''); + lines.push('To send a message, wrap it in a `...` block.'); + lines.push('You can include multiple `` blocks in one response to send to multiple destinations.'); + lines.push('Text outside of `` blocks is scratchpad — logged but not sent anywhere.'); + lines.push('Use `...` to make scratchpad intent explicit.'); + lines.push(''); + lines.push( + 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.', + ); + return lines.join('\n'); +} diff --git a/container/agent-runner/src/formatter.test.ts b/container/agent-runner/src/formatter.test.ts new file mode 100644 index 0000000..e34156c --- /dev/null +++ b/container/agent-runner/src/formatter.test.ts @@ -0,0 +1,167 @@ +/** + * v1-parity tests for formatter behavior. + * + * Port of src/v1/formatting.test.ts (at commit 27c5220, parent of the v1 + * deletion commit 86becf8). Covers: context timezone header, reply_to + + * quoted_message rendering, XML escaping, and stripInternalTags. + * + * Timestamp-format assertions use `formatLocalTime()` output format, which + * is host locale-dependent for decorators (month abbr, "," separator) but + * stable for the numeric parts we assert on (hour, minute, year). + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js'; +import { getPendingMessages } from './db/messages-in.js'; +import { formatMessages, stripInternalTags } from './formatter.js'; +import { TIMEZONE } from './timezone.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function insertMessage( + id: string, + kind: string, + content: object, + opts?: { timestamp?: string }, +) { + const timestamp = opts?.timestamp ?? new Date().toISOString(); + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, content) + VALUES (?, ?, ?, 'pending', ?)`, + ) + .run(id, kind, timestamp, JSON.stringify(content)); +} + +describe('context timezone header', () => { + it('prepends to formatted output', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hello' }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain(` { + const result = formatMessages([]); + expect(result).toContain(` block', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' }); + insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' }); + const result = formatMessages(getPendingMessages()); + const ctxIdx = result.indexOf(''); + expect(ctxIdx).toBeGreaterThanOrEqual(0); + expect(msgsIdx).toBeGreaterThan(ctxIdx); + }); +}); + +describe('timestamp formatting', () => { + it('renders time via formatLocalTime (user TZ)', () => { + // 2026-06-15T12:00:00Z — timezone-agnostic assertions (year is stable) + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T12:00:00.000Z' }); + const result = formatMessages(getPendingMessages()); + // formatLocalTime's format in en-US contains the year and a month abbrev + expect(result).toContain('2026'); + expect(result).toMatch(/Jun/); + }); + + it('uses 12-hour AM/PM format', () => { + // 15:30 UTC — some hour will show with AM or PM depending on TZ + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T15:30:00.000Z' }); + const result = formatMessages(getPendingMessages()); + expect(result).toMatch(/(AM|PM)/); + }); +}); + +describe('reply_to + quoted_message rendering', () => { + it('renders reply_to attribute and quoted_message when all fields present', () => { + insertMessage('m1', 'chat', { + sender: 'Alice', + text: 'Yes, on my way!', + replyTo: { id: '42', sender: 'Bob', text: 'Are you coming tonight?' }, + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('reply_to="42"'); + expect(result).toContain('Are you coming tonight?'); + expect(result).toContain('Yes, on my way!'); + }); + + it('omits reply_to and quoted_message when no reply context', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'plain' }); + const result = formatMessages(getPendingMessages()); + expect(result).not.toContain('reply_to'); + expect(result).not.toContain('quoted_message'); + }); + + it('renders reply_to but omits quoted_message when original content is missing', () => { + insertMessage('m1', 'chat', { + sender: 'Alice', + text: 'ack', + replyTo: { id: '42', sender: 'Bob' }, // no text + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('reply_to="42"'); + expect(result).not.toContain('quoted_message'); + }); + + it('XML-escapes reply context', () => { + insertMessage('m1', 'chat', { + sender: 'Alice', + text: 'reply', + replyTo: { id: '1', sender: 'A & B', text: '' }, + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('from="A & B"'); + expect(result).toContain('<script>'); + expect(result).toContain('"xss"'); + }); +}); + +describe('XML escaping', () => { + it('escapes <, >, &, " in sender and body', () => { + insertMessage('m1', 'chat', { + sender: 'A & B ', + text: '', + }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain('sender="A & B <Co>"'); + expect(result).toContain('<script>alert("xss")</script>'); + }); +}); + +describe('stripInternalTags', () => { + it('strips single-line internal tags and trims', () => { + expect(stripInternalTags('hello secret world')).toBe('hello world'); + }); + + it('strips multi-line internal tags', () => { + expect(stripInternalTags('hello \nsecret\nstuff\n world')).toBe( + 'hello world', + ); + }); + + it('strips multiple internal tag blocks', () => { + expect(stripInternalTags('ahellob')).toBe('hello'); + }); + + it('returns empty string when input is only internal tags', () => { + expect(stripInternalTags('only this')).toBe(''); + }); + + it('returns input unchanged when there are no internal tags', () => { + expect(stripInternalTags('hello world')).toBe('hello world'); + }); + + it('preserves content that surrounds internal tags', () => { + expect(stripInternalTags('thinkingThe answer is 42')).toBe( + 'The answer is 42', + ); + }); +}); diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts new file mode 100644 index 0000000..348d5ab --- /dev/null +++ b/container/agent-runner/src/formatter.ts @@ -0,0 +1,270 @@ +import { findByRouting } from './destinations.js'; +import type { MessageInRow } from './db/messages-in.js'; +import { TIMEZONE, formatLocalTime } from './timezone.js'; + +/** + * Command categories for messages starting with '/'. + * - admin: sender must be in NANOCLAW_ADMIN_USER_IDS + * - filtered: silently drop (mark completed without processing) + * - passthrough: pass raw to the agent (no XML wrapping) + * - none: not a command — format normally + */ +export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none'; + +const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']); +const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']); + +export interface CommandInfo { + category: CommandCategory; + command: string; // the command name (e.g., '/clear') + text: string; // full original text + senderId: string | null; +} + +/** + * Categorize a message as a command or not. + * Only applies to chat/chat-sdk messages. + * + * The extracted `senderId` is compared against `NANOCLAW_ADMIN_USER_IDS` + * which stores ids in the namespaced form `:` (see + * src/db/users.ts). chat-sdk-bridge serializes `author.userId` as a raw + * platform id with no prefix, so we prefix it here. If the id already + * contains a `:` we assume it's pre-namespaced (non-chat-sdk adapters + * that populate `senderId` directly) and leave it alone. + */ +export function categorizeMessage(msg: MessageInRow): CommandInfo { + const content = parseContent(msg.content); + const text = (content.text || '').trim(); + const senderId = extractSenderId(msg, content); + + if (!text.startsWith('/')) { + return { category: 'none', command: '', text, senderId }; + } + + // Extract the command name (e.g., '/clear' from '/clear some args') + const command = text.split(/\s/)[0].toLowerCase(); + + if (ADMIN_COMMANDS.has(command)) { + return { category: 'admin', command, text, senderId }; + } + + if (FILTERED_COMMANDS.has(command)) { + return { category: 'filtered', command, text, senderId }; + } + + return { category: 'passthrough', command, text, senderId }; +} + +/** + * Narrow check for /clear — the only command the runner handles directly. + * All other command gating (filtered, admin) is done by the host router + * before messages reach the container. + */ +export function isClearCommand(msg: MessageInRow): boolean { + const content = parseContent(msg.content); + const text = (content.text || '').trim(); + return text.toLowerCase().startsWith('/clear'); +} + +/** + * True for any chat that needs the outer loop's command path: /clear plus + * admin/passthrough slash commands the SDK can only dispatch when they are + * a query's first input. Used by the follow-up poller to bail out and let + * the outer loop reopen the query. + */ +export function isRunnerCommand(msg: MessageInRow): boolean { + if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') return false; + const cat = categorizeMessage(msg).category; + return cat === 'admin' || cat === 'passthrough'; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractSenderId(msg: MessageInRow, content: any): string | null { + const raw: string | null = content?.senderId || content?.author?.userId || null; + if (!raw) return null; + // Already namespaced (e.g. "telegram:123") — use as-is. + if (raw.includes(':')) return raw; + // Raw platform id from chat-sdk serialization — prefix with channel type. + if (!msg.channel_type) return raw; + return `${msg.channel_type}:${raw}`; +} + +/** + * Routing context extracted from messages_in rows. + * Copied to messages_out by default so responses go back to the sender. + */ +export interface RoutingContext { + platformId: string | null; + channelType: string | null; + threadId: string | null; + inReplyTo: string | null; +} + +/** + * Extract routing context from a batch of messages. + * Uses the first message's routing fields. + */ +export function extractRouting(messages: MessageInRow[]): RoutingContext { + const first = messages[0]; + return { + platformId: first?.platform_id ?? null, + channelType: first?.channel_type ?? null, + threadId: first?.thread_id ?? null, + inReplyTo: first?.id ?? null, + }; +} + +/** + * Format a batch of messages_in rows into a prompt string. + * + * Prepends a `` header so the agent always knows + * what timezone it's in — every timestamp it sees in message bodies is the + * user's local time, and every time it produces (schedules, suggests) should + * be interpreted as local time in that same zone. This header is v1 behavior + * (src/v1/router.ts:20-22); dropping it led to misinterpretations where the + * agent scheduled tasks for the wrong hour. + * + * Strips routing fields — the agent never sees platform_id, channel_type, thread_id. + */ +export function formatMessages(messages: MessageInRow[]): string { + const header = `\n`; + if (messages.length === 0) return header; + + // Group by kind + const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk'); + const taskMessages = messages.filter((m) => m.kind === 'task'); + const webhookMessages = messages.filter((m) => m.kind === 'webhook'); + const systemMessages = messages.filter((m) => m.kind === 'system'); + + const parts: string[] = []; + + if (chatMessages.length > 0) { + parts.push(formatChatMessages(chatMessages)); + } + if (taskMessages.length > 0) { + parts.push(...taskMessages.map(formatTaskMessage)); + } + if (webhookMessages.length > 0) { + parts.push(...webhookMessages.map(formatWebhookMessage)); + } + if (systemMessages.length > 0) { + parts.push(...systemMessages.map(formatSystemMessage)); + } + + return header + parts.join('\n\n'); +} + +function formatChatMessages(messages: MessageInRow[]): string { + if (messages.length === 1) { + return formatSingleChat(messages[0]); + } + + const lines = ['']; + for (const msg of messages) { + lines.push(formatSingleChat(msg)); + } + lines.push(''); + return lines.join('\n'); +} + +function formatSingleChat(msg: MessageInRow): string { + const content = parseContent(msg.content); + const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown'; + const time = formatLocalTime(msg.timestamp, TIMEZONE); + const text = content.text || ''; + const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; + const replyAttr = content.replyTo?.id ? ` reply_to="${escapeXml(String(content.replyTo.id))}"` : ''; + const replyPrefix = formatReplyContext(content.replyTo); + const attachmentsSuffix = formatAttachments(content.attachments); + + // Look up the destination name for the origin (reverse map lookup). + // If not found, fall back to a raw channel:platform_id marker so nothing + // gets silently dropped — this should only happen if the destination was + // removed between when the message was received and when it's being processed. + const fromDest = findByRouting(msg.channel_type, msg.platform_id); + const fromAttr = fromDest + ? ` from="${escapeXml(fromDest.name)}"` + : msg.channel_type || msg.platform_id + ? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"` + : ''; + + return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; +} + +function formatTaskMessage(msg: MessageInRow): string { + const content = parseContent(msg.content); + const parts = ['[SCHEDULED TASK]']; + if (content.scriptOutput) { + parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2)); + } + parts.push('', 'Instructions:', content.prompt || ''); + return parts.join('\n'); +} + +function formatWebhookMessage(msg: MessageInRow): string { + const content = parseContent(msg.content); + const source = content.source || 'unknown'; + const event = content.event || 'unknown'; + return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`; +} + +function formatSystemMessage(msg: MessageInRow): string { + const content = parseContent(msg.content); + return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`; +} + +/** + * Render the quoted original inside the body. + * + * Matches v1 format (src/v1/router.ts:10-18): `Y`. + * Requires BOTH sender and text — if only id is present the reply_to attribute + * on the parent carries the link without an inline preview. + * + * No truncation here (v1 didn't truncate). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function formatReplyContext(replyTo: any): string { + if (!replyTo) return ''; + const sender = replyTo.sender; + const text = replyTo.text; + if (!sender || !text) return ''; + return `\n ${escapeXml(text)}\n`; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function formatAttachments(attachments: any[] | undefined): string { + if (!Array.isArray(attachments) || attachments.length === 0) return ''; + const parts = attachments.map((a) => { + const name = a.name || a.filename || 'attachment'; + const type = a.type || 'file'; + const localPath = a.localPath ? `/workspace/${a.localPath}` : ''; + const url = a.url || ''; + if (localPath) { + return `[${type}: ${escapeXml(name)} — saved to ${escapeXml(localPath)}]`; + } + return url ? `[${type}: ${escapeXml(name)} (${escapeXml(url)})]` : `[${type}: ${escapeXml(name)}]`; + }); + return '\n' + parts.join('\n'); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseContent(json: string): any { + try { + return JSON.parse(json); + } catch { + return { text: json }; + } +} + +function escapeXml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** + * Strip `...` blocks from agent output, then trim. + * Ported from v1 (src/v1/router.ts:25-27). Used to remove the agent's + * own scratchpad/reasoning before a reply goes out over a channel. + */ +export function stripInternalTags(text: string): string { + return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); +} diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 25554f9..90c690f 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -1,629 +1,107 @@ /** - * NanoClaw Agent Runner - * Runs inside a container, receives config via stdin, outputs result to stdout + * NanoClaw Agent Runner v2 * - * Input protocol: - * Stdin: Full ContainerInput JSON (read until EOF, like before) - * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/ - * Files: {type:"message", text:"..."}.json — polled and consumed - * Sentinel: /workspace/ipc/input/_close — signals session end + * Runs inside a container. All IO goes through the session DB. + * No stdin, no stdout markers, no IPC files. * - * Stdout protocol: - * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. - * Multiple results may be emitted (one per agent teams result). - * Final marker after loop ends signals completion. + * Config is read from /workspace/agent/container.json (mounted RO). + * Only TZ and OneCLI networking vars come from env. + * + * Mount structure: + * /workspace/ + * inbound.db ← host-owned session DB (container reads only) + * outbound.db ← container-owned session DB + * .heartbeat ← container touches for liveness detection + * outbox/ ← outbound files + * agent/ ← agent group folder (CLAUDE.md, container.json, working files) + * container.json ← per-group config (RO nested mount) + * global/ ← shared global memory (RO) + * /app/src/ ← shared agent-runner source (RO) + * /app/skills/ ← shared skills (RO) + * /home/node/.claude/ ← Claude SDK state + skill symlinks (RW) */ import fs from 'fs'; import path from 'path'; -import { execFile } from 'child_process'; -import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; -interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - script?: string; +import { loadConfig } from './config.js'; +import { buildSystemPromptAddendum } from './destinations.js'; +// Providers barrel — each enabled provider self-registers on import. +// Provider skills append imports to providers/index.ts. +import './providers/index.js'; +import { createProvider, type ProviderName } from './providers/factory.js'; +import { runPollLoop } from './poll-loop.js'; + +function log(msg: string): void { + console.error(`[agent-runner] ${msg}`); } -interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} +const CWD = '/workspace/agent'; -interface SessionEntry { - sessionId: string; - fullPath: string; - summary: string; - firstPrompt: string; -} +async function main(): Promise { + const config = loadConfig(); + const providerName = config.provider.toLowerCase() as ProviderName; -interface SessionsIndex { - entries: SessionEntry[]; -} + log(`Starting v2 agent-runner (provider: ${providerName})`); -interface SDKUserMessage { - type: 'user'; - message: { role: 'user'; content: string }; - parent_tool_use_id: null; - session_id: string; -} - -const IPC_INPUT_DIR = '/workspace/ipc/input'; -const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); -const IPC_POLL_MS = 500; - -/** - * Push-based async iterable for streaming user messages to the SDK. - * Keeps the iterable alive until end() is called, preventing isSingleUserTurn. - */ -class MessageStream { - private queue: SDKUserMessage[] = []; - private waiting: (() => void) | null = null; - private done = false; - - push(text: string): void { - this.queue.push({ - type: 'user', - message: { role: 'user', content: text }, - parent_tool_use_id: null, - session_id: '', - }); - this.waiting?.(); - } - - end(): void { - this.done = true; - this.waiting?.(); - } - - async *[Symbol.asyncIterator](): AsyncGenerator { - while (true) { - while (this.queue.length > 0) { - yield this.queue.shift()!; - } - if (this.done) return; - await new Promise(r => { this.waiting = r; }); - this.waiting = null; - } - } -} - -async function readStdin(): Promise { - return new Promise((resolve, reject) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', reject); - }); -} - -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -function writeOutput(output: ContainerOutput): void { - console.log(OUTPUT_START_MARKER); - console.log(JSON.stringify(output)); - console.log(OUTPUT_END_MARKER); -} - -function log(message: string): void { - console.error(`[agent-runner] ${message}`); -} - -function getSessionSummary(sessionId: string, transcriptPath: string): string | null { - const projectDir = path.dirname(transcriptPath); - const indexPath = path.join(projectDir, 'sessions-index.json'); - - if (!fs.existsSync(indexPath)) { - log(`Sessions index not found at ${indexPath}`); - return null; - } - - try { - const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); - const entry = index.entries.find(e => e.sessionId === sessionId); - if (entry?.summary) { - return entry.summary; - } - } catch (err) { - log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); - } - - return null; -} - -/** - * Archive the full transcript to conversations/ before compaction. - */ -function createPreCompactHook(assistantName?: string): HookCallback { - return async (input, _toolUseId, _context) => { - const preCompact = input as PreCompactHookInput; - const transcriptPath = preCompact.transcript_path; - const sessionId = preCompact.session_id; - - if (!transcriptPath || !fs.existsSync(transcriptPath)) { - log('No transcript found for archiving'); - return {}; - } - - try { - const content = fs.readFileSync(transcriptPath, 'utf-8'); - const messages = parseTranscript(content); - - if (messages.length === 0) { - log('No messages to archive'); - return {}; - } - - const summary = getSessionSummary(sessionId, transcriptPath); - const name = summary ? sanitizeFilename(summary) : generateFallbackName(); - - const conversationsDir = '/workspace/group/conversations'; - fs.mkdirSync(conversationsDir, { recursive: true }); - - const date = new Date().toISOString().split('T')[0]; - const filename = `${date}-${name}.md`; - const filePath = path.join(conversationsDir, filename); - - const markdown = formatTranscriptMarkdown(messages, summary, assistantName); - fs.writeFileSync(filePath, markdown); - - log(`Archived conversation to ${filePath}`); - } catch (err) { - log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); - } - - return {}; - }; -} - -function sanitizeFilename(summary: string): string { - return summary - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 50); -} - -function generateFallbackName(): string { - const time = new Date(); - return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; -} - -interface ParsedMessage { - role: 'user' | 'assistant'; - content: string; -} - -function parseTranscript(content: string): ParsedMessage[] { - const messages: ParsedMessage[] = []; - - for (const line of content.split('\n')) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - if (entry.type === 'user' && entry.message?.content) { - const text = typeof entry.message.content === 'string' - ? entry.message.content - : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); - if (text) messages.push({ role: 'user', content: text }); - } else if (entry.type === 'assistant' && entry.message?.content) { - const textParts = entry.message.content - .filter((c: { type: string }) => c.type === 'text') - .map((c: { text: string }) => c.text); - const text = textParts.join(''); - if (text) messages.push({ role: 'assistant', content: text }); - } - } catch { - } - } - - return messages; -} - -function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { - const now = new Date(); - const formatDateTime = (d: Date) => d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }); - - const lines: string[] = []; - lines.push(`# ${title || 'Conversation'}`); - lines.push(''); - lines.push(`Archived: ${formatDateTime(now)}`); - lines.push(''); - lines.push('---'); - lines.push(''); - - for (const msg of messages) { - const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant'); - const content = msg.content.length > 2000 - ? msg.content.slice(0, 2000) + '...' - : msg.content; - lines.push(`**${sender}**: ${content}`); - lines.push(''); - } - - return lines.join('\n'); -} - -/** - * Check for _close sentinel. - */ -function shouldClose(): boolean { - if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - return true; - } - return false; -} - -/** - * Drain all pending IPC input messages. - * Returns messages found, or empty array. - */ -function drainIpcInput(): string[] { - try { - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - const files = fs.readdirSync(IPC_INPUT_DIR) - .filter(f => f.endsWith('.json')) - .sort(); - - const messages: string[] = []; - for (const file of files) { - const filePath = path.join(IPC_INPUT_DIR, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - fs.unlinkSync(filePath); - if (data.type === 'message' && data.text) { - messages.push(data.text); - } - } catch (err) { - log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`); - try { fs.unlinkSync(filePath); } catch { /* ignore */ } - } - } - return messages; - } catch (err) { - log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); - return []; - } -} - -/** - * Wait for a new IPC message or _close sentinel. - * Returns the messages as a single string, or null if _close. - */ -function waitForIpcMessage(): Promise { - return new Promise((resolve) => { - const poll = () => { - if (shouldClose()) { - resolve(null); - return; - } - const messages = drainIpcInput(); - if (messages.length > 0) { - resolve(messages.join('\n')); - return; - } - setTimeout(poll, IPC_POLL_MS); - }; - poll(); - }); -} - -/** - * Run a single query and stream results via writeOutput. - * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, - * allowing agent teams subagents to run to completion. - * Also pipes IPC messages into the stream during the query. - */ -async function runQuery( - prompt: string, - sessionId: string | undefined, - mcpServerPath: string, - containerInput: ContainerInput, - sdkEnv: Record, - resumeAt?: string, -): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> { - const stream = new MessageStream(); - stream.push(prompt); - - // Poll IPC for follow-up messages and _close sentinel during the query - let ipcPolling = true; - let closedDuringQuery = false; - const pollIpcDuringQuery = () => { - if (!ipcPolling) return; - if (shouldClose()) { - log('Close sentinel detected during query, ending stream'); - closedDuringQuery = true; - stream.end(); - ipcPolling = false; - return; - } - const messages = drainIpcInput(); - for (const text of messages) { - log(`Piping IPC message into active query (${text.length} chars)`); - stream.push(text); - } - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - }; - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - - let newSessionId: string | undefined; - let lastAssistantUuid: string | undefined; - let messageCount = 0; - let resultCount = 0; - - // Load global CLAUDE.md as additional system context (shared across all groups) - const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; - let globalClaudeMd: string | undefined; - if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { - globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); - } + // Runtime-generated system-prompt addendum: agent identity (name) plus + // the live destinations map. Everything else (capabilities, per-module + // instructions, per-channel formatting) is loaded by Claude Code from + // /workspace/agent/CLAUDE.md — the composed entry imports the shared + // base (/app/CLAUDE.md) and each enabled module's fragment. Per-group + // memory lives in /workspace/agent/CLAUDE.local.md (auto-loaded). + const instructions = buildSystemPromptAddendum(config.assistantName || undefined); // Discover additional directories mounted at /workspace/extra/* - // These are passed to the SDK so their CLAUDE.md files are loaded automatically - const extraDirs: string[] = []; + const additionalDirectories: string[] = []; const extraBase = '/workspace/extra'; if (fs.existsSync(extraBase)) { for (const entry of fs.readdirSync(extraBase)) { const fullPath = path.join(extraBase, entry); if (fs.statSync(fullPath).isDirectory()) { - extraDirs.push(fullPath); + additionalDirectories.push(fullPath); } } - } - if (extraDirs.length > 0) { - log(`Additional directories: ${extraDirs.join(', ')}`); - } - - for await (const message of query({ - prompt: stream, - options: { - cwd: '/workspace/group', - additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, - resume: sessionId, - resumeSessionAt: resumeAt, - systemPrompt: globalClaudeMd - ? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd } - : undefined, - allowedTools: [ - 'Bash', - 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'Task', 'TaskOutput', 'TaskStop', - 'TeamCreate', 'TeamDelete', 'SendMessage', - 'TodoWrite', 'ToolSearch', 'Skill', - 'NotebookEdit', - 'mcp__nanoclaw__*' - ], - env: sdkEnv, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - settingSources: ['project', 'user'], - mcpServers: { - nanoclaw: { - command: 'node', - args: [mcpServerPath], - env: { - NANOCLAW_CHAT_JID: containerInput.chatJid, - NANOCLAW_GROUP_FOLDER: containerInput.groupFolder, - NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', - }, - }, - }, - hooks: { - PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], - }, - } - })) { - messageCount++; - const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; - log(`[msg #${messageCount}] type=${msgType}`); - - if (message.type === 'assistant' && 'uuid' in message) { - lastAssistantUuid = (message as { uuid: string }).uuid; - } - - if (message.type === 'system' && message.subtype === 'init') { - newSessionId = message.session_id; - log(`Session initialized: ${newSessionId}`); - } - - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { - const tn = message as { task_id: string; status: string; summary: string }; - log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); - } - - if (message.type === 'result') { - resultCount++; - const textResult = 'result' in message ? (message as { result?: string }).result : null; - log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`); - writeOutput({ - status: 'success', - result: textResult || null, - newSessionId - }); + if (additionalDirectories.length > 0) { + log(`Additional directories: ${additionalDirectories.join(', ')}`); } } - ipcPolling = false; - log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`); - return { newSessionId, lastAssistantUuid, closedDuringQuery }; -} + // MCP server path — bun runs TS directly; no tsc build step in-image. + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.ts'); -interface ScriptResult { - wakeAgent: boolean; - data?: unknown; -} + // Build MCP servers config: nanoclaw built-in + any from container.json + const mcpServers: Record }> = { + nanoclaw: { + command: 'bun', + args: ['run', mcpServerPath], + env: {}, + }, + }; -const SCRIPT_TIMEOUT_MS = 30_000; + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + mcpServers[name] = serverConfig; + log(`Additional MCP server: ${name} (${serverConfig.command})`); + } -async function runScript(script: string): Promise { - const scriptPath = '/tmp/task-script.sh'; - fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + const provider = createProvider(providerName, { + assistantName: config.assistantName || undefined, + mcpServers, + env: { ...process.env }, + additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, + }); - return new Promise((resolve) => { - execFile('bash', [scriptPath], { - timeout: SCRIPT_TIMEOUT_MS, - maxBuffer: 1024 * 1024, - env: process.env, - }, (error, stdout, stderr) => { - if (stderr) { - log(`Script stderr: ${stderr.slice(0, 500)}`); - } - - if (error) { - log(`Script error: ${error.message}`); - return resolve(null); - } - - // Parse last non-empty line of stdout as JSON - const lines = stdout.trim().split('\n'); - const lastLine = lines[lines.length - 1]; - if (!lastLine) { - log('Script produced no output'); - return resolve(null); - } - - try { - const result = JSON.parse(lastLine); - if (typeof result.wakeAgent !== 'boolean') { - log(`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`); - return resolve(null); - } - resolve(result as ScriptResult); - } catch { - log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`); - resolve(null); - } - }); + await runPollLoop({ + provider, + providerName, + cwd: CWD, + systemContext: { instructions }, }); } -async function main(): Promise { - let containerInput: ContainerInput; - - try { - const stdinData = await readStdin(); - containerInput = JSON.parse(stdinData); - try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ } - log(`Received input for group: ${containerInput.groupFolder}`); - } catch (err) { - writeOutput({ - status: 'error', - result: null, - error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}` - }); - process.exit(1); - } - - // Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL. - // No real secrets exist in the container environment. - const sdkEnv: Record = { ...process.env }; - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); - - let sessionId = containerInput.sessionId; - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - - // Clean up stale _close sentinel from previous container runs - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - - // Build initial prompt (drain any pending IPC messages too) - let prompt = containerInput.prompt; - if (containerInput.isScheduledTask) { - prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`; - } - const pending = drainIpcInput(); - if (pending.length > 0) { - log(`Draining ${pending.length} pending IPC messages into initial prompt`); - prompt += '\n' + pending.join('\n'); - } - - // Script phase: run script before waking agent - if (containerInput.script && containerInput.isScheduledTask) { - log('Running task script...'); - const scriptResult = await runScript(containerInput.script); - - if (!scriptResult || !scriptResult.wakeAgent) { - const reason = scriptResult ? 'wakeAgent=false' : 'script error/no output'; - log(`Script decided not to wake agent: ${reason}`); - writeOutput({ - status: 'success', - result: null, - }); - return; - } - - // Script says wake agent — enrich prompt with script data - log(`Script wakeAgent=true, enriching prompt with data`); - prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`; - } - - // Query loop: run query → wait for IPC message → run new query → repeat - let resumeAt: string | undefined; - try { - while (true) { - log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`); - - const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt); - if (queryResult.newSessionId) { - sessionId = queryResult.newSessionId; - } - if (queryResult.lastAssistantUuid) { - resumeAt = queryResult.lastAssistantUuid; - } - - // If _close was consumed during the query, exit immediately. - // Don't emit a session-update marker (it would reset the host's - // idle timer and cause a 30-min delay before the next _close). - if (queryResult.closedDuringQuery) { - log('Close sentinel consumed during query, exiting'); - break; - } - - // Emit session update so host can track it - writeOutput({ status: 'success', result: null, newSessionId: sessionId }); - - log('Query ended, waiting for next IPC message...'); - - // Wait for the next message or _close sentinel - const nextMessage = await waitForIpcMessage(); - if (nextMessage === null) { - log('Close sentinel received, exiting'); - break; - } - - log(`Got new message (${nextMessage.length} chars), starting new query`); - prompt = nextMessage; - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - log(`Agent error: ${errorMessage}`); - writeOutput({ - status: 'error', - result: null, - newSessionId: sessionId, - error: errorMessage - }); - process.exit(1); - } -} - -main(); +main().catch((err) => { + log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts new file mode 100644 index 0000000..3447c38 --- /dev/null +++ b/container/agent-runner/src/integration.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; +import { getUndeliveredMessages } from './db/messages-out.js'; +import { getPendingMessages } from './db/messages-in.js'; +import { MockProvider } from './providers/mock.js'; +import { runPollLoop } from './poll-loop.js'; + +beforeEach(() => { + initTestSessionDb(); + // Seed a destination so output parsing can resolve "discord-test" → routing + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('discord-test', 'Discord Test', 'channel', 'discord', 'chan-1', NULL)`, + ) + .run(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content) + VALUES (?, 'chat', datetime('now'), 'pending', ?, ?, ?, ?)`, + ) + .run(id, opts?.platformId ?? null, opts?.channelType ?? null, opts?.threadId ?? null, JSON.stringify(content)); +} + +describe('poll loop integration', () => { + it('should pick up a message, process it, and write a response', async () => { + insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' }); + + const provider = new MockProvider({}, () => '42'); + + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('42'); + expect(out[0].platform_id).toBe('chan-1'); + expect(out[0].channel_type).toBe('discord'); + expect(out[0].in_reply_to).toBe('m1'); + + // Input message should be acked (not pending) + const pending = getPendingMessages(); + expect(pending).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); + + it('should process multiple messages in a batch', async () => { + insertMessage('m1', { sender: 'Alice', text: 'Hello' }); + insertMessage('m2', { sender: 'Bob', text: 'World' }); + + const provider = new MockProvider({}, () => 'Got both messages'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('Got both messages'); + + await loopPromise.catch(() => {}); + }); + + it('should process messages arriving after loop starts', async () => { + const provider = new MockProvider({}, () => 'Processed'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000); + + // Insert message after loop has started + await sleep(200); + insertMessage('m-late', { sender: 'Charlie', text: 'Late arrival' }); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out.length).toBeGreaterThanOrEqual(1); + + await loopPromise.catch(() => {}); + }); +}); + +// Helper: run poll loop until aborted or timeout +async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise { + return Promise.race([ + runPollLoop({ + provider, + providerName: 'mock', + cwd: '/tmp', + }), + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted'))); + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)), + ]); +} + +async function waitFor(condition: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout'); + await sleep(50); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts deleted file mode 100644 index 5b03478..0000000 --- a/container/agent-runner/src/ipc-mcp-stdio.ts +++ /dev/null @@ -1,342 +0,0 @@ -/** - * Stdio MCP Server for NanoClaw - * Standalone process that agent teams subagents can inherit. - * Reads context from environment variables, writes IPC files for the host. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; -import fs from 'fs'; -import path from 'path'; -import { CronExpressionParser } from 'cron-parser'; - -const IPC_DIR = '/workspace/ipc'; -const MESSAGES_DIR = path.join(IPC_DIR, 'messages'); -const TASKS_DIR = path.join(IPC_DIR, 'tasks'); - -// Context from environment variables (set by the agent runner) -const chatJid = process.env.NANOCLAW_CHAT_JID!; -const groupFolder = process.env.NANOCLAW_GROUP_FOLDER!; -const isMain = process.env.NANOCLAW_IS_MAIN === '1'; - -function writeIpcFile(dir: string, data: object): string { - fs.mkdirSync(dir, { recursive: true }); - - const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`; - const filepath = path.join(dir, filename); - - // Atomic write: temp file then rename - const tempPath = `${filepath}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); - fs.renameSync(tempPath, filepath); - - return filename; -} - -const server = new McpServer({ - name: 'nanoclaw', - version: '1.0.0', -}); - -server.tool( - 'send_message', - "Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.", - { - text: z.string().describe('The message text to send'), - sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'), - }, - async (args) => { - const data: Record = { - type: 'message', - chatJid, - text: args.text, - sender: args.sender || undefined, - groupFolder, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(MESSAGES_DIR, data); - - return { content: [{ type: 'text' as const, text: 'Message sent.' }] }; - }, -); - -server.tool( - 'schedule_task', - `Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. Returns the task ID for future reference. To modify an existing task, use update_task instead. - -CONTEXT MODE - Choose based on task type: -\u2022 "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions. -\u2022 "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself. - -If unsure which mode to use, you can ask the user. Examples: -- "Remind me about our discussion" \u2192 group (needs conversation context) -- "Check the weather every morning" \u2192 isolated (self-contained task) -- "Follow up on my request" \u2192 group (needs to know what was requested) -- "Generate a daily report" \u2192 isolated (just needs instructions in prompt) - -MESSAGING BEHAVIOR - The task agent's output is sent to the user or group. It can also use send_message for immediate delivery, or wrap output in tags to suppress it. Include guidance in the prompt about whether the agent should: -\u2022 Always send a message (e.g., reminders, daily briefings) -\u2022 Only send a message when there's something to report (e.g., "notify me if...") -\u2022 Never send a message (background maintenance tasks) - -SCHEDULE VALUE FORMAT (all times are LOCAL timezone): -\u2022 cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time) -\u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour) -\u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`, - { - prompt: z.string().describe('What the agent should do when the task runs. For isolated mode, include all necessary context here.'), - schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'), - schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), - context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), - target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'), - script: z.string().optional().describe('Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.'), - }, - async (args) => { - // Validate schedule_value before writing IPC - if (args.schedule_type === 'cron') { - try { - CronExpressionParser.parse(args.schedule_value); - } catch { - return { - content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).` }], - isError: true, - }; - } - } else if (args.schedule_type === 'interval') { - const ms = parseInt(args.schedule_value, 10); - if (isNaN(ms) || ms <= 0) { - return { - content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).` }], - isError: true, - }; - } - } else if (args.schedule_type === 'once') { - if (/[Zz]$/.test(args.schedule_value) || /[+-]\d{2}:\d{2}$/.test(args.schedule_value)) { - return { - content: [{ type: 'text' as const, text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".` }], - isError: true, - }; - } - const date = new Date(args.schedule_value); - if (isNaN(date.getTime())) { - return { - content: [{ type: 'text' as const, text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".` }], - isError: true, - }; - } - } - - // Non-main groups can only schedule for themselves - const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid; - - const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - const data = { - type: 'schedule_task', - taskId, - prompt: args.prompt, - script: args.script || undefined, - schedule_type: args.schedule_type, - schedule_value: args.schedule_value, - context_mode: args.context_mode || 'group', - targetJid, - createdBy: groupFolder, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(TASKS_DIR, data); - - return { - content: [{ type: 'text' as const, text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}` }], - }; - }, -); - -server.tool( - 'list_tasks', - "List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.", - {}, - async () => { - const tasksFile = path.join(IPC_DIR, 'current_tasks.json'); - - try { - if (!fs.existsSync(tasksFile)) { - return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] }; - } - - const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8')); - - const tasks = isMain - ? allTasks - : allTasks.filter((t: { groupFolder: string }) => t.groupFolder === groupFolder); - - if (tasks.length === 0) { - return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] }; - } - - const formatted = tasks - .map( - (t: { id: string; prompt: string; schedule_type: string; schedule_value: string; status: string; next_run: string }) => - `- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`, - ) - .join('\n'); - - return { content: [{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }] }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}` }], - }; - } - }, -); - -server.tool( - 'pause_task', - 'Pause a scheduled task. It will not run until resumed.', - { task_id: z.string().describe('The task ID to pause') }, - async (args) => { - const data = { - type: 'pause_task', - taskId: args.task_id, - groupFolder, - isMain, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(TASKS_DIR, data); - - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} pause requested.` }] }; - }, -); - -server.tool( - 'resume_task', - 'Resume a paused task.', - { task_id: z.string().describe('The task ID to resume') }, - async (args) => { - const data = { - type: 'resume_task', - taskId: args.task_id, - groupFolder, - isMain, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(TASKS_DIR, data); - - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} resume requested.` }] }; - }, -); - -server.tool( - 'cancel_task', - 'Cancel and delete a scheduled task.', - { task_id: z.string().describe('The task ID to cancel') }, - async (args) => { - const data = { - type: 'cancel_task', - taskId: args.task_id, - groupFolder, - isMain, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(TASKS_DIR, data); - - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} cancellation requested.` }] }; - }, -); - -server.tool( - 'update_task', - 'Update an existing scheduled task. Only provided fields are changed; omitted fields stay the same.', - { - task_id: z.string().describe('The task ID to update'), - prompt: z.string().optional().describe('New prompt for the task'), - schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), - schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), - script: z.string().optional().describe('New script for the task. Set to empty string to remove the script.'), - }, - async (args) => { - // Validate schedule_value if provided - if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) { - if (args.schedule_value) { - try { - CronExpressionParser.parse(args.schedule_value); - } catch { - return { - content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}".` }], - isError: true, - }; - } - } - } - if (args.schedule_type === 'interval' && args.schedule_value) { - const ms = parseInt(args.schedule_value, 10); - if (isNaN(ms) || ms <= 0) { - return { - content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}".` }], - isError: true, - }; - } - } - - const data: Record = { - type: 'update_task', - taskId: args.task_id, - groupFolder, - isMain: String(isMain), - timestamp: new Date().toISOString(), - }; - if (args.prompt !== undefined) data.prompt = args.prompt; - if (args.script !== undefined) data.script = args.script; - if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type; - if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value; - - writeIpcFile(TASKS_DIR, data); - - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} update requested.` }] }; - }, -); - -server.tool( - 'register_group', - `Register a new chat/group so the agent can respond to messages there. Main group only. - -Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`, - { - jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'), - name: z.string().describe('Display name for the group'), - folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'), - trigger: z.string().describe('Trigger word (e.g., "@Andy")'), - }, - async (args) => { - if (!isMain) { - return { - content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }], - isError: true, - }; - } - - const data = { - type: 'register_group', - jid: args.jid, - name: args.name, - folder: args.folder, - trigger: args.trigger, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(TASKS_DIR, data); - - return { - content: [{ type: 'text' as const, text: `Group "${args.name}" registered. It will start receiving messages immediately.` }], - }; - }, -); - -// Start the stdio transport -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/container/agent-runner/src/mcp-tools/agents.instructions.md b/container/agent-runner/src/mcp-tools/agents.instructions.md new file mode 100644 index 0000000..8ada129 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/agents.instructions.md @@ -0,0 +1,26 @@ +## Companion and collaborator agents (`create_agent`) + +`mcp__nanoclaw__create_agent({ name, instructions })` spins up a new long-lived agent and wires it as a destination — bidirectional, so you can send it tasks and it can message you back. + +### How it works + +- Creates a new agent with its own container, workspace, and session. Your `instructions` string seeds the agent's `CLAUDE.local.md` — its starting role and personality. +- The agent's `name` becomes a destination on both sides: you address it via `send_message({ to: "", ... })`, and its replies arrive as inbound messages with `from=""`. +- Each agent has its own persistent workspace under `groups//` — memory, conversation history, and notes all survive across sessions. This is a full standalone agent, not a stateless sub-query. +- **Fire-and-forget:** the call returns immediately without waiting for the agent to confirm it's ready. Messages you send will queue until it's up. + +### When to use + +- **Companions** — a long-running presence that accumulates context over time: a `Researcher` tracking an ongoing inquiry, a `Calendar` agent managing scheduling, an assistant that knows your preferences and history. +- **Collaborators** — a parallel specialist that works independently and reports back: a `Builder` handling code edits while you stay in conversation, a `Reviewer` running checks in the background. + +The right frame is: does this agent need its own memory and context that builds over time, or does it need to work independently without blocking your turn? Either is a good reason to spawn one. + +### When NOT to use + +- **One-off lookups or short tasks** — use the SDK `Agent` tool instead. It's stateless, spins up and completes in one shot, and leaves no persistent footprint. +- **Work that finishes before the user's next message** — agents persist indefinitely. Don't create one for something you could do inline. + +### Writing good `instructions` + +Cover: the agent's role, who it takes tasks from (you, by name), how it should report back (on completion only? with milestones for long work?), and any domain-specific rules. Don't restate NanoClaw base behavior — the shared base is already loaded on the agent's end. \ No newline at end of file diff --git a/container/agent-runner/src/mcp-tools/agents.ts b/container/agent-runner/src/mcp-tools/agents.ts new file mode 100644 index 0000000..b341b74 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/agents.ts @@ -0,0 +1,66 @@ +/** + * Agent management MCP tools: create_agent. + * + * send_to_agent was removed — sending to another agent is now just + * send_message(to="agent-name") since agents and channels share the + * unified destinations namespace. + * + * create_agent is admin-only. Non-admin containers never see this tool + * (see mcp-tools/index.ts). The host re-checks permission on receive. + */ +import { writeMessageOut } from '../db/messages-out.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +export const createAgent: McpToolDefinition = { + tool: { + name: 'create_agent', + description: + 'Create a long-lived companion sub-agent (research assistant, task manager, specialist) — the name becomes your destination for it. Admin-only. Fire-and-forget.', + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'Human-readable name (also becomes your destination name for this agent)' }, + instructions: { type: 'string', description: 'CLAUDE.md content for the new agent (personality, role, instructions)' }, + }, + required: ['name'], + }, + }, + async handler(args) { + const name = args.name as string; + if (!name) return err('name is required'); + + const requestId = generateId(); + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'create_agent', + requestId, + name, + instructions: (args.instructions as string) || null, + }), + }); + + log(`create_agent: ${requestId} → "${name}"`); + return ok(`Creating agent "${name}". You will be notified when it is ready.`); + }, +}; + +registerTools([createAgent]); diff --git a/container/agent-runner/src/mcp-tools/core.instructions.md b/container/agent-runner/src/mcp-tools/core.instructions.md new file mode 100644 index 0000000..d9995bf --- /dev/null +++ b/container/agent-runner/src/mcp-tools/core.instructions.md @@ -0,0 +1,27 @@ +## Sending messages + +Your final response is delivered via the `## Sending messages` rules in your runtime system prompt (single-destination: just write; multi-destination: use `...` blocks). See that section for the current destination list. + +### Mid-turn updates (`send_message`) + +Use the `mcp__nanoclaw__send_message` tool to send a message while you're still working (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work: + +- **Short turn (≤2 quick tool calls):** Don't narrate. Output any response. +- **Longer turn (multiple tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it, checking the logs now") so the user knows you got the message. +- **Long-running turns (long-running tasks with many stages):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. + +**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call. + +**Outcomes, not play-by-play.** When the turn is done, the final message should be about the result, not a transcript of what you did. + +### Sending files (`send_file`) + +Use `mcp__nanoclaw__send_file({ path, text?, filename?, to? })` to deliver a file from your workspace. `path` is absolute or relative to `/workspace/agent/`; `filename` overrides the display name shown in chat (defaults to the file's basename); `text` is an optional accompanying message. Use this for artifacts you produce (charts, PDFs, generated images, reports) rather than dumping contents into chat. + +### Reacting to messages (`add_reaction`) + +Use `mcp__nanoclaw__add_reaction({ messageId, emoji })` to react to a specific inbound message by its `#N` id — pass `messageId` as an integer (e.g. `22`, not `"22"`). Good for lightweight acknowledgment (`eyes` = seen, `white_check_mark` = done) when a full reply would be noise. `emoji` is the shortcode name (e.g. `thumbs_up`, `heart`), not the raw character. + +### Internal thoughts + +Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts new file mode 100644 index 0000000..bf89ef8 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -0,0 +1,262 @@ +/** + * Core MCP tools: send_message, send_file, edit_message, add_reaction. + * + * All outbound tools resolve destinations via the local destination map + * (see destinations.ts). Agents reference destinations by name; the map + * translates name → routing tuple. Permission enforcement happens on + * the host side in delivery.ts via the agent_destinations table. + */ +import fs from 'fs'; +import path from 'path'; + +import { findByName, getAllDestinations } from '../destinations.js'; +import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js'; +import { getSessionRouting } from '../db/session-routing.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +function destinationList(): string { + const all = getAllDestinations(); + if (all.length === 0) return '(none)'; + return all.map((d) => d.name).join(', '); +} + +/** + * Resolve a destination name to routing fields. + * + * If `to` is omitted, use the session's default reply routing (channel + + * thread the conversation is in) — the agent replies in place. + * + * If `to` is specified, look up the named destination. If it resolves to + * the same channel the session is bound to, the session's thread_id is + * preserved so replies land in the correct thread. Otherwise thread_id + * is null (a cross-destination send starts a new conversation). + */ +function resolveRouting( + to: string | undefined, +): + | { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } + | { error: string } { + if (!to) { + // Default: reply to whatever thread/channel this session is bound to. + const session = getSessionRouting(); + if (session.channel_type && session.platform_id) { + return { + channel_type: session.channel_type, + platform_id: session.platform_id, + thread_id: session.thread_id, + resolvedName: '(current conversation)', + }; + } + // No session routing (e.g., agent-shared or internal-only agent) — + // fall back to the legacy single-destination shortcut. + const all = getAllDestinations(); + if (all.length === 0) return { error: 'No destinations configured.' }; + if (all.length > 1) { + return { + error: `You have multiple destinations — specify "to". Options: ${all.map((d) => d.name).join(', ')}`, + }; + } + to = all[0].name; + } + const dest = findByName(to); + if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` }; + if (dest.type === 'channel') { + // If the destination is the same channel the session is bound to, + // preserve the thread_id so replies land in the correct thread. + const session = getSessionRouting(); + const threadId = + session.channel_type === dest.channelType && session.platform_id === dest.platformId + ? session.thread_id + : null; + return { + channel_type: dest.channelType!, + platform_id: dest.platformId!, + thread_id: threadId, + resolvedName: to, + }; + } + return { channel_type: 'agent', platform_id: dest.agentGroupId!, thread_id: null, resolvedName: to }; +} + +export const sendMessage: McpToolDefinition = { + tool: { + name: 'send_message', + description: + 'Send a message to a named destination. If you have only one destination, you can omit `to`.', + inputSchema: { + type: 'object' as const, + properties: { + to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' }, + text: { type: 'string', description: 'Message content' }, + }, + required: ['text'], + }, + }, + async handler(args) { + const text = args.text as string; + if (!text) return err('text is required'); + + const routing = resolveRouting(args.to as string | undefined); + if ('error' in routing) return err(routing.error); + + const id = generateId(); + const seq = writeMessageOut({ + id, + kind: 'chat', + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, + content: JSON.stringify({ text }), + }); + + log(`send_message: #${seq} → ${routing.resolvedName}`); + return ok(`Message sent to ${routing.resolvedName} (id: ${seq})`); + }, +}; + +export const sendFile: McpToolDefinition = { + tool: { + name: 'send_file', + description: 'Send a file to a named destination. If you have only one destination, you can omit `to`.', + inputSchema: { + type: 'object' as const, + properties: { + to: { type: 'string', description: 'Destination name. Optional if you have only one destination.' }, + path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' }, + text: { type: 'string', description: 'Optional accompanying message' }, + filename: { type: 'string', description: 'Display name (default: basename of path)' }, + }, + required: ['path'], + }, + }, + async handler(args) { + const filePath = args.path as string; + if (!filePath) return err('path is required'); + + const routing = resolveRouting(args.to as string | undefined); + if ('error' in routing) return err(routing.error); + + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath); + if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`); + + const id = generateId(); + const filename = (args.filename as string) || path.basename(resolvedPath); + + const outboxDir = path.join('/workspace/outbox', id); + fs.mkdirSync(outboxDir, { recursive: true }); + fs.copyFileSync(resolvedPath, path.join(outboxDir, filename)); + + writeMessageOut({ + id, + kind: 'chat', + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, + content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }), + }); + + log(`send_file: ${id} → ${routing.resolvedName} (${filename})`); + return ok(`File sent to ${routing.resolvedName} (id: ${id}, filename: ${filename})`); + }, +}; + +export const editMessage: McpToolDefinition = { + tool: { + name: 'edit_message', + description: 'Edit a previously sent message. Targets the same destination the original message was sent to.', + inputSchema: { + type: 'object' as const, + properties: { + messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' }, + text: { type: 'string', description: 'New message content' }, + }, + required: ['messageId', 'text'], + }, + }, + async handler(args) { + const seq = Number(args.messageId); + const text = args.text as string; + if (!seq || !text) return err('messageId and text are required'); + + const platformId = getMessageIdBySeq(seq); + if (!platformId) return err(`Message #${seq} not found`); + + const routing = getRoutingBySeq(seq); + if (!routing || !routing.channel_type || !routing.platform_id) { + return err(`Cannot determine destination for message #${seq}`); + } + + const id = generateId(); + writeMessageOut({ + id, + kind: 'chat', + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, + content: JSON.stringify({ operation: 'edit', messageId: platformId, text }), + }); + + log(`edit_message: #${seq} → ${platformId}`); + return ok(`Message edit queued for #${seq}`); + }, +}; + +export const addReaction: McpToolDefinition = { + tool: { + name: 'add_reaction', + description: 'Add an emoji reaction to a message.', + inputSchema: { + type: 'object' as const, + properties: { + messageId: { type: 'integer', description: 'Message ID (the numeric id shown in messages)' }, + emoji: { type: 'string', description: 'Emoji name (e.g., thumbs_up, heart, check)' }, + }, + required: ['messageId', 'emoji'], + }, + }, + async handler(args) { + const seq = Number(args.messageId); + const emoji = args.emoji as string; + if (!seq || !emoji) return err('messageId and emoji are required'); + + const platformId = getMessageIdBySeq(seq); + if (!platformId) return err(`Message #${seq} not found`); + + const routing = getRoutingBySeq(seq); + if (!routing || !routing.channel_type || !routing.platform_id) { + return err(`Cannot determine destination for message #${seq}`); + } + + const id = generateId(); + writeMessageOut({ + id, + kind: 'chat', + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, + content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }), + }); + + log(`add_reaction: #${seq} → ${emoji} on ${platformId}`); + return ok(`Reaction queued for #${seq}`); + }, +}; + +registerTools([sendMessage, sendFile, editMessage, addReaction]); diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts new file mode 100644 index 0000000..bdaef5c --- /dev/null +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -0,0 +1,22 @@ +/** + * MCP tools barrel — imports each tool module for its side-effect + * `registerTools([...])` call, then starts the MCP server. + * + * Adding a new tool module: create the file, call `registerTools([...])` + * at module scope, and append the import here. No central list. + */ +import './core.js'; +import './scheduling.js'; +import './interactive.js'; +import './agents.js'; +import './self-mod.js'; +import { startMcpServer } from './server.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +startMcpServer().catch((err) => { + log(`MCP server error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/container/agent-runner/src/mcp-tools/interactive.instructions.md b/container/agent-runner/src/mcp-tools/interactive.instructions.md new file mode 100644 index 0000000..f6601bd --- /dev/null +++ b/container/agent-runner/src/mcp-tools/interactive.instructions.md @@ -0,0 +1,22 @@ +## Interactive prompts + +The two tools here solve different problems: `ask_user_question` forces a decision and waits for it; `send_card` displays structured content and moves on. + +### Asking a multiple-choice question (`ask_user_question`) + +`mcp__nanoclaw__ask_user_question({ title, question, options, timeout? })` presents the user with a set of choices and **blocks your turn** until they tap one or the timeout expires (default: 300 seconds). Returns their chosen value. + +`options` can be plain strings or `{ label, selectedLabel?, value? }` objects: +- `label` — the button text shown before selection +- `selectedLabel` — the text shown on the button *after* selection (useful for confirmations, e.g. `"✓ Confirmed"`) +- `value` — the string returned to you when that option is chosen (defaults to `label`) + +Use this when you genuinely cannot proceed without a decision. For free-text input, send a normal message and wait for their reply — don't reach for this tool. + +### Structured cards (`send_card`) + +`mcp__nanoclaw__send_card({ card, fallbackText? })` renders a structured card and **returns immediately** — it does not pause your turn or collect a response. + +`card` supports: `title`, `description`, `children` (nested text or content blocks), and `actions` (buttons). `fallbackText` is sent as a plain message on platforms without card support. + +Use this for presenting information in a cleaner format than prose: summaries, options the user can read (but you're not waiting on), or results with contextual buttons. If you need the user to actually *choose* something and return a value, use `ask_user_question` instead. \ No newline at end of file diff --git a/container/agent-runner/src/mcp-tools/interactive.ts b/container/agent-runner/src/mcp-tools/interactive.ts new file mode 100644 index 0000000..6924a9e --- /dev/null +++ b/container/agent-runner/src/mcp-tools/interactive.ts @@ -0,0 +1,169 @@ +/** + * Interactive MCP tools: ask_user_question, send_card. + * + * ask_user_question is a blocking tool call — it writes a messages_out row + * with a question card, then polls messages_in for the response. + */ +import { findQuestionResponse, markCompleted } from '../db/messages-in.js'; +import { writeMessageOut } from '../db/messages-out.js'; +import { getSessionRouting } from '../db/session-routing.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function routing() { + return getSessionRouting(); +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const askUserQuestion: McpToolDefinition = { + tool: { + name: 'ask_user_question', + description: + 'Ask the user a multiple-choice question and wait for their response. This is a blocking call — execution pauses until the user responds or the timeout expires. Provide a short card title (e.g. "Confirm deletion") and an array of options — each option may be a plain string (used as both button label and result value) or an object { label, selectedLabel?, value? } where selectedLabel is the text shown on the card after the user clicks.', + inputSchema: { + type: 'object' as const, + properties: { + title: { type: 'string', description: 'Short card title shown above the question' }, + question: { type: 'string', description: 'The question to ask' }, + options: { + type: 'array', + items: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + label: { type: 'string' }, + selectedLabel: { type: 'string' }, + value: { type: 'string' }, + }, + required: ['label'], + }, + ], + }, + description: 'Options for the user to choose from (string or {label, selectedLabel?, value?})', + }, + timeout: { type: 'number', description: 'Timeout in seconds (default: 300)' }, + }, + required: ['title', 'question', 'options'], + }, + }, + async handler(args) { + const title = args.title as string; + const question = args.question as string; + const rawOptions = args.options as unknown[]; + const timeout = ((args.timeout as number) || 300) * 1000; + if (!title || !question || !rawOptions?.length) { + return err('title, question, and options are required'); + } + + const options = rawOptions.map((o) => { + if (typeof o === 'string') return { label: o, selectedLabel: o, value: o }; + const obj = o as { label: string; selectedLabel?: string; value?: string }; + return { + label: obj.label, + selectedLabel: obj.selectedLabel ?? obj.label, + value: obj.value ?? obj.label, + }; + }); + + const questionId = generateId(); + const r = routing(); + + // Write question card to outbound.db + writeMessageOut({ + id: questionId, + kind: 'chat-sdk', + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.thread_id, + content: JSON.stringify({ + type: 'ask_question', + questionId, + title, + question, + options, + }), + }); + + log(`ask_user_question: ${questionId} → "${question}" [${options.join(', ')}]`); + + // Poll for response in inbound.db (host writes the response there) + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const response = findQuestionResponse(questionId); + + if (response) { + const parsed = JSON.parse(response.content); + // Mark the response as completed via processing_ack (outbound.db) + markCompleted([response.id]); + + log(`ask_user_question response: ${questionId} → ${parsed.selectedOption}`); + return ok(parsed.selectedOption); + } + + await sleep(1000); + } + + log(`ask_user_question timeout: ${questionId}`); + return err(`Question timed out after ${timeout / 1000}s`); + }, +}; + +export const sendCard: McpToolDefinition = { + tool: { + name: 'send_card', + description: 'Send a structured card (interactive or display-only) to the current conversation.', + inputSchema: { + type: 'object' as const, + properties: { + card: { + type: 'object', + description: 'Card structure with title, description, and optional children/actions', + }, + fallbackText: { type: 'string', description: 'Text fallback for platforms without card support' }, + }, + required: ['card'], + }, + }, + async handler(args) { + const card = args.card as Record; + if (!card) return err('card is required'); + + const id = generateId(); + const r = routing(); + + writeMessageOut({ + id, + kind: 'chat-sdk', + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.thread_id, + content: JSON.stringify({ type: 'card', card, fallbackText: (args.fallbackText as string) || '' }), + }); + + log(`send_card: ${id}`); + return ok(`Card sent (id: ${id})`); + }, +}; + +registerTools([askUserQuestion, sendCard]); diff --git a/container/agent-runner/src/mcp-tools/scheduling.instructions.md b/container/agent-runner/src/mcp-tools/scheduling.instructions.md new file mode 100644 index 0000000..9b6b829 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/scheduling.instructions.md @@ -0,0 +1,40 @@ +## Task scheduling (`schedule_task`) + +For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. + +To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule. + +Frequent recurring scheduled tasks — more than a few times a day — consume API credits and can risk account restrictions. You can add a `script` that runs first, and you will only be called when the check passes. + +### How it works + +1. Provide a bash `script` alongside the `prompt` when scheduling +2. When the task fires, the script runs first +3. Script returns: `{ "wakeAgent": true/false, "data": {...} }` +4. If `wakeAgent: false` — nothing happens, task waits for next run +5. If `wakeAgent: true` — claude receives the script's data + prompt and handles + +### Always test your script first + +Before scheduling, run the script directly to verify it works: + +```bash +bash -c 'node --input-type=module -e " + const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); + const prs = await r.json(); + console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); +"' +``` + +### When NOT to use scripts + +If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. Do not attempt to do things like sentiment analysis or advanced nlp in scripts. + +### Frequent task guidance + +If a user wants a task to run more than a few times a day and a script can't be used: + +- Explain that each time the task fires it uses API credits and risks rate limits +- Suggest adjusting the task requirements in a way that will allow you to use a script +- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script +- Help the user find the minimum viable frequency diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts new file mode 100644 index 0000000..9b8451d --- /dev/null +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -0,0 +1,302 @@ +/** + * Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task. + * + * With the two-DB split, the container cannot write to inbound.db (host-owned). + * Scheduling operations are sent as system actions via messages_out — the host + * reads them during delivery and applies the changes to inbound.db. + */ +import { getInboundDb } from '../db/connection.js'; +import { writeMessageOut } from '../db/messages-out.js'; +import { getSessionRouting } from '../db/session-routing.js'; +import { TIMEZONE, parseZonedToUtc } from '../timezone.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function routing() { + return getSessionRouting(); +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +export const scheduleTask: McpToolDefinition = { + tool: { + name: 'schedule_task', + description: + `Schedule a one-shot or recurring task. The user's timezone is declared in the header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`, + inputSchema: { + type: 'object' as const, + properties: { + prompt: { type: 'string', description: 'Task instructions/prompt' }, + processAfter: { + type: 'string', + description: + `ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`, + }, + recurrence: { + type: 'string', + description: + 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.', + }, + script: { type: 'string', description: 'Optional pre-agent script to run before processing' }, + }, + required: ['prompt', 'processAfter'], + }, + }, + async handler(args) { + const prompt = args.prompt as string; + const processAfterIn = args.processAfter as string; + if (!prompt || !processAfterIn) return err('prompt and processAfter are required'); + + let processAfter: string; + try { + const d = parseZonedToUtc(processAfterIn, TIMEZONE); + if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`); + processAfter = d.toISOString(); + } catch { + return err(`invalid processAfter: ${processAfterIn}`); + } + + const id = generateId(); + const r = routing(); + const recurrence = (args.recurrence as string) || null; + const script = (args.script as string) || null; + + // Write as a system action — host will insert into inbound.db + writeMessageOut({ + id, + kind: 'system', + platform_id: r.platform_id, + channel_type: r.channel_type, + thread_id: r.thread_id, + content: JSON.stringify({ + action: 'schedule_task', + taskId: id, + prompt, + script, + processAfter, + recurrence, + platformId: r.platform_id, + channelType: r.channel_type, + threadId: r.thread_id, + }), + }); + + log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`); + return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`); + }, +}; + +export const listTasks: McpToolDefinition = { + tool: { + name: 'list_tasks', + description: + 'List scheduled tasks. Returns one row per series — the live (pending or paused) occurrence. The id shown is the series id, which is what update_task / cancel_task / pause_task / resume_task expect.', + inputSchema: { + type: 'object' as const, + properties: { + status: { type: 'string', description: 'Filter by status: pending or paused (default: both)' }, + }, + }, + }, + async handler(args) { + const status = args.status as string | undefined; + const db = getInboundDb(); + // One row per series — the live (pending or paused) occurrence. Recurring + // tasks accumulate one completed row per firing plus one live follow-up; + // exposing the whole pile to the agent is noisy and confuses task identity + // ("which id do I cancel?"). The series_id is the stable handle. + // + // SQLite quirk: when MAX(seq) appears in the SELECT list of a GROUP BY + // query, the bare columns take values from the row that contains that max + // — that's how we pick "the latest live row per series" in one pass. + let rows; + if (status) { + rows = db + .prepare( + `SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq + FROM messages_in + WHERE kind = 'task' AND status = ? + GROUP BY series_id + ORDER BY process_after ASC`, + ) + .all(status); + } else { + rows = db + .prepare( + `SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq + FROM messages_in + WHERE kind = 'task' AND status IN ('pending', 'paused') + GROUP BY series_id + ORDER BY process_after ASC`, + ) + .all(); + } + + if ((rows as unknown[]).length === 0) return ok('No tasks found.'); + + const lines = (rows as Array<{ id: string; status: string; process_after: string | null; recurrence: string | null; content: string }>).map((r) => { + const content = JSON.parse(r.content); + const prompt = (content.prompt as string || '').slice(0, 80); + return `- ${r.id} [${r.status}] at=${r.process_after || 'now'} ${r.recurrence ? `recur=${r.recurrence} ` : ''}→ ${prompt}`; + }); + + return ok(lines.join('\n')); + }, +}; + +export const cancelTask: McpToolDefinition = { + tool: { + name: 'cancel_task', + description: 'Cancel a scheduled task.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID to cancel' }, + }, + required: ['taskId'], + }, + }, + async handler(args) { + const taskId = args.taskId as string; + if (!taskId) return err('taskId is required'); + + // Write as a system action — host will update inbound.db + writeMessageOut({ + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'system', + content: JSON.stringify({ action: 'cancel_task', taskId }), + }); + + log(`cancel_task: ${taskId}`); + return ok(`Task cancellation requested: ${taskId}`); + }, +}; + +export const pauseTask: McpToolDefinition = { + tool: { + name: 'pause_task', + description: 'Pause a scheduled task. It will not run until resumed.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID to pause' }, + }, + required: ['taskId'], + }, + }, + async handler(args) { + const taskId = args.taskId as string; + if (!taskId) return err('taskId is required'); + + writeMessageOut({ + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'system', + content: JSON.stringify({ action: 'pause_task', taskId }), + }); + + log(`pause_task: ${taskId}`); + return ok(`Task pause requested: ${taskId}`); + }, +}; + +export const resumeTask: McpToolDefinition = { + tool: { + name: 'resume_task', + description: 'Resume a paused task.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Task ID to resume' }, + }, + required: ['taskId'], + }, + }, + async handler(args) { + const taskId = args.taskId as string; + if (!taskId) return err('taskId is required'); + + writeMessageOut({ + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'system', + content: JSON.stringify({ action: 'resume_task', taskId }), + }); + + log(`resume_task: ${taskId}`); + return ok(`Task resume requested: ${taskId}`); + }, +}; + +export const updateTask: McpToolDefinition = { + tool: { + name: 'update_task', + description: + 'Update a scheduled task. Pass the series id from list_tasks. Any field omitted is left unchanged. Use this instead of cancel + reschedule when adjusting an existing task.', + inputSchema: { + type: 'object' as const, + properties: { + taskId: { type: 'string', description: 'Series id of the task to update (as shown by list_tasks)' }, + prompt: { type: 'string', description: 'New task prompt (optional)' }, + recurrence: { + type: 'string', + description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.', + }, + processAfter: { + type: 'string', + description: + `New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`, + }, + script: { + type: 'string', + description: 'New pre-agent script (optional). Pass empty string to clear.', + }, + }, + required: ['taskId'], + }, + }, + async handler(args) { + const taskId = args.taskId as string; + if (!taskId) return err('taskId is required'); + + const update: Record = { taskId }; + if (typeof args.prompt === 'string') update.prompt = args.prompt; + if (typeof args.processAfter === 'string') { + try { + const d = parseZonedToUtc(args.processAfter, TIMEZONE); + if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`); + update.processAfter = d.toISOString(); + } catch { + return err(`invalid processAfter: ${args.processAfter}`); + } + } + // Empty string clears recurrence/script; undefined leaves them as-is. + if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence; + if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script; + + if (Object.keys(update).length === 1) return err('at least one field to update is required'); + + writeMessageOut({ + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'system', + content: JSON.stringify({ action: 'update_task', ...update }), + }); + + log(`update_task: ${taskId}`); + return ok(`Task update requested: ${taskId}`); + }, +}; + +registerTools([scheduleTask, listTasks, updateTask, cancelTask, pauseTask, resumeTask]); diff --git a/container/agent-runner/src/mcp-tools/self-mod.instructions.md b/container/agent-runner/src/mcp-tools/self-mod.instructions.md new file mode 100644 index 0000000..15057e0 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/self-mod.instructions.md @@ -0,0 +1,25 @@ +## Installing packages & tools + +To install packages that persist, use the self-modification tools: + +**`install_packages`** — request system (apt) or global npm packages. Requires admin approval. + +Example flow: +``` +install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" }) +# → Admin gets an approval card → approves +``` + +**When to use this vs workspace `pnpm install`:** +- `pnpm install` if you only need it temporarily to do one task. Will not be available in subsequent truns. +- `install_packages` persists for all future turns. Use especially if the user specifically asks you to add a capability + +### MCP servers (`add_mcp_server`) + +Use **`add_mcp_server`** to add an MCP server to your configuration. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.: + +``` +add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] }) +``` + +Do not ask the user to give you credentials. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential. diff --git a/container/agent-runner/src/mcp-tools/self-mod.ts b/container/agent-runner/src/mcp-tools/self-mod.ts new file mode 100644 index 0000000..3e2a2d8 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/self-mod.ts @@ -0,0 +1,120 @@ +/** + * Self-modification MCP tools: install_packages, add_mcp_server. + * + * Both are fire-and-forget — the tool writes a system action row and returns + * immediately. The host processes the request (including admin approval) + * and notifies the agent via a chat message when complete. Admin approval + * is approval to apply the change: `install_packages` auto-rebuilds the + * per-agent image and restarts the container; `add_mcp_server` just + * updates `container.json` and restarts (bun runs TS directly — no build + * step needed for a pure MCP wiring change). + * + * Package names are sanitized here at the tool boundary AND re-validated on + * the host side (defense in depth). + */ +import { writeMessageOut } from '../db/messages-out.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +function err(text: string) { + return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; +} + +const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/; +const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const MAX_PACKAGES = 20; + +export const installPackages: McpToolDefinition = { + tool: { + name: 'install_packages', + description: + 'Install apt and/or npm packages into YOUR per-agent container image. Requires admin approval; fire-and-forget. On approval, the image is rebuilt and the container is restarted automatically.', + inputSchema: { + type: 'object' as const, + properties: { + apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install (names only, no version specs or flags)' }, + npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally (names only, no version specs)' }, + reason: { type: 'string', description: 'Why these packages are needed' }, + }, + }, + }, + async handler(args) { + const apt = (args.apt as string[]) || []; + const npm = (args.npm as string[]) || []; + if (apt.length === 0 && npm.length === 0) return err('At least one apt or npm package is required'); + if (apt.length + npm.length > MAX_PACKAGES) return err(`Maximum ${MAX_PACKAGES} packages per request`); + + const invalidApt = apt.find((p) => !APT_RE.test(p)); + if (invalidApt) return err(`Invalid apt package name: "${invalidApt}". Only lowercase letters, digits, and ._+- allowed.`); + const invalidNpm = npm.find((p) => !NPM_RE.test(p)); + if (invalidNpm) return err(`Invalid npm package name: "${invalidNpm}". No version specs or shell characters.`); + + const requestId = generateId(); + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'install_packages', + apt, + npm, + reason: (args.reason as string) || '', + }), + }); + + log(`install_packages: ${requestId} → apt=[${apt.join(',')}] npm=[${npm.join(',')}]`); + return ok(`Package install request submitted. You will be notified when admin approves or rejects.`); + }, +}; + +export const addMcpServer: McpToolDefinition = { + tool: { + name: 'add_mcp_server', + description: + 'Wire an EXISTING third-party MCP server into YOUR per-agent runtime config — you must already know the exact `command` + `args` to invoke it (e.g. `npx @modelcontextprotocol/server-github`). Requires admin approval; fire-and-forget.', + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'MCP server name (unique identifier)' }, + command: { type: 'string', description: 'Command to run the MCP server' }, + args: { type: 'array', items: { type: 'string' }, description: 'Command arguments' }, + env: { type: 'object', description: 'Environment variables for the server' }, + }, + required: ['name', 'command'], + }, + }, + async handler(args) { + const name = args.name as string; + const command = args.command as string; + if (!name || !command) return err('name and command are required'); + + const requestId = generateId(); + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'add_mcp_server', + name, + command, + args: (args.args as string[]) || [], + env: (args.env as Record) || {}, + }), + }); + + log(`add_mcp_server: ${requestId} → "${name}" (${command})`); + return ok(`MCP server request submitted. You will be notified when admin approves or rejects.`); + }, +}; + +registerTools([installPackages, addMcpServer]); diff --git a/container/agent-runner/src/mcp-tools/server.ts b/container/agent-runner/src/mcp-tools/server.ts new file mode 100644 index 0000000..3df45ed --- /dev/null +++ b/container/agent-runner/src/mcp-tools/server.ts @@ -0,0 +1,54 @@ +/** + * MCP server bootstrap + tool self-registration. + * + * Each tool module calls `registerTools([...])` at import time. The + * barrel (`index.ts`) imports every tool module for side effects, then + * calls `startMcpServer()` which uses whatever was registered. + * + * Default when only `core.ts` is imported: the core `send_message` / + * `send_file` / `edit_message` / `add_reaction` tools are available. + */ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +const allTools: McpToolDefinition[] = []; +const toolMap = new Map(); + +export function registerTools(tools: McpToolDefinition[]): void { + for (const t of tools) { + if (toolMap.has(t.tool.name)) { + log(`Warning: tool "${t.tool.name}" already registered, skipping duplicate`); + continue; + } + allTools.push(t); + toolMap.set(t.tool.name, t); + } +} + +export async function startMcpServer(): Promise { + const server = new Server({ name: 'nanoclaw', version: '2.0.0' }, { capabilities: { tools: {} } }); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: allTools.map((t) => t.tool), + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const tool = toolMap.get(name); + if (!tool) { + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] }; + } + return tool.handler(args ?? {}); + }); + + const transport = new StdioServerTransport(); + await server.connect(transport); + log(`MCP server started with ${allTools.length} tools: ${allTools.map((t) => t.tool.name).join(', ')}`); +} diff --git a/container/agent-runner/src/mcp-tools/types.ts b/container/agent-runner/src/mcp-tools/types.ts new file mode 100644 index 0000000..d4637d0 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/types.ts @@ -0,0 +1,6 @@ +import type { Tool, CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +export interface McpToolDefinition { + tool: Tool; + handler: (args: Record) => Promise; +} diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts new file mode 100644 index 0000000..356108f --- /dev/null +++ b/container/agent-runner/src/poll-loop.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; +import { getPendingMessages, markCompleted } from './db/messages-in.js'; +import { getUndeliveredMessages } from './db/messages-out.js'; +import { formatMessages, extractRouting } from './formatter.js'; +import { MockProvider } from './providers/mock.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`, + ) + .run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content)); +} + +describe('formatter', () => { + it('should format a single chat message', () => { + insertMessage('m1', 'chat', { sender: 'John', text: 'Hello world' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('sender="John"'); + expect(prompt).toContain('Hello world'); + }); + + it('should format multiple chat messages as XML block', () => { + insertMessage('m1', 'chat', { sender: 'John', text: 'Hello' }); + insertMessage('m2', 'chat', { sender: 'Jane', text: 'Hi there' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain('sender="John"'); + expect(prompt).toContain('sender="Jane"'); + }); + + it('should format task messages', () => { + insertMessage('m1', 'task', { prompt: 'Review open PRs' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('[SCHEDULED TASK]'); + expect(prompt).toContain('Review open PRs'); + }); + + it('should format webhook messages', () => { + insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('[WEBHOOK: github/push]'); + }); + + it('should format system messages', () => { + insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('[SYSTEM RESPONSE]'); + expect(prompt).toContain('register_group'); + }); + + it('should handle mixed kinds', () => { + insertMessage('m1', 'chat', { sender: 'John', text: 'Hello' }); + insertMessage('m2', 'system', { action: 'test', status: 'ok', result: null }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('sender="John"'); + expect(prompt).toContain('[SYSTEM RESPONSE]'); + }); + + it('should escape XML in content', () => { + insertMessage('m1', 'chat', { sender: 'A y && z' }); + const messages = getPendingMessages(); + const prompt = formatMessages(messages); + expect(prompt).toContain('A<B'); + expect(prompt).toContain('x > y && z'); + }); +}); + +describe('accumulate gate (trigger column)', () => { + it('getPendingMessages returns both trigger=0 and trigger=1 rows', () => { + // trigger=0 rides along as context, trigger=1 is the wake-eligible row. + // The poll loop's gate depends on this data contract. + insertMessage('m1', 'chat', { sender: 'A', text: 'chit chat' }, { trigger: 0 }); + insertMessage('m2', 'chat', { sender: 'B', text: 'actual mention' }, { trigger: 1 }); + const messages = getPendingMessages(); + expect(messages).toHaveLength(2); + const byId = Object.fromEntries(messages.map((m) => [m.id, m])); + expect(byId.m1.trigger).toBe(0); + expect(byId.m2.trigger).toBe(1); + }); + + it('trigger=0-only batch: gate predicate `some(trigger===1)` is false', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'noise' }, { trigger: 0 }); + insertMessage('m2', 'chat', { sender: 'B', text: 'more noise' }, { trigger: 0 }); + const messages = getPendingMessages(); + // This is the exact predicate the poll loop uses to skip accumulate-only + // batches — gate should be false, so the loop sleeps without waking the agent. + expect(messages.some((m) => m.trigger === 1)).toBe(false); + }); + + it('mixed batch: gate is true → loop proceeds, accumulated rows ride along', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'earlier chatter' }, { trigger: 0 }); + insertMessage('m2', 'chat', { sender: 'B', text: 'the real mention' }, { trigger: 1 }); + const messages = getPendingMessages(); + expect(messages.some((m) => m.trigger === 1)).toBe(true); + // Both messages are present for the formatter → agent sees the prior context. + expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']); + }); + + it('trigger column defaults to 1 for legacy inserts without explicit value', () => { + // The schema default is 1 (see src/db/schema.ts INBOUND_SCHEMA) — existing + // rows / tests without the column set are effectively wake-eligible. + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, content) + VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`, + ) + .run(); + const [msg] = getPendingMessages(); + expect(msg.trigger).toBe(1); + }); +}); + +describe('routing', () => { + it('should extract routing from messages', () => { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, thread_id, content) + VALUES ('m1', 'chat', datetime('now'), 'pending', 'chan-123', 'discord', 'thread-456', '{"text":"hi"}')`, + ) + .run(); + + const messages = getPendingMessages(); + const routing = extractRouting(messages); + expect(routing.platformId).toBe('chan-123'); + expect(routing.channelType).toBe('discord'); + expect(routing.threadId).toBe('thread-456'); + expect(routing.inReplyTo).toBe('m1'); + }); +}); + +describe('mock provider', () => { + it('should produce init + result events', async () => { + const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`); + const query = provider.query({ + prompt: 'Hello', + cwd: '/tmp', + }); + + const events: Array<{ type: string }> = []; + setTimeout(() => query.end(), 50); + + for await (const event of query.events) { + events.push(event); + } + + const typed = events.filter((e) => e.type !== 'activity'); + expect(typed.length).toBeGreaterThanOrEqual(2); + expect(typed[0].type).toBe('init'); + expect(typed[1].type).toBe('result'); + expect((typed[1] as { text: string }).text).toBe('Echo: Hello'); + }); + + it('should handle push() during active query', async () => { + const provider = new MockProvider({}, (prompt) => `Re: ${prompt}`); + const query = provider.query({ + prompt: 'First', + cwd: '/tmp', + }); + + const events: Array<{ type: string; text?: string }> = []; + + setTimeout(() => query.push('Second'), 30); + setTimeout(() => query.end(), 60); + + for await (const event of query.events) { + events.push(event); + } + + const results = events.filter((e) => e.type === 'result'); + expect(results).toHaveLength(2); + expect(results[0].text).toBe('Re: First'); + expect(results[1].text).toBe('Re: Second'); + }); +}); + +describe('end-to-end with mock provider', () => { + it('should read messages_in, process with mock provider, write messages_out', async () => { + // Insert a chat message into inbound DB + insertMessage('m1', 'chat', { sender: 'User', text: 'What is 2+2?' }); + + // Read and process + const messages = getPendingMessages(); + expect(messages).toHaveLength(1); + + const routing = extractRouting(messages); + const prompt = formatMessages(messages); + + // Create mock provider and run query + const provider = new MockProvider({}, () => 'The answer is 4'); + const query = provider.query({ + prompt, + cwd: '/tmp', + }); + + // Process events — simulate what poll-loop does + const { markProcessing } = await import('./db/messages-in.js'); + const { writeMessageOut } = await import('./db/messages-out.js'); + + markProcessing(['m1']); + + setTimeout(() => query.end(), 50); + + for await (const event of query.events) { + if (event.type === 'result' && event.text) { + writeMessageOut({ + id: `out-${Date.now()}`, + in_reply_to: routing.inReplyTo, + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: event.text }), + }); + } + } + + markCompleted(['m1']); + + // Verify: message was processed (not pending, acked in processing_ack) + const processed = getPendingMessages(); + expect(processed).toHaveLength(0); + + // Verify: response was written to outbound DB + const outMessages = getUndeliveredMessages(); + expect(outMessages).toHaveLength(1); + expect(JSON.parse(outMessages[0].content).text).toBe('The answer is 4'); + expect(outMessages[0].in_reply_to).toBe('m1'); + }); +}); diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts new file mode 100644 index 0000000..e825184 --- /dev/null +++ b/container/agent-runner/src/poll-loop.ts @@ -0,0 +1,491 @@ +import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js'; +import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; +import { writeMessageOut } from './db/messages-out.js'; +import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; +import { + clearContinuation, + migrateLegacyContinuation, + setContinuation, +} from './db/session-state.js'; +import { formatMessages, extractRouting, categorizeMessage, isClearCommand, isRunnerCommand, stripInternalTags, type RoutingContext } from './formatter.js'; +import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; + +const POLL_INTERVAL_MS = 1000; +const ACTIVE_POLL_INTERVAL_MS = 500; + +function log(msg: string): void { + console.error(`[poll-loop] ${msg}`); +} + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export interface PollLoopConfig { + provider: AgentProvider; + /** + * Name of the provider (e.g. "claude", "codex", "opencode"). Used to key + * the stored continuation per-provider so flipping providers doesn't + * resurrect a stale id from a different backend. + */ + providerName: string; + cwd: string; + systemContext?: { + instructions?: string; + }; +} + +/** + * Main poll loop. Runs indefinitely until the process is killed. + * + * 1. Poll messages_in for pending rows + * 2. Format into prompt, call provider.query() + * 3. While query active: continue polling, push new messages via provider.push() + * 4. On result: write messages_out + * 5. Mark messages completed + * 6. Loop + */ +export async function runPollLoop(config: PollLoopConfig): Promise { + // Resume the agent's prior session from a previous container run if one + // was persisted. The continuation is opaque to the poll-loop — the + // provider decides how to use it (Claude resumes a .jsonl transcript, + // other providers may reload a thread ID, etc.). Keyed per-provider so + // a Codex thread id never gets handed to Claude or vice versa. + let continuation: string | undefined = migrateLegacyContinuation(config.providerName); + + if (continuation) { + log(`Resuming agent session ${continuation}`); + } + + // Clear leftover 'processing' acks from a previous crashed container. + // This lets the new container re-process those messages. + clearStaleProcessingAcks(); + + let pollCount = 0; + while (true) { + // Skip system messages — they're responses for MCP tools (e.g., ask_user_question) + const messages = getPendingMessages().filter((m) => m.kind !== 'system'); + pollCount++; + + // Periodic heartbeat so we know the loop is alive + if (pollCount % 30 === 0) { + log(`Poll heartbeat (${pollCount} iterations, ${messages.length} pending)`); + } + + if (messages.length === 0) { + await sleep(POLL_INTERVAL_MS); + continue; + } + + // Accumulate gate: if the batch contains only trigger=0 rows + // (context-only, router-stored under ignored_message_policy='accumulate'), + // don't wake the agent. Leave them `pending` — they'll ride along the + // next time a real trigger=1 message lands via this same getPendingMessages + // query. Without this gate, a warm container keeps processing + // (and potentially responding to) every accumulate-only batch, defeating + // the "store as context, don't engage" contract. Host-side countDueMessages + // gates the same way for wake-from-cold (see src/db/session-db.ts). + if (!messages.some((m) => m.trigger === 1)) { + await sleep(POLL_INTERVAL_MS); + continue; + } + + const ids = messages.map((m) => m.id); + markProcessing(ids); + + const routing = extractRouting(messages); + + // Command handling: the host router gates filtered and unauthorized + // admin commands before they reach the container. The only command + // the runner handles directly is /clear (session reset). + const normalMessages: MessageInRow[] = []; + const commandIds: string[] = []; + + for (const msg of messages) { + if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) { + log('Clearing session (resetting continuation)'); + continuation = undefined; + clearContinuation(config.providerName); + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: 'Session cleared.' }), + }); + commandIds.push(msg.id); + continue; + } + normalMessages.push(msg); + } + + if (commandIds.length > 0) { + markCompleted(commandIds); + } + + if (normalMessages.length === 0) { + const remainingIds = ids.filter((id) => !commandIds.includes(id)); + if (remainingIds.length > 0) markCompleted(remainingIds); + log(`All ${messages.length} message(s) were commands, skipping query`); + continue; + } + + // Pre-task scripts: for any task rows with a `script`, run it before the + // provider call. Scripts returning wakeAgent=false (or erroring) gate + // their own task row only — surviving messages still go to the agent. + // Without the scheduling module, the marker block is empty, `keep` + // falls back to `normalMessages`, and no gating happens. + let keep: MessageInRow[] = normalMessages; + let skipped: string[] = []; + // MODULE-HOOK:scheduling-pre-task:start + const { applyPreTaskScripts } = await import('./scheduling/task-script.js'); + const preTask = await applyPreTaskScripts(normalMessages); + keep = preTask.keep; + skipped = preTask.skipped; + if (skipped.length > 0) { + markCompleted(skipped); + log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`); + } + // MODULE-HOOK:scheduling-pre-task:end + + if (keep.length === 0) { + log(`All ${normalMessages.length} non-command message(s) gated by script, skipping query`); + continue; + } + + // Format messages: passthrough commands get raw text (only if the + // provider natively handles slash commands), others get XML. + const prompt = formatMessagesWithCommands(keep, config.provider.supportsNativeSlashCommands); + + log(`Processing ${keep.length} message(s), kinds: ${[...new Set(keep.map((m) => m.kind))].join(',')}`); + + const query = config.provider.query({ + prompt, + continuation, + cwd: config.cwd, + systemContext: config.systemContext, + }); + + // Process the query while concurrently polling for new messages + const skippedSet = new Set(skipped); + const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); + try { + const result = await processQuery(query, routing, processingIds, config.providerName); + if (result.continuation && result.continuation !== continuation) { + continuation = result.continuation; + setContinuation(config.providerName, continuation); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + log(`Query error: ${errMsg}`); + + // Stale/corrupt continuation recovery: ask the provider whether + // this error means the stored continuation is unusable, and clear + // it so the next attempt starts fresh. + if (continuation && config.provider.isSessionInvalid(err)) { + log(`Stale session detected (${continuation}) — clearing for next retry`); + continuation = undefined; + clearContinuation(config.providerName); + } + + // Write error response so the user knows something went wrong + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); + } + + // Ensure completed even if processQuery ended without a result event + // (e.g. stream closed unexpectedly). + markCompleted(processingIds); + log(`Completed ${ids.length} message(s)`); + } +} + +/** + * Format messages, handling passthrough commands differently. + * When the provider handles slash commands natively (Claude Code), + * passthrough commands are sent raw (no XML wrapping) so the SDK can + * dispatch them. Otherwise they fall through to standard XML formatting. + */ +function formatMessagesWithCommands(messages: MessageInRow[], nativeSlashCommands: boolean): string { + const parts: string[] = []; + const normalBatch: MessageInRow[] = []; + + for (const msg of messages) { + if (nativeSlashCommands && (msg.kind === 'chat' || msg.kind === 'chat-sdk')) { + const cmdInfo = categorizeMessage(msg); + if (cmdInfo.category === 'passthrough' || cmdInfo.category === 'admin') { + // Flush normal batch first + if (normalBatch.length > 0) { + parts.push(formatMessages(normalBatch)); + normalBatch.length = 0; + } + // Pass raw command text (no XML wrapping) — SDK handles it natively + parts.push(cmdInfo.text); + continue; + } + } + normalBatch.push(msg); + } + + if (normalBatch.length > 0) { + parts.push(formatMessages(normalBatch)); + } + + return parts.join('\n\n'); +} + +interface QueryResult { + continuation?: string; +} + +async function processQuery( + query: AgentQuery, + routing: RoutingContext, + initialBatchIds: string[], + providerName: string, +): Promise { + let queryContinuation: string | undefined; + let done = false; + + // Concurrent polling: push follow-ups into the active query as they arrive. + // We do NOT force-end the stream on silence — keeping the query open avoids + // re-spawning the SDK subprocess (~few seconds) and re-loading the .jsonl + // transcript on every turn. The Anthropic prompt cache is server-side with + // a 5-min TTL keyed on prefix hash, so stream lifecycle does NOT affect + // cache lifetime — close+reopen within 5 min still gets cache hits. + // Stream liveness is decided host-side via the heartbeat file + processing + // claim age (see src/host-sweep.ts); if something is truly stuck, the host + // will kill the container and messages get reset to pending. + let pollInFlight = false; + let endedForCommand = false; + const pollHandle = setInterval(() => { + if (done || pollInFlight || endedForCommand) return; + pollInFlight = true; + + void (async () => { + try { + const pending = getPendingMessages(); + + // Slash commands need a fresh query: /clear resets the SDK's + // resume id (fixed at sdkQuery() time); admin/passthrough commands + // (/compact, /cost, …) only dispatch when they're the first input + // of a query — pushed mid-stream they arrive as plain text and + // the SDK never runs them. End the stream and leave the rows + // pending; the outer loop handles them on next iteration via the + // canonical command path + formatMessagesWithCommands. + if (pending.some((m) => isRunnerCommand(m))) { + log('Pending slash command — ending stream so outer loop can process'); + endedForCommand = true; + query.end(); + return; + } + + // Skip system messages (MCP tool responses). + // Thread routing is the router's concern — if a message landed in this + // session, the agent should see it. Per-thread sessions already isolate + // threads into separate containers; shared sessions intentionally merge + // everything. Filtering on thread_id here caused deadlocks when the + // initial batch and follow-ups had mismatched thread_ids (e.g. a + // host-generated welcome trigger with null thread vs a Discord DM reply). + const newMessages = pending.filter((m) => m.kind !== 'system'); + if (newMessages.length === 0) return; + + const newIds = newMessages.map((m) => m.id); + markProcessing(newIds); + + // Run pre-task scripts on follow-ups too — without this, a task that + // arrives during an active query (e.g. a */10 monitoring cron) bypasses + // its script gate and always wakes the agent, defeating the gate. + // Mirrors the initial-batch hook above. + let keep = newMessages; + let skipped: string[] = []; + // MODULE-HOOK:scheduling-pre-task-followup:start + const { applyPreTaskScripts } = await import('./scheduling/task-script.js'); + const preTask = await applyPreTaskScripts(newMessages); + keep = preTask.keep; + skipped = preTask.skipped; + if (skipped.length > 0) { + markCompleted(skipped); + log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`); + } + // MODULE-HOOK:scheduling-pre-task-followup:end + + if (keep.length === 0) return; + // Re-check done — the outer query may have finished while the script + // was awaited. Pushing into a closed stream is wasted work; the + // claimed messages get released by the host's processing-claim sweep. + if (done) return; + + const keptIds = keep.map((m) => m.id); + const prompt = formatMessages(keep); + log(`Pushing ${keep.length} follow-up message(s) into active query`); + query.push(prompt); + markCompleted(keptIds); + } catch (err) { + // Without this catch the rejection escapes the void IIFE and Node + // terminates the container on unhandled-rejection. The initial-batch + // path is wrapped by processQuery's outer try/catch; the follow-up + // path is not, so it needs its own. + const errMsg = err instanceof Error ? err.message : String(err); + log(`Follow-up poll error: ${errMsg}`); + } finally { + pollInFlight = false; + } + })(); + }, ACTIVE_POLL_INTERVAL_MS); + + try { + for await (const event of query.events) { + handleEvent(event, routing); + touchHeartbeat(); + + if (event.type === 'init') { + queryContinuation = event.continuation; + // Persist immediately so a mid-turn container crash still lets the + // next wake resume the conversation. Without this, the session id + // was only written after the full stream completed — if the + // container died between `init` and `result`, the SDK session was + // effectively orphaned and the next message started a blank + // Claude session with no prior context. + setContinuation(providerName, event.continuation); + } else if (event.type === 'result') { + // A result — with or without text — means the turn is done. Mark + // the initial batch completed now so the host sweep doesn't see + // stale 'processing' claims while the query stays open for + // follow-up pushes. The agent may have responded via MCP + // (send_message) mid-turn, or the message may not need a response + // at all — either way the turn is finished. + markCompleted(initialBatchIds); + if (event.text) { + dispatchResultText(event.text, routing); + } + } + } + } finally { + done = true; + clearInterval(pollHandle); + } + + return { continuation: queryContinuation }; +} + +function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { + switch (event.type) { + case 'init': + log(`Session: ${event.continuation}`); + break; + case 'result': + log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`); + break; + case 'error': + log(`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`); + break; + case 'progress': + log(`Progress: ${event.message}`); + break; + } +} + +/** + * Parse the agent's final text for ... blocks + * and dispatch each one to its resolved destination. Text outside of blocks + * (including ...) is normally scratchpad — logged but + * not sent. + * + * Single-destination shortcut: if the agent has exactly one configured + * destination AND the output contains zero blocks, the entire + * cleaned text (with tags stripped) is sent to that destination. + * This preserves the simple case of one user on one channel — the agent + * doesn't need to know about wrapping syntax at all. + */ +function dispatchResultText(text: string, routing: RoutingContext): void { + const MESSAGE_RE = /([\s\S]*?)<\/message>/g; + + let match: RegExpExecArray | null; + let sent = 0; + let lastIndex = 0; + const scratchpadParts: string[] = []; + + while ((match = MESSAGE_RE.exec(text)) !== null) { + if (match.index > lastIndex) { + scratchpadParts.push(text.slice(lastIndex, match.index)); + } + const toName = match[1]; + const body = match[2].trim(); + lastIndex = MESSAGE_RE.lastIndex; + + const dest = findByName(toName); + if (!dest) { + log(`Unknown destination in , dropping block`); + scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`); + continue; + } + sendToDestination(dest, body, routing); + sent++; + } + if (lastIndex < text.length) { + scratchpadParts.push(text.slice(lastIndex)); + } + + const scratchpad = stripInternalTags(scratchpadParts.join('')); + + // Single-destination shortcut: the agent wrote plain text — send to + // the session's originating channel (from session_routing) if available, + // otherwise fall back to the single destination. + if (sent === 0 && scratchpad) { + if (routing.channelType && routing.platformId) { + // Reply to the channel/thread the message came from + writeMessageOut({ + id: generateId(), + in_reply_to: routing.inReplyTo, + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: scratchpad }), + }); + return; + } + const all = getAllDestinations(); + if (all.length === 1) { + sendToDestination(all[0], scratchpad, routing); + return; + } + } + + if (scratchpad) { + log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); + } + + if (sent === 0 && text.trim()) { + log(`WARNING: agent output had no blocks — nothing was sent`); + } +} + +function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void { + const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; + const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; + // Inherit thread_id from the inbound routing context so replies land in the + // same thread the conversation is in. For non-threaded adapters the router + // strips thread_id at ingest, so this will already be null. + writeMessageOut({ + id: generateId(), + in_reply_to: routing.inReplyTo, + kind: 'chat', + platform_id: platformId, + channel_type: channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: body }), + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts new file mode 100644 index 0000000..c9478b8 --- /dev/null +++ b/container/agent-runner/src/providers/claude.ts @@ -0,0 +1,340 @@ +import fs from 'fs'; +import path from 'path'; + +import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; + +import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js'; +import { registerProvider } from './provider-registry.js'; +import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; + +function log(msg: string): void { + console.error(`[claude-provider] ${msg}`); +} + +// Deferred SDK builtins that either sidestep nanoclaw's own scheduling or +// don't fit our async message-passing model (they're designed for Claude +// Code's interactive UI and would hang here). +// +// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable +// scheduling via mcp__nanoclaw__schedule_task. +// - AskUserQuestion: SDK returns a placeholder instead of blocking on a +// real answer — we have mcp__nanoclaw__ask_user_question that persists +// the question and blocks on the real reply. +// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude +// Code UI affordances; in a headless container they'd appear stuck. +const SDK_DISALLOWED_TOOLS = [ + 'CronCreate', + 'CronDelete', + 'CronList', + 'ScheduleWakeup', + 'AskUserQuestion', + 'EnterPlanMode', + 'ExitPlanMode', + 'EnterWorktree', + 'ExitWorktree', +]; + +// Tool allowlist for NanoClaw agent containers +const TOOL_ALLOWLIST = [ + 'Bash', + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'WebSearch', + 'WebFetch', + 'Task', + 'TaskOutput', + 'TaskStop', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'TodoWrite', + 'ToolSearch', + 'Skill', + 'NotebookEdit', + 'mcp__nanoclaw__*', +]; + +interface SDKUserMessage { + type: 'user'; + message: { role: 'user'; content: string }; + parent_tool_use_id: null; + session_id: string; +} + +/** + * Push-based async iterable for streaming user messages to the Claude SDK. + */ +class MessageStream { + private queue: SDKUserMessage[] = []; + private waiting: (() => void) | null = null; + private done = false; + + push(text: string): void { + this.queue.push({ + type: 'user', + message: { role: 'user', content: text }, + parent_tool_use_id: null, + session_id: '', + }); + this.waiting?.(); + } + + end(): void { + this.done = true; + this.waiting?.(); + } + + async *[Symbol.asyncIterator](): AsyncGenerator { + while (true) { + while (this.queue.length > 0) { + yield this.queue.shift()!; + } + if (this.done) return; + await new Promise((r) => { + this.waiting = r; + }); + this.waiting = null; + } + } +} + +// ── Transcript archiving (PreCompact hook) ── + +interface ParsedMessage { + role: 'user' | 'assistant'; + content: string; +} + +function parseTranscript(content: string): ParsedMessage[] { + const messages: ParsedMessage[] = []; + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + if (entry.type === 'user' && entry.message?.content) { + const text = typeof entry.message.content === 'string' ? entry.message.content : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); + if (text) messages.push({ role: 'user', content: text }); + } else if (entry.type === 'assistant' && entry.message?.content) { + const textParts = entry.message.content.filter((c: { type: string }) => c.type === 'text').map((c: { text: string }) => c.text); + const text = textParts.join(''); + if (text) messages.push({ role: 'assistant', content: text }); + } + } catch { + /* skip unparseable lines */ + } + } + return messages; +} + +function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { + const now = new Date(); + const dateStr = now.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); + const lines = [`# ${title || 'Conversation'}`, '', `Archived: ${dateStr}`, '', '---', '']; + for (const msg of messages) { + const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant'; + const content = msg.content.length > 2000 ? msg.content.slice(0, 2000) + '...' : msg.content; + lines.push(`**${sender}**: ${content}`, ''); + } + return lines.join('\n'); +} + +/** + * PreToolUse hook: record the current tool + its declared timeout so the host + * sweep can widen its stuck tolerance while Bash is running a long-declared + * script. Defense-in-depth: if SDK_DISALLOWED_TOOLS slips through somehow, + * block the call here instead of letting the agent hang. + */ +const preToolUseHook: HookCallback = async (input) => { + const i = input as { tool_name?: string; tool_input?: Record }; + const toolName = i.tool_name ?? ''; + if (SDK_DISALLOWED_TOOLS.includes(toolName)) { + return { + decision: 'block', + stopReason: `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`, + } as unknown as ReturnType; + } + // Bash exposes its timeout via the tool_input.timeout field (ms). Any other + // tool: no declared timeout. + const declaredTimeoutMs = + toolName === 'Bash' && typeof i.tool_input?.timeout === 'number' ? (i.tool_input.timeout as number) : null; + try { + setContainerToolInFlight(toolName, declaredTimeoutMs); + } catch (err) { + log(`PreToolUse: failed to record container_state: ${err instanceof Error ? err.message : String(err)}`); + } + return { continue: true }; +}; + +/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */ +const postToolUseHook: HookCallback = async () => { + try { + clearContainerToolInFlight(); + } catch (err) { + log(`PostToolUse: failed to clear container_state: ${err instanceof Error ? err.message : String(err)}`); + } + return { continue: true }; +}; + +function createPreCompactHook(assistantName?: string): HookCallback { + return async (input) => { + const preCompact = input as PreCompactHookInput; + const { transcript_path: transcriptPath, session_id: sessionId } = preCompact; + + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + log('No transcript found for archiving'); + return {}; + } + + try { + const content = fs.readFileSync(transcriptPath, 'utf-8'); + const messages = parseTranscript(content); + if (messages.length === 0) return {}; + + // Try to get summary from sessions index + let summary: string | undefined; + const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json'); + if (fs.existsSync(indexPath)) { + try { + const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); + summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary; + } catch { + /* ignore */ + } + } + + const name = summary + ? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) + : `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`; + + const conversationsDir = '/workspace/agent/conversations'; + fs.mkdirSync(conversationsDir, { recursive: true }); + const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`; + fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName)); + log(`Archived conversation to ${filename}`); + } catch (err) { + log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); + } + return {}; + }; +} + +// ── Provider ── + +/** + * Claude Code auto-compacts context at this window (tokens). Kept here so + * the generic bootstrap doesn't need to know about Claude-specific env vars. + * + * Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to + * raise or lower the threshold without editing source — useful when running + * with a 1M-context model variant or when emergency-tuning a deployment. + */ +const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000'; + +/** + * Stale-session detection. Matches Claude Code's error text when a + * resumed session can't be found — missing transcript .jsonl, unknown + * session ID, etc. + */ +const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; + +export class ClaudeProvider implements AgentProvider { + readonly supportsNativeSlashCommands = true; + + private assistantName?: string; + private mcpServers: Record; + private env: Record; + private additionalDirectories?: string[]; + + constructor(options: ProviderOptions = {}) { + this.assistantName = options.assistantName; + this.mcpServers = options.mcpServers ?? {}; + this.additionalDirectories = options.additionalDirectories; + this.env = { + ...(options.env ?? {}), + CLAUDE_CODE_AUTO_COMPACT_WINDOW, + }; + } + + isSessionInvalid(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return STALE_SESSION_RE.test(msg); + } + + query(input: QueryInput): AgentQuery { + const stream = new MessageStream(); + stream.push(input.prompt); + + const instructions = input.systemContext?.instructions; + + const sdkResult = sdkQuery({ + prompt: stream, + options: { + cwd: input.cwd, + additionalDirectories: this.additionalDirectories, + resume: input.continuation, + pathToClaudeCodeExecutable: '/pnpm/claude', + systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined, + allowedTools: TOOL_ALLOWLIST, + disallowedTools: SDK_DISALLOWED_TOOLS, + env: this.env, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + settingSources: ['project', 'user'], + mcpServers: this.mcpServers, + hooks: { + PreToolUse: [{ hooks: [preToolUseHook] }], + PostToolUse: [{ hooks: [postToolUseHook] }], + PostToolUseFailure: [{ hooks: [postToolUseHook] }], + PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }], + }, + }, + }); + + let aborted = false; + + async function* translateEvents(): AsyncGenerator { + let messageCount = 0; + for await (const message of sdkResult) { + if (aborted) return; + messageCount++; + + // Yield activity for every SDK event so the poll loop knows the agent is working + yield { type: 'activity' }; + + if (message.type === 'system' && message.subtype === 'init') { + yield { type: 'init', continuation: message.session_id }; + } else if (message.type === 'result') { + const text = 'result' in message ? (message as { result?: string }).result ?? null : null; + yield { type: 'result', text }; + } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') { + yield { type: 'error', message: 'API retry', retryable: true }; + } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') { + yield { type: 'error', message: 'Rate limit', retryable: false, classification: 'quota' }; + } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { + const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata; + const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : ''; + yield { type: 'result', text: `Context compacted${detail}.` }; + } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { + const tn = message as { summary?: string }; + yield { type: 'progress', message: tn.summary || 'Task notification' }; + } + } + log(`Query completed after ${messageCount} SDK messages`); + } + + return { + push: (msg) => stream.push(msg), + end: () => stream.end(), + events: translateEvents(), + abort: () => { + aborted = true; + stream.end(); + }, + }; + } +} + +registerProvider('claude', (opts) => new ClaudeProvider(opts)); diff --git a/container/agent-runner/src/providers/factory.test.ts b/container/agent-runner/src/providers/factory.test.ts new file mode 100644 index 0000000..61fa7a8 --- /dev/null +++ b/container/agent-runner/src/providers/factory.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'bun:test'; + +import { createProvider, type ProviderName } from './factory.js'; +import { ClaudeProvider } from './claude.js'; +import { MockProvider } from './mock.js'; + +describe('createProvider', () => { + it('returns ClaudeProvider for claude', () => { + expect(createProvider('claude')).toBeInstanceOf(ClaudeProvider); + }); + + it('returns MockProvider for mock', () => { + expect(createProvider('mock')).toBeInstanceOf(MockProvider); + }); + + it('throws for unknown name', () => { + expect(() => createProvider('bogus' as ProviderName)).toThrow(/Unknown provider/); + }); +}); diff --git a/container/agent-runner/src/providers/factory.ts b/container/agent-runner/src/providers/factory.ts new file mode 100644 index 0000000..8a14da9 --- /dev/null +++ b/container/agent-runner/src/providers/factory.ts @@ -0,0 +1,13 @@ +import type { AgentProvider, ProviderOptions } from './types.js'; +import { getProviderFactory } from './provider-registry.js'; + +/** + * Any registered provider name. Kept as a named alias for readability; the + * set of valid names is open and determined at runtime by whichever provider + * modules the `providers/index.ts` barrel imports. + */ +export type ProviderName = string; + +export function createProvider(name: ProviderName, options: ProviderOptions = {}): AgentProvider { + return getProviderFactory(name)(options); +} diff --git a/container/agent-runner/src/providers/index.ts b/container/agent-runner/src/providers/index.ts new file mode 100644 index 0000000..70497cf --- /dev/null +++ b/container/agent-runner/src/providers/index.ts @@ -0,0 +1,6 @@ +// Provider self-registration barrel. +// Each import triggers the provider module's registerProvider() call at top +// level. Skills add a new provider by appending one import line below. + +import './claude.js'; +import './mock.js'; diff --git a/container/agent-runner/src/providers/mock.ts b/container/agent-runner/src/providers/mock.ts new file mode 100644 index 0000000..f941f09 --- /dev/null +++ b/container/agent-runner/src/providers/mock.ts @@ -0,0 +1,77 @@ +import { registerProvider } from './provider-registry.js'; +import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; + +/** + * Mock provider for testing. Returns canned responses. + * Supports push() — queued messages produce additional results. + */ +export class MockProvider implements AgentProvider { + readonly supportsNativeSlashCommands = false; + + private responseFactory: (prompt: string) => string; + + constructor(_options: ProviderOptions = {}, responseFactory?: (prompt: string) => string) { + this.responseFactory = responseFactory ?? ((prompt) => `Mock response to: ${prompt.slice(0, 100)}`); + } + + isSessionInvalid(_err: unknown): boolean { + return false; + } + + query(input: QueryInput): AgentQuery { + const pending: string[] = []; + let waiting: (() => void) | null = null; + let ended = false; + let aborted = false; + const responseFactory = this.responseFactory; + + const events: AsyncIterable = { + async *[Symbol.asyncIterator]() { + yield { type: 'activity' }; + yield { type: 'init', continuation: `mock-session-${Date.now()}` }; + + // Process initial prompt + yield { type: 'activity' }; + yield { type: 'result', text: responseFactory(input.prompt) }; + + // Process any pushed follow-ups + while (!ended && !aborted) { + if (pending.length > 0) { + const msg = pending.shift()!; + yield { type: 'result', text: responseFactory(msg) }; + continue; + } + // Wait for push() or end() + await new Promise((resolve) => { + waiting = resolve; + }); + waiting = null; + } + + // Drain remaining + while (pending.length > 0) { + const msg = pending.shift()!; + yield { type: 'result', text: responseFactory(msg) }; + } + }, + }; + + return { + push(message: string) { + pending.push(message); + waiting?.(); + }, + end() { + ended = true; + waiting?.(); + }, + events, + abort() { + aborted = true; + waiting?.(); + }, + }; + } +} + +registerProvider('mock', (opts) => new MockProvider(opts)); diff --git a/container/agent-runner/src/providers/provider-registry.ts b/container/agent-runner/src/providers/provider-registry.ts new file mode 100644 index 0000000..250cf72 --- /dev/null +++ b/container/agent-runner/src/providers/provider-registry.ts @@ -0,0 +1,33 @@ +/** + * Provider self-registration registry. + * + * Mirrors `src/channels/channel-registry.ts` on the host. Each provider module + * calls `registerProvider()` at top level; the barrel (`providers/index.ts`) + * imports every provider module for its side effect so registrations fire + * before `createProvider()` is called. + */ +import type { AgentProvider, ProviderOptions } from './types.js'; + +export type ProviderFactory = (options: ProviderOptions) => AgentProvider; + +const registry = new Map(); + +export function registerProvider(name: string, factory: ProviderFactory): void { + if (registry.has(name)) { + throw new Error(`Provider already registered: ${name}`); + } + registry.set(name, factory); +} + +export function getProviderFactory(name: string): ProviderFactory { + const factory = registry.get(name); + if (!factory) { + const known = [...registry.keys()].join(', ') || '(none)'; + throw new Error(`Unknown provider: ${name}. Registered: ${known}`); + } + return factory; +} + +export function listProviderNames(): string[] { + return [...registry.keys()]; +} diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts new file mode 100644 index 0000000..55ab919 --- /dev/null +++ b/container/agent-runner/src/providers/types.ts @@ -0,0 +1,82 @@ +export interface AgentProvider { + /** + * True if the provider's underlying SDK handles slash commands natively and + * wants them passed through as raw text. When false, the poll-loop formats + * slash commands like any other chat message. + */ + readonly supportsNativeSlashCommands: boolean; + + /** Start a new query. Returns a handle for streaming input and output. */ + query(input: QueryInput): AgentQuery; + + /** + * True if the given error indicates the stored continuation is invalid + * (missing transcript, unknown session, etc.) and should be cleared. + */ + isSessionInvalid(err: unknown): boolean; +} + +/** + * Options passed to provider constructors. Fields are common to most + * providers; individual providers may ignore any they don't need. + */ +export interface ProviderOptions { + assistantName?: string; + mcpServers?: Record; + env?: Record; + additionalDirectories?: string[]; +} + +export interface QueryInput { + /** Initial prompt (already formatted by agent-runner). */ + prompt: string; + + /** + * Opaque continuation token from a previous query. The provider decides + * what this means (session ID, thread ID, nothing at all). + */ + continuation?: string; + + /** Working directory inside the container. */ + cwd: string; + + /** + * System context to inject. Providers translate this into whatever their + * SDK expects (preset append, full system prompt, per-turn injection…). + */ + systemContext?: { + instructions?: string; + }; +} + +export interface McpServerConfig { + command: string; + args: string[]; + env: Record; +} + +export interface AgentQuery { + /** Push a follow-up message into the active query. */ + push(message: string): void; + + /** Signal that no more input will be sent. */ + end(): void; + + /** Output event stream. */ + events: AsyncIterable; + + /** Force-stop the query. */ + abort(): void; +} + +export type ProviderEvent = + | { type: 'init'; continuation: string } + | { type: 'result'; text: string | null } + | { type: 'error'; message: string; retryable: boolean; classification?: string } + | { type: 'progress'; message: string } + /** + * Liveness signal. Providers MUST yield this on every underlying SDK + * event (tool call, thinking, partial message, anything) so the + * poll-loop's idle timer stays honest during long tool runs. + */ + | { type: 'activity' }; diff --git a/container/agent-runner/src/scheduling/task-script.ts b/container/agent-runner/src/scheduling/task-script.ts new file mode 100644 index 0000000..112d175 --- /dev/null +++ b/container/agent-runner/src/scheduling/task-script.ts @@ -0,0 +1,121 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { MessageInRow } from '../db/messages-in.js'; +import { touchHeartbeat } from '../db/connection.js'; + +const SCRIPT_TIMEOUT_MS = 30_000; +const SCRIPT_MAX_BUFFER = 1024 * 1024; + +export interface ScriptResult { + wakeAgent: boolean; + data?: unknown; +} + +function log(msg: string): void { + console.error(`[task-script] ${msg}`); +} + +export async function runScript(script: string, taskId: string): Promise { + const scriptPath = path.join('/tmp', `task-script-${taskId}.sh`); + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + + return new Promise((resolve) => { + execFile( + 'bash', + [scriptPath], + { timeout: SCRIPT_TIMEOUT_MS, maxBuffer: SCRIPT_MAX_BUFFER, env: process.env }, + (error, stdout, stderr) => { + try { + fs.unlinkSync(scriptPath); + } catch { + /* best-effort cleanup */ + } + + if (stderr) { + log(`[${taskId}] stderr: ${stderr.slice(0, 500)}`); + } + + if (error) { + log(`[${taskId}] error: ${error.message}`); + return resolve(null); + } + + const lines = stdout.trim().split('\n'); + const lastLine = lines[lines.length - 1]; + if (!lastLine) { + log(`[${taskId}] no output`); + return resolve(null); + } + + try { + const result = JSON.parse(lastLine); + if (typeof result.wakeAgent !== 'boolean') { + log(`[${taskId}] output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`); + return resolve(null); + } + resolve(result as ScriptResult); + } catch { + log(`[${taskId}] output is not valid JSON: ${lastLine.slice(0, 200)}`); + resolve(null); + } + }, + ); + }); +} + +export interface TaskScriptOutcome { + keep: MessageInRow[]; + skipped: string[]; +} + +/** + * Run pre-task scripts for any task messages that carry one, serially. + * - Errors / missing output / wakeAgent=false → task id added to `skipped`. + * - wakeAgent=true → content JSON is mutated to carry `scriptOutput`, so the + * formatter renders it into the prompt. + * Non-task messages and tasks without scripts pass through unchanged. + */ +export async function applyPreTaskScripts(messages: MessageInRow[]): Promise { + const keep: MessageInRow[] = []; + const skipped: string[] = []; + + for (const msg of messages) { + if (msg.kind !== 'task') { + keep.push(msg); + continue; + } + + let content: Record; + try { + content = JSON.parse(msg.content); + } catch { + keep.push(msg); + continue; + } + + const script = typeof content.script === 'string' ? (content.script as string) : null; + if (!script) { + keep.push(msg); + continue; + } + + log(`running script for task ${msg.id}`); + touchHeartbeat(); + const result = await runScript(script, msg.id); + touchHeartbeat(); + + if (!result || !result.wakeAgent) { + const reason = result ? 'wakeAgent=false' : 'script error/no output'; + log(`task ${msg.id} skipped: ${reason}`); + skipped.push(msg.id); + continue; + } + + log(`task ${msg.id} wakeAgent=true, enriching prompt`); + content.scriptOutput = result.data ?? null; + keep.push({ ...msg, content: JSON.stringify(content) }); + } + + return { keep, skipped }; +} diff --git a/container/agent-runner/src/timezone.test.ts b/container/agent-runner/src/timezone.test.ts new file mode 100644 index 0000000..a4539e9 --- /dev/null +++ b/container/agent-runner/src/timezone.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'bun:test'; + +import { formatLocalTime, isValidTimezone, parseZonedToUtc, resolveTimezone } from './timezone.js'; + +// --- formatLocalTime --- + +describe('formatLocalTime', () => { + it('converts UTC to local time display', () => { + // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM + const result = formatLocalTime('2026-02-04T18:30:00.000Z', 'America/New_York'); + expect(result).toContain('1:30'); + expect(result).toContain('PM'); + expect(result).toContain('Feb'); + expect(result).toContain('2026'); + }); + + it('handles different timezones', () => { + // Same UTC time should produce different local times + const utc = '2026-06-15T12:00:00.000Z'; + const ny = formatLocalTime(utc, 'America/New_York'); + const tokyo = formatLocalTime(utc, 'Asia/Tokyo'); + // NY is UTC-4 in summer (EDT), Tokyo is UTC+9 + expect(ny).toContain('8:00'); + expect(tokyo).toContain('9:00'); + }); + + it('does not throw on invalid timezone, falls back to UTC', () => { + expect(() => formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2')).not.toThrow(); + const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2'); + // Should format as UTC (noon UTC = 12:00 PM) + expect(result).toContain('12:00'); + expect(result).toContain('PM'); + }); +}); + +describe('isValidTimezone', () => { + it('accepts valid IANA identifiers', () => { + expect(isValidTimezone('America/New_York')).toBe(true); + expect(isValidTimezone('UTC')).toBe(true); + expect(isValidTimezone('Asia/Tokyo')).toBe(true); + expect(isValidTimezone('Asia/Jerusalem')).toBe(true); + }); + + it('rejects invalid timezone strings', () => { + expect(isValidTimezone('IST-2')).toBe(false); + expect(isValidTimezone('XYZ+3')).toBe(false); + }); + + it('rejects empty and garbage strings', () => { + expect(isValidTimezone('')).toBe(false); + expect(isValidTimezone('NotATimezone')).toBe(false); + }); +}); + +describe('resolveTimezone', () => { + it('returns the timezone if valid', () => { + expect(resolveTimezone('America/New_York')).toBe('America/New_York'); + }); + + it('falls back to UTC for invalid timezone', () => { + expect(resolveTimezone('IST-2')).toBe('UTC'); + expect(resolveTimezone('')).toBe('UTC'); + }); +}); + +describe('parseZonedToUtc', () => { + it('passes strings with Z suffix through unchanged', () => { + const d = parseZonedToUtc('2026-01-15T09:00:00Z', 'America/New_York'); + expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z'); + }); + + it('passes strings with numeric offset through unchanged', () => { + const d = parseZonedToUtc('2026-01-15T09:00:00+02:00', 'America/New_York'); + expect(d.toISOString()).toBe('2026-01-15T07:00:00.000Z'); + }); + + it('interprets naive ISO as wall-clock in the given timezone', () => { + // 09:00 naive in NY in January = 09:00 EST = 14:00 UTC + const d = parseZonedToUtc('2026-01-15T09:00:00', 'America/New_York'); + expect(d.toISOString()).toBe('2026-01-15T14:00:00.000Z'); + }); + + it('handles a different positive-offset zone', () => { + // 09:00 naive in Tokyo (UTC+9) = 00:00 UTC + const d = parseZonedToUtc('2026-06-15T09:00:00', 'Asia/Tokyo'); + expect(d.toISOString()).toBe('2026-06-15T00:00:00.000Z'); + }); + + it('treats invalid timezone as UTC', () => { + const d = parseZonedToUtc('2026-01-15T09:00:00', 'NotATimezone'); + expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z'); + }); +}); diff --git a/container/agent-runner/src/timezone.ts b/container/agent-runner/src/timezone.ts new file mode 100644 index 0000000..d9a2e1b --- /dev/null +++ b/container/agent-runner/src/timezone.ts @@ -0,0 +1,107 @@ +/** + * Timezone utilities — mirror of src/timezone.ts (host). + * + * The container can't import from src/ (separate tsconfig, different runtime). + * Kept deliberately byte-aligned with the host module so behaviour is the + * same on both sides of the session-DB boundary. + * + * TIMEZONE is resolved once at module load from process.env.TZ (which the host + * sets from its own TIMEZONE constant when spawning the container; see + * src/container-runner.ts). Invalid values fall back to UTC. + */ + +/** + * Check whether a timezone string is a valid IANA identifier + * that Intl.DateTimeFormat can use. + */ +export function isValidTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** + * Return the given timezone if valid IANA, otherwise fall back to UTC. + */ +export function resolveTimezone(tz: string): string { + return isValidTimezone(tz) ? tz : 'UTC'; +} + +/** + * Convert a UTC ISO timestamp to a localized display string. + * Uses the Intl API (no external dependencies). + * Falls back to UTC if the timezone is invalid. + */ +export function formatLocalTime(utcIso: string, timezone: string): string { + const date = new Date(utcIso); + return date.toLocaleString('en-US', { + timeZone: resolveTimezone(timezone), + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +function resolveContainerTimezone(): string { + const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; + for (const tz of candidates) { + if (tz && isValidTimezone(tz)) return tz; + } + return 'UTC'; +} + +export const TIMEZONE = resolveContainerTimezone(); + +/** + * Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock + * time in `tz` and return the corresponding UTC Date. Strings that already carry + * offset info (`Z` or `±HH:MM`) are passed through to the Date constructor + * unchanged. + * + * Algorithm: treat the naive string as UTC, ask Intl.DateTimeFormat what that + * UTC instant is called in `tz`, then invert the offset. Near DST boundaries + * this can be off by an hour for ~1h of wall-clock time per year; acceptable + * for scheduling where the agent normally picks round-hour targets. + */ +export function parseZonedToUtc(input: string, tz: string): Date { + const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim()); + if (hasOffset) return new Date(input); + + const zone = resolveTimezone(tz); + const asIfUtc = new Date(input + 'Z'); + if (Number.isNaN(asIfUtc.getTime())) return asIfUtc; + + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts = Object.fromEntries( + fmt + .formatToParts(asIfUtc) + .filter((p) => p.type !== 'literal') + .map((p) => [p.type, p.value]), + ); + const hour = parts.hour === '24' ? '00' : parts.hour; + const zonedAsUtcMs = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(hour), + Number(parts.minute), + Number(parts.second), + ); + const offsetMs = zonedAsUtcMs - asIfUtc.getTime(); + return new Date(asIfUtc.getTime() - offsetMs); +} diff --git a/container/agent-runner/tsconfig.json b/container/agent-runner/tsconfig.json index de6431e..6ca456d 100644 --- a/container/agent-runner/tsconfig.json +++ b/container/agent-runner/tsconfig.json @@ -3,13 +3,12 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "declaration": true + "types": ["bun"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/container/build.sh b/container/build.sh index 8321fdf..ae0c3d9 100755 --- a/container/build.sh +++ b/container/build.sh @@ -1,19 +1,41 @@ #!/bin/bash -# Build the NanoClaw agent container image +# Build the NanoClaw agent container image. +# +# Reads one optional build flag from ../.env: +# INSTALL_CJK_FONTS=true — add Chinese/Japanese/Korean fonts (~200MB) +# setup/container.ts reads the same file, so both build paths stay in sync. +# Callers can also override by exporting INSTALL_CJK_FONTS directly. set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$SCRIPT_DIR" -IMAGE_NAME="nanoclaw-agent" +# Derive the image name from the project root so two NanoClaw installs on the +# same host don't overwrite each other's `nanoclaw-agent:latest` tag. Matches +# setup/lib/install-slug.sh + src/install-slug.ts. +# shellcheck source=../setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +IMAGE_NAME="$(container_image_base)" TAG="${1:-latest}" CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" +# Caller's env takes precedence; fall back to .env. +if [ -z "${INSTALL_CJK_FONTS:-}" ] && [ -f "../.env" ]; then + INSTALL_CJK_FONTS="$(grep '^INSTALL_CJK_FONTS=' ../.env | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'" | tr -d '[:space:]')" +fi + +BUILD_ARGS=() +if [ "${INSTALL_CJK_FONTS:-false}" = "true" ]; then + echo "CJK fonts: enabled (adds ~200MB)" + BUILD_ARGS+=(--build-arg INSTALL_CJK_FONTS=true) +fi + echo "Building NanoClaw agent container image..." echo "Image: ${IMAGE_NAME}:${TAG}" -${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" . +${CONTAINER_RUNTIME} build "${BUILD_ARGS[@]}" -t "${IMAGE_NAME}:${TAG}" . echo "" echo "Build complete!" diff --git a/container/entrypoint.sh b/container/entrypoint.sh new file mode 100755 index 0000000..1c867f2 --- /dev/null +++ b/container/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# NanoClaw agent container entrypoint. +# +# The host passes initial session parameters via stdin as a single JSON blob, +# then the agent-runner opens the session DBs at /workspace/{inbound,outbound}.db +# and enters its poll loop. All further IO flows through those DBs. +# +# We capture stdin to a file first so /tmp/input.json is available for +# post-mortem inspection if the container exits unexpectedly, then exec bun +# so that bun becomes PID 1's direct child (under tini) and receives signals. + +set -e + +cat > /tmp/input.json + +exec bun run /app/src/index.ts < /tmp/input.json diff --git a/container/skills/capabilities/SKILL.md b/container/skills/capabilities/SKILL.md deleted file mode 100644 index 8e8be14..0000000 --- a/container/skills/capabilities/SKILL.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -name: capabilities -description: Show what this NanoClaw instance can do — installed skills, available tools, and system info. Read-only. Use when the user asks what the bot can do, what's installed, or runs /capabilities. ---- - -# /capabilities — System Capabilities Report - -Generate a structured read-only report of what this NanoClaw instance can do. - -**Main-channel check:** Only the main channel has `/workspace/project` mounted. Run: - -```bash -test -d /workspace/project && echo "MAIN" || echo "NOT_MAIN" -``` - -If `NOT_MAIN`, respond with: -> This command is available in your main chat only. Send `/capabilities` there to see what I can do. - -Then stop — do not generate the report. - -## How to gather the information - -Run these commands and compile the results into the report format below. - -### 1. Installed skills - -List skill directories available to you: - -```bash -ls -1 /home/node/.claude/skills/ 2>/dev/null || echo "No skills found" -``` - -Each directory is an installed skill. The directory name is the skill name (e.g., `agent-browser` → `/agent-browser`). - -### 2. Available tools - -Read the allowed tools from your SDK configuration. You always have access to: -- **Core:** Bash, Read, Write, Edit, Glob, Grep -- **Web:** WebSearch, WebFetch -- **Orchestration:** Task, TaskOutput, TaskStop, TeamCreate, TeamDelete, SendMessage -- **Other:** TodoWrite, ToolSearch, Skill, NotebookEdit -- **MCP:** mcp__nanoclaw__* (messaging, tasks, group management) - -### 3. MCP server tools - -The NanoClaw MCP server exposes these tools (via `mcp__nanoclaw__*` prefix): -- `send_message` — send a message to the user/group -- `schedule_task` — schedule a recurring or one-time task -- `list_tasks` — list scheduled tasks -- `pause_task` — pause a scheduled task -- `resume_task` — resume a paused task -- `cancel_task` — cancel and delete a task -- `update_task` — update an existing task -- `register_group` — register a new chat/group (main only) - -### 4. Container skills (Bash tools) - -Check for executable tools in the container: - -```bash -which agent-browser 2>/dev/null && echo "agent-browser: available" || echo "agent-browser: not found" -``` - -### 5. Group info - -```bash -ls /workspace/group/CLAUDE.md 2>/dev/null && echo "Group memory: yes" || echo "Group memory: no" -ls /workspace/extra/ 2>/dev/null && echo "Extra mounts: $(ls /workspace/extra/ 2>/dev/null | wc -l | tr -d ' ')" || echo "Extra mounts: none" -``` - -## Report format - -Present the report as a clean, readable message. Example: - -``` -📋 *NanoClaw Capabilities* - -*Installed Skills:* -• /agent-browser — Browse the web, fill forms, extract data -• /capabilities — This report -(list all found skills) - -*Tools:* -• Core: Bash, Read, Write, Edit, Glob, Grep -• Web: WebSearch, WebFetch -• Orchestration: Task, TeamCreate, SendMessage -• MCP: send_message, schedule_task, list_tasks, pause/resume/cancel/update_task, register_group - -*Container Tools:* -• agent-browser: ✓ - -*System:* -• Group memory: yes/no -• Extra mounts: N directories -• Main channel: yes -``` - -Adapt the output based on what you actually find — don't list things that aren't installed. - -**See also:** `/status` for a quick health check of session, workspace, and tasks. diff --git a/container/skills/frontend-engineer/SKILL.md b/container/skills/frontend-engineer/SKILL.md new file mode 100644 index 0000000..ef09224 --- /dev/null +++ b/container/skills/frontend-engineer/SKILL.md @@ -0,0 +1,157 @@ +--- +name: frontend-engineer +description: Pro frontend engineering discipline. Enforces build-test-verify workflow for every web project. Never declare done until the site is built, tested, responsive, accessible, and visually verified in a real browser. Use alongside vercel-cli for production-quality deployments. +--- + +# Frontend Engineer + +You are a senior frontend engineer. You build production-quality websites and web applications. You do not cut corners. You do not declare work done until everything is tested and working. + +## Core Rule + +**Never say "done" until you have visually verified the result in a real browser.** Screenshots are your proof. If you can't take a screenshot, you're not done. + +## Build Workflow + +Every frontend task follows this sequence. Do not skip steps. + +### 1. Understand Before Coding + +- For existing projects: read `package.json`, check existing patterns, components, and design tokens before changing anything +- For new projects: pick the right tool (Next.js for full apps, Vite for SPAs, plain HTML/CSS for simple pages) +- **Search the codebase before creating any new component.** If an existing component does 80% of what you need, extend it with props. If two components share the same pattern, extract a shared component. + +### 2. Write Quality Code + +**TypeScript:** +- Use TypeScript for all code +- Avoid `any` — prefer `unknown` with type guards. If `any` is genuinely the simplest correct approach (e.g. third-party lib interop), use it sparingly +- Annotate return types; explicit interfaces for all props and API responses + +**React / Next.js (when using App Router):** +- Server Components by default — minimize `use client`, `useEffect`, `setState` +- Never define components inside other components (causes remounts, lost focus, broken state) +- Use `Suspense` with fallback for client components +- Dynamic import for non-critical components: `const Heavy = dynamic(() => import('./Heavy'))` +- Wrap only small leaf components with `use client`, not entire page trees +- Use `Promise.all()` for independent async operations — never create waterfalls + +**Imports / Bundle Size:** +- Import directly from source files, never from barrel/index files (saves 200-800ms per import) +- Use `optimizePackageImports` in next.config for icon/UI libraries (lucide-react, @mui/material, etc.) +- Defer third-party scripts; lazy load below-the-fold content + +**HTML:** +- Semantic tags: `

`, `