diff --git a/.claude/skills/add-compact/SKILL.md b/.claude/skills/add-compact/SKILL.md index 0c46165..fa0b614 100644 --- a/.claude/skills/add-compact/SKILL.md +++ b/.claude/skills/add-compact/SKILL.md @@ -1,135 +1,46 @@ --- 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. +description: Verify or port the existing /compact session command in current NanoClaw. --- # 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. +현재 NanoClaw에는 `/compact`가 이미 들어 있습니다. 이 스킬은 새로 브랜치를 머지하는 용도보다, 오래된 포크에 같은 기능을 옮기거나 리팩터링 뒤 동작을 검증하는 체크리스트로 씁니다. -**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 +- `src/session-commands.ts` - 명령 파싱, 권한 검사, pre-compact 처리 +- `src/index.ts` - 메시지 루프에서 세션 명령을 가로채는 호출부 +- `runners/agent-runner/src/index.ts` - Claude Code 쪽 compaction 처리 +- `runners/codex-runner/src/index.ts` - Codex 쪽 compaction 처리 -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 +## 확인할 동작 + +1. `/compact`가 정확히 명령으로 인식되는지 +2. 메인 그룹 또는 admin/trusted sender만 허용되는지 +3. `/compact` 전에 들어온 메시지를 먼저 세션에 반영하는지 +4. compaction 후 세션이 이어지고 `newSessionId`가 갱신되는지 +5. 대화 아카이브가 `groups//conversations/`에 남는지 + +## 포팅할 때 원칙 + +- 예전 skill branch를 다시 머지하지 말고 현재 트리의 관련 파일을 기준으로 옮깁니다 +- Claude 러너와 Codex 러너 양쪽을 같이 봅니다 +- `/clear`와 `/compact`의 의미를 섞지 않습니다 + +## 검증 ```bash +npm run typecheck npm test +npm run build:runners npm run build ``` -### Rebuild container +그 다음 디스코드에서 확인합니다. -```bash -./container/build.sh -``` +- 메인 채널에서 `/compact` +- 비메인 채널에서 일반 사용자 `/compact` 또는 멘션 포함 `/compact` +- 비메인 채널에서 admin/trusted sender의 `/compact` -### 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-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index f4a3164..c56e889 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -1,212 +1,84 @@ --- name: add-discord -description: Add Discord bot channel integration to NanoClaw. +description: Finish Discord bot setup and registration for NanoClaw. --- # Add Discord Channel -This skill adds Discord support to NanoClaw, then walks through interactive setup. +현재 저장소에는 디스코드 채널 구현이 이미 포함되어 있습니다. 이 스킬은 외부 브랜치를 머지하는 용도가 아니라, 토큰 설정과 채널 등록을 빠르게 끝내는 체크리스트로 사용합니다. -## Phase 1: Pre-flight +## 1. 전제 확인 -### Check if already applied +확인할 것: -Check if `src/channels/discord.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. +- `src/channels/discord.ts`가 존재하는지 +- `package.json`에 `discord.js`가 있는지 +- `npm run typecheck`가 통과하는지 -### Ask the user +## 2. 봇 토큰 준비 -Use `AskUserQuestion` to collect configuration: +토큰이 없다면 디스코드 개발자 포털에서 새 애플리케이션과 봇을 만들고 아래 권한을 켭니다. -AskUserQuestion: Do you have a Discord bot token, or do you need to create one? +- `Message Content Intent` +- 필요하면 `Server Members Intent` -If they have one, collect it now. If not, we'll create one in Phase 3. +봇 초대 링크는 `bot` 스코프와 최소 `Send Messages`, `Read Message History`, `View Channels` 권한으로 생성합니다. -## Phase 2: Apply Code Changes +## 3. 환경 변수 설정 -### Ensure channel remote +`.env`: ```bash -git remote -v +DISCORD_BOT_TOKEN= ``` -If `discord` is missing, add it: +선택: ```bash -git remote add discord https://github.com/qwibitai/nanoclaw-discord.git +DISCORD_CODEX_BOT_TOKEN= ``` -### Merge the skill branch +두 번째 토큰이 있으면 Codex 전용 디스코드 봇도 따로 띄울 수 있습니다. + +## 4. 채널 등록 + +디스코드에서 개발자 모드를 켜고 채널 ID를 복사합니다. 등록 JID는 `dc:` 형식입니다. + +메인 채널: ```bash -git fetch discord main -git merge discord/main +npm run setup -- --step register -- \ + --jid dc: \ + --name " #" \ + --folder discord_main \ + --trigger @Andy \ + --is-main \ + --no-trigger-required ``` -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 +보조 채널: ```bash -npm install -npm run build -npx vitest run src/channels/discord.test.ts +npm run setup -- --step register -- \ + --jid dc: \ + --name " #" \ + --folder discord_ \ + --trigger @Andy ``` -All tests must pass (including the new Discord tests) and build must be clean before proceeding. - -## Phase 3: Setup - -### 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. - -### Configure environment - -Add to `.env`: - -```bash -DISCORD_BOT_TOKEN= -``` - -Channels auto-enable when their credentials are present — no extra configuration needed. - -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 +## 5. 빌드와 검증 ```bash npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw +npm run build:runners +npm run setup -- --step service +npm run setup -- --step verify ``` -## 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 - -Use the IPC register flow or register directly. The channel ID, name, and folder name are needed. - -For a main channel (responds to all messages): - -```typescript -registerGroup("dc:", { - name: " #", - folder: "discord_main", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: false, - isMain: true, -}); -``` - -For additional channels (trigger-only): - -```typescript -registerGroup("dc:", { - name: " #", - folder: "discord_", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: true, -}); -``` - -## 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 +메인 채널이면 일반 메시지, 보조 채널이면 멘션 또는 트리거 메시지로 테스트합니다. diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md deleted file mode 100644 index f77bbf7..0000000 --- a/.claude/skills/add-gmail/SKILL.md +++ /dev/null @@ -1,216 +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 -``` - -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 53ef471..0000000 --- a/.claude/skills/add-image-vision/SKILL.md +++ /dev/null @@ -1,90 +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 -``` - -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-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md index a347b49..ee3bcc6 100644 --- a/.claude/skills/add-ollama-tool/SKILL.md +++ b/.claude/skills/add-ollama-tool/SKILL.md @@ -1,153 +1,84 @@ --- 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 an Ollama MCP tool to the current host-process Claude runner. --- -# Add Ollama Integration +# Add Ollama Tool -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. +현재 구조에서는 컨테이너를 건드리지 않습니다. Ollama 연동은 호스트에서 실행되는 Claude 러너에 MCP를 추가하는 방식으로 넣습니다. -Tools added: -- `ollama_list_models` — lists installed Ollama models -- `ollama_generate` — sends a prompt to a specified model and returns the response - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure). - -### Check prerequisites - -Verify Ollama is installed and running on the host: +## 전제 조건 ```bash ollama list ``` -If Ollama is not installed, direct the user to https://ollama.com/download. +- Ollama가 설치되어 있어야 합니다. +- 최소 한 개 이상의 모델이 있어야 합니다. -If no models are installed, suggest pulling one: - -> You need at least one model. I recommend: -> -> ```bash -> ollama pull gemma3:1b # Small, fast (1GB) -> ollama pull llama3.2 # Good general purpose (2GB) -> ollama pull qwen3-coder:30b # Best for code tasks (18GB) -> ``` - -## Phase 2: Apply Code Changes - -### Ensure upstream remote +권장 예시: ```bash -git remote -v +ollama pull gemma3:1b +ollama pull llama3.2 ``` -If `upstream` is missing, add it: - -```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git -``` - -### Merge the skill branch - -```bash -git fetch upstream skill/ollama-tool -git merge upstream/skill/ollama-tool -``` - -This merges in: -- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server) -- `scripts/ollama-watch.sh` (macOS notification watcher) -- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers) -- `[OLLAMA]` log surfacing in `src/container-runner.ts` -- `OLLAMA_HOST` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Copy to per-group agent-runner - -Existing groups have a cached copy of the agent-runner source. Copy the new files: - -```bash -for dir in data/sessions/*/agent-runner-src; do - cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/" - cp container/agent-runner/src/index.ts "$dir/" -done -``` - -### Validate code changes +## 수정 지점 + +### 1. 환경 변수 전달 + +`src/agent-runner.ts` + +- `.env`에서 `OLLAMA_HOST`를 읽도록 `readEnvFile([...])` 목록에 추가합니다. +- Claude 러너 child env에 `OLLAMA_HOST`를 명시적으로 넣습니다. + +기본값을 쓰면 생략 가능하지만, 원격 Ollama나 다른 포트를 쓸 때는 필요합니다. + +## 2. MCP 서버 추가 + +`runners/agent-runner/src/index.ts` + +- 호스트에서 동작하는 stdio MCP 서버 파일을 추가합니다. + - 예: `runners/agent-runner/src/ollama-mcp-stdio.ts` +- `query({ options: { mcpServers, allowedTools } })` 쪽에 Ollama 서버를 등록합니다. +- `allowedTools`에 `mcp__ollama__*` 또는 실제 툴 prefix를 추가합니다. + +핵심은 두 가지입니다. + +- Claude가 Ollama를 CLI가 아니라 MCP 도구로 보게 만들 것 +- 툴 이름이 `allowedTools`에 포함될 것 + +## 3. 프롬프트 문서화 + +`groups/global/CLAUDE.md` 또는 대상 그룹의 `CLAUDE.md` + +- 언제 Ollama를 쓰는지 +- 어떤 모델을 우선 쓰는지 +- 빠른 요약/분류/초안 작업에 먼저 쓰도록 할지 + +이 부분을 적어두지 않으면 에이전트가 도구를 잘 안 고릅니다. + +## 4. 빌드와 검증 ```bash +npm run build:runners npm run build -./container/build.sh +npm run setup -- --step service ``` -Build must be clean before proceeding. +디스코드에서 테스트: -## Phase 3: Configure +> `ollama 도구를 써서 이 문단을 3줄로 요약해줘` -### 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`: +### 에이전트가 `ollama` CLI를 직접 치려고 함 -```bash -OLLAMA_HOST=http://your-ollama-host:11434 -``` +- MCP 서버 등록이 빠졌거나 +- `allowedTools`에 툴 prefix가 없거나 +- 프롬프트 문서에 사용 규칙이 없습니다 -### Restart the service +### 연결 실패 -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Verify - -### Test via WhatsApp - -Tell the user: - -> Send a message like: "use ollama to tell me the capital of France" -> -> The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response. - -### Monitor activity (optional) - -Run the watcher script for macOS notifications when Ollama is used: - -```bash -./scripts/ollama-watch.sh -``` - -### Check logs if needed - -```bash -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] <<< Done` — generation completed - -## Troubleshooting - -### Agent says "Ollama is not installed" - -The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means: -1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` -2. The per-group source wasn't updated — re-copy files (see Phase 2) -3. The container wasn't rebuilt — run `./container/build.sh` - -### "Failed to connect to Ollama" - -1. Verify Ollama is running: `ollama list` -2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags` -3. If using a custom host, check `OLLAMA_HOST` in `.env` - -### 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 list`가 호스트에서 되는지 먼저 확인합니다 +- 원격 호스트면 `.env`의 `OLLAMA_HOST`를 확인합니다 diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index f4c1982..084f2e9 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -1,290 +1,73 @@ +--- +name: add-parallel +description: Add Parallel AI MCP tools to the current host-process Claude runner. +--- + # Add Parallel AI Integration -Adds Parallel AI MCP integration to NanoClaw for advanced web research capabilities. +Parallel AI는 현재 구조에서도 유효한 도구 연동입니다. 다만 예전 문서처럼 컨테이너를 수정하지 않고, Claude 러너의 MCP 설정만 바꿉니다. -## What This Adds +## 1. API 키 준비 -- **Quick Search** - Fast web lookups using Parallel Search API (free to use) -- **Deep Research** - Comprehensive analysis using Parallel Task API (asks permission) -- **Non-blocking Design** - Uses NanoClaw scheduler for result polling (no container blocking) - -## Prerequisites - -User must have: -1. Parallel AI API key from https://platform.parallel.ai -2. NanoClaw already set up and running -3. Docker installed and running - -## Implementation Steps - -Run all steps automatically. Only pause for user input when explicitly needed. - -### 1. Get Parallel AI API Key - -Use `AskUserQuestion: Do you have a Parallel AI API key, or should I help you get one?` - -**If they have one:** -Collect it now. - -**If they need one:** -Tell them: -> 1. Go to https://platform.parallel.ai -> 2. Sign up or log in -> 3. Navigate to API Keys section -> 4. Create a new API key -> 5. Copy the key and paste it here - -Wait for the API key. - -### 2. Add API Key to Environment - -Add `PARALLEL_API_KEY` to `.env`: +`PARALLEL_API_KEY`를 `.env`에 넣습니다. ```bash -# Check if .env exists, create if not -if [ ! -f .env ]; then - touch .env -fi - -# Add PARALLEL_API_KEY if not already present -if ! grep -q "PARALLEL_API_KEY=" .env; then - echo "PARALLEL_API_KEY=${API_KEY_FROM_USER}" >> .env - echo "✓ Added PARALLEL_API_KEY to .env" -else - # Update existing key - sed -i.bak "s/^PARALLEL_API_KEY=.*/PARALLEL_API_KEY=${API_KEY_FROM_USER}/" .env - echo "✓ Updated PARALLEL_API_KEY in .env" -fi +PARALLEL_API_KEY=... ``` -Verify: -```bash -grep "PARALLEL_API_KEY" .env | head -c 50 -``` +## 2. 환경 변수 전달 -### 3. Update Container Runner +`src/agent-runner.ts` -Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`: +- `.env`에서 `PARALLEL_API_KEY`를 읽도록 `readEnvFile([...])` 목록에 추가합니다. +- Claude 러너 child env에 `PARALLEL_API_KEY`를 넣습니다. -Find the line: -```typescript -const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; -``` +## 3. MCP 서버 등록 -Replace with: -```typescript -const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY']; -``` +`runners/agent-runner/src/index.ts` -### 4. Configure MCP Servers in Agent Runner +- `query({ options: { mcpServers } })`에 Parallel HTTP MCP 서버를 추가합니다. +- `allowedTools`에 `mcp__parallel-search__*`, `mcp__parallel-task__*`를 추가합니다. -Update `container/agent-runner/src/index.ts`: +구분은 이렇게 가져가는 편이 안전합니다. -Find the section where `mcpServers` is configured (around line 237-252): -```typescript -const mcpServers: Record = { - nanoclaw: ipcMcp -}; -``` +- `parallel-search`: 빠른 검색, 저비용, 기본 허용 +- `parallel-task`: 긴 조사 작업, 비용/시간이 더 큼, 명시적 승인 후 사용 -Add Parallel AI MCP servers after the nanoclaw server: -```typescript -const mcpServers: Record = { - nanoclaw: ipcMcp -}; +## 4. 프롬프트 규칙 추가 -// Add Parallel AI MCP servers if API key is available -const parallelApiKey = process.env.PARALLEL_API_KEY; -if (parallelApiKey) { - mcpServers['parallel-search'] = { - type: 'http', // REQUIRED: Must specify type for HTTP MCP servers - url: 'https://search-mcp.parallel.ai/mcp', - headers: { - 'Authorization': `Bearer ${parallelApiKey}` - } - }; - mcpServers['parallel-task'] = { - type: 'http', // REQUIRED: Must specify type for HTTP MCP servers - url: 'https://task-mcp.parallel.ai/mcp', - headers: { - 'Authorization': `Bearer ${parallelApiKey}` - } - }; - log('Parallel AI MCP servers configured'); -} else { - log('PARALLEL_API_KEY not set, skipping Parallel AI integration'); -} -``` +`groups/global/CLAUDE.md` 또는 대상 그룹의 `CLAUDE.md` -Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248): -```typescript -allowedTools: [ - 'Bash', - 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'mcp__nanoclaw__*', - 'mcp__parallel-search__*', - 'mcp__parallel-task__*' -], -``` +아래 원칙을 문서화합니다. -### 5. Add Usage Instructions to CLAUDE.md +- 빠른 사실 조회나 최신 정보 확인은 `parallel-search` +- 긴 조사나 깊은 분석은 `parallel-task` +- `parallel-task`는 항상 먼저 사용자 허가를 받을 것 +- 오래 걸리는 작업은 답변을 붙잡고 있지 말고 `mcp__nanoclaw__schedule_task`로 후속 체크를 맡길 것 -Add Parallel AI usage instructions to `groups/main/CLAUDE.md`: - -Find the "## What You Can Do" section and add after the existing bullet points: -```markdown -- Use Parallel AI for web research and deep learning tasks -``` - -Then add a new section after "## What You Can Do": -```markdown -## Web Research Tools - -You have access to two Parallel AI research tools: - -### Quick Web Search (`mcp__parallel-search__search`) -**When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts. - -**Examples:** -- "Who invented the transistor?" -- "What's the latest news about quantum computing?" -- "When was the UN founded?" -- "What are the top programming languages in 2026?" - -**Speed:** Fast (2-5 seconds) -**Cost:** Low -**Permission:** Not needed - use whenever it helps answer the question - -### Deep Research (`mcp__parallel-task__create_task_run`) -**When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research. - -**Examples:** -- "Explain the development of quantum mechanics from 1900-1930" -- "Compare the literary styles of Hemingway and Faulkner" -- "Research the evolution of jazz from bebop to fusion" -- "Analyze the causes of the French Revolution" - -**Speed:** Slower (1-20 minutes depending on depth) -**Cost:** Higher (varies by processor tier) -**Permission:** ALWAYS use `AskUserQuestion` before using this tool - -**How to ask permission:** -``` -AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed? -``` - -**After permission - DO NOT BLOCK! Use scheduler instead:** - -1. Create the task using `mcp__parallel-task__create_task_run` -2. Get the `run_id` from the response -3. Create a polling scheduled task using `mcp__nanoclaw__schedule_task`: - ``` - Prompt: "Check Parallel AI task run [run_id] and send results when ready. - - 1. Use the Parallel Task MCP to check the task status - 2. If status is 'completed', extract the results - 3. Send results to user with mcp__nanoclaw__send_message - 4. Use mcp__nanoclaw__complete_scheduled_task to mark this task as done - - If status is still 'running' or 'pending', do nothing (task will run again in 30s). - If status is 'failed', send error message and complete the task." - - Schedule: interval every 30 seconds - Context mode: isolated - ``` -4. Send acknowledgment with tracking link -5. Exit immediately - scheduler handles the rest - -### Choosing Between Them - -**Use Search when:** -- Question needs a quick fact or recent information -- Simple definition or clarification -- Verifying specific details -- Current events or news - -**Use Deep Research (with permission) when:** -- User wants to learn about a complex topic -- Question requires analysis or comparison -- Historical context or evolution of concepts -- Structured, comprehensive understanding needed -- User explicitly asks to "research" or "explain in depth" - -**Default behavior:** Prefer search for most questions. Only suggest deep research when the topic genuinely requires comprehensive analysis. -``` - -### 6. Rebuild Container - -Build the container with updated agent runner: - -```bash -./container/build.sh -``` - -Verify the build: -```bash -echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK" -``` - -### 7. Restart Service - -Rebuild the main app and restart: +## 5. 빌드와 검증 ```bash +npm run build:runners npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw +npm run setup -- --step service ``` -Wait 3 seconds for service to start, then verify: -```bash -sleep 3 -launchctl list | grep nanoclaw # macOS -# Linux: systemctl --user status nanoclaw -``` +디스코드 테스트 예시: -### 8. Test Integration +> `최신 AI 뉴스 찾아줘` -Tell the user to test: -> Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?` -> -> The assistant should use Parallel Search API to find current information. -> -> Then try: `@[YourAssistantName] can you research the history of artificial intelligence?` -> -> The assistant should ask for permission before using the Task API. +> `AI 에이전트 역사 자세히 조사해줘` -Check logs to verify MCP servers loaded: -```bash -tail -20 logs/nanoclaw.log -``` +두 번째 요청에서는 비용/시간 안내 후 허가를 먼저 묻게 만드는 게 맞습니다. -Look for: `Parallel AI MCP servers configured` +## 문제 확인 -## Troubleshooting +### MCP가 안 뜸 -**Container hangs or times out:** -- Check that `type: 'http'` is specified in MCP server config -- Verify API key is correct in .env -- Check container logs: `cat groups/main/logs/container-*.log | tail -50` +- `PARALLEL_API_KEY`가 child env까지 전달되는지 확인합니다 +- `allowedTools`에 Parallel prefix가 빠지지 않았는지 봅니다 -**MCP servers not loading:** -- Ensure PARALLEL_API_KEY is in .env -- Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars -- Check agent-runner logs for "Parallel AI MCP servers configured" message +### 긴 작업이 응답을 오래 붙잡음 -**Task polling not working:** -- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"` -- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"` -- Ensure task prompt includes proper Parallel MCP tool names - -## Uninstalling - -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` -5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) +- `parallel-task` 결과를 기다리며 블로킹하지 말고 스케줄러로 넘기도록 프롬프트와 구현을 같이 수정합니다 diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md deleted file mode 100644 index cd3736b..0000000 --- a/.claude/skills/add-pdf-reader/SKILL.md +++ /dev/null @@ -1,100 +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 -``` - -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 be725c3..0000000 --- a/.claude/skills/add-reactions/SKILL.md +++ /dev/null @@ -1,113 +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 -``` - -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-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md deleted file mode 100644 index 4eb9225..0000000 --- a/.claude/skills/add-slack/SKILL.md +++ /dev/null @@ -1,216 +0,0 @@ ---- -name: add-slack -description: Add Slack as a channel. Can replace WhatsApp entirely or run alongside it. Uses Socket Mode (no public URL needed). ---- - -# Add Slack Channel - -This skill adds Slack support to NanoClaw, then walks through interactive setup. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/channels/slack.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. - -### Ask the user - -**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. - -## Phase 2: Apply Code Changes - -### Ensure channel remote - -```bash -git remote -v -``` - -If `slack` is missing, add it: - -```bash -git remote add slack https://github.com/qwibitai/nanoclaw-slack.git -``` - -### Merge the skill branch - -```bash -git fetch slack main -git merge slack/main -``` - -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 - -```bash -npm install -npm run build -npx vitest run src/channels/slack.test.ts -``` - -All tests must pass (including the new Slack tests) and build must be clean before proceeding. - -## Phase 3: Setup - -### Create Slack App (if needed) - -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. - -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-...`) - -Wait for the user to provide both tokens. - -### Configure environment - -Add to `.env`: - -```bash -SLACK_BOT_TOKEN=xoxb-your-bot-token -SLACK_APP_TOKEN=xapp-your-app-token -``` - -Channels auto-enable when their credentials are present — no extra configuration needed. - -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 -``` - -## Phase 4: Registration - -### Get Channel ID - -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 - -Use the IPC register flow or register directly. The channel ID, name, and folder name are needed. - -For a main channel (responds to all messages): - -```typescript -registerGroup("slack:", { - name: "", - folder: "slack_main", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: false, - isMain: true, -}); -``` - -For additional channels (trigger-only): - -```typescript -registerGroup("slack:", { - name: "", - folder: "slack_", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: true, -}); -``` - -## 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. 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/SKILL.md b/.claude/skills/add-telegram/SKILL.md deleted file mode 100644 index a2e29d7..0000000 --- a/.claude/skills/add-telegram/SKILL.md +++ /dev/null @@ -1,231 +0,0 @@ ---- -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). ---- - -# Add Telegram Channel - -This skill adds Telegram support to NanoClaw, then walks through interactive setup. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/channels/telegram.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. - -### Ask the user - -Use `AskUserQuestion` to collect configuration: - -AskUserQuestion: Do you have a Telegram bot token, or do you need to create one? - -If they have one, collect it now. If not, we'll create one in Phase 3. - -## Phase 2: Apply Code Changes - -### Ensure channel remote - -```bash -git remote -v -``` - -If `telegram` is missing, add it: - -```bash -git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git -``` - -### Merge the skill branch - -```bash -git fetch telegram main -git merge telegram/main -``` - -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 - -```bash -npm install -npm run build -npx vitest run src/channels/telegram.test.ts -``` - -All tests must pass (including the new Telegram tests) and build must be clean before proceeding. - -## Phase 3: Setup - -### Create Telegram Bot (if needed) - -If the user doesn't have a bot token, tell them: - -> 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. - -### Configure environment - -Add to `.env`: - -```bash -TELEGRAM_BOT_TOKEN= -``` - -Channels auto-enable when their credentials are present — no extra configuration needed. - -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. - -### Disable Group Privacy (for group chats) - -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 - -Use the IPC register flow or register directly. The chat ID, name, and folder name are needed. - -For a main chat (responds to all messages): - -```typescript -registerGroup("tg:", { - name: "", - folder: "telegram_main", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: false, - isMain: true, -}); -``` - -For additional chats (trigger-only): - -```typescript -registerGroup("tg:", { - name: "", - folder: "telegram_", - trigger: `@${ASSISTANT_NAME}`, - added_at: new Date().toISOString(), - requiresTrigger: true, -}); -``` - -## 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) diff --git a/.claude/skills/add-voice-transcription/SKILL.md b/.claude/skills/add-voice-transcription/SKILL.md index 431e3cd..71aed24 100644 --- a/.claude/skills/add-voice-transcription/SKILL.md +++ b/.claude/skills/add-voice-transcription/SKILL.md @@ -1,90 +1,60 @@ --- name: add-voice-transcription -description: Add voice message transcription to NanoClaw using Groq Whisper (fast, free) with OpenAI fallback. Works on Discord and WhatsApp. +description: Enable Discord voice transcription using Groq Whisper with OpenAI fallback. --- # Add Voice Transcription -This skill adds automatic voice message transcription to NanoClaw. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice message transcription]: `. +디스코드 음성 첨부 전사는 이미 코드에 들어 있습니다. 현재 구현은 `src/channels/discord.ts`에 있고, 별도 브랜치 머지나 채널 추가 작업은 필요 없습니다. -**Provider priority:** Groq Whisper (fast, free) > OpenAI Whisper (fallback). +우선순위는 `Groq Whisper -> OpenAI Whisper fallback` 입니다. -## Phase 1: Pre-flight +## 1. 환경 변수 설정 -### Discord - -Voice transcription is built into `src/channels/discord.ts`. No code changes needed — just configure API keys (Phase 3). - -### WhatsApp - -Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place. - -If not, merge the skill branch: +`.env`에 아래 중 하나 이상을 넣습니다. ```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git 2>/dev/null -git fetch whatsapp skill/voice-transcription -git merge whatsapp/skill/voice-transcription -npm install --legacy-peer-deps -npm run build +GROQ_API_KEY=gsk_... # 권장. 빠르고 무료 티어가 있음 +OPENAI_API_KEY=sk-... # fallback ``` -## Phase 2: Configure +Groq를 쓰면 `whisper-large-v3-turbo`, OpenAI를 쓰면 `whisper-1` 경로를 탑니다. -### Get API key - -**Groq (recommended — fast + free):** - -> 1. Go to https://console.groq.com -> 2. Sign up (no credit card needed) -> 3. Create an API key (starts with `gsk_`) -> -> Free tier: 2,000 requests/day, 8 hours of audio/day. Uses `whisper-large-v3-turbo` at ~200x real-time speed. - -**OpenAI (fallback):** - -> 1. Go to https://platform.openai.com/api-keys -> 2. Create a key (starts with `sk-`) -> -> Cost: ~$0.006 per minute of audio. Requires funded account. - -### Add to environment - -Add to `.env`: - -```bash -GROQ_API_KEY=gsk_... # Primary (fast, free) -OPENAI_API_KEY=sk-... # Fallback (optional if Groq is set) -``` - -### Build and restart +## 2. 재시작 ```bash npm run build -systemctl --user restart nanoclaw nanoclaw-codex # Linux -# macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw +npm run setup -- --step service ``` -## Phase 3: Verify +이미 서비스가 떠 있다면 플랫폼에 맞게 재시작만 해도 됩니다. -Send a voice note in any registered chat. The agent should receive it as `[Voice message transcription]: `. +## 3. 검증 -### Check logs +등록된 디스코드 채널에 음성 메시지나 오디오 첨부를 보냅니다. 정상이라면 에이전트 입력에 전사 텍스트가 포함됩니다. ```bash -tail -f logs/nanoclaw.log | grep -iE "transcri|audio" +tail -f logs/nanoclaw.log | grep -iE 'transcri|audio' ``` -Look for: -- `Audio transcribed + cached` with `provider: "groq"` and `elapsed` — success -- `Transcription cache hit` — second service read from cache (no duplicate API call) -- `no transcription API key` — neither `GROQ_API_KEY` nor `OPENAI_API_KEY` set -- `groq Whisper 4xx` — check key validity +성공 신호: + +- `Audio transcribed + cached` +- `provider: "groq"` 또는 `provider: "openai"` +- 동일 첨부 재처리 시 cache hit ## Troubleshooting -**No transcription:** Check `GROQ_API_KEY` (or `OPENAI_API_KEY`) is set in `.env`. Restart service after changes. +**전사가 전혀 안 됨** -**Slow transcription:** Verify `provider: "groq"` in logs. If it shows `openai`, the Groq key may be missing or invalid. +- `.env`에 `GROQ_API_KEY`나 `OPENAI_API_KEY`가 없는 경우가 대부분입니다. +- 서비스 재시작 전에는 새 키가 반영되지 않습니다. -**Agent doesn't respond to voice notes:** Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups. +**너무 느림** + +- 로그에 `provider: "groq"`가 안 보이면 Groq 키가 빠졌거나 잘못된 상태입니다. + +**채널에서는 보이는데 에이전트가 못 읽음** + +- 채널 등록과 서비스 상태를 먼저 확인합니다. +- `npm run setup -- --step verify` 결과에서 `REGISTERED_GROUPS`와 `SERVICE`를 같이 봅니다. diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md deleted file mode 100644 index 8ce68be..0000000 --- a/.claude/skills/add-whatsapp/SKILL.md +++ /dev/null @@ -1,368 +0,0 @@ ---- -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. ---- - -# Add WhatsApp Channel - -This skill adds WhatsApp support to NanoClaw. It installs the WhatsApp channel code, dependencies, and guides through authentication, registration, and configuration. - -## Phase 1: Pre-flight - -### 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). - -```bash -ls store/auth/creds.json 2>/dev/null && echo "WhatsApp auth exists" || echo "No WhatsApp auth" -``` - -### Detect environment - -Check whether the environment is headless (no display server): - -```bash -[[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false" -``` - -### Ask the user - -Use `AskUserQuestion` to collect configuration. **Adapt auth options based on environment:** - -If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenticate WhatsApp? -- **Pairing code** (Recommended) - Enter a numeric code on your phone (no camera needed, requires phone number) -- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays) - -Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp? -- **QR code in browser** (Recommended) - Opens a browser window with a large, scannable QR code -- **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number) -- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays) - -If they chose pairing code: - -AskUserQuestion: What is your phone number? (Include country code without +, e.g., 1234567890) - -## 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 -``` - -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 -rm -rf store/auth/ -``` - -### Run WhatsApp authentication - -For QR code in browser (recommended): - -```bash -npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser -``` - -(Bash timeout: 150000ms) - -Tell the user: - -> A browser window will open with a QR code. -> -> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** -> 2. Scan the QR code in the browser -> 3. The page will show "Authenticated!" when done - -For QR code in terminal: - -```bash -npx tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal -``` - -Tell the user to run `npm run auth` in another terminal, then: - -> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** -> 2. Scan the QR code displayed in the terminal - -For pairing code: - -Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Device**, ready to tap **"Link with phone number instead"** — the code expires in ~60 seconds and must be entered immediately. - -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 & -``` - -Then immediately poll for the code (do NOT wait for the background command to finish): - -```bash -for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done -``` - -Display the code to the user the moment it appears. Tell them: - -> **Enter this code now** — it expires in ~60 seconds. -> -> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** -> 2. Tap **Link with phone number instead** -> 3. Enter the code immediately - -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 -``` - -**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry. - -### Verify authentication succeeded - -```bash -test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed" -``` - -### Configure environment - -Channels auto-enable when their credentials are present — WhatsApp activates when `store/auth/creds.json` exists. - -Sync to container environment: - -```bash -mkdir -p data/env && cp .env data/env/env -``` - -## Phase 4: Registration - -### Configure trigger and channel type - -Get the bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"` - -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 - -AskUserQuestion: What trigger word should activate the assistant? -- **@Andy** - Default trigger -- **@Claw** - Short and easy -- **@Claude** - Match the AI name - -AskUserQuestion: What should the assistant call itself? -- **Andy** - Default name -- **Claw** - Short and easy -- **Claude** - Match the AI name - -AskUserQuestion: Where do you want to chat with the assistant? - -**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 -``` - -## Troubleshooting - -### QR code expired - -QR codes expire after ~60 seconds. Re-run the auth command: - -```bash -rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts -``` - -### Pairing code not working - -Codes expire in ~60 seconds. To retry: - -```bash -rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone -``` - -Enter the code **immediately** when it appears. Also ensure: -1. Phone number includes country code without `+` (e.g., `1234567890`) -2. Phone has internet access -3. WhatsApp is updated to the latest version - -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 -``` - -### "conflict" disconnection - -This happens when two instances connect with the same credentials. Ensure only one NanoClaw process is running: - -```bash -pkill -f "node dist/index.js" -# Then restart -``` - -### 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` - -### Group names not showing - -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) diff --git a/.claude/skills/convert-to-apple-container/SKILL.md b/.claude/skills/convert-to-apple-container/SKILL.md deleted file mode 100644 index caf9c22..0000000 --- a/.claude/skills/convert-to-apple-container/SKILL.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -name: convert-to-apple-container -description: Switch from Docker to Apple Container for macOS-native container isolation. Use when the user wants Apple Container instead of Docker, or is setting up on macOS and prefers the native runtime. Triggers on "apple container", "convert to apple container", "switch to apple container", or "use apple container". ---- - -# Convert to Apple Container - -This skill switches NanoClaw's container runtime from Docker to Apple Container (macOS-only). It uses the skills engine for deterministic code changes, then walks through verification. - -**What this changes:** -- Container runtime binary: `docker` → `container` -- Mount syntax: `-v path:path:ro` → `--mount type=bind,source=...,target=...,readonly` -- Startup check: `docker info` → `container system status` (with auto-start) -- Orphan detection: `docker ps --filter` → `container ls --format json` -- Build script default: `docker` → `container` -- Dockerfile entrypoint: `.env` shadowing via `mount --bind` inside the container (Apple Container only supports directory mounts, not file mounts like Docker's `/dev/null` overlay) -- Container runner: main-group containers start as root for `mount --bind`, then drop privileges via `setpriv` - -**What stays the same:** -- Mount security/allowlist validation -- All exported interfaces and IPC protocol -- Non-main container behavior (still uses `--user` flag) -- All other functionality - -## Prerequisites - -Verify Apple Container is installed: - -```bash -container --version && echo "Apple Container ready" || echo "Install Apple Container first" -``` - -If not installed: -- Download from https://github.com/apple/container/releases -- Install the `.pkg` file -- Verify: `container --version` - -Apple Container requires macOS. It does not work on Linux. - -## Phase 1: Pre-flight - -### Check if already applied - -```bash -grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts -``` - -If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3. - -## Phase 2: Apply Code Changes - -### Ensure upstream remote - -```bash -git remote -v -``` - -If `upstream` is missing, add it: - -```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git -``` - -### Merge the skill branch - -```bash -git fetch upstream skill/apple-container -git merge upstream/skill/apple-container -``` - -This merges in: -- `src/container-runtime.ts` — Apple Container implementation (replaces Docker) -- `src/container-runtime.test.ts` — Apple Container-specific tests -- `src/container-runner.ts` — .env shadow mount fix and privilege dropping -- `container/Dockerfile` — entrypoint that shadows .env via `mount --bind` -- `container/build.sh` — default runtime set to `container` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -npm test -npm run build -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Verify - -### Ensure Apple Container runtime is running - -```bash -container system status || container system start -``` - -### Build the container image - -```bash -./container/build.sh -``` - -### Test basic execution - -```bash -echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK" -``` - -### Test readonly mounts - -```bash -mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt -container run --rm --entrypoint /bin/bash \ - --mount type=bind,source=/tmp/test-ro,target=/test,readonly \ - nanoclaw-agent:latest \ - -c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'" -rm -rf /tmp/test-ro -``` - -Expected: Read succeeds, write fails with "Read-only file system". - -### Test read-write mounts - -```bash -mkdir -p /tmp/test-rw -container run --rm --entrypoint /bin/bash \ - -v /tmp/test-rw:/test \ - nanoclaw-agent:latest \ - -c "echo 'test write' > /test/new.txt && cat /test/new.txt" -cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw -``` - -Expected: Both operations succeed. - -### Full integration test - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -Send a message via WhatsApp and verify the agent responds. - -## Troubleshooting - -**Apple Container not found:** -- Download from https://github.com/apple/container/releases -- Install the `.pkg` file -- Verify: `container --version` - -**Runtime won't start:** -```bash -container system start -container system status -``` - -**Image build fails:** -```bash -# Clean rebuild — Apple Container caches aggressively -container builder stop && container builder rm && container builder start -./container/build.sh -``` - -**Container can't write to mounted directories:** -Check directory permissions on the host. The container runs as uid 1000. - -## Summary of Changed Files - -| File | Type of Change | -|------|----------------| -| `src/container-runtime.ts` | Full replacement — Docker → Apple Container API | -| `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior | -| `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop | -| `container/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop | -| `container/build.sh` | Default runtime: `docker` → `container` | diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md index 13b5b89..a42b966 100644 --- a/.claude/skills/customize/SKILL.md +++ b/.claude/skills/customize/SKILL.md @@ -1,115 +1,70 @@ --- name: customize -description: Add new capabilities or modify NanoClaw behavior. Use when user wants to add channels (Telegram, Slack, email input), change triggers, add integrations, modify the router, or make any other customizations. This is an interactive skill that asks questions to understand what the user wants. +description: Customize Discord-only NanoClaw behavior, routing, integrations, prompts, or commands. --- # NanoClaw Customization -This skill helps users add capabilities or modify behavior. Use AskUserQuestion to understand what they want before making changes. +이 스킬은 현재 디스코드 전용 NanoClaw를 기준으로 동작합니다. 새 채널 추가보다 기존 디스코드 흐름, 러너, 프롬프트, 스케줄링, 도구 연결을 직접 수정하는 쪽을 우선합니다. -## Workflow +## 진행 순서 -1. **Install marketplace** - If feature skills aren't available yet, install the marketplace plugin: - ```bash - claude plugin install nanoclaw-skills@nanoclaw-skills --scope project - ``` - This is hot-loaded — all feature skills become immediately available. -2. **Understand the request** - Ask clarifying questions -3. **Plan the changes** - Identify files to modify. If a skill exists for the request (e.g., `/add-telegram` for adding Telegram), invoke it instead of implementing manually. -4. **Implement** - Make changes directly to the code -5. **Test guidance** - Tell user how to verify +1. 요청을 `행동 변경`, `명령 추가`, `도구 연동`, `설정/배포`, `프롬프트 수정` 중 어디에 속하는지 먼저 정리합니다. +2. 영향 범위를 좁힙니다. +3. 관련 파일만 수정합니다. +4. 가능하면 바로 검증합니다. -## Key Files +## 자주 보는 수정 지점 -| File | Purpose | -|------|---------| -| `src/index.ts` | Orchestrator: state, message loop, agent invocation | -| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive | -| `src/ipc.ts` | IPC watcher and task processing | -| `src/router.ts` | Message formatting and outbound routing | -| `src/types.ts` | TypeScript interfaces (includes Channel) | -| `src/config.ts` | Assistant name, trigger pattern, directories | -| `src/db.ts` | Database initialization and queries | -| `src/whatsapp-auth.ts` | Standalone WhatsApp authentication script | -| `groups/CLAUDE.md` | Global memory/persona | +- `src/channels/discord.ts` - 디스코드 메시지 파싱, 멘션 규칙, 첨부파일 처리, 음성 전사 +- `src/index.ts` - 오케스트레이션, 세션 명령, 라우팅, 상태 갱신 +- `src/session-commands.ts` - `/compact`, `/clear` 같은 세션 명령 +- `src/db.ts` - 등록 그룹, 세션, 스케줄, 마이그레이션 +- `src/config.ts` - 서비스 식별값, 디렉터리, 타임아웃, 기능 플래그 +- `src/agent-runner.ts` - 에이전트 실행과 환경 변수 전달 +- `runners/agent-runner/src/index.ts` - Claude Code 쪽 허용 도구와 MCP +- `runners/codex-runner/src/index.ts` - Codex 쪽 실행 경로 +- `setup/register.ts` - 디스코드 채널 등록 +- `groups/global/CLAUDE.md` 및 각 그룹의 `CLAUDE.md` - 프롬프트/운영 규칙 -## Common Customization Patterns +## 요청별 가이드 -### Adding a New Input Channel (e.g., Telegram, Slack, Email) +### 동작을 바꾸고 싶을 때 -Questions to ask: -- Which channel? (Telegram, Slack, Discord, email, SMS, etc.) -- Same trigger word or different? -- Same memory hierarchy or separate? -- Should messages from this channel go to existing groups or new ones? +- 응답 조건, 멘션 처리, 첨부파일 처리: `src/channels/discord.ts` +- 세션 명령, 상태머신, 런루프: `src/index.ts`, `src/session-commands.ts` +- 페르소나/응답 스타일: `groups/global/CLAUDE.md` -Implementation pattern: -1. Create `src/channels/{name}.ts` implementing the `Channel` interface from `src/types.ts` (see `src/channels/whatsapp.ts` for reference) -2. Add the channel instance to `main()` in `src/index.ts` and wire callbacks (`onMessage`, `onChatMetadata`) -3. Messages are stored via the `onMessage` callback; routing is automatic via `ownsJid()` +### 도구나 MCP를 붙일 때 -### Adding a New MCP Integration +- 러너에 실제 도구 허용과 MCP 설정을 추가합니다. +- Claude Code 쪽은 `runners/agent-runner/src/index.ts`를 먼저 보고, 필요하면 `src/agent-runner.ts`의 env 전달 범위를 같이 수정합니다. +- 그룹별 사용 규칙은 해당 `CLAUDE.md`에 문서화합니다. -Questions to ask: -- What service? (Calendar, Notion, database, etc.) -- What operations needed? (read, write, both) -- Which groups should have access? +### 새 명령을 만들 때 -Implementation: -1. Add MCP server config to the container settings (see `src/container-runner.ts` for how MCP servers are mounted) -2. Document available tools in `groups/CLAUDE.md` +- 먼저 슬래시 명령인지, 자연어 규칙인지 구분합니다. +- 슬래시/예약 명령이면 `src/session-commands.ts`와 `src/index.ts` 경로를 봅니다. +- 자연어 지시만으로 충분하면 프롬프트 문서만 수정합니다. -### Changing Assistant Behavior +### 등록/배포 흐름을 바꿀 때 -Questions to ask: -- What aspect? (name, trigger, persona, response style) -- Apply to all groups or specific ones? +- 설치/검증 단계는 `setup/*` +- 서비스 동작은 `setup/service.ts` +- 그룹 등록 형식은 `setup/register.ts` -Simple changes → edit `src/config.ts` -Persona changes → edit `groups/CLAUDE.md` -Per-group behavior → edit specific group's `CLAUDE.md` +## 검증 원칙 -### Adding New Commands +작게 바꿨으면 최소 이 정도는 확인합니다. -Questions to ask: -- What should the command do? -- Available in all groups or main only? -- Does it need new MCP tools? - -Implementation: -1. Commands are handled by the agent naturally — add instructions to `groups/CLAUDE.md` or the group's `CLAUDE.md` -2. For trigger-level routing changes, modify `processGroupMessages()` in `src/index.ts` - -### Changing Deployment - -Questions to ask: -- Target platform? (Linux server, Docker, different Mac) -- Service manager? (systemd, Docker, supervisord) - -Implementation: -1. Create appropriate service files -2. Update paths in config -3. Provide setup instructions - -## After Changes - -Always tell the user: ```bash -# Rebuild and restart -npm run build -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -# Linux: -# systemctl --user restart nanoclaw +npm run typecheck +npm test ``` -## Example Interaction +러너나 실행 경로를 건드렸으면 추가로 확인합니다. -User: "Add Telegram as an input channel" - -1. Ask: "Should Telegram use the same @Andy trigger, or a different one?" -2. Ask: "Should Telegram messages create separate conversation contexts, or share with WhatsApp groups?" -3. Create `src/channels/telegram.ts` implementing the `Channel` interface (see `src/channels/whatsapp.ts`) -4. Add the channel to `main()` in `src/index.ts` -5. Tell user how to authenticate and test +```bash +npm run build:runners +npm run setup -- --step verify +``` diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 03c34de..7709a4b 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -1,349 +1,91 @@ --- name: debug -description: Debug container agent issues. Use when things aren't working, container fails, authentication problems, or to understand how the container system works. Covers logs, environment variables, mounts, and common issues. +description: Debug NanoClaw runtime issues on the current Discord-only, host-process architecture. --- -# NanoClaw Container Debugging +# NanoClaw Debugging -This guide covers debugging the containerized agent execution system. +현재 기준 NanoClaw는 디스코드 전용이고, 에이전트는 컨테이너가 아니라 호스트 프로세스로 실행됩니다. 디버깅은 `채널`, `서비스`, `러너`, `DB 등록`, `자격 증명` 순서로 좁혀갑니다. -## Architecture Overview +## 빠른 점검 순서 -``` -Host (macOS) Container (Linux VM) -───────────────────────────────────────────────────────────── -src/container-runner.ts container/agent-runner/ - │ │ - │ spawns container │ runs Claude Agent SDK - │ with volume mounts │ with MCP servers - │ │ - ├── data/env/env ──────────────> /workspace/env-dir/env - ├── groups/{folder} ───────────> /workspace/group - ├── data/ipc/{folder} ────────> /workspace/ipc - ├── data/sessions/{folder}/.claude/ ──> /home/node/.claude/ (isolated per-group) - └── (main only) project root ──> /workspace/project -``` +1. 타입과 테스트부터 확인 + ```bash + npm run typecheck + npm test + ``` +2. 서비스 상태 확인 + ```bash + npm run setup -- --step verify + ``` +3. 런타임 로그 확인 + ```bash + tail -f logs/nanoclaw.log + tail -f logs/nanoclaw.error.log + ls -t groups/*/logs/agent-*.log | head + ``` +4. 러너 빌드 확인 + ```bash + npm run build:runners + ``` -**Important:** The container runs as user `node` with `HOME=/home/node`. Session files must be mounted to `/home/node/.claude/` (not `/root/.claude/`) for session resumption to work. +## 핵심 파일 -## Log Locations +- `src/index.ts` - 메시지 루프, 세션 명령, 라우팅 +- `src/channels/discord.ts` - 디스코드 수신/송신, 멘션, 첨부파일, 음성 전사 +- `src/agent-runner.ts` - 러너 실행, 환경 변수 전달, 워크디렉터리 처리 +- `runners/agent-runner/src/index.ts` - Claude Code 러너 +- `runners/codex-runner/src/index.ts` - Codex 러너 +- `src/db.ts` - 등록 그룹, 세션, 스케줄 저장 +- `setup/register.ts` - 디스코드 채널 등록 +- `setup/verify.ts` - 설치 상태 검증 -| Log | Location | Content | -|-----|----------|---------| -| **Main app logs** | `logs/nanoclaw.log` | Host-side WhatsApp, routing, container spawning | -| **Main app errors** | `logs/nanoclaw.error.log` | Host-side errors | -| **Container run logs** | `groups/{folder}/logs/container-*.log` | Per-run: input, mounts, stderr, stdout | -| **Claude sessions** | `~/.claude/projects/` | Claude Code session history | +## 자주 보는 문제 -## Enabling Debug Logging - -Set `LOG_LEVEL=debug` for verbose output: +### 봇이 아예 말이 없음 ```bash -# For development -LOG_LEVEL=debug npm run dev - -# For launchd service (macOS), add to plist EnvironmentVariables: -LOG_LEVEL -debug -# For systemd service (Linux), add to unit [Service] section: -# Environment=LOG_LEVEL=debug +grep -n '^DISCORD_BOT_TOKEN=' .env +npm run setup -- --step verify ``` -Debug level shows: -- Full mount configurations -- Container command arguments -- Real-time container stderr +- `DISCORD_BOT_TOKEN`이 없으면 채널이 안 붙습니다. +- `REGISTERED_GROUPS=0`이면 채널 등록이 안 된 상태입니다. +- 메인 채널이 아니면 디스코드 멘션이나 트리거 조건을 먼저 확인합니다. -## Common Issues - -### 1. "Claude Code process exited with code 1" - -**Check the container log file** in `groups/{folder}/logs/container-*.log` - -Common causes: - -#### Missing Authentication -``` -Invalid API key · Please run /login -``` -**Fix:** Ensure `.env` file exists with either OAuth token or API key: -```bash -cat .env # Should show one of: -# CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... (subscription) -# ANTHROPIC_API_KEY=sk-ant-api03-... (pay-per-use) -``` - -#### Root User Restriction -``` ---dangerously-skip-permissions cannot be used with root/sudo privileges -``` -**Fix:** Container must run as non-root user. Check Dockerfile has `USER node`. - -### 2. Environment Variables Not Passing - -**Runtime note:** Environment variables passed via `-e` may be lost when using `-i` (interactive/piped stdin). - -**Workaround:** The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed. - -To verify env vars are reaching the container: -```bash -echo '{}' | docker run -i \ - -v $(pwd)/data/env:/workspace/env-dir:ro \ - --entrypoint /bin/bash nanoclaw-agent:latest \ - -c 'export $(cat /workspace/env-dir/env | xargs); echo "OAuth: ${#CLAUDE_CODE_OAUTH_TOKEN} chars, API: ${#ANTHROPIC_API_KEY} chars"' -``` - -### 3. Mount Issues - -**Container mount notes:** -- Docker supports both `-v` and `--mount` syntax -- Use `:ro` suffix for readonly mounts: - ```bash - # Readonly - -v /path:/container/path:ro - - # Read-write - -v /path:/container/path - ``` - -To check what's mounted inside a container: -```bash -docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /workspace/' -``` - -Expected structure: -``` -/workspace/ -├── env-dir/env # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY) -├── group/ # Current group folder (cwd) -├── project/ # Project root (main channel only) -├── global/ # Global CLAUDE.md (non-main only) -├── ipc/ # Inter-process communication -│ ├── messages/ # Outgoing WhatsApp messages -│ ├── tasks/ # Scheduled task commands -│ ├── current_tasks.json # Read-only: scheduled tasks visible to this group -│ └── available_groups.json # Read-only: WhatsApp groups for activation (main only) -└── extra/ # Additional custom mounts -``` - -### 4. Permission Issues - -The container runs as user `node` (uid 1000). Check ownership: -```bash -docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c ' - whoami - ls -la /workspace/ - ls -la /app/ -' -``` - -All of `/workspace/` and `/app/` should be owned by `node`. - -### 5. Session Not Resuming / "Claude Code process exited with code 1" - -If sessions aren't being resumed (new session ID every time), or Claude Code exits with code 1 when resuming: - -**Root cause:** The SDK looks for sessions at `$HOME/.claude/projects/`. Inside the container, `HOME=/home/node`, so it looks at `/home/node/.claude/projects/`. - -**Check the mount path:** -```bash -# In container-runner.ts, verify mount is to /home/node/.claude/, NOT /root/.claude/ -grep -A3 "Claude sessions" src/container-runner.ts -``` - -**Verify sessions are accessible:** -```bash -docker run --rm --entrypoint /bin/bash \ - -v ~/.claude:/home/node/.claude \ - nanoclaw-agent:latest -c ' -echo "HOME=$HOME" -ls -la $HOME/.claude/projects/ 2>&1 | head -5 -' -``` - -**Fix:** Ensure `container-runner.ts` mounts to `/home/node/.claude/`: -```typescript -mounts.push({ - hostPath: claudeDir, - containerPath: '/home/node/.claude', // NOT /root/.claude - readonly: false -}); -``` - -### 6. MCP Server Failures - -If an MCP server fails to start, the agent may exit. Check the container logs for MCP initialization errors. - -## Manual Container Testing - -### Test the full agent flow: -```bash -# Set up env file -mkdir -p data/env groups/test -cp .env data/env/env - -# Run test query -echo '{"prompt":"What is 2+2?","groupFolder":"test","chatJid":"test@g.us","isMain":false}' | \ - docker run -i \ - -v $(pwd)/data/env:/workspace/env-dir:ro \ - -v $(pwd)/groups/test:/workspace/group \ - -v $(pwd)/data/ipc:/workspace/ipc \ - nanoclaw-agent:latest -``` - -### Test Claude Code directly: -```bash -docker run --rm --entrypoint /bin/bash \ - -v $(pwd)/data/env:/workspace/env-dir:ro \ - nanoclaw-agent:latest -c ' - export $(cat /workspace/env-dir/env | xargs) - claude -p "Say hello" --dangerously-skip-permissions --allowedTools "" -' -``` - -### Interactive shell in container: -```bash -docker run --rm -it --entrypoint /bin/bash nanoclaw-agent:latest -``` - -## SDK Options Reference - -The agent-runner uses these Claude Agent SDK options: - -```typescript -query({ - prompt: input.prompt, - options: { - cwd: '/workspace/group', - allowedTools: ['Bash', 'Read', 'Write', ...], - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, // Required with bypassPermissions - settingSources: ['project'], - mcpServers: { ... } - } -}) -``` - -**Important:** `allowDangerouslySkipPermissions: true` is required when using `permissionMode: 'bypassPermissions'`. Without it, Claude Code exits with code 1. - -## Rebuilding After Changes +### 에이전트가 실행 직후 죽음 ```bash -# Rebuild main app -npm run build - -# Rebuild container (use --no-cache for clean rebuild) -./container/build.sh - -# Or force full rebuild -docker builder prune -af -./container/build.sh +grep -nE '^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_OPENAI_API_KEY)=' .env +ls -t groups/*/logs/agent-*.log | head -3 ``` -## Checking Container Image +- Claude 계열은 `CLAUDE_CODE_OAUTH_TOKEN` 또는 `ANTHROPIC_API_KEY`가 필요합니다. +- Codex 계열은 `OPENAI_API_KEY` 또는 `CODEX_OPENAI_API_KEY`가 필요합니다. +- 러너 로그에 인증 실패나 CLI 실행 오류가 바로 찍힙니다. + +### 음성 전사가 안 됨 ```bash -# List images -docker images - -# Check what's in the image -docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c ' - echo "=== Node version ===" - node --version - - echo "=== Claude Code version ===" - claude --version - - echo "=== Installed packages ===" - ls /app/node_modules/ -' +grep -nE '^(GROQ_API_KEY|OPENAI_API_KEY)=' .env +tail -f logs/nanoclaw.log | grep -iE 'transcri|audio|whisper|groq' ``` -## Session Persistence +- 기본 우선순위는 Groq Whisper, 없으면 OpenAI Whisper fallback입니다. +- 키가 없으면 디스코드 채널은 음성 첨부를 텍스트로 확장하지 못합니다. -Claude sessions are stored per-group in `data/sessions/{group}/.claude/` for security isolation. Each group has its own session directory, preventing cross-group access to conversation history. - -**Critical:** The mount path must match the container user's HOME directory: -- Container user: `node` -- Container HOME: `/home/node` -- Mount target: `/home/node/.claude/` (NOT `/root/.claude/`) - -To clear sessions: +### 등록은 되어 있는데 응답이 이상함 ```bash -# Clear all sessions for all groups -rm -rf data/sessions/ - -# Clear sessions for a specific group -rm -rf data/sessions/{groupFolder}/.claude/ - -# Also clear the session ID from NanoClaw's tracking (stored in SQLite) -sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'" +sqlite3 store/messages.db "select jid, folder, requires_trigger, is_main, agent_type from registered_groups;" ``` -To verify session resumption is working, check the logs for the same session ID across messages: -```bash -grep "Session initialized" logs/nanoclaw.log | tail -5 -# Should show the SAME session ID for consecutive messages in the same group -``` +- `jid`는 `dc:` 형식이어야 합니다. +- `folder`는 `discord_main` 또는 `discord_` 형태를 유지합니다. +- 세션 명령 문제면 `src/session-commands.ts`와 `src/index.ts` 호출부를 같이 봅니다. -## IPC Debugging +## 원칙 -The container communicates back to the host via files in `/workspace/ipc/`: - -```bash -# Check pending messages -ls -la data/ipc/messages/ - -# Check pending task operations -ls -la data/ipc/tasks/ - -# Read a specific IPC file -cat data/ipc/messages/*.json - -# Check available groups (main channel only) -cat data/ipc/main/available_groups.json - -# Check current tasks snapshot -cat data/ipc/{groupFolder}/current_tasks.json -``` - -**IPC file types:** -- `messages/*.json` - Agent writes: outgoing WhatsApp messages -- `tasks/*.json` - Agent writes: task operations (schedule, pause, resume, cancel, refresh_groups) -- `current_tasks.json` - Host writes: read-only snapshot of scheduled tasks -- `available_groups.json` - Host writes: read-only list of WhatsApp groups (main only) - -## Quick Diagnostic Script - -Run this to check common issues: - -```bash -echo "=== Checking NanoClaw Container Setup ===" - -echo -e "\n1. Authentication configured?" -[ -f .env ] && (grep -q "CLAUDE_CODE_OAUTH_TOKEN=sk-" .env || grep -q "ANTHROPIC_API_KEY=sk-" .env) && echo "OK" || echo "MISSING - add CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY to .env" - -echo -e "\n2. Env file copied for container?" -[ -f data/env/env ] && echo "OK" || echo "MISSING - will be created on first run" - -echo -e "\n3. Container runtime running?" -docker info &>/dev/null && echo "OK" || echo "NOT RUNNING - start Docker Desktop (macOS) or sudo systemctl start docker (Linux)" - -echo -e "\n4. Container image exists?" -echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh" - -echo -e "\n5. Session mount path correct?" -grep -q "/home/node/.claude" src/container-runner.ts 2>/dev/null && echo "OK" || echo "WRONG - should mount to /home/node/.claude/, not /root/.claude/" - -echo -e "\n6. Groups directory?" -ls -la groups/ 2>/dev/null || echo "MISSING - run setup" - -echo -e "\n7. Recent container logs?" -ls -t groups/*/logs/container-*.log 2>/dev/null | head -3 || echo "No container logs yet" - -echo -e "\n8. Session continuity working?" -SESSIONS=$(grep "Session initialized" logs/nanoclaw.log 2>/dev/null | tail -5 | awk '{print $NF}' | sort -u | wc -l) -[ "$SESSIONS" -le 2 ] && echo "OK (recent sessions reusing IDs)" || echo "CHECK - multiple different session IDs, may indicate resumption issues" -``` +- 채널 문제와 에이전트 문제를 섞지 말고 분리해서 봅니다. +- `.env`, DB 등록, 서비스, 러너 빌드 중 하나라도 틀리면 상위 증상이 비슷하게 보입니다. +- 컨테이너 전제 문서는 무시하고 `runners/*`와 `setup/*` 기준으로 확인합니다. diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 02e9a87..e5d9d17 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -1,203 +1,114 @@ --- 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 for the current Discord-only, host-process architecture. --- # 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`. +설치는 `bash setup.sh`로 부트스트랩하고, 나머지는 `npm run setup -- --step `으로 진행합니다. 현재 기준 채널은 디스코드만 지원합니다. -**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. - -**Architecture:** NanoClaw runs agents as direct host processes (no Docker/containers). Each agent type (Claude Code, Codex) has its own runner in `container/agent-runner/` and `container/codex-runner/`. - -## 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) - -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` and `package-lock.json`, 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. - -## 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 - -## 3. Build Agent Runners - -Run `npx tsx setup/index.ts --step runners` and parse the status block. - -This builds the agent runners that execute as host processes: -- `container/agent-runner/` — Claude Code agent (via Claude Agent SDK) -- `container/codex-runner/` — Codex agent (via Codex CLI) - -**If BUILD_OK=false:** Read `logs/setup.log` for the error. Common fix: `cd container/agent-runner && npm install && npm run build`. - -**If CODEX_RUNNER=false but AGENT_RUNNER=true:** Codex runner failed to build. Not critical if you only use Claude Code. Fix: `cd container/codex-runner && npm install && npm run build`. - -## 4. Claude Authentication (No Script) - -If HAS_ENV=true from step 2, read `.env` and check for `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If present, confirm with user: keep or reconfigure? - -AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key? - -**Subscription:** Tell user to run `claude setup-token` in another terminal, copy the token, add `CLAUDE_CODE_OAUTH_TOKEN=` to `.env`. Do NOT collect the token in chat. - -**API key:** Tell user to add `ANTHROPIC_API_KEY=` to `.env`. - -### Codex Authentication (Optional) - -AskUserQuestion: Do you also want to use Codex (OpenAI) agents? - -If yes: Tell user to add `OPENAI_API_KEY=` (or `CODEX_OPENAI_API_KEY=`) to `.env`. The Codex runner will use this key. - -### Voice Transcription (Optional) - -AskUserQuestion: Do you want voice message transcription in Discord/WhatsApp? - -If yes: Tell user to get a free API key at https://console.groq.com (no credit card needed) and add `GROQ_API_KEY=` to `.env`. Uses Groq Whisper (whisper-large-v3-turbo, ~200x faster than OpenAI). Falls back to OpenAI Whisper (`OPENAI_API_KEY`) if Groq key is not set. - -## 5. Install Skills Marketplace - -Register and install the NanoClaw skills marketplace plugin so all feature skills (channel integrations, add-ons) are available: +## 1. 부트스트랩 ```bash -claude plugin marketplace add qwibitai/nanoclaw-skills -claude plugin marketplace update nanoclaw-skills -claude plugin install nanoclaw-skills@nanoclaw-skills --scope project +bash setup.sh ``` -The marketplace update ensures the local cache is fresh before installing. This is hot-loaded — no restart needed. All feature skills become immediately available. +- Node 20 이상과 의존성이 준비되어야 합니다. +- 실패하면 `logs/setup.log`를 먼저 봅니다. -## 6. 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: +## 2. 현재 상태 확인 ```bash -npm install && npm run build +npm run setup -- --step environment ``` -If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 7. +여기서 확인할 것: -## 7. Mount Allowlist +- `.env` 존재 여부 +- 기존 등록 그룹 존재 여부 +- 이미 초기화된 설치인지 여부 -AskUserQuestion: Agent access to external directories? +## 3. 필수 환경 변수 -**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}'` +`.env`에 최소한 아래 값이 있어야 합니다. -## 8. Start Service +```bash +DISCORD_BOT_TOKEN=... +CLAUDE_CODE_OAUTH_TOKEN=... # 또는 ANTHROPIC_API_KEY=... +``` -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. +```bash +OPENAI_API_KEY=... # Codex 사용 시 +CODEX_OPENAI_API_KEY=... # Codex 전용 키를 따로 쓸 때 +GROQ_API_KEY=... # Discord 음성 전사 +``` -**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. +## 4. 러너 빌드 -**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. +```bash +npm run setup -- --step runners +``` -## 9. Verify +이 단계는 아래 두 러너를 빌드합니다. -Run `npx tsx setup/index.ts --step verify` and parse the status block. +- `runners/agent-runner` +- `runners/codex-runner` -**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 8 -- CREDENTIALS=missing → re-run step 4 -- 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 6 -- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` +실패하면 보통 `npm run build:runners` 출력과 각 러너의 `package.json` 의존성을 같이 보면 됩니다. -Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log` +## 5. 디스코드 채널 등록 -## Troubleshooting +먼저 디스코드에서 개발자 모드를 켜고 채널 ID를 복사합니다. 등록 JID는 `dc:` 형식입니다. -**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 8), missing `.env` (step 4), missing channel credentials (re-invoke channel skill). +메인 채널 예시: -**Agent fails ("Claude Code process exited with code 1"):** Check agent logs in `groups//logs/agent-*.log`. Common causes: missing credentials in `.env`, `codex` CLI not in PATH, stale session IDs. +```bash +npm run setup -- --step register -- \ + --jid dc:123456789012345678 \ + --name "My Server #general" \ + --folder discord_main \ + --trigger @Andy \ + --is-main \ + --no-trigger-required +``` -**Codex agent not responding:** Ensure `OPENAI_API_KEY` is set in `.env` and `codex` CLI is installed globally (`npm install -g @openai/codex`). The runner needs `codex` in PATH. +보조 채널 예시: -**Voice transcription not working:** Check `GROQ_API_KEY` (primary) or `OPENAI_API_KEY` (fallback) is set in `.env`. Test: send a voice message in Discord and check `logs/nanoclaw.log` for `Audio transcribed` entries with `provider` and `elapsed` fields. +```bash +npm run setup -- --step register -- \ + --jid dc:123456789012345678 \ + --name "My Server #ops" \ + --folder discord_ops \ + --trigger @Andy +``` -**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`. +## 6. 서비스 시작 -**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. +```bash +npm run setup -- --step service +``` -**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw` +- macOS는 `launchd` +- Linux는 `systemd` 또는 fallback wrapper + +## 7. 최종 검증 + +```bash +npm run setup -- --step verify +``` + +성공 기준: + +- 서비스가 running +- Claude 인증이 configured +- `CHANNEL_AUTH`에 `discord` +- 등록 그룹 수가 1 이상 + +## 빠른 문제 해결 + +- 빌드 문제: `npm run typecheck`, `npm test`, `npm run build:runners` +- 서비스 문제: `logs/nanoclaw.error.log` +- 디스코드 연결 문제: `.env`의 `DISCORD_BOT_TOKEN`과 등록된 `dc:*` JID 확인 +- 응답 문제: `tail -f logs/nanoclaw.log` diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index b0b478c..e7e36cf 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -1,235 +1,100 @@ --- name: update-nanoclaw -description: Efficiently bring upstream NanoClaw updates into a customized install, with preview, selective cherry-pick, and low token usage. +description: Safely merge upstream NanoClaw changes into a customized install with minimal drift. --- -# About +# Update NanoClaw -Your NanoClaw fork drifts from upstream as you customize it. This skill pulls upstream changes into your install without losing your modifications. +커스텀된 NanoClaw에 upstream 변경을 가져올 때 쓰는 절차입니다. 현재 기준 중요 경로는 `src/`, `runners/`, `setup/`, `.claude/skills/`, `groups/`, `README.md` 입니다. -Run `/update-nanoclaw` in Claude Code. +## 원칙 -## How it works +- 워킹트리가 더럽다면 진행하지 않습니다 +- 시작 전에 항상 백업 브랜치와 태그를 만듭니다 +- 먼저 `preview`, 그 다음 `merge` 또는 `cherry-pick` 순서로 갑니다 +- 충돌이 나면 충돌 파일만 엽니다 -**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`). +## 1. 사전 점검 -**Backup**: creates a timestamped backup branch and tag (`backup/pre-update--`, `pre-update--`) before touching anything. Safe to run multiple times. - -**Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories: -- **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill -- **Source** (`src/`): may conflict if you modified the same files -- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed - -**Update paths** (you pick one): -- `merge` (default): `git merge upstream/`. Resolves all conflicts in one pass. -- `cherry-pick`: `git cherry-pick `. Pull in only the commits you want. -- `rebase`: `git rebase upstream/`. Linear history, but conflicts resolve per-commit. -- `abort`: just view the changelog, change nothing. - -**Conflict preview**: before merging, runs a dry-run (`git merge --no-commit --no-ff`) to show which files would conflict. You can still abort at this point. - -**Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact. - -**Validation**: runs `npm run build` and `npm 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. - -## Rollback - -The backup tag is printed at the end of each run: -``` -git reset --hard pre-update-- +```bash +git status --porcelain +git remote -v +git fetch upstream --prune ``` -Backup branch `backup/pre-update--` also exists. +- 변경 파일이 남아 있으면 먼저 정리하게 합니다 +- `upstream`이 없으면 추가합니다 +- upstream 기본 브랜치가 `main`인지 먼저 확인합니다 -## Token usage +## 2. 백업 -Only opens files with actual conflicts. Uses `git log`, `git diff`, and `git status` for everything else. Does not scan or refactor unrelated code. - ---- - -# Goal -Help a user with a customized NanoClaw install safely incorporate upstream changes without a fresh reinstall and without blowing tokens. - -# Operating principles -- Never proceed with a dirty working tree. -- Always create a rollback point (backup branch + tag) before touching anything. -- Prefer git-native operations (fetch, merge, cherry-pick). Do not manually rewrite files except conflict markers. -- Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option. -- Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files. - -# Step 0: Preflight (stop early if unsafe) -Run: -- `git status --porcelain` -If output is non-empty: -- Tell the user to commit or stash first, then stop. - -Confirm remotes: -- `git remote -v` -If `upstream` is missing: -- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`). -- Add it: `git remote add upstream ` -- Then: `git fetch upstream --prune` - -Determine the upstream branch name: -- `git branch -r | grep upstream/` -- If `upstream/main` exists, use `main`. -- If only `upstream/master` exists, use `master`. -- Otherwise, ask the user which branch to use. -- Store this as UPSTREAM_BRANCH for all subsequent commands. Every command below that references `upstream/main` should use `upstream/$UPSTREAM_BRANCH` instead. - -Fetch: -- `git fetch upstream --prune` - -# Step 1: Create a safety net -Capture current state: -- `HASH=$(git rev-parse --short HEAD)` -- `TIMESTAMP=$(date +%Y%m%d-%H%M%S)` - -Create backup branch and tag (using timestamp to avoid collisions on retry): -- `git branch backup/pre-update-$HASH-$TIMESTAMP` -- `git tag pre-update-$HASH-$TIMESTAMP` - -Save the tag name for later reference in the summary and rollback instructions. - -# Step 2: Preview what upstream changed (no edits yet) -Compute common base: -- `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)` - -Show upstream commits since BASE: -- `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH` - -Show local commits since BASE (custom drift): -- `git log --oneline $BASE..HEAD` - -Show file-level impact from upstream: -- `git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH` - -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 -- **Other**: docs, tests, misc - -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 -- C) **Abort**: they only wanted the preview -- D) **Rebase mode**: advanced, linear history (warn: resolves conflicts per-commit) - -If Abort: stop here. - -# Step 3: Conflict preview (before committing anything) -If Full update or Rebase: -- Dry-run merge to preview conflicts. Run these as a single chained command so the abort always executes: - ``` - git merge --no-commit --no-ff upstream/$UPSTREAM_BRANCH; git diff --name-only --diff-filter=U; git merge --abort - ``` -- If conflicts were listed: show them and ask user if they want to proceed. -- If no conflicts: tell user it is clean and proceed. - -# Step 4A: Full update (MERGE, default) -Run: -- `git merge upstream/$UPSTREAM_BRANCH --no-edit` - -If conflicts occur: -- Run `git status` and identify conflicted files. -- For each conflicted file: - - Open the file. - - Resolve only conflict markers. - - Preserve intentional local customizations. - - Incorporate upstream fixes/improvements. - - Do not refactor surrounding code. - - `git add ` -- When all resolved: - - If merge did not auto-commit: `git commit --no-edit` - -# Step 4B: Selective update (CHERRY-PICK) -If user chose Selective: -- Recompute BASE if needed: `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)` -- Show commit list again: `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH` -- Ask user which commit hashes they want. -- Apply: `git cherry-pick ...` - -If conflicts during cherry-pick: -- Resolve only conflict markers, then: - - `git add ` - - `git cherry-pick --continue` -If user wants to stop: - - `git cherry-pick --abort` - -# Step 4C: Rebase (only if user explicitly chose option D) -Run: -- `git rebase upstream/$UPSTREAM_BRANCH` - -If conflicts: -- Resolve conflict markers only, then: - - `git add ` - - `git rebase --continue` -If it gets messy (more than 3 rounds of conflicts): - - `git rebase --abort` - - Recommend merge instead. - -# Step 5: Validation -Run: -- `npm run build` -- `npm test` (do not fail the flow if tests are not configured) - -If build fails: -- Show the error. -- Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code). -- Do not refactor unrelated code. -- If unclear, ask the user before making changes. - -# Step 6: Breaking changes check -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: -``` -[BREAKING] . Run `/` to . +```bash +HASH=$(git rev-parse --short HEAD) +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +git branch backup/pre-update-$HASH-$TIMESTAMP +git tag pre-update-$HASH-$TIMESTAMP ``` -If no `[BREAKING]` lines are found: -- Skip this step silently. Proceed to Step 7 (skill updates check). +## 3. 미리 보기 -If one or more `[BREAKING]` lines are found: -- Display a warning header to the user: "This update includes breaking changes that may require action:" -- For each breaking change, display the full description. -- Collect all skill names referenced in the breaking change entries (the `/` part). -- Use AskUserQuestion to ask the user which migration skills they want to run now. Options: - - One option per referenced skill (e.g., "Run /add-whatsapp to re-add WhatsApp channel") - - "Skip — I'll handle these manually" -- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes. -- For each skill the user selects, invoke it using the Skill tool. -- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check). +```bash +BASE=$(git merge-base HEAD upstream/main) +git log --oneline $BASE..upstream/main +git log --oneline $BASE..HEAD +git diff --name-only $BASE..upstream/main +``` -# Step 7: Check for skill updates -After the summary, check if skills are distributed as branches in this repo: -- `git branch -r --list 'upstream/skill/*'` +파일은 대략 이렇게 나눠서 봅니다. -If any `upstream/skill/*` branches exist: -- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?" - - Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates") - - Option 2: "No, skip" (description: "You can run /update-skills later any time") -- If user selects yes, invoke `/update-skills` using the Skill tool. -- After the skill completes (or if user selected no), proceed to Step 8. +- `.claude/skills/` - 스킬 문서/도우미 +- `src/` - 런타임과 채널 처리 +- `runners/` - Claude/Codex 실행 경로 +- `setup/` - 설치, 등록, 검증, 서비스 +- `README.md`, `groups/` - 운영 문서와 프롬프트 -# Step 8: Summary + rollback instructions -Show: -- Backup tag: the tag name created in Step 1 -- New HEAD: `git rev-parse --short HEAD` -- Upstream HEAD: `git rev-parse --short upstream/$UPSTREAM_BRANCH` -- Conflicts resolved (list files, if any) -- Breaking changes applied (list skills run, if any) -- Remaining local diff vs upstream: `git diff --name-only upstream/$UPSTREAM_BRANCH..HEAD` +## 4. 적용 -Tell the user: -- To rollback: `git reset --hard ` -- 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` +기본은 merge 입니다. + +```bash +git merge upstream/main --no-edit +``` + +일부 커밋만 가져올 거면 cherry-pick으로 갑니다. + +```bash +git cherry-pick +``` + +## 5. 충돌 처리 + +- 충돌 파일만 엽니다 +- 로컬 커스터마이징은 유지합니다 +- 주변 리팩터링은 하지 않습니다 +- 해결 후 바로 `git add ` 합니다 + +## 6. 검증 + +```bash +npm run typecheck +npm test +npm run build:runners +npm run build +``` + +런타임 경로가 바뀌었으면 추가로 확인합니다. + +```bash +npm run setup -- --step verify +``` + +## 7. 요약 + +사용자에게 최소한 이 네 가지를 알려줍니다. + +- 백업 태그 이름 +- 새 HEAD +- 해결한 충돌 파일 목록 +- upstream 대비 아직 남은 로컬 diff + +롤백 안내는 태그 기준으로 알려줍니다. diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md deleted file mode 100644 index 76851f3..0000000 --- a/.claude/skills/use-local-whisper/SKILL.md +++ /dev/null @@ -1,148 +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 -``` - -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/x-integration/SKILL.md b/.claude/skills/x-integration/SKILL.md deleted file mode 100644 index 29a7be6..0000000 --- a/.claude/skills/x-integration/SKILL.md +++ /dev/null @@ -1,417 +0,0 @@ ---- -name: x-integration -description: X (Twitter) integration for NanoClaw. Post tweets, like, reply, retweet, and quote. Use for setup, testing, or troubleshooting X functionality. Triggers on "setup x", "x integration", "twitter", "post tweet", "tweet". ---- - -# X (Twitter) Integration - -Browser automation for X interactions via WhatsApp. - -> **Compatibility:** NanoClaw v1.0.0. Directory structure may change in future versions. - -## Features - -| Action | Tool | Description | -|--------|------|-------------| -| Post | `x_post` | Publish new tweets | -| Like | `x_like` | Like any tweet | -| Reply | `x_reply` | Reply to tweets | -| Retweet | `x_retweet` | Retweet without comment | -| Quote | `x_quote` | Quote tweet with comment | - -## Prerequisites - -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 - ``` -3. **CHROME_PATH configured** in `.env` (if Chrome is not at default location): - ```bash - # Find your Chrome path - mdfind "kMDItemCFBundleIdentifier == 'com.google.Chrome'" 2>/dev/null | head -1 - # Add to .env - CHROME_PATH=/path/to/Google Chrome.app/Contents/MacOS/Google Chrome - ``` - -## Quick Start - -```bash -# 1. Setup authentication (interactive) -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts -# Verify: data/x-auth.json should exist after successful login - -# 2. Rebuild container to include skill -./container/build.sh -# Verify: Output shows "COPY .claude/skills/x-integration/agent.ts" - -# 3. Rebuild host and restart service -npm 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) -``` - -## Configuration - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `CHROME_PATH` | `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` | Chrome executable path | -| `NANOCLAW_ROOT` | `process.cwd()` | Project root directory | -| `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) | - -Set in `.env` file (loaded via `dotenv-cli` at runtime): - -```bash -# .env -CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome -``` - -### Configuration File - -Edit `lib/config.ts` to modify defaults: - -```typescript -export const config = { - // Browser viewport - viewport: { width: 1280, height: 800 }, - - // Timeouts (milliseconds) - timeouts: { - navigation: 30000, // Page navigation - elementWait: 5000, // Wait for element - afterClick: 1000, // Delay after click - afterFill: 1000, // Delay after form fill - afterSubmit: 3000, // Delay after submit - pageLoad: 3000, // Initial page load - }, - - // Tweet limits - limits: { - tweetMaxLength: 280, - }, -}; -``` - -### Data Directories - -Paths relative to project root: - -| Path | Purpose | Git | -|------|---------|-----| -| `data/x-browser-profile/` | Chrome profile with X session | Ignored | -| `data/x-auth.json` | Auth state marker | Ignored | -| `logs/nanoclaw.log` | Service logs (contains X operation logs) | Ignored | - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Container (Linux VM) │ -│ └── agent.ts → MCP tool definitions (x_post, etc.) │ -│ └── Writes IPC request to /workspace/ipc/tasks/ │ -└──────────────────────┬──────────────────────────────────────┘ - │ IPC (file system) - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Host (macOS) │ -│ └── src/ipc.ts → processTaskIpc() │ -│ └── host.ts → handleXIpc() │ -│ └── spawn subprocess → scripts/*.ts │ -│ └── Playwright → Chrome → X Website │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Why This Design? - -- **API is expensive** - X official API requires paid subscription ($100+/month) for posting -- **Bot browsers get blocked** - X detects and bans headless browsers and common automation fingerprints -- **Must use user's real browser** - Reuses the user's actual Chrome on Host with real browser fingerprint to avoid detection -- **One-time authorization** - User logs in manually once, session persists in Chrome profile for future use - -### File Structure - -``` -.claude/skills/x-integration/ -├── SKILL.md # This documentation -├── host.ts # Host-side IPC handler -├── agent.ts # Container-side MCP tool definitions -├── lib/ -│ ├── config.ts # Centralized configuration -│ └── browser.ts # Playwright utilities -└── scripts/ - ├── setup.ts # Interactive login - ├── post.ts # Post tweet - ├── like.ts # Like tweet - ├── reply.ts # Reply to tweet - ├── retweet.ts # Retweet - └── quote.ts # Quote tweet -``` - -### Integration Points - -To integrate this skill into NanoClaw, make the following modifications: - ---- - -**1. Host side: `src/ipc.ts`** - -Add import after other local imports: -```typescript -import { handleXIpc } from '../.claude/skills/x-integration/host.js'; -``` - -Modify `processTaskIpc` function's switch statement default case: -```typescript -// Find: -default: -logger.warn({ type: data.type }, 'Unknown IPC task type'); - -// Replace with: -default: -const handled = await handleXIpc(data, sourceGroup, isMain, DATA_DIR); -if (!handled) { - logger.warn({ type: data.type }, 'Unknown IPC task type'); -} -``` - ---- - -**2. Container side: `container/agent-runner/src/ipc-mcp.ts`** - -Add import after `cron-parser` import: -```typescript -// @ts-ignore - Copied during Docker build from .claude/skills/x-integration/ -import { createXTools } from './skills/x-integration/agent.js'; -``` - -Add to the end of tools array (before the closing `]`): -```typescript - ...createXTools({ groupFolder, isMain }) -``` - ---- - -**3. Build script: `container/build.sh`** - -Change build context from `container/` to project root (required to access `.claude/skills/`): -```bash -# Find: -docker build -t "${IMAGE_NAME}:${TAG}" . - -# Replace with: -cd "$SCRIPT_DIR/.." -docker build -t "${IMAGE_NAME}:${TAG}" -f container/Dockerfile . -``` - ---- - -**4. Dockerfile: `container/Dockerfile`** - -First, update the build context paths (required to access `.claude/skills/` from project root): -```dockerfile -# Find: -COPY agent-runner/package*.json ./ -... -COPY agent-runner/ ./ - -# Replace with: -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`: -```dockerfile -# Copy skill MCP tools -COPY .claude/skills/x-integration/agent.ts ./src/skills/x-integration/ -``` - -## Setup - -All paths below are relative to project root (`NANOCLAW_ROOT`). - -### 1. Check Chrome Path - -```bash -# Check if Chrome exists at configured path -cat .env | grep CHROME_PATH -ls -la "$(grep CHROME_PATH .env | cut -d= -f2)" 2>/dev/null || \ -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 -``` - -This opens Chrome for manual X login. Session saved to `data/x-browser-profile/`. - -**Verify success:** -```bash -cat data/x-auth.json # Should show {"authenticated": true, ...} -``` - -### 3. Rebuild Container - -```bash -./container/build.sh -``` - -**Verify success:** -```bash -./container/build.sh 2>&1 | grep -i "agent.ts" # Should show COPY line -``` - -### 4. Restart Service - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -**Verify success:** -```bash -launchctl list | grep nanoclaw # macOS — should show PID and exit code 0 or - -# Linux: systemctl --user status nanoclaw -``` - -## Usage via WhatsApp - -Replace `@Assistant` with your configured trigger name (`ASSISTANT_NAME` in `.env`): - -``` -@Assistant post a tweet: Hello world! - -@Assistant like this tweet https://x.com/user/status/123 - -@Assistant reply to https://x.com/user/status/123 with: Great post! - -@Assistant retweet https://x.com/user/status/123 - -@Assistant quote https://x.com/user/status/123 with comment: Interesting -``` - -**Note:** Only the main group can use X tools. Other groups will receive an error. - -## Testing - -Scripts require environment variables from `.env`. Use `dotenv-cli` to load them: - -### Check Authentication Status - -```bash -# Check if auth file exists and is valid -cat data/x-auth.json 2>/dev/null && echo "Auth configured" || echo "Auth not configured" - -# Check if browser profile exists -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 -``` - -### 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 -``` - -### Test Like - -```bash -echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx dotenv -e .env -- npx 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 -``` - -## Troubleshooting - -### Authentication Expired - -```bash -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -### Browser Lock Files - -If Chrome fails to launch: - -```bash -rm -f data/x-browser-profile/SingletonLock -rm -f data/x-browser-profile/SingletonSocket -rm -f data/x-browser-profile/SingletonCookie -``` - -### Check Logs - -```bash -# Host logs (relative to project root) -grep -i "x_post\|x_like\|x_reply\|handleXIpc" logs/nanoclaw.log | tail -20 - -# Script errors -grep -i "error\|failed" logs/nanoclaw.log | tail -20 -``` - -### Script Timeout - -Default timeout is 2 minutes (120s). Increase in `host.ts`: - -```typescript -const timer = setTimeout(() => { - proc.kill('SIGTERM'); - resolve({ success: false, message: 'Script timed out (120s)' }); -}, 120000); // ← Increase this value -``` - -### X UI Selector Changes - -If X updates their UI, selectors in scripts may break. Current selectors: - -| Element | Selector | -|---------|----------| -| Tweet input | `[data-testid="tweetTextarea_0"]` | -| Post button | `[data-testid="tweetButtonInline"]` | -| Reply button | `[data-testid="reply"]` | -| Like | `[data-testid="like"]` | -| Unlike | `[data-testid="unlike"]` | -| Retweet | `[data-testid="retweet"]` | -| Unretweet | `[data-testid="unretweet"]` | -| Confirm retweet | `[data-testid="retweetConfirm"]` | -| Modal dialog | `[role="dialog"][aria-modal="true"]` | -| Modal submit | `[data-testid="tweetButton"]` | - -### Container Build Issues - -If MCP tools not found in container: - -```bash -# Verify build copies skill -./container/build.sh 2>&1 | grep -i skill - -# Check container has the file -docker run nanoclaw-agent ls -la /app/src/skills/ -``` - -## Security - -- `data/x-browser-profile/` - Contains X session cookies (in `.gitignore`) -- `data/x-auth.json` - Auth state marker (in `.gitignore`) -- Only main group can use X tools (enforced in `agent.ts` and `host.ts`) -- Scripts run as subprocesses with limited environment \ No newline at end of file diff --git a/.claude/skills/x-integration/agent.ts b/.claude/skills/x-integration/agent.ts deleted file mode 100644 index 2b3ab5a..0000000 --- a/.claude/skills/x-integration/agent.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * X Integration - MCP Tool Definitions (Agent/Container Side) - * - * These tools run inside the container and communicate with the host via IPC. - * The host-side implementation is in host.ts. - * - * Note: This file is compiled in the container, not on the host. - * The @ts-ignore is needed because the SDK is only available in the container. - */ - -// @ts-ignore - SDK available in container environment only -import { tool } from '@anthropic-ai/claude-agent-sdk'; -import { z } from 'zod'; -import fs from 'fs'; -import path from 'path'; - -// IPC directories (inside container) -const IPC_DIR = '/workspace/ipc'; -const TASKS_DIR = path.join(IPC_DIR, 'tasks'); -const RESULTS_DIR = path.join(IPC_DIR, 'x_results'); - -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); - const tempPath = `${filepath}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); - fs.renameSync(tempPath, filepath); - return filename; -} - -async function waitForResult(requestId: string, maxWait = 60000): Promise<{ success: boolean; message: string }> { - const resultFile = path.join(RESULTS_DIR, `${requestId}.json`); - const pollInterval = 1000; - let elapsed = 0; - - while (elapsed < maxWait) { - if (fs.existsSync(resultFile)) { - try { - const result = JSON.parse(fs.readFileSync(resultFile, 'utf-8')); - fs.unlinkSync(resultFile); - return result; - } catch (err) { - return { success: false, message: `Failed to read result: ${err}` }; - } - } - await new Promise(resolve => setTimeout(resolve, pollInterval)); - elapsed += pollInterval; - } - - return { success: false, message: 'Request timed out' }; -} - -export interface SkillToolsContext { - groupFolder: string; - isMain: boolean; -} - -/** - * Create X integration MCP tools - */ -export function createXTools(ctx: SkillToolsContext) { - const { groupFolder, isMain } = ctx; - - return [ - tool( - 'x_post', - `Post a tweet to X (Twitter). Main group only. - -The host machine will execute the browser automation to post the tweet. -Make sure the content is appropriate and within X's character limit (280 chars for text).`, - { - content: z.string().max(280).describe('The tweet content to post (max 280 characters)') - }, - async (args: { content: string }) => { - if (!isMain) { - return { - content: [{ type: 'text', text: 'Only the main group can post tweets.' }], - isError: true - }; - } - - if (args.content.length > 280) { - return { - content: [{ type: 'text', text: `Tweet exceeds 280 character limit (current: ${args.content.length})` }], - isError: true - }; - } - - const requestId = `xpost-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - writeIpcFile(TASKS_DIR, { - type: 'x_post', - requestId, - content: args.content, - groupFolder, - timestamp: new Date().toISOString() - }); - - const result = await waitForResult(requestId); - return { - content: [{ type: 'text', text: result.message }], - isError: !result.success - }; - } - ), - - tool( - 'x_like', - `Like a tweet on X (Twitter). Main group only. - -Provide the tweet URL or tweet ID to like.`, - { - tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID') - }, - async (args: { tweet_url: string }) => { - if (!isMain) { - return { - content: [{ type: 'text', text: 'Only the main group can interact with X.' }], - isError: true - }; - } - - const requestId = `xlike-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - writeIpcFile(TASKS_DIR, { - type: 'x_like', - requestId, - tweetUrl: args.tweet_url, - groupFolder, - timestamp: new Date().toISOString() - }); - - const result = await waitForResult(requestId); - return { - content: [{ type: 'text', text: result.message }], - isError: !result.success - }; - } - ), - - tool( - 'x_reply', - `Reply to a tweet on X (Twitter). Main group only. - -Provide the tweet URL and your reply content.`, - { - tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'), - content: z.string().max(280).describe('The reply content (max 280 characters)') - }, - async (args: { tweet_url: string; content: string }) => { - if (!isMain) { - return { - content: [{ type: 'text', text: 'Only the main group can interact with X.' }], - isError: true - }; - } - - const requestId = `xreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - writeIpcFile(TASKS_DIR, { - type: 'x_reply', - requestId, - tweetUrl: args.tweet_url, - content: args.content, - groupFolder, - timestamp: new Date().toISOString() - }); - - const result = await waitForResult(requestId); - return { - content: [{ type: 'text', text: result.message }], - isError: !result.success - }; - } - ), - - tool( - 'x_retweet', - `Retweet a tweet on X (Twitter). Main group only. - -Provide the tweet URL to retweet.`, - { - tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID') - }, - async (args: { tweet_url: string }) => { - if (!isMain) { - return { - content: [{ type: 'text', text: 'Only the main group can interact with X.' }], - isError: true - }; - } - - const requestId = `xretweet-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - writeIpcFile(TASKS_DIR, { - type: 'x_retweet', - requestId, - tweetUrl: args.tweet_url, - groupFolder, - timestamp: new Date().toISOString() - }); - - const result = await waitForResult(requestId); - return { - content: [{ type: 'text', text: result.message }], - isError: !result.success - }; - } - ), - - tool( - 'x_quote', - `Quote tweet on X (Twitter). Main group only. - -Retweet with your own comment added.`, - { - tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'), - comment: z.string().max(280).describe('Your comment for the quote tweet (max 280 characters)') - }, - async (args: { tweet_url: string; comment: string }) => { - if (!isMain) { - return { - content: [{ type: 'text', text: 'Only the main group can interact with X.' }], - isError: true - }; - } - - const requestId = `xquote-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - writeIpcFile(TASKS_DIR, { - type: 'x_quote', - requestId, - tweetUrl: args.tweet_url, - comment: args.comment, - groupFolder, - timestamp: new Date().toISOString() - }); - - const result = await waitForResult(requestId); - return { - content: [{ type: 'text', text: result.message }], - isError: !result.success - }; - } - ) - ]; -} diff --git a/.claude/skills/x-integration/host.ts b/.claude/skills/x-integration/host.ts deleted file mode 100644 index a56269d..0000000 --- a/.claude/skills/x-integration/host.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * X Integration IPC Handler - * - * Handles all x_* IPC messages from container agents. - * This is the entry point for X integration in the host process. - */ - -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 } } -}); - -interface SkillResult { - success: boolean; - message: string; - data?: unknown; -} - -// Run a skill script as subprocess -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], { - cwd: process.cwd(), - env: { ...process.env, NANOCLAW_ROOT: process.cwd() }, - stdio: ['pipe', 'pipe', 'pipe'] - }); - - let stdout = ''; - proc.stdout.on('data', (data) => { stdout += data.toString(); }); - proc.stdin.write(JSON.stringify(args)); - proc.stdin.end(); - - const timer = setTimeout(() => { - proc.kill('SIGTERM'); - resolve({ success: false, message: 'Script timed out (120s)' }); - }, 120000); - - proc.on('close', (code) => { - clearTimeout(timer); - if (code !== 0) { - resolve({ success: false, message: `Script exited with code: ${code}` }); - return; - } - try { - const lines = stdout.trim().split('\n'); - resolve(JSON.parse(lines[lines.length - 1])); - } catch { - resolve({ success: false, message: `Failed to parse output: ${stdout.slice(0, 200)}` }); - } - }); - - proc.on('error', (err) => { - clearTimeout(timer); - resolve({ success: false, message: `Failed to spawn: ${err.message}` }); - }); - }); -} - -// Write result to IPC results directory -function writeResult(dataDir: string, sourceGroup: string, requestId: string, result: SkillResult): void { - const resultsDir = path.join(dataDir, 'ipc', sourceGroup, 'x_results'); - fs.mkdirSync(resultsDir, { recursive: true }); - fs.writeFileSync(path.join(resultsDir, `${requestId}.json`), JSON.stringify(result)); -} - -/** - * Handle X integration IPC messages - * - * @returns true if message was handled, false if not an X message - */ -export async function handleXIpc( - data: Record, - sourceGroup: string, - isMain: boolean, - dataDir: string -): Promise { - const type = data.type as string; - - // Only handle x_* types - if (!type?.startsWith('x_')) { - return false; - } - - // Only main group can use X integration - if (!isMain) { - logger.warn({ sourceGroup, type }, 'X integration blocked: not main group'); - return true; - } - - const requestId = data.requestId as string; - if (!requestId) { - logger.warn({ type }, 'X integration blocked: missing requestId'); - return true; - } - - logger.info({ type, requestId }, 'Processing X request'); - - let result: SkillResult; - - switch (type) { - case 'x_post': - if (!data.content) { - result = { success: false, message: 'Missing content' }; - break; - } - result = await runScript('post', { content: data.content }); - break; - - case 'x_like': - if (!data.tweetUrl) { - result = { success: false, message: 'Missing tweetUrl' }; - break; - } - result = await runScript('like', { tweetUrl: data.tweetUrl }); - break; - - case 'x_reply': - if (!data.tweetUrl || !data.content) { - result = { success: false, message: 'Missing tweetUrl or content' }; - break; - } - result = await runScript('reply', { tweetUrl: data.tweetUrl, content: data.content }); - break; - - case 'x_retweet': - if (!data.tweetUrl) { - result = { success: false, message: 'Missing tweetUrl' }; - break; - } - result = await runScript('retweet', { tweetUrl: data.tweetUrl }); - break; - - case 'x_quote': - if (!data.tweetUrl || !data.comment) { - result = { success: false, message: 'Missing tweetUrl or comment' }; - break; - } - result = await runScript('quote', { tweetUrl: data.tweetUrl, comment: data.comment }); - break; - - default: - return false; - } - - writeResult(dataDir, sourceGroup, requestId, result); - if (result.success) { - logger.info({ type, requestId }, 'X request completed'); - } else { - logger.error({ type, requestId, message: result.message }, 'X request failed'); - } - return true; -} diff --git a/.claude/skills/x-integration/lib/browser.ts b/.claude/skills/x-integration/lib/browser.ts deleted file mode 100644 index a7868cc..0000000 --- a/.claude/skills/x-integration/lib/browser.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * X Integration - Shared utilities - * Used by all X scripts - */ - -import { chromium, BrowserContext, Page } from 'playwright'; -import fs from 'fs'; -import path from 'path'; -import { config } from './config.js'; - -export { config }; - -export interface ScriptResult { - success: boolean; - message: string; - data?: unknown; -} - -/** - * Read input from stdin - */ -export async function readInput(): Promise { - return new Promise((resolve, reject) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); - process.stdin.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch (err) { - reject(new Error(`Invalid JSON input: ${err}`)); - } - }); - process.stdin.on('error', reject); - }); -} - -/** - * Write result to stdout - */ -export function writeResult(result: ScriptResult): void { - console.log(JSON.stringify(result)); -} - -/** - * Clean up browser lock files - */ -export function cleanupLockFiles(): void { - for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { - const lockPath = path.join(config.browserDataDir, lockFile); - if (fs.existsSync(lockPath)) { - try { fs.unlinkSync(lockPath); } catch {} - } - } -} - -/** - * Validate tweet/reply content - */ -export function validateContent(content: string | undefined, type = 'Tweet'): ScriptResult | null { - if (!content || content.length === 0) { - return { success: false, message: `${type} content cannot be empty` }; - } - if (content.length > config.limits.tweetMaxLength) { - return { success: false, message: `${type} exceeds ${config.limits.tweetMaxLength} character limit (current: ${content.length})` }; - } - return null; // Valid -} - -/** - * Get browser context with persistent profile - */ -export async function getBrowserContext(): Promise { - if (!fs.existsSync(config.authPath)) { - throw new Error('X authentication not configured. Run /x-integration to complete login.'); - } - - cleanupLockFiles(); - - const context = await chromium.launchPersistentContext(config.browserDataDir, { - executablePath: config.chromePath, - headless: false, - viewport: config.viewport, - args: config.chromeArgs, - ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, - }); - - return context; -} - -/** - * Extract tweet ID from URL or raw ID - */ -export function extractTweetId(input: string): string | null { - const urlMatch = input.match(/(?:x\.com|twitter\.com)\/\w+\/status\/(\d+)/); - if (urlMatch) return urlMatch[1]; - if (/^\d+$/.test(input.trim())) return input.trim(); - return null; -} - -/** - * Navigate to a tweet page - */ -export async function navigateToTweet( - context: BrowserContext, - tweetUrl: string -): Promise<{ page: Page; success: boolean; error?: string }> { - const page = context.pages()[0] || await context.newPage(); - - let url = tweetUrl; - const tweetId = extractTweetId(tweetUrl); - if (tweetId && !tweetUrl.startsWith('http')) { - url = `https://x.com/i/status/${tweetId}`; - } - - try { - await page.goto(url, { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(config.timeouts.pageLoad); - - const exists = await page.locator('article[data-testid="tweet"]').first().isVisible().catch(() => false); - if (!exists) { - return { page, success: false, error: 'Tweet not found. It may have been deleted or the URL is invalid.' }; - } - - return { page, success: true }; - } catch (err) { - return { page, success: false, error: `Navigation failed: ${err instanceof Error ? err.message : String(err)}` }; - } -} - -/** - * Run script with error handling - */ -export async function runScript( - handler: (input: T) => Promise -): Promise { - try { - const input = await readInput(); - const result = await handler(input); - writeResult(result); - } catch (err) { - writeResult({ - success: false, - message: `Script execution failed: ${err instanceof Error ? err.message : String(err)}` - }); - process.exit(1); - } -} diff --git a/.claude/skills/x-integration/lib/config.ts b/.claude/skills/x-integration/lib/config.ts deleted file mode 100644 index f56cde4..0000000 --- a/.claude/skills/x-integration/lib/config.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * X Integration - Configuration - * - * All environment-specific settings in one place. - * Override via environment variables or modify defaults here. - */ - -import path from 'path'; - -// Project root - can be overridden for different deployments -const PROJECT_ROOT = process.env.NANOCLAW_ROOT || process.cwd(); - -/** - * Configuration object with all settings - */ -export const config = { - // Chrome executable path - // Default: standard macOS Chrome location - // Override: CHROME_PATH environment variable - chromePath: process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - - // Browser profile directory for persistent login sessions - browserDataDir: path.join(PROJECT_ROOT, 'data', 'x-browser-profile'), - - // Auth state marker file - authPath: path.join(PROJECT_ROOT, 'data', 'x-auth.json'), - - // Browser viewport settings - viewport: { - width: 1280, - height: 800, - }, - - // Timeouts (in milliseconds) - timeouts: { - navigation: 30000, - elementWait: 5000, - afterClick: 1000, - afterFill: 1000, - afterSubmit: 3000, - pageLoad: 3000, - }, - - // X character limits - limits: { - tweetMaxLength: 280, - }, - - // Chrome launch arguments - chromeArgs: [ - '--disable-blink-features=AutomationControlled', - '--no-sandbox', - '--disable-setuid-sandbox', - '--no-first-run', - '--no-default-browser-check', - '--disable-sync', - ], - - // Args to ignore when launching Chrome - chromeIgnoreDefaultArgs: ['--enable-automation'], -}; - diff --git a/.claude/skills/x-integration/scripts/like.ts b/.claude/skills/x-integration/scripts/like.ts deleted file mode 100644 index c55b8b4..0000000 --- a/.claude/skills/x-integration/scripts/like.ts +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * X Integration - Like Tweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx like.ts - */ - -import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; - -interface LikeInput { - tweetUrl: string; -} - -async function likeTweet(input: LikeInput): Promise { - const { tweetUrl } = input; - - if (!tweetUrl) { - return { success: false, message: 'Please provide a tweet URL' }; - } - - let context = null; - try { - context = await getBrowserContext(); - const { page, success, error } = await navigateToTweet(context, tweetUrl); - - if (!success) { - return { success: false, message: error || 'Navigation failed' }; - } - - const tweet = page.locator('article[data-testid="tweet"]').first(); - const unlikeButton = tweet.locator('[data-testid="unlike"]'); - const likeButton = tweet.locator('[data-testid="like"]'); - - // Check if already liked - const alreadyLiked = await unlikeButton.isVisible().catch(() => false); - if (alreadyLiked) { - return { success: true, message: 'Tweet already liked' }; - } - - await likeButton.waitFor({ timeout: config.timeouts.elementWait }); - await likeButton.click(); - await page.waitForTimeout(config.timeouts.afterClick); - - // Verify - const nowLiked = await unlikeButton.isVisible().catch(() => false); - if (nowLiked) { - return { success: true, message: 'Like successful' }; - } - - return { success: false, message: 'Like action completed but could not verify success' }; - - } finally { - if (context) await context.close(); - } -} - -runScript(likeTweet); diff --git a/.claude/skills/x-integration/scripts/post.ts b/.claude/skills/x-integration/scripts/post.ts deleted file mode 100644 index f7b47dc..0000000 --- a/.claude/skills/x-integration/scripts/post.ts +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * X Integration - Post Tweet - * Usage: echo '{"content":"Hello world"}' | npx tsx post.ts - */ - -import { getBrowserContext, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; - -interface PostInput { - content: string; -} - -async function postTweet(input: PostInput): Promise { - const { content } = input; - - const validationError = validateContent(content, 'Tweet'); - if (validationError) return validationError; - - let context = null; - try { - context = await getBrowserContext(); - const page = context.pages()[0] || await context.newPage(); - - await page.goto('https://x.com/home', { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(config.timeouts.pageLoad); - - // Check if logged in - const isLoggedIn = await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').isVisible().catch(() => false); - if (!isLoggedIn) { - const onLoginPage = await page.locator('input[autocomplete="username"]').isVisible().catch(() => false); - if (onLoginPage) { - return { success: false, message: 'X login expired. Run /x-integration to re-authenticate.' }; - } - } - - // Find and fill tweet input - const tweetInput = page.locator('[data-testid="tweetTextarea_0"]'); - await tweetInput.waitFor({ timeout: config.timeouts.elementWait * 2 }); - await tweetInput.click(); - await page.waitForTimeout(config.timeouts.afterClick / 2); - await tweetInput.fill(content); - await page.waitForTimeout(config.timeouts.afterFill); - - // Click post button - const postButton = page.locator('[data-testid="tweetButtonInline"]'); - await postButton.waitFor({ timeout: config.timeouts.elementWait }); - - const isDisabled = await postButton.getAttribute('aria-disabled'); - if (isDisabled === 'true') { - return { success: false, message: 'Post button disabled. Content may be empty or exceed character limit.' }; - } - - await postButton.click(); - await page.waitForTimeout(config.timeouts.afterSubmit); - - return { - success: true, - message: `Tweet posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}` - }; - - } finally { - if (context) await context.close(); - } -} - -runScript(postTweet); diff --git a/.claude/skills/x-integration/scripts/quote.ts b/.claude/skills/x-integration/scripts/quote.ts deleted file mode 100644 index e0d2c33..0000000 --- a/.claude/skills/x-integration/scripts/quote.ts +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * X Integration - Quote Tweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","comment":"My thoughts"}' | npx tsx quote.ts - */ - -import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; - -interface QuoteInput { - tweetUrl: string; - comment: string; -} - -async function quoteTweet(input: QuoteInput): Promise { - const { tweetUrl, comment } = input; - - if (!tweetUrl) { - return { success: false, message: 'Please provide a tweet URL' }; - } - - const validationError = validateContent(comment, 'Comment'); - if (validationError) return validationError; - - let context = null; - try { - context = await getBrowserContext(); - const { page, success, error } = await navigateToTweet(context, tweetUrl); - - if (!success) { - return { success: false, message: error || 'Navigation failed' }; - } - - // Click retweet button to open menu - const tweet = page.locator('article[data-testid="tweet"]').first(); - const retweetButton = tweet.locator('[data-testid="retweet"]'); - await retweetButton.waitFor({ timeout: config.timeouts.elementWait }); - await retweetButton.click(); - await page.waitForTimeout(config.timeouts.afterClick); - - // Click quote option - const quoteOption = page.getByRole('menuitem').filter({ hasText: /Quote/i }); - await quoteOption.waitFor({ timeout: config.timeouts.elementWait }); - await quoteOption.click(); - await page.waitForTimeout(config.timeouts.afterClick * 1.5); - - // Find dialog with aria-modal="true" - const dialog = page.locator('[role="dialog"][aria-modal="true"]'); - await dialog.waitFor({ timeout: config.timeouts.elementWait }); - - // Fill comment - const quoteInput = dialog.locator('[data-testid="tweetTextarea_0"]'); - await quoteInput.waitFor({ timeout: config.timeouts.elementWait }); - await quoteInput.click(); - await page.waitForTimeout(config.timeouts.afterClick / 2); - await quoteInput.fill(comment); - await page.waitForTimeout(config.timeouts.afterFill); - - // Click submit button - const submitButton = dialog.locator('[data-testid="tweetButton"]'); - await submitButton.waitFor({ timeout: config.timeouts.elementWait }); - - const isDisabled = await submitButton.getAttribute('aria-disabled'); - if (isDisabled === 'true') { - return { success: false, message: 'Submit button disabled. Content may be empty or exceed character limit.' }; - } - - await submitButton.click(); - await page.waitForTimeout(config.timeouts.afterSubmit); - - return { - success: true, - message: `Quote tweet posted: ${comment.slice(0, 50)}${comment.length > 50 ? '...' : ''}` - }; - - } finally { - if (context) await context.close(); - } -} - -runScript(quoteTweet); diff --git a/.claude/skills/x-integration/scripts/reply.ts b/.claude/skills/x-integration/scripts/reply.ts deleted file mode 100644 index e981cab..0000000 --- a/.claude/skills/x-integration/scripts/reply.ts +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * X Integration - Reply to Tweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","content":"Great post!"}' | npx tsx reply.ts - */ - -import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; - -interface ReplyInput { - tweetUrl: string; - content: string; -} - -async function replyToTweet(input: ReplyInput): Promise { - const { tweetUrl, content } = input; - - if (!tweetUrl) { - return { success: false, message: 'Please provide a tweet URL' }; - } - - const validationError = validateContent(content, 'Reply'); - if (validationError) return validationError; - - let context = null; - try { - context = await getBrowserContext(); - const { page, success, error } = await navigateToTweet(context, tweetUrl); - - if (!success) { - return { success: false, message: error || 'Navigation failed' }; - } - - // Click reply button - const tweet = page.locator('article[data-testid="tweet"]').first(); - const replyButton = tweet.locator('[data-testid="reply"]'); - await replyButton.waitFor({ timeout: config.timeouts.elementWait }); - await replyButton.click(); - await page.waitForTimeout(config.timeouts.afterClick * 1.5); - - // Find dialog with aria-modal="true" to avoid matching other dialogs - const dialog = page.locator('[role="dialog"][aria-modal="true"]'); - await dialog.waitFor({ timeout: config.timeouts.elementWait }); - - // Fill reply content - const replyInput = dialog.locator('[data-testid="tweetTextarea_0"]'); - await replyInput.waitFor({ timeout: config.timeouts.elementWait }); - await replyInput.click(); - await page.waitForTimeout(config.timeouts.afterClick / 2); - await replyInput.fill(content); - await page.waitForTimeout(config.timeouts.afterFill); - - // Click submit button - const submitButton = dialog.locator('[data-testid="tweetButton"]'); - await submitButton.waitFor({ timeout: config.timeouts.elementWait }); - - const isDisabled = await submitButton.getAttribute('aria-disabled'); - if (isDisabled === 'true') { - return { success: false, message: 'Submit button disabled. Content may be empty or exceed character limit.' }; - } - - await submitButton.click(); - await page.waitForTimeout(config.timeouts.afterSubmit); - - return { - success: true, - message: `Reply posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}` - }; - - } finally { - if (context) await context.close(); - } -} - -runScript(replyToTweet); diff --git a/.claude/skills/x-integration/scripts/retweet.ts b/.claude/skills/x-integration/scripts/retweet.ts deleted file mode 100644 index 05b7437..0000000 --- a/.claude/skills/x-integration/scripts/retweet.ts +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * X Integration - Retweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx retweet.ts - */ - -import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; - -interface RetweetInput { - tweetUrl: string; -} - -async function retweet(input: RetweetInput): Promise { - const { tweetUrl } = input; - - if (!tweetUrl) { - return { success: false, message: 'Please provide a tweet URL' }; - } - - let context = null; - try { - context = await getBrowserContext(); - const { page, success, error } = await navigateToTweet(context, tweetUrl); - - if (!success) { - return { success: false, message: error || 'Navigation failed' }; - } - - const tweet = page.locator('article[data-testid="tweet"]').first(); - const unretweetButton = tweet.locator('[data-testid="unretweet"]'); - const retweetButton = tweet.locator('[data-testid="retweet"]'); - - // Check if already retweeted - const alreadyRetweeted = await unretweetButton.isVisible().catch(() => false); - if (alreadyRetweeted) { - return { success: true, message: 'Tweet already retweeted' }; - } - - await retweetButton.waitFor({ timeout: config.timeouts.elementWait }); - await retweetButton.click(); - await page.waitForTimeout(config.timeouts.afterClick); - - // Click retweet confirm option - const retweetConfirm = page.locator('[data-testid="retweetConfirm"]'); - await retweetConfirm.waitFor({ timeout: config.timeouts.elementWait }); - await retweetConfirm.click(); - await page.waitForTimeout(config.timeouts.afterClick * 2); - - // Verify - const nowRetweeted = await unretweetButton.isVisible().catch(() => false); - if (nowRetweeted) { - return { success: true, message: 'Retweet successful' }; - } - - return { success: false, message: 'Retweet action completed but could not verify success' }; - - } finally { - if (context) await context.close(); - } -} - -runScript(retweet); diff --git a/.claude/skills/x-integration/scripts/setup.ts b/.claude/skills/x-integration/scripts/setup.ts deleted file mode 100644 index 94e5c03..0000000 --- a/.claude/skills/x-integration/scripts/setup.ts +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * X Integration - Authentication Setup - * Usage: npx tsx setup.ts - * - * Interactive script - opens browser for manual login - */ - -import { chromium } from 'playwright'; -import * as readline from 'readline'; -import fs from 'fs'; -import path from 'path'; -import { config, cleanupLockFiles } from '../lib/browser.js'; - -async function setup(): Promise { - console.log('=== X (Twitter) Authentication Setup ===\n'); - console.log('This will open Chrome for you to log in to X.'); - console.log('Your login session will be saved for automated interactions.\n'); - console.log(`Chrome path: ${config.chromePath}`); - console.log(`Profile dir: ${config.browserDataDir}\n`); - - // Ensure directories exist - fs.mkdirSync(path.dirname(config.authPath), { recursive: true }); - fs.mkdirSync(config.browserDataDir, { recursive: true }); - - cleanupLockFiles(); - - console.log('Launching browser...\n'); - - const context = await chromium.launchPersistentContext(config.browserDataDir, { - executablePath: config.chromePath, - headless: false, - viewport: config.viewport, - args: config.chromeArgs.slice(0, 3), // Use first 3 args for setup (less restrictive) - ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, - }); - - const page = context.pages()[0] || await context.newPage(); - - // Navigate to login page - await page.goto('https://x.com/login'); - - console.log('Please log in to X in the browser window.'); - console.log('After you see your home feed, come back here and press Enter.\n'); - - // Wait for user to complete login - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - await new Promise(resolve => { - rl.question('Press Enter when logged in... ', () => { - rl.close(); - resolve(); - }); - }); - - // Verify login by navigating to home and checking for account button - console.log('\nVerifying login status...'); - await page.goto('https://x.com/home'); - await page.waitForTimeout(config.timeouts.pageLoad); - - const isLoggedIn = await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').isVisible().catch(() => false); - - if (isLoggedIn) { - // Save auth marker - fs.writeFileSync(config.authPath, JSON.stringify({ - authenticated: true, - timestamp: new Date().toISOString() - }, null, 2)); - - console.log('\n✅ Authentication successful!'); - console.log(`Session saved to: ${config.browserDataDir}`); - console.log('\nYou can now use X integration features.'); - } else { - console.log('\n❌ Could not verify login status.'); - console.log('Please try again and make sure you are logged in to X.'); - } - - await context.close(); -} - -setup().catch(err => { - console.error('Setup failed:', err.message); - process.exit(1); -}); diff --git a/.gitignore b/.gitignore index f6ab2f3..713bd89 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .npm-cache/ # Build output dist/ +runners/*/dist/ # Local data & auth store/ @@ -24,12 +25,22 @@ groups/global/* # Secrets *.keys.json .env +.env.codex # Temp files .tmp-* +tmp/ +backups/ +dist-backups/ +.deploy-backups/ +cache/ +runners/*/node_modules/ +runners/codex-runner-backups/ +*.bak-* # OS .DS_Store +._* # IDE .idea/ diff --git a/CLAUDE.md b/CLAUDE.md index 44c7ecc..a872827 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/n ## Quick Context -Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups. Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses `codex app-server` via JSON-RPC. Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). +Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups (will be unified — DB supports shared access via WAL mode + service partitioning). Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). ## Key Files @@ -18,8 +18,8 @@ Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but | `src/config.ts` | Trigger pattern, paths, intervals | | `src/task-scheduler.ts` | Runs scheduled tasks | | `src/db.ts` | SQLite operations | -| `container/agent-runner/` | Claude Code runner (Agent SDK) | -| `container/codex-runner/` | Codex runner (app-server JSON-RPC, streaming, turn/steer) | +| `runners/agent-runner/` | Claude Code runner (Agent SDK) | +| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) | | `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | ## Skills @@ -54,19 +54,39 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/` ## Dual-Service Architecture -- `nanoclaw.service` — Claude Code bot (`@claude`), uses `store/`, `data/`, `groups/` -- `nanoclaw-codex.service` — Codex bot (`@codex`), uses `store-codex/`, `data-codex/`, `groups-codex/` -- Both share the same codebase (`dist/index.js`), differentiated by env vars (`NANOCLAW_STORE_DIR`, etc.) -- Channel registration is per-service DB (`registered_groups` table) +- `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code` +- `nanoclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex` +- Both share the same codebase (`dist/index.js`), differentiated by env vars +- Unified dirs (`store/`, `groups/`, `data/` shared by both services): + - `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`) + - `sessions`: composite PK `(group_folder, agent_type)` + - `registered_groups`: filtered by `agent_type` on load + - SQLite WAL mode + `busy_timeout=5000` for concurrent access -## Codex App-Server +## Debugging Paths (Server: clone-ej@100.64.185.108) -Codex runner uses `codex app-server` JSON-RPC (not `codex exec`): -- `thread/start` / `thread/resume` for session persistence (threadId-based) -- `turn/start` for streaming responses (`item/agentMessage/delta`) -- `turn/steer` for mid-execution message injection (IPC polling during turn) -- `approvalPolicy: "never"` + `sandbox: "danger-full-access"` for bypass +Unified DB + directories (both services share `store/`, `groups/`, `data/`): + +| 항목 | 경로 | +|------|------| +| **DB** | `store/messages.db` (공유, WAL 모드) | +| 서비스 로그 (Claude) | `journalctl --user -u nanoclaw -f` 또는 `logs/nanoclaw.log` | +| 서비스 로그 (Codex) | `journalctl --user -u nanoclaw-codex -f` 또는 `logs/nanoclaw-codex.log` | +| 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) | +| Claude 세션 | `data/sessions/{name}/.claude/` | +| Codex 세션 | `data/sessions/{name}/.codex/` | +| Claude 플랫폼 규칙 | `prompts/claude-platform.md` | +| Codex 플랫폼 규칙 | `prompts/codex-platform.md` | +| Claude 글로벌 메모리 | `groups/global/CLAUDE.md` | + +## Codex SDK + +Codex runner uses `@openai/codex-sdk` (wraps `codex exec`): +- `codex.startThread()` / `codex.resumeThread()` for session persistence +- `thread.run(input)` for single-shot turn execution (completes all work before returning) +- `approvalPolicy: "never"` + `sandboxMode: "danger-full-access"` for bypass - Per-group: model (`CODEX_MODEL`), effort (`CODEX_EFFORT`), MCP servers via `config.toml` +- `CODEX_HOME` set to per-group session dir, reads `AGENTS.md` from there + CWD ## Voice Transcription diff --git a/README.md b/README.md index 27aaae1..76d012b 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ nanoclaw/ │ └── channels/ │ ├── registry.ts # Channel self-registration system │ └── discord.ts # Discord: mentions, images, typing, file attachments -├── container/ +├── runners/ │ ├── agent-runner/ # Claude Code runner (Agent SDK, multimodal input) │ ├── codex-runner/ # Codex runner (app-server JSON-RPC, auto-continue) │ └── skills/ # Shared agent skills (browser, etc.) @@ -77,7 +77,7 @@ nanoclaw/ ### Codex App-Server Integration -The Codex runner (`container/codex-runner/`) communicates with `codex app-server` via JSON-RPC over stdio: +The Codex runner (`runners/codex-runner/`) communicates with `codex app-server` via JSON-RPC over stdio: - **Session persistence**: Thread IDs stored in DB, sessions saved as JSONL on disk - **Streaming**: `item/agentMessage/delta` notifications for real-time text @@ -98,19 +98,10 @@ Bidirectional image support through Discord: Skills are managed from a single source of truth (`~/.claude/skills/` on the server) and automatically synced to all agent session directories at process start: -- Claude Code sessions: `~/.claude/skills/` + project `container/skills/` +- Claude Code sessions: `~/.claude/skills/` + project `runners/skills/` - Codex sessions: Same sources, synced to per-group `.codex/` directories - Skills auto-register as slash commands (`/name`) in Claude Code and `$name` in Codex -### OAuth Token Auto-Refresh - -`src/token-refresh.ts` handles Claude Code OAuth token lifecycle: - -- Checks every 5 minutes, refreshes 30 minutes before expiry -- Tries `platform.claude.com` then falls back to `api.anthropic.com` -- Syncs refreshed credentials to all per-group session directories -- Solves the known headless environment token expiry issue - ### GroupQueue `src/group-queue.ts` manages agent execution with: @@ -257,7 +248,7 @@ Fields: | `agent_type` | `claude-code`, `codex`, or `both` | | `trigger_pattern` | Regex for activation (e.g., `@claude`) | | `work_dir` | Optional working directory override | -| `container_config` | Optional JSON (e.g., `{"codexEffort":"high"}`) | +| `agent_config` | Optional JSON (e.g., `{"codexEffort":"high"}`) | ### macOS (launchd) diff --git a/container/codex-runner/package-lock.json b/container/codex-runner/package-lock.json deleted file mode 100644 index 3c1b3f4..0000000 --- a/container/codex-runner/package-lock.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "nanoclaw-codex-runner", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "nanoclaw-codex-runner", - "version": "1.0.0", - "devDependencies": { - "@types/node": "^22.10.7", - "typescript": "^5.7.3" - } - }, - "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "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" - } - } -} diff --git a/container/codex-runner/src/index.ts b/container/codex-runner/src/index.ts deleted file mode 100644 index 67e4634..0000000 --- a/container/codex-runner/src/index.ts +++ /dev/null @@ -1,665 +0,0 @@ -/** - * NanoClaw Codex Runner (app-server mode) - * - * Spawns a single `codex app-server` process and communicates via JSON-RPC - * over stdio. Supports streaming responses, session persistence (threadId), - * and mid-turn message injection via turn/steer. - * - * Input protocol: - * Stdin: Full ContainerInput JSON (read until EOF) - * IPC: Follow-up messages as JSON files in $NANOCLAW_IPC_DIR/input/ - * Sentinel: _close — signals session end - * - * Stdout protocol: - * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. - */ - -import { spawn, ChildProcess } from 'child_process'; -import fs from 'fs'; -import path from 'path'; -import readline from 'readline'; - -// ── Types ────────────────────────────────────────────────────────── - -interface ContainerInput { - prompt: string; - sessionId?: string; // threadId from previous session - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - agentType?: string; -} - -interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface JsonRpcRequest { - method: string; - id?: number; - params?: Record; -} - -interface JsonRpcResponse { - id?: number; - method?: string; - result?: Record; - error?: { code: number; message: string }; - params?: Record; -} - -// ── Constants ────────────────────────────────────────────────────── - -const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; -const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; -const WORK_DIR = process.env.NANOCLAW_WORK_DIR || ''; -const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); -const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); -const IPC_POLL_MS = 500; -const MAX_TURNS = 100; - -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR; -const CODEX_MODEL = process.env.CODEX_MODEL || ''; -const CODEX_EFFORT = process.env.CODEX_EFFORT || ''; - -// ── Helpers ──────────────────────────────────────────────────────── - -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(`[codex-runner] ${message}`); -} - -async function readStdin(): Promise { - return new Promise((resolve, reject) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', (chunk: string) => { data += chunk; }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', reject); - }); -} - -function shouldClose(): boolean { - if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - return true; - } - return false; -} - -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 []; - } -} - -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(); - }); -} - -// ── App-Server Client ────────────────────────────────────────────── - -class CodexAppServer { - private proc: ChildProcess; - private rl: readline.Interface; - private nextId = 1; - private pending = new Map void; - reject: (err: Error) => void; - }>(); - private notificationHandler: ((msg: JsonRpcResponse) => void) | null = null; - private serverRequestHandler: ((msg: JsonRpcResponse) => void) | null = null; - - constructor() { - this.proc = spawn('codex', ['app-server'], { - stdio: ['pipe', 'pipe', 'pipe'], - cwd: EFFECTIVE_CWD, - env: { ...process.env }, - }); - - this.rl = readline.createInterface({ input: this.proc.stdout! }); - - this.rl.on('line', (line: string) => { - if (!line.trim()) return; - try { - const msg: JsonRpcResponse = JSON.parse(line); - this.handleMessage(msg); - } catch { - // Non-JSON output, ignore - } - }); - - this.proc.stderr?.on('data', (data: Buffer) => { - for (const line of data.toString().trim().split('\n')) { - if (line) log(line); - } - }); - - this.proc.on('error', (err: Error) => { - log(`App-server spawn error: ${err.message}`); - // Reject all pending requests - for (const [, { reject }] of this.pending) { - reject(err); - } - this.pending.clear(); - }); - - this.proc.on('close', (code: number | null) => { - log(`App-server exited with code ${code}`); - const err = new Error(`App-server exited with code ${code}`); - for (const [, { reject }] of this.pending) { - reject(err); - } - this.pending.clear(); - }); - } - - private handleMessage(msg: JsonRpcResponse): void { - // Response to a request we made - if (msg.id !== undefined && this.pending.has(msg.id)) { - const handler = this.pending.get(msg.id)!; - this.pending.delete(msg.id); - handler.resolve(msg); - return; - } - - // Server-initiated request (has id + method) — needs a response - if (msg.id !== undefined && msg.method) { - this.serverRequestHandler?.(msg); - return; - } - - // Notification (has method, no id) - if (msg.method) { - this.notificationHandler?.(msg); - } - } - - setNotificationHandler(handler: ((msg: JsonRpcResponse) => void) | null): void { - this.notificationHandler = handler; - } - - setServerRequestHandler(handler: ((msg: JsonRpcResponse) => void) | null): void { - this.serverRequestHandler = handler; - } - - send(msg: JsonRpcRequest): void { - this.proc.stdin!.write(JSON.stringify(msg) + '\n'); - } - - async request(method: string, params: Record = {}, timeoutMs = 30_000): Promise { - const id = this.nextId++; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - this.pending.set(id, { - resolve: (resp) => { - clearTimeout(timer); - resolve(resp); - }, - reject: (err) => { - clearTimeout(timer); - reject(err); - }, - }); - - this.send({ method, id, params }); - }); - } - - respond(id: number, result: Record): void { - this.proc.stdin!.write(JSON.stringify({ id, result }) + '\n'); - } - - async initialize(): Promise { - const resp = await this.request('initialize', { - clientInfo: { name: 'nanoclaw', title: 'NanoClaw Codex', version: '1.0' }, - capabilities: { experimentalApi: false }, - }, 15_000); - - if (resp.error) { - throw new Error(`Initialize failed: ${resp.error.message}`); - } - - // Send initialized notification - this.send({ method: 'initialized' }); - log('App-server initialized'); - } - - async startThread(): Promise { - const params: Record = { - cwd: EFFECTIVE_CWD, - approvalPolicy: 'never', - sandbox: 'danger-full-access', - experimentalRawEvents: false, - persistExtendedHistory: false, - }; - if (CODEX_MODEL) params.model = CODEX_MODEL; - - const resp = await this.request('thread/start', params, 30_000); - if (resp.error) { - throw new Error(`thread/start failed: ${resp.error.message}`); - } - - const thread = resp.result?.thread as Record | undefined; - const threadId = thread?.id as string; - log(`Thread started: ${threadId}`); - return threadId; - } - - async resumeThread(threadId: string): Promise { - const params: Record = { - threadId, - cwd: EFFECTIVE_CWD, - approvalPolicy: 'never', - sandbox: 'danger-full-access', - persistExtendedHistory: false, - }; - if (CODEX_MODEL) params.model = CODEX_MODEL; - - const resp = await this.request('thread/resume', params, 30_000); - if (resp.error) { - // If resume fails (e.g., thread not found), start a new one - log(`thread/resume failed: ${resp.error.message}, starting new thread`); - return this.startThread(); - } - - const thread = resp.result?.thread as Record | undefined; - const actualId = (thread?.id as string) || threadId; - log(`Thread resumed: ${actualId}`); - return actualId; - } - - async startTurn(threadId: string, text: string): Promise { - // Parse [Image: /absolute/path] patterns and convert to multimodal input - const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g; - const input: Array> = []; - const imagePaths: string[] = []; - let match; - while ((match = imagePattern.exec(text)) !== null) { - imagePaths.push(match[1].trim()); - } - // Add text (with image tags stripped) as first input block - const cleanText = text.replace(imagePattern, '').trim(); - if (cleanText) { - input.push({ type: 'text', text: cleanText, text_elements: [] }); - } - // Add image input blocks - for (const imgPath of imagePaths) { - if (fs.existsSync(imgPath)) { - input.push({ type: 'localImage', path: imgPath }); - log(`Adding image input: ${imgPath}`); - } else { - log(`Image not found, skipping: ${imgPath}`); - } - } - if (input.length === 0) { - input.push({ type: 'text', text, text_elements: [] }); - } - - const params: Record = { - threadId, - input, - }; - if (CODEX_EFFORT) params.effort = CODEX_EFFORT; - - const resp = await this.request('turn/start', params, 30_000); - if (resp.error) { - throw new Error(`turn/start failed: ${resp.error.message}`); - } - - const turn = resp.result?.turn as Record | undefined; - const turnId = turn?.id as string; - log(`Turn started: ${turnId}`); - return turnId; - } - - async steerTurn(threadId: string, turnId: string, text: string): Promise { - const resp = await this.request('turn/steer', { - threadId, - input: [{ type: 'text', text, text_elements: [] }], - expectedTurnId: turnId, - }, 10_000); - - if (resp.error) { - log(`turn/steer failed: ${resp.error.message}`); - } - } - - async interruptTurn(threadId: string, turnId: string): Promise { - try { - await this.request('turn/interrupt', { threadId, turnId }, 10_000); - } catch (err) { - log(`turn/interrupt failed: ${err instanceof Error ? err.message : String(err)}`); - } - } - - kill(): void { - try { - this.proc.kill('SIGTERM'); - setTimeout(() => { - if (!this.proc.killed) this.proc.kill('SIGKILL'); - }, 5000); - } catch { /* ignore */ } - } - - get alive(): boolean { - return !this.proc.killed && this.proc.exitCode === null; - } -} - -// ── Turn Execution ───────────────────────────────────────────────── - -/** - * Execute a turn and collect the agent's text response. - * While the turn is running, polls IPC for new messages and injects - * them via turn/steer (mid-execution message injection). - * Returns when turn/completed notification is received. - */ -async function executeTurn( - server: CodexAppServer, - threadId: string, - prompt: string, -): Promise<{ result: string; error?: string; turnId: string }> { - return new Promise((resolve) => { - let agentText = ''; - let turnId = ''; - let resolved = false; - let ipcPollTimer: ReturnType | null = null; - - const cleanup = () => { - server.setNotificationHandler(null); - server.setServerRequestHandler(null); - if (ipcPollTimer) { - clearTimeout(ipcPollTimer); - ipcPollTimer = null; - } - }; - - // Timeout safety (5 minutes per turn) - const timer = setTimeout(() => { - if (!resolved) { - resolved = true; - cleanup(); - log('Turn execution timed out (5min)'); - resolve({ - result: agentText || '', - error: 'Turn timed out after 5 minutes', - turnId, - }); - } - }, 5 * 60 * 1000); - - // IPC polling during turn — steer messages into the running turn - const pollIpcDuringTurn = () => { - if (resolved) return; - - // Check close sentinel - if (shouldClose()) { - log('Close sentinel during turn, interrupting'); - if (turnId) { - server.interruptTurn(threadId, turnId).catch(() => {}); - } - return; - } - - // Check for new messages to steer - const messages = drainIpcInput(); - if (messages.length > 0 && turnId) { - const text = messages.join('\n'); - log(`Steering message into turn (${text.length} chars)`); - server.steerTurn(threadId, turnId, text).catch((err) => { - log(`Steer failed: ${err instanceof Error ? err.message : String(err)}`); - }); - } - - ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS); - }; - - // Handle notifications (streaming events) - server.setNotificationHandler((msg) => { - if (resolved) return; - - switch (msg.method) { - case 'item/agentMessage/delta': { - const delta = (msg.params as Record)?.delta as string; - if (delta) agentText += delta; - break; - } - - case 'item/completed': { - const item = (msg.params as Record)?.item as Record; - if (item?.type === 'agentMessage') { - // Use authoritative text from completed item - const text = item.text as string; - if (text) agentText = text; - } - break; - } - - case 'turn/completed': { - const turn = (msg.params as Record)?.turn as Record; - const status = turn?.status as string; - const error = turn?.error as Record | null; - - clearTimeout(timer); - resolved = true; - cleanup(); - - if (status === 'failed') { - const errMsg = (error?.message as string) || 'Turn failed'; - resolve({ result: agentText || '', error: errMsg, turnId }); - } else { - resolve({ result: agentText, turnId }); - } - break; - } - - case 'turn/started': { - const turn = (msg.params as Record)?.turn as Record; - if (turn?.id) turnId = turn.id as string; - break; - } - } - }); - - // Handle server requests (approval auto-accept) - server.setServerRequestHandler((msg) => { - if (msg.id === undefined) return; - - if (msg.method === 'item/commandExecution/requestApproval' || - msg.method === 'item/fileChange/requestApproval' || - msg.method === 'item/permissions/requestApproval') { - server.respond(msg.id, { decision: 'accept' }); - return; - } - - // Unknown server request — accept generically - server.respond(msg.id, {}); - }); - - // Start IPC polling for mid-turn message injection - ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS); - - // Start the turn - server.startTurn(threadId, prompt) - .then((id) => { turnId = id; }) - .catch((err) => { - if (!resolved) { - clearTimeout(timer); - resolved = true; - cleanup(); - resolve({ - result: '', - error: `Failed to start turn: ${err.message}`, - turnId: '', - }); - } - }); - }); -} - -// ── Main ─────────────────────────────────────────────────────────── - -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); - } - - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - - // Build initial prompt - let prompt = containerInput.prompt; - if (containerInput.isScheduledTask) { - prompt = `[SCHEDULED TASK]\n\n${prompt}`; - } - const pending = drainIpcInput(); - if (pending.length > 0) { - prompt += '\n' + pending.join('\n'); - } - - // Spawn app-server - const server = new CodexAppServer(); - - try { - await server.initialize(); - - // Start or resume thread - const threadId = containerInput.sessionId - ? await server.resumeThread(containerInput.sessionId) - : await server.startThread(); - - let turnCount = 0; - - // Main turn loop - while (true) { - turnCount++; - if (turnCount > MAX_TURNS) { - log(`Turn limit reached (${MAX_TURNS}), exiting`); - writeOutput({ - status: 'success', - result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]', - newSessionId: threadId, - }); - break; - } - - log(`Starting turn ${turnCount}/${MAX_TURNS}...`); - - const { result, error } = await executeTurn(server, threadId, prompt); - - // Check close sentinel - if (shouldClose()) { - if (result) { - writeOutput({ status: 'success', result, newSessionId: threadId }); - } - log('Close sentinel detected, exiting'); - break; - } - - if (error) { - log(`Turn error: ${error}`); - writeOutput({ - status: 'error', - result: result || null, - newSessionId: threadId, - error, - }); - } else { - writeOutput({ - status: 'success', - result: result || null, - newSessionId: threadId, - }); - } - - log('Turn done, waiting for next IPC message...'); - - const nextMessage = await waitForIpcMessage(); - if (nextMessage === null) { - log('Close sentinel received, exiting'); - break; - } - - log(`Got new message (${nextMessage.length} chars)`); - prompt = nextMessage; - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - log(`Runner error: ${errorMessage}`); - writeOutput({ - status: 'error', - result: null, - error: errorMessage, - }); - } finally { - server.kill(); - } -} - -main(); diff --git a/docs/APPLE-CONTAINER-NETWORKING.md b/docs/APPLE-CONTAINER-NETWORKING.md deleted file mode 100644 index 6bde195..0000000 --- a/docs/APPLE-CONTAINER-NETWORKING.md +++ /dev/null @@ -1,90 +0,0 @@ -# Apple Container Networking Setup (macOS 26) - -Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs). - -## Quick Setup - -Run these two commands (requires `sudo`): - -```bash -# 1. Enable IP forwarding so the host routes container traffic -sudo sysctl -w net.inet.ip.forwarding=1 - -# 2. Enable NAT so container traffic gets masqueraded through your internet interface -echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef - -``` - -> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface` - -## Making It Persistent - -These settings reset on reboot. To make them permanent: - -**IP Forwarding** — add to `/etc/sysctl.conf`: -``` -net.inet.ip.forwarding=1 -``` - -**NAT Rules** — add to `/etc/pf.conf` (before any existing rules): -``` -nat on en0 from 192.168.64.0/24 to any -> (en0) -``` - -Then reload: `sudo pfctl -f /etc/pf.conf` - -## IPv6 DNS Issue - -By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail. - -The container image and runner are configured to prefer IPv4 via: -``` -NODE_OPTIONS=--dns-result-order=ipv4first -``` - -This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`. - -## Verification - -```bash -# Check IP forwarding is enabled -sysctl net.inet.ip.forwarding -# Expected: net.inet.ip.forwarding: 1 - -# Test container internet access -container run --rm --entrypoint curl nanoclaw-agent:latest \ - -s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com -# Expected: 404 - -# Check bridge interface (only exists when a container is running) -ifconfig bridge100 -``` - -## Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` | -| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` | -| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules | -| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image | - -## How It Works - -``` -Container VM (192.168.64.x) - │ - ├── eth0 → gateway 192.168.64.1 - │ -bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs - │ - ├── IP forwarding (sysctl) routes packets from bridge100 → en0 - │ - ├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP - │ -en0 (your WiFi/Ethernet) → Internet -``` - -## References - -- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26 -- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building diff --git a/docs/DEBUG_CHECKLIST.md b/docs/DEBUG_CHECKLIST.md deleted file mode 100644 index 5597067..0000000 --- a/docs/DEBUG_CHECKLIST.md +++ /dev/null @@ -1,143 +0,0 @@ -# NanoClaw Debug Checklist - -## Known Issues (2026-02-08) - -### 1. [FIXED] Resume branches from stale tree position -When agent teams spawns subagent CLI processes, they write to the same session JSONL. On subsequent `query()` resumes, the CLI reads the JSONL but may pick a stale branch tip (from before the subagent activity), causing the agent's response to land on a branch the host never receives a `result` for. **Fix**: pass `resumeSessionAt` with the last assistant message UUID to explicitly anchor each resume. - -### 2. IDLE_TIMEOUT == CONTAINER_TIMEOUT (both 30 min) -Both timers fire at the same time, so containers always exit via hard SIGKILL (code 137) instead of graceful `_close` sentinel shutdown. The idle timeout should be shorter (e.g., 5 min) so containers wind down between messages, while container timeout stays at 30 min as a safety net for stuck agents. - -### 3. Cursor advanced before agent succeeds -`processGroupMessages` advances `lastAgentTimestamp` before the agent runs. If the container times out, retries find no messages (cursor already past them). Messages are permanently lost on timeout. - -## Quick Status Check - -```bash -# 1. Is the service running? -launchctl list | grep nanoclaw -# Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed) - -# 2. Any running containers? -container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw - -# 3. Any stopped/orphaned containers? -container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw - -# 4. Recent errors in service log? -grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20 - -# 5. Is WhatsApp connected? (look for last connection event) -grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/nanoclaw.log | tail -5 - -# 6. Are groups loaded? -grep 'groupCount' logs/nanoclaw.log | tail -3 -``` - -## Session Transcript Branching - -```bash -# Check for concurrent CLI processes in session debug logs -ls -la data/sessions//.claude/debug/ - -# Count unique SDK processes that handled messages -# Each .txt file = one CLI subprocess. Multiple = concurrent queries. - -# Check parentUuid branching in transcript -python3 -c " -import json, sys -lines = open('data/sessions//.claude/projects/-workspace-group/.jsonl').read().strip().split('\n') -for i, line in enumerate(lines): - try: - d = json.loads(line) - if d.get('type') == 'user' and d.get('message'): - parent = d.get('parentUuid', 'ROOT')[:8] - content = str(d['message'].get('content', ''))[:60] - print(f'L{i+1} parent={parent} {content}') - except: pass -" -``` - -## Container Timeout Investigation - -```bash -# Check for recent timeouts -grep -E 'Container timeout|timed out' logs/nanoclaw.log | tail -10 - -# Check container log files for the timed-out container -ls -lt groups/*/logs/container-*.log | head -10 - -# Read the most recent container log (replace path) -cat groups//logs/container-.log - -# Check if retries were scheduled and what happened -grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10 -``` - -## Agent Not Responding - -```bash -# Check if messages are being received from WhatsApp -grep 'New messages' logs/nanoclaw.log | tail -10 - -# Check if messages are being processed (container spawned) -grep -E 'Processing messages|Spawning container' logs/nanoclaw.log | tail -10 - -# Check if messages are being piped to active container -grep -E 'Piped messages|sendMessage' logs/nanoclaw.log | tail -10 - -# Check the queue state — any active containers? -grep -E 'Starting container|Container active|concurrency limit' logs/nanoclaw.log | tail -10 - -# Check lastAgentTimestamp vs latest message timestamp -sqlite3 store/messages.db "SELECT chat_jid, MAX(timestamp) as latest FROM messages GROUP BY chat_jid ORDER BY latest DESC LIMIT 5;" -``` - -## Container Mount Issues - -```bash -# Check mount validation logs (shows on container spawn) -grep -E 'Mount validated|Mount.*REJECTED|mount' logs/nanoclaw.log | tail -10 - -# Verify the mount allowlist is readable -cat ~/.config/nanoclaw/mount-allowlist.json - -# Check group's container_config in DB -sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;" - -# Test-run a container to check mounts (dry run) -# Replace with the group's folder name -container run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/ -``` - -## WhatsApp Auth Issues - -```bash -# Check if QR code was requested (means auth expired) -grep 'QR\|authentication required\|qr' logs/nanoclaw.log | tail -5 - -# Check auth files exist -ls -la store/auth/ - -# Re-authenticate if needed -npm run auth -``` - -## Service Management - -```bash -# Restart the service -launchctl kickstart -k gui/$(id -u)/com.nanoclaw - -# View live logs -tail -f logs/nanoclaw.log - -# Stop the service (careful — running containers are detached, not killed) -launchctl bootout gui/$(id -u)/com.nanoclaw - -# Start the service -launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist - -# Rebuild after code changes -npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md deleted file mode 100644 index 227c9ad..0000000 --- a/docs/REQUIREMENTS.md +++ /dev/null @@ -1,196 +0,0 @@ -# NanoClaw Requirements - -Original requirements and design decisions from the project creator. - ---- - -## Why This Exists - -This is a lightweight, secure alternative to OpenClaw (formerly ClawBot). That project became a monstrosity - 4-5 different processes running different gateways, endless configuration files, endless integrations. It's a security nightmare where agents don't run in isolated processes; there's all kinds of leaky workarounds trying to prevent them from accessing parts of the system they shouldn't. It's impossible for anyone to realistically understand the whole codebase. When you run it you're kind of just yoloing it. - -NanoClaw gives you the core functionality without that mess. - ---- - -## Philosophy - -### Small Enough to Understand - -The entire codebase should be something you can read and understand. One Node.js process. A handful of source files. No microservices, no message queues, no abstraction layers. - -### Security Through True Isolation - -Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac. - -### Built for One User - -This isn't a framework or a platform. It's working software for my specific needs. I use WhatsApp and Email, so it supports WhatsApp and Email. I don't use Telegram, so it doesn't support Telegram. I add the integrations I actually want, not every possible integration. - -### Customization = Code Changes - -No configuration sprawl. If you want different behavior, modify the code. The codebase is small enough that this is safe and practical. Very minimal things like the trigger word are in config. Everything else - just change the code to do what you want. - -### AI-Native Development - -I don't need an installation wizard - Claude Code guides the setup. I don't need a monitoring dashboard - I ask Claude Code what's happening. I don't need elaborate logging UIs - I ask Claude to read the logs. I don't need debugging tools - I describe the problem and Claude fixes it. - -The codebase assumes you have an AI collaborator. It doesn't need to be excessively self-documenting or self-debugging because Claude is always there. - -### Skills Over Features - -When people contribute, they shouldn't add "Telegram support alongside WhatsApp." They should contribute a skill like `/add-telegram` that transforms the codebase. Users fork the repo, run skills to customize, and end up with clean code that does exactly what they need - not a bloated system trying to support everyone's use case simultaneously. - ---- - -## RFS (Request for Skills) - -Skills we'd love contributors to build: - -### Communication Channels -Skills to add or switch to different messaging platforms: -- `/add-telegram` - Add Telegram as an input channel -- `/add-slack` - Add Slack as an input channel -- `/add-discord` - Add Discord as an input channel -- `/add-sms` - Add SMS via Twilio or similar -- `/convert-to-telegram` - Replace WhatsApp with Telegram entirely - -### Container Runtime -The project uses Docker by default (cross-platform). For macOS users who prefer Apple Container: -- `/convert-to-apple-container` - Switch from Docker to Apple Container (macOS-only) - -### Platform Support -- `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion) -- `/setup-windows` - Windows support via WSL2 + Docker - ---- - -## Vision - -A personal Claude assistant accessible via WhatsApp, with minimal custom code. - -**Core components:** -- **Claude Agent SDK** as the core agent -- **Containers** for isolated agent execution (Linux VMs) -- **WhatsApp** as the primary I/O channel -- **Persistent memory** per conversation and globally -- **Scheduled tasks** that run Claude and can message back -- **Web access** for search and browsing -- **Browser automation** via agent-browser - -**Implementation approach:** -- Use existing tools (WhatsApp connector, Claude Agent SDK, MCP servers) -- Minimal glue code -- File-based systems where possible (CLAUDE.md for memory, folders for groups) - ---- - -## Architecture Decisions - -### Message Routing -- A router listens to WhatsApp and routes messages based on configuration -- Only messages from registered groups are processed -- Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var -- Unregistered groups are ignored completely - -### Memory System -- **Per-group memory**: Each group has a folder with its own `CLAUDE.md` -- **Global memory**: Root `CLAUDE.md` is read by all groups, but only writable from "main" (self-chat) -- **Files**: Groups can create/read files in their folder and reference them -- Agent runs in the group's folder, automatically inherits both CLAUDE.md files - -### Session Management -- Each group maintains a conversation session (via Claude Agent SDK) -- Sessions auto-compact when context gets too long, preserving critical information - -### Container Isolation -- All agents run inside containers (lightweight Linux VMs) -- Each agent invocation spawns a container with mounted directories -- Containers provide filesystem isolation - agents can only see mounted paths -- Bash access is safe because commands run inside the container, not on the host -- Browser automation via agent-browser with Chromium in the container - -### Scheduled Tasks -- Users can ask Claude to schedule recurring or one-time tasks from any group -- Tasks run as full agents in the context of the group that created them -- Tasks have access to all tools including Bash (safe in container) -- Tasks can optionally send messages to their group via `send_message` tool, or complete silently -- Task runs are logged to the database with duration and result -- Schedule types: cron expressions, intervals (ms), or one-time (ISO timestamp) -- From main: can schedule tasks for any group, view/manage all tasks -- From other groups: can only manage that group's tasks - -### Group Management -- New groups are added explicitly via the main channel -- Groups are registered in SQLite (via the main channel or IPC `register_group` command) -- Each group gets a dedicated folder under `groups/` -- Groups can have additional directories mounted via `containerConfig` - -### Main Channel Privileges -- Main channel is the admin/control group (typically self-chat) -- Can write to global memory (`groups/CLAUDE.md`) -- Can schedule tasks for any group -- Can view and manage tasks from all groups -- Can configure additional directory mounts for any group - ---- - -## Integration Points - -### WhatsApp -- Using baileys library for WhatsApp Web connection -- Messages stored in SQLite, polled by router -- QR code authentication during setup - -### Scheduler -- Built-in scheduler runs on the host, spawns containers for task execution -- Custom `nanoclaw` MCP server (inside container) provides scheduling tools -- Tools: `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`, `send_message` -- Tasks stored in SQLite with run history -- Scheduler loop checks for due tasks every minute -- Tasks execute Claude Agent SDK in containerized group context - -### Web Access -- Built-in WebSearch and WebFetch tools -- Standard Claude Agent SDK capabilities - -### Browser Automation -- agent-browser CLI with Chromium in container -- Snapshot-based interaction with element references (@e1, @e2, etc.) -- Screenshots, PDFs, video recording -- Authentication state persistence - ---- - -## Setup & Customization - -### Philosophy -- Minimal configuration files -- Setup and customization done via Claude Code -- Users clone the repo and run Claude Code to configure -- Each user gets a custom setup matching their exact needs - -### Skills -- `/setup` - Install dependencies, authenticate WhatsApp, configure scheduler, start services -- `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes) -- `/update` - Pull upstream changes, merge with customizations, run migrations - -### Deployment -- Runs on local Mac via launchd -- Single Node.js process handles everything - ---- - -## Personal Configuration (Reference) - -These are the creator's settings, stored here for reference: - -- **Trigger**: `@Andy` (case insensitive) -- **Response prefix**: `Andy:` -- **Persona**: Default Claude (no custom personality) -- **Main channel**: Self-chat (messaging yourself in WhatsApp) - ---- - -## Project Name - -**NanoClaw** - A reference to Clawdbot (now OpenClaw). diff --git a/docs/SDK_DEEP_DIVE.md b/docs/SDK_DEEP_DIVE.md deleted file mode 100644 index c59db7a..0000000 --- a/docs/SDK_DEEP_DIVE.md +++ /dev/null @@ -1,643 +0,0 @@ -# Claude Agent SDK Deep Dive - -Findings from reverse-engineering `@anthropic-ai/claude-agent-sdk` v0.2.29–0.2.34 to understand how `query()` works, why agent teams subagents were being killed, and how to fix it. Supplemented with official SDK reference docs. - -## Architecture - -``` -Agent Runner (our code) - └── query() → SDK (sdk.mjs) - └── spawns CLI subprocess (cli.js) - └── Claude API calls, tool execution - └── Task tool → spawns subagent subprocesses -``` - -The SDK spawns `cli.js` as a child process with `--output-format stream-json --input-format stream-json --print --verbose` flags. Communication happens via JSON-lines on stdin/stdout. - -`query()` returns a `Query` object extending `AsyncGenerator`. Internally: - -- SDK spawns CLI as a child process, communicates via stdin/stdout JSON lines -- SDK's `readMessages()` reads from CLI stdout, enqueues into internal stream -- `readSdkMessages()` async generator yields from that stream -- `[Symbol.asyncIterator]` returns `readSdkMessages()` -- Iterator returns `done: true` only when CLI closes stdout - -Both V1 (`query()`) and V2 (`createSession`/`send`/`stream`) use the exact same three-layer architecture: - -``` -SDK (sdk.mjs) CLI Process (cli.js) --------------- -------------------- -XX Transport ------> stdin reader (bd1) - (spawn cli.js) | -$X Query <------ stdout writer - (JSON-lines) | - EZ() recursive generator - | - Anthropic Messages API -``` - -## The Core Agent Loop (EZ) - -Inside the CLI, the agentic loop is a **recursive async generator called `EZ()`**, not an iterative while loop: - -``` -EZ({ messages, systemPrompt, canUseTool, maxTurns, turnCount=1, ... }) -``` - -Each invocation = one API call to Claude (one "turn"). - -### Flow per turn: - -1. **Prepare messages** — trim context, run compaction if needed -2. **Call the Anthropic API** (via `mW1` streaming function) -3. **Extract tool_use blocks** from the response -4. **Branch:** - - If **no tool_use blocks** → stop (run stop hooks, return) - - If **tool_use blocks present** → execute tools, increment turnCount, recurse - -All complex logic — the agent loop, tool execution, background tasks, teammate orchestration — runs inside the CLI subprocess. `query()` is a thin transport wrapper. - -## query() Options - -Full `Options` type from the official docs: - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `abortController` | `AbortController` | `new AbortController()` | Controller for cancelling operations | -| `additionalDirectories` | `string[]` | `[]` | Additional directories Claude can access | -| `agents` | `Record` | `undefined` | Programmatically define subagents (not agent teams — no orchestration) | -| `allowDangerouslySkipPermissions` | `boolean` | `false` | Required when using `permissionMode: 'bypassPermissions'` | -| `allowedTools` | `string[]` | All tools | List of allowed tool names | -| `betas` | `SdkBeta[]` | `[]` | Beta features (e.g., `['context-1m-2025-08-07']` for 1M context) | -| `canUseTool` | `CanUseTool` | `undefined` | Custom permission function for tool usage | -| `continue` | `boolean` | `false` | Continue the most recent conversation | -| `cwd` | `string` | `process.cwd()` | Current working directory | -| `disallowedTools` | `string[]` | `[]` | List of disallowed tool names | -| `enableFileCheckpointing` | `boolean` | `false` | Enable file change tracking for rewinding | -| `env` | `Dict` | `process.env` | Environment variables | -| `executable` | `'bun' \| 'deno' \| 'node'` | Auto-detected | JavaScript runtime | -| `fallbackModel` | `string` | `undefined` | Model to use if primary fails | -| `forkSession` | `boolean` | `false` | When resuming, fork to a new session ID instead of continuing original | -| `hooks` | `Partial>` | `{}` | Hook callbacks for events | -| `includePartialMessages` | `boolean` | `false` | Include partial message events (streaming) | -| `maxBudgetUsd` | `number` | `undefined` | Maximum budget in USD for the query | -| `maxThinkingTokens` | `number` | `undefined` | Maximum tokens for thinking process | -| `maxTurns` | `number` | `undefined` | Maximum conversation turns | -| `mcpServers` | `Record` | `{}` | MCP server configurations | -| `model` | `string` | Default from CLI | Claude model to use | -| `outputFormat` | `{ type: 'json_schema', schema: JSONSchema }` | `undefined` | Structured output format | -| `pathToClaudeCodeExecutable` | `string` | Uses built-in | Path to Claude Code executable | -| `permissionMode` | `PermissionMode` | `'default'` | Permission mode | -| `plugins` | `SdkPluginConfig[]` | `[]` | Load custom plugins from local paths | -| `resume` | `string` | `undefined` | Session ID to resume | -| `resumeSessionAt` | `string` | `undefined` | Resume session at a specific message UUID | -| `sandbox` | `SandboxSettings` | `undefined` | Sandbox behavior configuration | -| `settingSources` | `SettingSource[]` | `[]` (none) | Which filesystem settings to load. Must include `'project'` to load CLAUDE.md | -| `stderr` | `(data: string) => void` | `undefined` | Callback for stderr output | -| `systemPrompt` | `string \| { type: 'preset'; preset: 'claude_code'; append?: string }` | `undefined` | System prompt. Use preset to get Claude Code's prompt, with optional `append` | -| `tools` | `string[] \| { type: 'preset'; preset: 'claude_code' }` | `undefined` | Tool configuration | - -### PermissionMode - -```typescript -type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; -``` - -### SettingSource - -```typescript -type SettingSource = 'user' | 'project' | 'local'; -// 'user' → ~/.claude/settings.json -// 'project' → .claude/settings.json (version controlled) -// 'local' → .claude/settings.local.json (gitignored) -``` - -When omitted, SDK loads NO filesystem settings (isolation by default). Precedence: local > project > user. Programmatic options always override filesystem settings. - -### AgentDefinition - -Programmatic subagents (NOT agent teams — these are simpler, no inter-agent coordination): - -```typescript -type AgentDefinition = { - description: string; // When to use this agent - tools?: string[]; // Allowed tools (inherits all if omitted) - prompt: string; // Agent's system prompt - model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; -} -``` - -### McpServerConfig - -```typescript -type McpServerConfig = - | { type?: 'stdio'; command: string; args?: string[]; env?: Record } - | { type: 'sse'; url: string; headers?: Record } - | { type: 'http'; url: string; headers?: Record } - | { type: 'sdk'; name: string; instance: McpServer } // in-process -``` - -### SdkBeta - -```typescript -type SdkBeta = 'context-1m-2025-08-07'; -// Enables 1M token context window for Opus 4.6, Sonnet 4.5, Sonnet 4 -``` - -### CanUseTool - -```typescript -type CanUseTool = ( - toolName: string, - input: ToolInput, - options: { signal: AbortSignal; suggestions?: PermissionUpdate[] } -) => Promise; - -type PermissionResult = - | { behavior: 'allow'; updatedInput: ToolInput; updatedPermissions?: PermissionUpdate[] } - | { behavior: 'deny'; message: string; interrupt?: boolean }; -``` - -## SDKMessage Types - -`query()` can yield 16 message types. The official docs show a simplified union of 7, but `sdk.d.ts` has the full set: - -| Type | Subtype | Purpose | -|------|---------|---------| -| `system` | `init` | Session initialized, contains session_id, tools, model | -| `system` | `task_notification` | Background agent completed/failed/stopped | -| `system` | `compact_boundary` | Conversation was compacted | -| `system` | `status` | Status change (e.g. compacting) | -| `system` | `hook_started` | Hook execution started | -| `system` | `hook_progress` | Hook progress output | -| `system` | `hook_response` | Hook completed | -| `system` | `files_persisted` | Files saved | -| `assistant` | — | Claude's response (text + tool calls) | -| `user` | — | User message (internal) | -| `user` (replay) | — | Replayed user message on resume | -| `result` | `success` / `error_*` | Final result of a prompt processing round | -| `stream_event` | — | Partial streaming (when includePartialMessages) | -| `tool_progress` | — | Long-running tool progress | -| `auth_status` | — | Authentication state changes | -| `tool_use_summary` | — | Summary of preceding tool uses | - -### SDKTaskNotificationMessage (sdk.d.ts:1507) - -```typescript -type SDKTaskNotificationMessage = { - type: 'system'; - subtype: 'task_notification'; - task_id: string; - status: 'completed' | 'failed' | 'stopped'; - output_file: string; - summary: string; - uuid: UUID; - session_id: string; -}; -``` - -### SDKResultMessage (sdk.d.ts:1375) - -Two variants with shared fields: - -```typescript -// Shared fields on both variants: -// uuid, session_id, duration_ms, duration_api_ms, is_error, num_turns, -// total_cost_usd, usage: NonNullableUsage, modelUsage, permission_denials - -// Success: -type SDKResultSuccess = { - type: 'result'; - subtype: 'success'; - result: string; - structured_output?: unknown; - // ...shared fields -}; - -// Error: -type SDKResultError = { - type: 'result'; - subtype: 'error_during_execution' | 'error_max_turns' | 'error_max_budget_usd' | 'error_max_structured_output_retries'; - errors: string[]; - // ...shared fields -}; -``` - -Useful fields on result: `total_cost_usd`, `duration_ms`, `num_turns`, `modelUsage` (per-model breakdown with `costUSD`, `inputTokens`, `outputTokens`, `contextWindow`). - -### SDKAssistantMessage - -```typescript -type SDKAssistantMessage = { - type: 'assistant'; - uuid: UUID; - session_id: string; - message: APIAssistantMessage; // From Anthropic SDK - parent_tool_use_id: string | null; // Non-null when from subagent -}; -``` - -### SDKSystemMessage (init) - -```typescript -type SDKSystemMessage = { - type: 'system'; - subtype: 'init'; - uuid: UUID; - session_id: string; - apiKeySource: ApiKeySource; - cwd: string; - tools: string[]; - mcp_servers: { name: string; status: string }[]; - model: string; - permissionMode: PermissionMode; - slash_commands: string[]; - output_style: string; -}; -``` - -## Turn Behavior: When the Agent Stops vs Continues - -### When the Agent STOPS (no more API calls) - -**1. No tool_use blocks in response (THE PRIMARY CASE)** - -Claude responded with text only — it decided it has completed the task. The API's `stop_reason` will be `"end_turn"`. The SDK does NOT make this decision — it's entirely driven by Claude's model output. - -**2. Max turns exceeded** — Results in `SDKResultError` with `subtype: "error_max_turns"`. - -**3. Abort signal** — User interruption via `abortController`. - -**4. Budget exceeded** — `totalCost >= maxBudgetUsd` → `"error_max_budget_usd"`. - -**5. Stop hook prevents continuation** — Hook returns `{preventContinuation: true}`. - -### When the Agent CONTINUES (makes another API call) - -**1. Response contains tool_use blocks (THE PRIMARY CASE)** — Execute tools, increment turnCount, recurse into EZ. - -**2. max_output_tokens recovery** — Up to 3 retries with a "break your work into smaller pieces" context message. - -**3. Stop hook blocking errors** — Errors fed back as context messages, loop continues. - -**4. Model fallback** — Retry with fallback model (one-time). - -### Decision Table - -| Condition | Action | Result Type | -|-----------|--------|-------------| -| Response has `tool_use` blocks | Execute tools, recurse into `EZ` | continues | -| Response has NO `tool_use` blocks | Run stop hooks, return | `success` | -| `turnCount > maxTurns` | Yield max_turns_reached | `error_max_turns` | -| `totalCost >= maxBudgetUsd` | Yield budget error | `error_max_budget_usd` | -| `abortController.signal.aborted` | Yield interrupted msg | depends on context | -| `stop_reason === "max_tokens"` (output) | Retry up to 3x with recovery prompt | continues | -| Stop hook `preventContinuation` | Return immediately | `success` | -| Stop hook blocking error | Feed error back, recurse | continues | -| Model fallback error | Retry with fallback model (one-time) | continues | - -## Subagent Execution Modes - -### Case 1: Synchronous Subagents (`run_in_background: false`) — BLOCKS - -Parent agent calls Task tool → `VR()` runs `EZ()` for subagent → parent waits for full result → tool result returned to parent → parent continues. - -The subagent runs the full recursive EZ loop. The parent's tool execution is suspended via `await`. There is a mid-execution "promotion" mechanism: a synchronous subagent can be promoted to background via `Promise.race()` against a `backgroundSignal` promise. - -### Case 2: Background Tasks (`run_in_background: true`) — DOES NOT WAIT - -- **Bash tool:** Command spawned, tool returns immediately with empty result + `backgroundTaskId` -- **Task/Agent tool:** Subagent launched in fire-and-forget wrapper (`g01()`), tool returns immediately with `status: "async_launched"` + `outputFile` path - -Zero "wait for background tasks" logic before emitting the `type: "result"` message. When a background task completes, an `SDKTaskNotificationMessage` is emitted separately. - -### Case 3: Agent Teams (TeammateTool / SendMessage) — RESULT FIRST, THEN POLLING - -The team leader runs its normal EZ loop, which includes spawning teammates. When the leader's EZ loop finishes, `type: "result"` is emitted. Then the leader enters a post-result polling loop: - -```javascript -while (true) { - // Check if no active teammates AND no running tasks → break - // Check for unread messages from teammates → re-inject as new prompt, restart EZ loop - // If stdin closed with active teammates → inject shutdown prompt - // Poll every 500ms -} -``` - -From the SDK consumer's perspective: you receive the initial `type: "result"`, but the AsyncGenerator may continue yielding more messages as the team leader processes teammate responses and re-enters the agent loop. The generator only truly finishes when all teammates have shut down. - -## The isSingleUserTurn Problem - -From sdk.mjs: - -```javascript -QK = typeof X === "string" // isSingleUserTurn = true when prompt is a string -``` - -When `isSingleUserTurn` is true and the first `result` message arrives: - -```javascript -if (this.isSingleUserTurn) { - this.transport.endInput(); // closes stdin to CLI -} -``` - -This triggers a chain reaction: - -1. SDK closes CLI stdin -2. CLI detects stdin close -3. Polling loop sees `D = true` (stdin closed) with active teammates -4. Injects shutdown prompt → leader sends `shutdown_request` to all teammates -5. **Teammates get killed mid-research** - -The shutdown prompt (found via `BGq` variable in minified cli.js): - -``` -You are running in non-interactive mode and cannot return a response -to the user until your team is shut down. - -You MUST shut down your team before preparing your final response: -1. Use requestShutdown to ask each team member to shut down gracefully -2. Wait for shutdown approvals -3. Use the cleanup operation to clean up the team -4. Only then provide your final response to the user -``` - -### The practical problem - -With V1 `query()` + string prompt + agent teams: - -1. Leader spawns teammates, they start researching -2. Leader's EZ loop ends ("I've dispatched the team, they're working on it") -3. `type: "result"` emitted -4. SDK sees `isSingleUserTurn = true` → closes stdin immediately -5. Polling loop detects stdin closed + active teammates → injects shutdown prompt -6. Leader sends `shutdown_request` to all teammates -7. **Teammates could be 10 seconds into a 5-minute research task and they get told to stop** - -## The Fix: Streaming Input Mode - -Instead of passing a string prompt (which sets `isSingleUserTurn = true`), pass an `AsyncIterable`: - -```typescript -// Before (broken for agent teams): -query({ prompt: "do something" }) - -// After (keeps CLI alive): -query({ prompt: asyncIterableOfMessages }) -``` - -When prompt is an `AsyncIterable`: -- `isSingleUserTurn = false` -- SDK does NOT close stdin after first result -- CLI stays alive, continues processing -- Background agents keep running -- `task_notification` messages flow through the iterator -- We control when to end the iterable - -### Additional Benefit: Streaming New Messages - -With the async iterable approach, we can push new incoming WhatsApp messages into the iterable while the agent is still working. Instead of queuing messages until the container exits and spawning a new container, we stream them directly into the running session. - -### Intended Lifecycle with Agent Teams - -With the async iterable fix (`isSingleUserTurn = false`), stdin stays open so the CLI never hits the teammate check or shutdown prompt injection: - -``` -1. system/init → session initialized -2. assistant/user → Claude reasoning, tool calls, tool results -3. ... → more assistant/user turns (spawning subagents, etc.) -4. result #1 → lead agent's first response (capture) -5. task_notification(s) → background agents complete/fail/stop -6. assistant/user → lead agent continues (processing subagent results) -7. result #2 → lead agent's follow-up response (capture) -8. [iterator done] → CLI closed stdout, all done -``` - -All results are meaningful — capture every one, not just the first. - -## V1 vs V2 API - -### V1: `query()` — One-shot async generator - -```typescript -const q = query({ prompt: "...", options: {...} }); -for await (const msg of q) { /* process events */ } -``` - -- When `prompt` is a string: `isSingleUserTurn = true` → stdin auto-closes after first result -- For multi-turn: must pass an `AsyncIterable` and manage coordination yourself - -### V2: `createSession()` + `send()` / `stream()` — Persistent session - -```typescript -await using session = unstable_v2_createSession({ model: "..." }); -await session.send("first message"); -for await (const msg of session.stream()) { /* events */ } -await session.send("follow-up"); -for await (const msg of session.stream()) { /* events */ } -``` - -- `isSingleUserTurn = false` always → stdin stays open -- `send()` enqueues into an async queue (`QX`) -- `stream()` yields from the same message generator, stopping on `result` type -- Multi-turn is natural — just alternate `send()` / `stream()` -- V2 does NOT call V1 `query()` internally — both independently create Transport + Query - -### Comparison Table - -| Aspect | V1 | V2 | -|--------|----|----| -| `isSingleUserTurn` | `true` for string prompt | always `false` | -| Multi-turn | Requires managing `AsyncIterable` | Just call `send()`/`stream()` | -| stdin lifecycle | Auto-closes after first result | Stays open until `close()` | -| Agentic loop | Identical `EZ()` | Identical `EZ()` | -| Stop conditions | Same | Same | -| Session persistence | Must pass `resume` to new `query()` | Built-in via session object | -| API stability | Stable | Unstable preview (`unstable_v2_*` prefix) | - -**Key finding: Zero difference in turn behavior.** Both use the same CLI process, the same `EZ()` recursive generator, and the same decision logic. - -## Hook Events - -```typescript -type HookEvent = - | 'PreToolUse' // Before tool execution - | 'PostToolUse' // After successful tool execution - | 'PostToolUseFailure' // After failed tool execution - | 'Notification' // Notification messages - | 'UserPromptSubmit' // User prompt submitted - | 'SessionStart' // Session started (startup/resume/clear/compact) - | 'SessionEnd' // Session ended - | 'Stop' // Agent stopping - | 'SubagentStart' // Subagent spawned - | 'SubagentStop' // Subagent stopped - | 'PreCompact' // Before conversation compaction - | 'PermissionRequest'; // Permission being requested -``` - -### Hook Configuration - -```typescript -interface HookCallbackMatcher { - matcher?: string; // Optional tool name matcher - hooks: HookCallback[]; -} - -type HookCallback = ( - input: HookInput, - toolUseID: string | undefined, - options: { signal: AbortSignal } -) => Promise; -``` - -### Hook Return Values - -```typescript -type HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput; - -type AsyncHookJSONOutput = { async: true; asyncTimeout?: number }; - -type SyncHookJSONOutput = { - continue?: boolean; - suppressOutput?: boolean; - stopReason?: string; - decision?: 'approve' | 'block'; - systemMessage?: string; - reason?: string; - hookSpecificOutput?: - | { hookEventName: 'PreToolUse'; permissionDecision?: 'allow' | 'deny' | 'ask'; updatedInput?: Record } - | { hookEventName: 'UserPromptSubmit'; additionalContext?: string } - | { hookEventName: 'SessionStart'; additionalContext?: string } - | { hookEventName: 'PostToolUse'; additionalContext?: string }; -}; -``` - -### Subagent Hooks (from sdk.d.ts) - -```typescript -type SubagentStartHookInput = BaseHookInput & { - hook_event_name: 'SubagentStart'; - agent_id: string; - agent_type: string; -}; - -type SubagentStopHookInput = BaseHookInput & { - hook_event_name: 'SubagentStop'; - stop_hook_active: boolean; - agent_id: string; - agent_transcript_path: string; - agent_type: string; -}; - -// BaseHookInput = { session_id, transcript_path, cwd, permission_mode? } -``` - -## Query Interface Methods - -The `Query` object (sdk.d.ts:931). Official docs list these public methods: - -```typescript -interface Query extends AsyncGenerator { - interrupt(): Promise; // Stop current execution (streaming input mode only) - rewindFiles(userMessageUuid: string): Promise; // Restore files to state at message (needs enableFileCheckpointing) - setPermissionMode(mode: PermissionMode): Promise; // Change permissions (streaming input mode only) - setModel(model?: string): Promise; // Change model (streaming input mode only) - setMaxThinkingTokens(max: number | null): Promise; // Change thinking tokens (streaming input mode only) - supportedCommands(): Promise; // Available slash commands - supportedModels(): Promise; // Available models - mcpServerStatus(): Promise; // MCP server connection status - accountInfo(): Promise; // Authenticated user info -} -``` - -Found in sdk.d.ts but NOT in official docs (may be internal): -- `streamInput(stream)` — stream additional user messages -- `close()` — forcefully end the query -- `setMcpServers(servers)` — dynamically add/remove MCP servers - -## Sandbox Configuration - -```typescript -type SandboxSettings = { - enabled?: boolean; - autoAllowBashIfSandboxed?: boolean; - excludedCommands?: string[]; - allowUnsandboxedCommands?: boolean; - network?: { - allowLocalBinding?: boolean; - allowUnixSockets?: string[]; - allowAllUnixSockets?: boolean; - httpProxyPort?: number; - socksProxyPort?: number; - }; - ignoreViolations?: { - file?: string[]; - network?: string[]; - }; -}; -``` - -When `allowUnsandboxedCommands` is true, the model can set `dangerouslyDisableSandbox: true` in Bash tool input, which falls back to the `canUseTool` permission handler. - -## MCP Server Helpers - -### tool() - -Creates type-safe MCP tool definitions with Zod schemas: - -```typescript -function tool( - name: string, - description: string, - inputSchema: Schema, - handler: (args: z.infer>, extra: unknown) => Promise -): SdkMcpToolDefinition -``` - -### createSdkMcpServer() - -Creates an in-process MCP server (we use stdio instead for subagent inheritance): - -```typescript -function createSdkMcpServer(options: { - name: string; - version?: string; - tools?: Array>; -}): McpSdkServerConfigWithInstance -``` - -## Internals Reference - -### Key minified identifiers (sdk.mjs) - -| Minified | Purpose | -|----------|---------| -| `s_` | V1 `query()` export | -| `e_` | `unstable_v2_createSession` | -| `Xx` | `unstable_v2_resumeSession` | -| `Qx` | `unstable_v2_prompt` | -| `U9` | V2 Session class (`send`/`stream`/`close`) | -| `XX` | ProcessTransport (spawns cli.js) | -| `$X` | Query class (JSON-line routing, async iterable) | -| `QX` | AsyncQueue (input stream buffer) | - -### Key minified identifiers (cli.js) - -| Minified | Purpose | -|----------|---------| -| `EZ` | Core recursive agentic loop (async generator) | -| `_t4` | Stop hook handler (runs when no tool_use blocks) | -| `PU1` | Streaming tool executor (parallel during API response) | -| `TP6` | Standard tool executor (after API response) | -| `GU1` | Individual tool executor | -| `lTq` | SDK session runner (calls EZ directly) | -| `bd1` | stdin reader (JSON-lines from transport) | -| `mW1` | Anthropic API streaming caller | - -## Key Files - -- `sdk.d.ts` — All type definitions (1777 lines) -- `sdk-tools.d.ts` — Tool input schemas -- `sdk.mjs` — SDK runtime (minified, 376KB) -- `cli.js` — CLI executable (minified, runs as subprocess) diff --git a/docs/SECURITY.md b/docs/SECURITY.md deleted file mode 100644 index db6fc18..0000000 --- a/docs/SECURITY.md +++ /dev/null @@ -1,122 +0,0 @@ -# NanoClaw Security Model - -## Trust Model - -| Entity | Trust Level | Rationale | -|--------|-------------|-----------| -| Main group | Trusted | Private self-chat, admin control | -| Non-main groups | Untrusted | Other users may be malicious | -| Container agents | Sandboxed | Isolated execution environment | -| WhatsApp messages | User input | Potential prompt injection | - -## Security Boundaries - -### 1. Container Isolation (Primary Boundary) - -Agents execute in containers (lightweight Linux VMs), providing: -- **Process isolation** - Container processes cannot affect the host -- **Filesystem isolation** - Only explicitly mounted directories are visible -- **Non-root execution** - Runs as unprivileged `node` user (uid 1000) -- **Ephemeral containers** - Fresh environment per invocation (`--rm`) - -This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted. - -### 2. Mount Security - -**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is: -- Outside project root -- Never mounted into containers -- Cannot be modified by agents - -**Default Blocked Patterns:** -``` -.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker, -credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519, -private_key, .secret -``` - -**Protections:** -- Symlink resolution before validation (prevents traversal attacks) -- Container path validation (rejects `..` and absolute paths) -- `nonMainReadOnly` option forces read-only for non-main groups - -**Read-Only Project Root:** - -The main group's project root is mounted read-only. Writable paths the agent needs (group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. - -### 3. Session Isolation - -Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`: -- Groups cannot see other groups' conversation history -- Session data includes full message history and file contents read -- Prevents cross-group information disclosure - -### 4. IPC Authorization - -Messages and task operations are verified against group identity: - -| Operation | Main Group | Non-Main Group | -|-----------|------------|----------------| -| Send message to own chat | ✓ | ✓ | -| Send message to other chats | ✓ | ✗ | -| Schedule task for self | ✓ | ✓ | -| Schedule task for others | ✓ | ✗ | -| View all tasks | ✓ | Own only | -| Manage other groups | ✓ | ✗ | - -### 5. Credential Isolation (Credential Proxy) - -Real API credentials **never enter containers**. Instead, the host runs an HTTP credential proxy that injects authentication headers transparently. - -**How it works:** -1. Host starts a credential proxy on `CREDENTIAL_PROXY_PORT` (default: 3001) -2. Containers receive `ANTHROPIC_BASE_URL=http://host.docker.internal:` and `ANTHROPIC_API_KEY=placeholder` -3. The SDK sends API requests to the proxy with the placeholder key -4. The proxy strips placeholder auth, injects real credentials (`x-api-key` or `Authorization: Bearer`), and forwards to `api.anthropic.com` -5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc` - -**NOT Mounted:** -- WhatsApp session (`store/auth/`) - host only -- Mount allowlist - external, never mounted -- Any credentials matching blocked patterns -- `.env` is shadowed with `/dev/null` in the project root mount - -## Privilege Comparison - -| Capability | Main Group | Non-Main Group | -|------------|------------|----------------| -| Project root access | `/workspace/project` (ro) | None | -| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) | -| Global memory | Implicit via project | `/workspace/global` (ro) | -| Additional mounts | Configurable | Read-only unless allowed | -| Network access | Unrestricted | Unrestricted | -| MCP tools | All | All | - -## Security Architecture Diagram - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ UNTRUSTED ZONE │ -│ WhatsApp Messages (potentially malicious) │ -└────────────────────────────────┬─────────────────────────────────┘ - │ - ▼ Trigger check, input escaping -┌──────────────────────────────────────────────────────────────────┐ -│ HOST PROCESS (TRUSTED) │ -│ • Message routing │ -│ • IPC authorization │ -│ • Mount validation (external allowlist) │ -│ • Container lifecycle │ -│ • Credential proxy (injects auth headers) │ -└────────────────────────────────┬─────────────────────────────────┘ - │ - ▼ Explicit mounts only, no secrets -┌──────────────────────────────────────────────────────────────────┐ -│ CONTAINER (ISOLATED/SANDBOXED) │ -│ • Agent execution │ -│ • Bash commands (sandboxed) │ -│ • File operations (limited to mounts) │ -│ • API calls routed through credential proxy │ -│ • No real credentials in environment or filesystem │ -└──────────────────────────────────────────────────────────────────┘ -``` diff --git a/docs/SPEC.md b/docs/SPEC.md deleted file mode 100644 index d2b4723..0000000 --- a/docs/SPEC.md +++ /dev/null @@ -1,785 +0,0 @@ -# NanoClaw Specification - -A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution. - ---- - -## Table of Contents - -1. [Architecture](#architecture) -2. [Architecture: Channel System](#architecture-channel-system) -3. [Folder Structure](#folder-structure) -4. [Configuration](#configuration) -5. [Memory System](#memory-system) -6. [Session Management](#session-management) -7. [Message Flow](#message-flow) -8. [Commands](#commands) -9. [Scheduled Tasks](#scheduled-tasks) -10. [MCP Servers](#mcp-servers) -11. [Deployment](#deployment) -12. [Security Considerations](#security-considerations) - ---- - -## Architecture - -``` -┌──────────────────────────────────────────────────────────────────────┐ -│ HOST (macOS / Linux) │ -│ (Main Node.js Process) │ -├──────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────┐ ┌────────────────────┐ │ -│ │ Channels │─────────────────▶│ SQLite Database │ │ -│ │ (self-register │◀────────────────│ (messages.db) │ │ -│ │ at startup) │ store/send └─────────┬──────────┘ │ -│ └──────────────────┘ │ │ -│ │ │ -│ ┌─────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │ -│ │ Message Loop │ │ Scheduler Loop │ │ IPC Watcher │ │ -│ │ (polls SQLite) │ │ (checks tasks) │ │ (file-based) │ │ -│ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │ -│ │ │ │ -│ └───────────┬───────────┘ │ -│ │ spawns container │ -│ ▼ │ -├──────────────────────────────────────────────────────────────────────┤ -│ CONTAINER (Linux VM) │ -├──────────────────────────────────────────────────────────────────────┤ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ AGENT RUNNER │ │ -│ │ │ │ -│ │ Working directory: /workspace/group (mounted from host) │ │ -│ │ Volume mounts: │ │ -│ │ • groups/{name}/ → /workspace/group │ │ -│ │ • groups/global/ → /workspace/global/ (non-main only) │ │ -│ │ • data/sessions/{group}/.claude/ → /home/node/.claude/ │ │ -│ │ • Additional dirs → /workspace/extra/* │ │ -│ │ │ │ -│ │ Tools (all groups): │ │ -│ │ • Bash (safe - sandboxed in container!) │ │ -│ │ • Read, Write, Edit, Glob, Grep (file operations) │ │ -│ │ • WebSearch, WebFetch (internet access) │ │ -│ │ • agent-browser (browser automation) │ │ -│ │ • mcp__nanoclaw__* (scheduler tools via IPC) │ │ -│ │ │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -└───────────────────────────────────────────────────────────────────────┘ -``` - -### Technology Stack - -| Component | Technology | Purpose | -|-----------|------------|---------| -| Channel System | Channel registry (`src/channels/registry.ts`) | Channels self-register at startup | -| Message Storage | SQLite (better-sqlite3) | Store messages for polling | -| Container Runtime | Containers (Linux VMs) | Isolated environments for agent execution | -| Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | Run Claude with tools and MCP servers | -| Browser Automation | agent-browser + Chromium | Web interaction and screenshots | -| Runtime | Node.js 20+ | Host process for routing and scheduling | - ---- - -## Architecture: Channel System - -The core ships with no channels built in — each channel (WhatsApp, Telegram, Slack, Discord, Gmail) is installed as a [Claude Code skill](https://code.claude.com/docs/en/skills) that adds the channel code to your fork. Channels self-register at startup; installed channels with missing credentials emit a WARN log and are skipped. - -### System Diagram - -```mermaid -graph LR - subgraph Channels["Channels"] - WA[WhatsApp] - TG[Telegram] - SL[Slack] - DC[Discord] - New["Other Channel (Signal, Gmail...)"] - end - - subgraph Orchestrator["Orchestrator — index.ts"] - ML[Message Loop] - GQ[Group Queue] - RT[Router] - TS[Task Scheduler] - DB[(SQLite)] - end - - subgraph Execution["Container Execution"] - CR[Container Runner] - LC["Linux Container"] - IPC[IPC Watcher] - end - - %% Flow - WA & TG & SL & DC & New -->|onMessage| ML - ML --> GQ - GQ -->|concurrency| CR - CR --> LC - LC -->|filesystem IPC| IPC - IPC -->|tasks & messages| RT - RT -->|Channel.sendMessage| Channels - TS -->|due tasks| CR - - %% DB Connections - DB <--> ML - DB <--> TS - - %% Styling for the dynamic channel - style New stroke-dasharray: 5 5,stroke-width:2px -``` - -### Channel Registry - -The channel system is built on a factory registry in `src/channels/registry.ts`: - -```typescript -export type ChannelFactory = (opts: ChannelOpts) => Channel | null; - -const registry = new Map(); - -export function registerChannel(name: string, factory: ChannelFactory): void { - registry.set(name, factory); -} - -export function getChannelFactory(name: string): ChannelFactory | undefined { - return registry.get(name); -} - -export function getRegisteredChannelNames(): string[] { - return [...registry.keys()]; -} -``` - -Each factory receives `ChannelOpts` (callbacks for `onMessage`, `onChatMetadata`, and `registeredGroups`) and returns either a `Channel` instance or `null` if that channel's credentials are not configured. - -### Channel Interface - -Every channel implements this interface (defined in `src/types.ts`): - -```typescript -interface Channel { - name: string; - connect(): Promise; - sendMessage(jid: string, text: string): Promise; - isConnected(): boolean; - ownsJid(jid: string): boolean; - disconnect(): Promise; - setTyping?(jid: string, isTyping: boolean): Promise; - syncGroups?(force: boolean): Promise; -} -``` - -### Self-Registration Pattern - -Channels self-register using a barrel-import pattern: - -1. Each channel skill adds a file to `src/channels/` (e.g. `whatsapp.ts`, `telegram.ts`) that calls `registerChannel()` at module load time: - - ```typescript - // src/channels/whatsapp.ts - import { registerChannel, ChannelOpts } from './registry.js'; - - export class WhatsAppChannel implements Channel { /* ... */ } - - registerChannel('whatsapp', (opts: ChannelOpts) => { - // Return null if credentials are missing - if (!existsSync(authPath)) return null; - return new WhatsAppChannel(opts); - }); - ``` - -2. The barrel file `src/channels/index.ts` imports all channel modules, triggering registration: - - ```typescript - import './whatsapp.js'; - import './telegram.js'; - // ... each skill adds its import here - ``` - -3. At startup, the orchestrator (`src/index.ts`) loops through registered channels and connects whichever ones return a valid instance: - - ```typescript - for (const name of getRegisteredChannelNames()) { - const factory = getChannelFactory(name); - const channel = factory?.(channelOpts); - if (channel) { - await channel.connect(); - channels.push(channel); - } - } - ``` - -### Key Files - -| File | Purpose | -|------|---------| -| `src/channels/registry.ts` | Channel factory registry | -| `src/channels/index.ts` | Barrel imports that trigger channel self-registration | -| `src/types.ts` | `Channel` interface, `ChannelOpts`, message types | -| `src/index.ts` | Orchestrator — instantiates channels, runs message loop | -| `src/router.ts` | Finds the owning channel for a JID, formats messages | - -### Adding a New Channel - -To add a new channel, contribute a skill to `.claude/skills/add-/` that: - -1. Adds a `src/channels/.ts` file implementing the `Channel` interface -2. Calls `registerChannel(name, factory)` at module load -3. Returns `null` from the factory if credentials are missing -4. Adds an import line to `src/channels/index.ts` - -See existing skills (`/add-whatsapp`, `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail`) for the pattern. - ---- - -## Folder Structure - -``` -nanoclaw/ -├── CLAUDE.md # Project context for Claude Code -├── docs/ -│ ├── SPEC.md # This specification document -│ ├── REQUIREMENTS.md # Architecture decisions -│ └── SECURITY.md # Security model -├── README.md # User documentation -├── package.json # Node.js dependencies -├── tsconfig.json # TypeScript configuration -├── .mcp.json # MCP server configuration (reference) -├── .gitignore -│ -├── src/ -│ ├── index.ts # Orchestrator: state, message loop, agent invocation -│ ├── channels/ -│ │ ├── registry.ts # Channel factory registry -│ │ └── index.ts # Barrel imports for channel self-registration -│ ├── ipc.ts # IPC watcher and task processing -│ ├── router.ts # Message formatting and outbound routing -│ ├── config.ts # Configuration constants -│ ├── types.ts # TypeScript interfaces (includes Channel) -│ ├── logger.ts # Pino logger setup -│ ├── db.ts # SQLite database initialization and queries -│ ├── group-queue.ts # Per-group queue with global concurrency limit -│ ├── mount-security.ts # Mount allowlist validation for containers -│ ├── whatsapp-auth.ts # Standalone WhatsApp authentication -│ ├── task-scheduler.ts # Runs scheduled tasks when due -│ └── container-runner.ts # Spawns agents in containers -│ -├── container/ -│ ├── Dockerfile # Container image (runs as 'node' user, includes Claude Code CLI) -│ ├── build.sh # Build script for container image -│ ├── agent-runner/ # Code that runs inside the container -│ │ ├── package.json -│ │ ├── tsconfig.json -│ │ └── src/ -│ │ ├── index.ts # Entry point (query loop, IPC polling, session resume) -│ │ └── ipc-mcp-stdio.ts # Stdio-based MCP server for host communication -│ └── skills/ -│ └── agent-browser.md # Browser automation skill -│ -├── dist/ # Compiled JavaScript (gitignored) -│ -├── .claude/ -│ └── skills/ -│ ├── setup/SKILL.md # /setup - First-time installation -│ ├── customize/SKILL.md # /customize - Add capabilities -│ ├── debug/SKILL.md # /debug - Container debugging -│ ├── add-telegram/SKILL.md # /add-telegram - Telegram channel -│ ├── add-gmail/SKILL.md # /add-gmail - Gmail integration -│ ├── add-voice-transcription/ # /add-voice-transcription - Whisper -│ ├── x-integration/SKILL.md # /x-integration - X/Twitter -│ ├── convert-to-apple-container/ # /convert-to-apple-container - Apple Container runtime -│ └── add-parallel/SKILL.md # /add-parallel - Parallel agents -│ -├── groups/ -│ ├── CLAUDE.md # Global memory (all groups read this) -│ ├── {channel}_main/ # Main control channel (e.g., whatsapp_main/) -│ │ ├── CLAUDE.md # Main channel memory -│ │ └── logs/ # Task execution logs -│ └── {channel}_{group-name}/ # Per-group folders (created on registration) -│ ├── CLAUDE.md # Group-specific memory -│ ├── logs/ # Task logs for this group -│ └── *.md # Files created by the agent -│ -├── store/ # Local data (gitignored) -│ ├── auth/ # WhatsApp authentication state -│ └── messages.db # SQLite database (messages, chats, scheduled_tasks, task_run_logs, registered_groups, sessions, router_state) -│ -├── data/ # Application state (gitignored) -│ ├── sessions/ # Per-group session data (.claude/ dirs with JSONL transcripts) -│ ├── env/env # Copy of .env for container mounting -│ └── ipc/ # Container IPC (messages/, tasks/) -│ -├── logs/ # Runtime logs (gitignored) -│ ├── nanoclaw.log # Host stdout -│ └── nanoclaw.error.log # Host stderr -│ # Note: Per-container logs are in groups/{folder}/logs/container-*.log -│ -└── launchd/ - └── com.nanoclaw.plist # macOS service configuration -``` - ---- - -## Configuration - -Configuration constants are in `src/config.ts`: - -```typescript -import path from 'path'; - -export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy'; -export const POLL_INTERVAL = 2000; -export const SCHEDULER_POLL_INTERVAL = 60000; - -// Paths are absolute (required for container mounts) -const PROJECT_ROOT = process.cwd(); -export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); -export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); -export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); - -// Container configuration -export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; -export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default -export const IPC_POLL_INTERVAL = 1000; -export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result -export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5); - -export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); -``` - -**Note:** Paths must be absolute for container volume mounts to work correctly. - -### Container Configuration - -Groups can have additional directories mounted via `containerConfig` in the SQLite `registered_groups` table (stored as JSON in the `container_config` column). Example registration: - -```typescript -registerGroup("1234567890@g.us", { - name: "Dev Team", - folder: "whatsapp_dev-team", - trigger: "@Andy", - added_at: new Date().toISOString(), - containerConfig: { - additionalMounts: [ - { - hostPath: "~/projects/webapp", - containerPath: "webapp", - readonly: false, - }, - ], - timeout: 600000, - }, -}); -``` - -Folder names follow the convention `{channel}_{group-name}` (e.g., `whatsapp_family-chat`, `telegram_dev-team`). The main group has `isMain: true` set during registration. - -Additional mounts appear at `/workspace/extra/{containerPath}` inside the container. - -**Mount syntax note:** Read-write mounts use `-v host:container`, but readonly mounts require `--mount "type=bind,source=...,target=...,readonly"` (the `:ro` suffix may not work on all runtimes). - -### Claude Authentication - -Configure authentication in a `.env` file in the project root. Two options: - -**Option 1: Claude Subscription (OAuth token)** -```bash -CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... -``` -The token can be extracted from `~/.claude/.credentials.json` if you're logged in to Claude Code. - -**Option 2: Pay-per-use API Key** -```bash -ANTHROPIC_API_KEY=sk-ant-api03-... -``` - -Only the authentication variables (`CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY`) are extracted from `.env` and written to `data/env/env`, then mounted into the container at `/workspace/env-dir/env` and sourced by the entrypoint script. This ensures other environment variables in `.env` are not exposed to the agent. This workaround is needed because some container runtimes lose `-e` environment variables when using `-i` (interactive mode with piped stdin). - -### Changing the Assistant Name - -Set the `ASSISTANT_NAME` environment variable: - -```bash -ASSISTANT_NAME=Bot npm start -``` - -Or edit the default in `src/config.ts`. This changes: -- The trigger pattern (messages must start with `@YourName`) -- The response prefix (`YourName:` added automatically) - -### Placeholder Values in launchd - -Files with `{{PLACEHOLDER}}` values need to be configured: -- `{{PROJECT_ROOT}}` - Absolute path to your nanoclaw installation -- `{{NODE_PATH}}` - Path to node binary (detected via `which node`) -- `{{HOME}}` - User's home directory - ---- - -## Memory System - -NanoClaw uses a hierarchical memory system based on CLAUDE.md files. - -### Memory Hierarchy - -| Level | Location | Read By | Written By | Purpose | -|-------|----------|---------|------------|---------| -| **Global** | `groups/CLAUDE.md` | All groups | Main only | Preferences, facts, context shared across all conversations | -| **Group** | `groups/{name}/CLAUDE.md` | That group | That group | Group-specific context, conversation memory | -| **Files** | `groups/{name}/*.md` | That group | That group | Notes, research, documents created during conversation | - -### How Memory Works - -1. **Agent Context Loading** - - Agent runs with `cwd` set to `groups/{group-name}/` - - Claude Agent SDK with `settingSources: ['project']` automatically loads: - - `../CLAUDE.md` (parent directory = global memory) - - `./CLAUDE.md` (current directory = group memory) - -2. **Writing Memory** - - When user says "remember this", agent writes to `./CLAUDE.md` - - When user says "remember this globally" (main channel only), agent writes to `../CLAUDE.md` - - Agent can create files like `notes.md`, `research.md` in the group folder - -3. **Main Channel Privileges** - - Only the "main" group (self-chat) can write to global memory - - Main can manage registered groups and schedule tasks for any group - - Main can configure additional directory mounts for any group - - All groups have Bash access (safe because it runs inside container) - ---- - -## Session Management - -Sessions enable conversation continuity - Claude remembers what you talked about. - -### How Sessions Work - -1. Each group has a session ID stored in SQLite (`sessions` table, keyed by `group_folder`) -2. Session ID is passed to Claude Agent SDK's `resume` option -3. Claude continues the conversation with full context -4. Session transcripts are stored as JSONL files in `data/sessions/{group}/.claude/` - ---- - -## Message Flow - -### Incoming Message Flow - -``` -1. User sends a message via any connected channel - │ - ▼ -2. Channel receives message (e.g. Baileys for WhatsApp, Bot API for Telegram) - │ - ▼ -3. Message stored in SQLite (store/messages.db) - │ - ▼ -4. Message loop polls SQLite (every 2 seconds) - │ - ▼ -5. Router checks: - ├── Is chat_jid in registered groups (SQLite)? → No: ignore - └── Does message match trigger pattern? → No: store but don't process - │ - ▼ -6. Router catches up conversation: - ├── Fetch all messages since last agent interaction - ├── Format with timestamp and sender name - └── Build prompt with full conversation context - │ - ▼ -7. Router invokes Claude Agent SDK: - ├── cwd: groups/{group-name}/ - ├── prompt: conversation history + current message - ├── resume: session_id (for continuity) - └── mcpServers: nanoclaw (scheduler) - │ - ▼ -8. Claude processes message: - ├── Reads CLAUDE.md files for context - └── Uses tools as needed (search, email, etc.) - │ - ▼ -9. Router prefixes response with assistant name and sends via the owning channel - │ - ▼ -10. Router updates last agent timestamp and saves session ID -``` - -### Trigger Word Matching - -Messages must start with the trigger pattern (default: `@Andy`): -- `@Andy what's the weather?` → ✅ Triggers Claude -- `@andy help me` → ✅ Triggers (case insensitive) -- `Hey @Andy` → ❌ Ignored (trigger not at start) -- `What's up?` → ❌ Ignored (no trigger) - -### Conversation Catch-Up - -When a triggered message arrives, the agent receives all messages since its last interaction in that chat. Each message is formatted with timestamp and sender name: - -``` -[Jan 31 2:32 PM] John: hey everyone, should we do pizza tonight? -[Jan 31 2:33 PM] Sarah: sounds good to me -[Jan 31 2:35 PM] John: @Andy what toppings do you recommend? -``` - -This allows the agent to understand the conversation context even if it wasn't mentioned in every message. - ---- - -## Commands - -### Commands Available in Any Group - -| Command | Example | Effect | -|---------|---------|--------| -| `@Assistant [message]` | `@Andy what's the weather?` | Talk to Claude | - -### Commands Available in Main Channel Only - -| Command | Example | Effect | -|---------|---------|--------| -| `@Assistant add group "Name"` | `@Andy add group "Family Chat"` | Register a new group | -| `@Assistant remove group "Name"` | `@Andy remove group "Work Team"` | Unregister a group | -| `@Assistant list groups` | `@Andy list groups` | Show registered groups | -| `@Assistant remember [fact]` | `@Andy remember I prefer dark mode` | Add to global memory | - ---- - -## Scheduled Tasks - -NanoClaw has a built-in scheduler that runs tasks as full agents in their group's context. - -### How Scheduling Works - -1. **Group Context**: Tasks created in a group run with that group's working directory and memory -2. **Full Agent Capabilities**: Scheduled tasks have access to all tools (WebSearch, file operations, etc.) -3. **Optional Messaging**: Tasks can send messages to their group using the `send_message` tool, or complete silently -4. **Main Channel Privileges**: The main channel can schedule tasks for any group and view all tasks - -### Schedule Types - -| Type | Value Format | Example | -|------|--------------|---------| -| `cron` | Cron expression | `0 9 * * 1` (Mondays at 9am) | -| `interval` | Milliseconds | `3600000` (every hour) | -| `once` | ISO timestamp | `2024-12-25T09:00:00Z` | - -### Creating a Task - -``` -User: @Andy remind me every Monday at 9am to review the weekly metrics - -Claude: [calls mcp__nanoclaw__schedule_task] - { - "prompt": "Send a reminder to review weekly metrics. Be encouraging!", - "schedule_type": "cron", - "schedule_value": "0 9 * * 1" - } - -Claude: Done! I'll remind you every Monday at 9am. -``` - -### One-Time Tasks - -``` -User: @Andy at 5pm today, send me a summary of today's emails - -Claude: [calls mcp__nanoclaw__schedule_task] - { - "prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.", - "schedule_type": "once", - "schedule_value": "2024-01-31T17:00:00Z" - } -``` - -### Managing Tasks - -From any group: -- `@Andy list my scheduled tasks` - View tasks for this group -- `@Andy pause task [id]` - Pause a task -- `@Andy resume task [id]` - Resume a paused task -- `@Andy cancel task [id]` - Delete a task - -From main channel: -- `@Andy list all tasks` - View tasks from all groups -- `@Andy schedule task for "Family Chat": [prompt]` - Schedule for another group - ---- - -## MCP Servers - -### NanoClaw MCP (built-in) - -The `nanoclaw` MCP server is created dynamically per agent call with the current group's context. - -**Available Tools:** -| Tool | Purpose | -|------|---------| -| `schedule_task` | Schedule a recurring or one-time task | -| `list_tasks` | Show tasks (group's tasks, or all if main) | -| `get_task` | Get task details and run history | -| `update_task` | Modify task prompt or schedule | -| `pause_task` | Pause a task | -| `resume_task` | Resume a paused task | -| `cancel_task` | Delete a task | -| `send_message` | Send a message to the group via its channel | - ---- - -## Deployment - -NanoClaw runs as a single macOS launchd service. - -### Startup Sequence - -When NanoClaw starts, it: -1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs -2. Initializes the SQLite database (migrates from JSON files if they exist) -3. Loads state from SQLite (registered groups, sessions, router state) -4. **Connects channels** — loops through registered channels, instantiates those with credentials, calls `connect()` on each -5. Once at least one channel is connected: - - Starts the scheduler loop - - Starts the IPC watcher for container messages - - Sets up the per-group queue with `processGroupMessages` - - Recovers any unprocessed messages from before shutdown - - Starts the message polling loop - -### Service: com.nanoclaw - -**launchd/com.nanoclaw.plist:** -```xml - - - - - Label - com.nanoclaw - ProgramArguments - - {{NODE_PATH}} - {{PROJECT_ROOT}}/dist/index.js - - WorkingDirectory - {{PROJECT_ROOT}} - RunAtLoad - - KeepAlive - - EnvironmentVariables - - PATH - {{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin - HOME - {{HOME}} - ASSISTANT_NAME - Andy - - StandardOutPath - {{PROJECT_ROOT}}/logs/nanoclaw.log - StandardErrorPath - {{PROJECT_ROOT}}/logs/nanoclaw.error.log - - -``` - -### Managing the Service - -```bash -# Install service -cp launchd/com.nanoclaw.plist ~/Library/LaunchAgents/ - -# Start service -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist - -# Stop service -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist - -# Check status -launchctl list | grep nanoclaw - -# View logs -tail -f logs/nanoclaw.log -``` - ---- - -## Security Considerations - -### Container Isolation - -All agents run inside containers (lightweight Linux VMs), providing: -- **Filesystem isolation**: Agents can only access mounted directories -- **Safe Bash access**: Commands run inside the container, not on your Mac -- **Network isolation**: Can be configured per-container if needed -- **Process isolation**: Container processes can't affect the host -- **Non-root user**: Container runs as unprivileged `node` user (uid 1000) - -### Prompt Injection Risk - -WhatsApp messages could contain malicious instructions attempting to manipulate Claude's behavior. - -**Mitigations:** -- Container isolation limits blast radius -- Only registered groups are processed -- Trigger word required (reduces accidental processing) -- Agents can only access their group's mounted directories -- Main can configure additional directories per group -- Claude's built-in safety training - -**Recommendations:** -- Only register trusted groups -- Review additional directory mounts carefully -- Review scheduled tasks periodically -- Monitor logs for unusual activity - -### Credential Storage - -| Credential | Storage Location | Notes | -|------------|------------------|-------| -| Claude CLI Auth | data/sessions/{group}/.claude/ | Per-group isolation, mounted to /home/node/.claude/ | -| WhatsApp Session | store/auth/ | Auto-created, persists ~20 days | - -### File Permissions - -The groups/ folder contains personal memory and should be protected: -```bash -chmod 700 groups/ -``` - ---- - -## Troubleshooting - -### Common Issues - -| Issue | Cause | Solution | -|-------|-------|----------| -| No response to messages | Service not running | Check `launchctl list | grep nanoclaw` | -| "Claude Code process exited with code 1" | Container runtime failed to start | Check logs; NanoClaw auto-starts container runtime but may fail | -| "Claude Code process exited with code 1" | Session mount path wrong | Ensure mount is to `/home/node/.claude/` not `/root/.claude/` | -| Session not continuing | Session ID not saved | Check SQLite: `sqlite3 store/messages.db "SELECT * FROM sessions"` | -| Session not continuing | Mount path mismatch | Container user is `node` with HOME=/home/node; sessions must be at `/home/node/.claude/` | -| "QR code expired" | WhatsApp session expired | Delete store/auth/ and restart | -| "No groups registered" | Haven't added groups | Use `@Andy add group "Name"` in main | - -### Log Location - -- `logs/nanoclaw.log` - stdout -- `logs/nanoclaw.error.log` - stderr - -### Debug Mode - -Run manually for verbose output: -```bash -npm run dev -# or -node dist/index.js -``` diff --git a/docs/nanoclaw-architecture-final.md b/docs/nanoclaw-architecture-final.md deleted file mode 100644 index 103b38b..0000000 --- a/docs/nanoclaw-architecture-final.md +++ /dev/null @@ -1,1063 +0,0 @@ -# NanoClaw Skills Architecture - -## Core Principle - -Skills are self-contained, auditable packages that apply programmatically via standard git merge mechanics. Claude Code orchestrates the process — running git commands, reading skill manifests, and stepping in only when git can't resolve a conflict on its own. The system uses existing git features (`merge-file`, `rerere`, `apply`) rather than custom merge infrastructure. - -### The Three-Level Resolution Model - -Every operation in the system follows this escalation: - -1. **Git** — deterministic, programmatic. `git merge-file` merges, `git rerere` replays cached resolutions, structured operations apply without merging. No AI involved. This handles the vast majority of cases. -2. **Claude Code** — reads `SKILL.md`, `.intent.md`, migration guides, and `state.yaml` to understand context. Resolves conflicts that git can't handle programmatically. Caches the resolution via `git rerere` so it never needs to resolve the same conflict again. -3. **User** — Claude Code asks the user when it lacks context or intent. This happens when two features genuinely conflict at an application level (not just a text-level merge conflict) and a human decision is needed about desired behavior. - -The goal is that Level 1 handles everything on a mature, well-tested installation. Level 2 handles first-time conflicts and edge cases. Level 3 is rare and only for genuine ambiguity. - -**Important**: a clean merge (exit code 0) does not guarantee working code. Semantic conflicts — a renamed variable, a shifted reference, a changed function signature — can produce clean text merges that break at runtime. **Tests must run after every operation**, regardless of whether the merge was clean. A clean merge with failing tests escalates to Level 2. - -### Safe Operations via Backup/Restore - -Many users clone the repo without forking, don't commit their changes, and don't think of themselves as git users. The system must work safely for them without requiring any git knowledge. - -Before any operation, the system copies all files that will be modified to `.nanoclaw/backup/`. On success, the backup is deleted. On failure, the backup is restored. This provides rollback safety regardless of whether the user commits, pushes, or understands git. - ---- - -## 1. The Shared Base - -`.nanoclaw/base/` holds the clean core — the original codebase before any skills or customizations were applied. This is the stable common ancestor for all three-way merges, and it only changes on core updates. - -- `git merge-file` uses the base to compute two diffs: what the user changed (current vs base) and what the skill wants to change (base vs skill's modified file), then combines both -- The base enables drift detection: if a file's hash differs from its base hash, something has been modified (skills, user customizations, or both) -- Each skill's `modify/` files contain the full file as it should look with that skill applied (including any prerequisite skill changes), all authored against the same clean core base - -On a **fresh codebase**, the user's files are identical to the base. This means `git merge-file` always exits cleanly for the first skill — the merge trivially produces the skill's modified version. No special-casing needed. - -When multiple skills modify the same file, the three-way merge handles the overlap naturally. If Telegram and Discord both modify `src/index.ts`, and both skill files include the Telegram changes, those common changes merge cleanly against the base. The result is the base + all skill changes + user customizations. - ---- - -## 2. Two Types of Changes: Code Merges vs. Structured Operations - -Not all files should be merged as text. The system distinguishes between **code files** (merged via `git merge-file`) and **structured data** (modified via deterministic operations). - -### Code Files (Three-Way Merge) - -Source code files where skills weave in logic — route handlers, middleware, business logic. These are merged using `git merge-file` against the shared base. The skill carries a full modified version of the file. - -### Structured Data (Deterministic Operations) - -Files like `package.json`, `docker-compose.yml`, `.env.example`, and generated configs are not code you merge — they're structured data you aggregate. Multiple skills adding npm dependencies to `package.json` shouldn't require a three-way text merge. Instead, skills declare their structured requirements in the manifest, and the system applies them programmatically. - -**Structured operations are implicit.** If a skill declares `npm_dependencies`, the system handles dependency installation automatically. There is no need for the skill author to add `npm install` to `post_apply`. When multiple skills are applied in sequence, the system batches structured operations: merge all dependency declarations first, write `package.json` once, run `npm install` once at the end. - -```yaml -# In manifest.yaml -structured: - npm_dependencies: - whatsapp-web.js: "^2.1.0" - qrcode-terminal: "^0.12.0" - env_additions: - - WHATSAPP_TOKEN - - WHATSAPP_VERIFY_TOKEN - - WHATSAPP_PHONE_ID - docker_compose_services: - whatsapp-redis: - image: redis:alpine - ports: ["6380:6379"] -``` - -### Structured Operation Conflicts - -Structured operations eliminate text merge conflicts but can still conflict at a semantic level: - -- **NPM version conflicts**: two skills request incompatible semver ranges for the same package -- **Port collisions**: two docker-compose services claim the same host port -- **Service name collisions**: two skills define a service with the same name -- **Env var duplicates**: two skills declare the same variable with different expectations - -The resolution policy: - -1. **Automatic where possible**: widen semver ranges to find a compatible version, detect and flag port/name collisions -2. **Level 2 (Claude Code)**: if automatic resolution fails, Claude proposes options based on skill intents -3. **Level 3 (User)**: if it's a genuine product choice (which Redis instance should get port 6379?), ask the user - -Structured operation conflicts are included in the CI overlap graph alongside code file overlaps, so the maintainer test matrix catches these before users encounter them. - -### State Records Structured Outcomes - -`state.yaml` records not just the declared dependencies but the resolved outcomes — actual installed versions, resolved port assignments, final env var list. This makes structured operations replayable and auditable. - -### Deterministic Serialization - -All structured output (YAML, JSON) uses stable serialization: sorted keys, consistent quoting, normalized whitespace. This prevents noisy diffs in git history from non-functional formatting changes. - ---- - -## 3. Skill Package Structure - -A skill contains only the files it adds or modifies. For modified code files, the skill carries the **full modified file** (the clean core with the skill's changes applied). - -``` -skills/ - add-whatsapp/ - SKILL.md # Context, intent, what this skill does and why - manifest.yaml # Metadata, dependencies, env vars, post-apply steps - tests/ # Integration tests for this skill - whatsapp.test.ts - add/ # New files — copied directly - src/channels/whatsapp.ts - src/channels/whatsapp.config.ts - modify/ # Modified code files — merged via git merge-file - src/ - server.ts # Full file: clean core + whatsapp changes - server.ts.intent.md # "Adds WhatsApp webhook route and message handler" - config.ts # Full file: clean core + whatsapp config options - config.ts.intent.md # "Adds WhatsApp channel configuration block" -``` - -### Why Full Modified Files - -- `git merge-file` requires three full files — no intermediate reconstruction step -- Git's three-way merge uses context matching, so it works even if the user has moved code around — unlike line-number-based diffs that break immediately -- Auditable: `diff .nanoclaw/base/src/server.ts skills/add-whatsapp/modify/src/server.ts` shows exactly what the skill changes -- Deterministic: same three inputs always produce the same merge result -- Size is negligible since NanoClaw's core files are small - -### Intent Files - -Each modified code file has a corresponding `.intent.md` with structured headings: - -```markdown -# Intent: server.ts modifications - -## What this skill adds -Adds WhatsApp webhook route and message handler to the Express server. - -## Key sections -- Route registration at `/webhook/whatsapp` (POST and GET for verification) -- Message handler middleware between auth and response pipeline - -## Invariants -- Must not interfere with other channel webhook routes -- Auth middleware must run before the WhatsApp handler -- Error handling must propagate to the global error handler - -## Must-keep sections -- The webhook verification flow (GET route) is required by WhatsApp Cloud API -``` - -Structured headings (What, Key sections, Invariants, Must-keep) give Claude Code specific guidance during conflict resolution instead of requiring it to infer from unstructured text. - -### Manifest Format - -```yaml -# --- Required fields --- -skill: whatsapp -version: 1.2.0 -description: "WhatsApp Business API integration via Cloud API" -core_version: 0.1.0 # The core version this skill was authored against - -# Files this skill adds -adds: - - src/channels/whatsapp.ts - - src/channels/whatsapp.config.ts - -# Code files this skill modifies (three-way merge) -modifies: - - src/server.ts - - src/config.ts - -# File operations (renames, deletes, moves — see Section 5) -file_ops: [] - -# Structured operations (deterministic, no merge — implicit handling) -structured: - npm_dependencies: - whatsapp-web.js: "^2.1.0" - qrcode-terminal: "^0.12.0" - env_additions: - - WHATSAPP_TOKEN - - WHATSAPP_VERIFY_TOKEN - - WHATSAPP_PHONE_ID - -# Skill relationships -conflicts: [] # Skills that cannot coexist without agent resolution -depends: [] # Skills that must be applied first - -# Test command — runs after apply to validate the skill works -test: "npx vitest run src/channels/whatsapp.test.ts" - -# --- Future fields (not yet implemented in v0.1) --- -# author: nanoclaw-team -# license: MIT -# min_skills_system_version: "0.1.0" -# tested_with: [telegram@1.0.0] -# post_apply: [] -``` - -Note: `post_apply` is only for operations that can't be expressed as structured declarations. Dependency installation is **never** in `post_apply` — it's handled implicitly by the structured operations system. - ---- - -## 4. Skills, Customization, and Layering - -### One Skill, One Happy Path - -A skill implements **one way of doing something — the reasonable default that covers 80% of users.** `add-telegram` gives you a clean, solid Telegram integration. It doesn't try to anticipate every use case with predefined configuration options and modes. - -### Customization Is Just More Patching - -The entire system is built around applying transformations to a codebase. Customizing a skill after applying it is no different from any other modification: - -- **Apply the skill** — get the standard Telegram integration -- **Modify from there** — using the customize flow (tracked patch), direct editing (detected by hash tracking), or by applying additional skills that build on top - -### Layered Skills - -Skills can build on other skills: - -``` -add-telegram # Core Telegram integration (happy path) - ├── telegram-reactions # Adds reaction handling (depends: [telegram]) - ├── telegram-multi-bot # Multiple bot instances (depends: [telegram]) - └── telegram-filters # Custom message filtering (depends: [telegram]) -``` - -Each layer is a separate skill with its own `SKILL.md`, manifest (with `depends: [telegram]`), tests, and modified files. The user composes exactly what they want by stacking skills. - -### Custom Skill Application - -A user can apply a skill with their own modifications in a single step: - -1. Apply the skill normally (programmatic merge) -2. Claude Code asks if the user wants to make any modifications -3. User describes what they want different -4. Claude Code makes the modifications on top of the freshly applied skill -5. The modifications are recorded as a custom patch tied to this skill - -Recorded in `state.yaml`: - -```yaml -applied_skills: - - skill: telegram - version: 1.0.0 - custom_patch: .nanoclaw/custom/telegram-group-only.patch - custom_patch_description: "Restrict bot responses to group chats only" -``` - -On replay, the skill applies programmatically, then the custom patch applies on top. - ---- - -## 5. File Operations: Renames, Deletes, Moves - -Core updates and some skills will need to rename, delete, or move files. These are not text merges — they're structural changes handled as explicit scripted operations. - -### Declaration in Manifest - -```yaml -file_ops: - - type: rename - from: src/server.ts - to: src/app.ts - - type: delete - path: src/deprecated/old-handler.ts - - type: move - from: src/utils/helpers.ts - to: src/lib/helpers.ts -``` - -### Execution Order - -File operations run **before** code merges, because merges need to target the correct file paths: - -1. Pre-flight checks (state validation, core version, dependencies, conflicts, drift detection) -2. Acquire operation lock -3. **Backup** all files that will be touched -4. **File operations** (renames, deletes, moves) -5. Copy new files from `add/` -6. Three-way merge modified code files -7. Conflict resolution (rerere auto-resolve, or return with `backupPending: true`) -8. Apply structured operations (npm deps, env vars, docker-compose — batched) -9. Run `npm install` (once, if any structured npm_dependencies exist) -10. Update state (record skill application, file hashes, structured outcomes) -11. Run tests (if `manifest.test` defined; rollback state + backup on failure) -12. Clean up (delete backup on success, release lock) - -### Path Remapping for Skills - -When the core renames a file (e.g., `server.ts` → `app.ts`), skills authored against the old path still reference `server.ts` in their `modifies` and `modify/` directories. **Skill packages are never mutated on the user's machine.** - -Instead, core updates ship a **compatibility map**: - -```yaml -# In the update package -path_remap: - src/server.ts: src/app.ts - src/old-config.ts: src/config/main.ts -``` - -The system resolves paths at apply time: if a skill targets `src/server.ts` and the remap says it's now `src/app.ts`, the merge runs against `src/app.ts`. The remap is recorded in `state.yaml` so future operations are consistent. - -### Safety Checks - -Before executing file operations: - -- Verify the source file exists -- For deletes: warn if the file has modifications beyond the base (user or skill changes would be lost) - ---- - -## 6. The Apply Flow - -When a user runs the skill's slash command in Claude Code: - -### Step 1: Pre-flight Checks - -- Core version compatibility -- Dependencies satisfied -- No unresolvable conflicts with applied skills -- Check for untracked changes (see Section 9) - -### Step 2: Backup - -Copy all files that will be modified to `.nanoclaw/backup/`. If the operation fails at any point, restore from backup. - -### Step 3: File Operations - -Execute renames, deletes, or moves with safety checks. Apply path remapping if needed. - -### Step 4: Apply New Files - -```bash -cp skills/add-whatsapp/add/src/channels/whatsapp.ts src/channels/whatsapp.ts -``` - -### Step 5: Merge Modified Code Files - -For each file in `modifies` (with path remapping applied): - -```bash -git merge-file src/server.ts .nanoclaw/base/src/server.ts skills/add-whatsapp/modify/src/server.ts -``` - -- **Exit code 0**: clean merge, move on -- **Exit code > 0**: conflict markers in file, proceed to resolution - -### Step 6: Conflict Resolution (Three-Level) - -1. **Check shared resolution cache** (`.nanoclaw/resolutions/`) — load into local `git rerere` if a verified resolution exists for this skill combination. **Only apply if input hashes match exactly** (base hash + current hash + skill modified hash). -2. **`git rerere`** — checks local cache. If found, applied automatically. Done. -3. **Claude Code** — reads conflict markers + `SKILL.md` + `.intent.md` (Invariants, Must-keep sections) of current and previously applied skills. Resolves. `git rerere` caches the resolution. -4. **User** — if Claude Code cannot determine intent, it asks the user for the desired behavior. - -### Step 7: Apply Structured Operations - -Collect all structured declarations (from this skill and any previously applied skills if batching). Apply deterministically: - -- Merge npm dependencies into `package.json` (check for version conflicts) -- Append env vars to `.env.example` -- Merge docker-compose services (check for port/name collisions) -- Run `npm install` **once** at the end -- Record resolved outcomes in state - -### Step 8: Post-Apply and Validate - -1. Run any `post_apply` commands (non-structured operations only) -2. Update `.nanoclaw/state.yaml` — skill record, file hashes (base, skill, merged per file), structured outcomes -3. **Run skill tests** — mandatory, even if all merges were clean -4. If tests fail on a clean merge → escalate to Level 2 (Claude Code diagnoses the semantic conflict) - -### Step 9: Clean Up - -If tests pass, delete `.nanoclaw/backup/`. The operation is complete. - -If tests fail and Level 2 can't resolve, restore from `.nanoclaw/backup/` and report the failure. - ---- - -## 7. Shared Resolution Cache - -### The Problem - -`git rerere` is local by default. But NanoClaw has thousands of users applying the same skill combinations. Every user hitting the same conflict and waiting for Claude Code to resolve it is wasteful. - -### The Solution - -NanoClaw maintains a verified resolution cache in `.nanoclaw/resolutions/` that ships with the project. This is the shared artifact — **not** `.git/rr-cache/`, which stays local. - -``` -.nanoclaw/ - resolutions/ - whatsapp@1.2.0+telegram@1.0.0/ - src/ - server.ts.resolution - server.ts.preimage - config.ts.resolution - config.ts.preimage - meta.yaml -``` - -### Hash Enforcement - -A cached resolution is **only applied if input hashes match exactly**: - -```yaml -# meta.yaml -skills: - - whatsapp@1.2.0 - - telegram@1.0.0 -apply_order: [whatsapp, telegram] -core_version: 0.6.0 -resolved_at: 2026-02-15T10:00:00Z -tested: true -test_passed: true -resolution_source: maintainer -input_hashes: - base: "aaa..." - current_after_whatsapp: "bbb..." - telegram_modified: "ccc..." -output_hash: "ddd..." -``` - -If any input hash doesn't match, the cached resolution is skipped and the system proceeds to Level 2. - -### Validated: rerere + merge-file Require an Index Adapter - -`git rerere` does **not** natively recognize `git merge-file` output. This was validated in Phase 0 testing (`tests/phase0-merge-rerere.sh`, 33 tests). - -The issue is not about conflict marker format — `merge-file` uses filenames as labels (`<<<<<<< current.ts`) while `git merge` uses branch names (`<<<<<<< HEAD`), but rerere strips all labels and hashes only the conflict body. The formats are compatible. - -The actual issue: **rerere requires unmerged index entries** (stages 1/2/3) to detect that a merge conflict exists. A normal `git merge` creates these automatically. `git merge-file` operates on the filesystem only and does not touch the index. - -#### The Adapter - -After `git merge-file` produces a conflict, the system must create the index state that rerere expects: - -```bash -# 1. Run the merge (produces conflict markers in the working tree) -git merge-file current.ts .nanoclaw/base/src/file.ts skills/add-whatsapp/modify/src/file.ts - -# 2. If exit code > 0 (conflict), set up rerere adapter: - -# Create blob objects for the three versions -base_hash=$(git hash-object -w .nanoclaw/base/src/file.ts) -ours_hash=$(git hash-object -w skills/previous-skill/modify/src/file.ts) # or the pre-merge current -theirs_hash=$(git hash-object -w skills/add-whatsapp/modify/src/file.ts) - -# Create unmerged index entries at stages 1 (base), 2 (ours), 3 (theirs) -printf '100644 %s 1\tsrc/file.ts\0' "$base_hash" | git update-index --index-info -printf '100644 %s 2\tsrc/file.ts\0' "$ours_hash" | git update-index --index-info -printf '100644 %s 3\tsrc/file.ts\0' "$theirs_hash" | git update-index --index-info - -# Set merge state (rerere checks for MERGE_HEAD) -echo "$(git rev-parse HEAD)" > .git/MERGE_HEAD -echo "skill merge" > .git/MERGE_MSG - -# 3. Now rerere can see the conflict -git rerere # Records preimage, or auto-resolves from cache - -# 4. After resolution (manual or auto): -git add src/file.ts -git rerere # Records postimage (caches the resolution) - -# 5. Clean up merge state -rm .git/MERGE_HEAD .git/MERGE_MSG -git reset HEAD -``` - -#### Key Properties Validated - -- **Conflict body identity**: `merge-file` and `git merge` produce identical conflict bodies for the same inputs. Rerere hashes the body only, so resolutions learned from either source are interchangeable. -- **Hash determinism**: The same conflict always produces the same rerere hash. This is critical for the shared resolution cache. -- **Resolution portability**: Copying `preimage` and `postimage` files (plus the hash directory name) from one repo's `.git/rr-cache/` to another works. Rerere auto-resolves in the target repo. -- **Adjacent line sensitivity**: Changes within ~3 lines of each other are treated as a single conflict hunk by `merge-file`. Skills that modify the same area of a file will conflict even if they modify different lines. This is expected and handled by the resolution cache. - -#### Implication: Git Repository Required - -The adapter requires `git hash-object`, `git update-index`, and `.git/rr-cache/`. This means the project directory must be a git repository for rerere caching to work. Users who download a zip (no `.git/`) lose resolution caching but not functionality — conflicts escalate directly to Level 2 (Claude Code resolves). The system should detect this case and skip rerere operations gracefully. - -### Maintainer Workflow - -When releasing a core update or new skill version: - -1. Fresh codebase at target core version -2. Apply each official skill individually — verify clean merge, run tests -3. Apply pairwise combinations **for skills that modify at least one common file or have overlapping structured operations** -4. Apply curated three-skill stacks based on popularity and high overlap -5. Resolve all conflicts (code and structured) -6. Record all resolutions with input hashes -7. Run full test suite for every combination -8. Ship verified resolutions with the release - -The bar: **a user with any common combination of official skills should never encounter an unresolved conflict.** - ---- - -## 8. State Tracking - -`.nanoclaw/state.yaml` records everything about the installation: - -```yaml -skills_system_version: "0.1.0" # Schema version — tooling checks this before any operation -core_version: 0.1.0 - -applied_skills: - - name: telegram - version: 1.0.0 - applied_at: 2026-02-16T22:47:02.139Z - file_hashes: - src/channels/telegram.ts: "f627b9cf..." - src/channels/telegram.test.ts: "400116769..." - src/config.ts: "9ae28d1f..." - src/index.ts: "46dbe495..." - src/routing.test.ts: "5e1aede9..." - structured_outcomes: - npm_dependencies: - grammy: "^1.39.3" - env_additions: - - TELEGRAM_BOT_TOKEN - - TELEGRAM_ONLY - test: "npx vitest run src/channels/telegram.test.ts" - - - name: discord - version: 1.0.0 - applied_at: 2026-02-17T17:29:37.821Z - file_hashes: - src/channels/discord.ts: "5d669123..." - src/channels/discord.test.ts: "19e1c6b9..." - src/config.ts: "a0a32df4..." - src/index.ts: "d61e3a9d..." - src/routing.test.ts: "edbacb00..." - structured_outcomes: - npm_dependencies: - discord.js: "^14.18.0" - env_additions: - - DISCORD_BOT_TOKEN - - DISCORD_ONLY - test: "npx vitest run src/channels/discord.test.ts" - -custom_modifications: - - description: "Added custom logging middleware" - applied_at: 2026-02-15T12:00:00Z - files_modified: - - src/server.ts - patch_file: .nanoclaw/custom/001-logging-middleware.patch -``` - -**v0.1 implementation notes:** -- `file_hashes` stores a single SHA-256 hash per file (the final merged result). Three-part hashes (base/skill_modified/merged) are planned for a future version to improve drift diagnosis. -- Applied skills use `name` as the key field (not `skill`), matching the TypeScript `AppliedSkill` interface. -- `structured_outcomes` stores the raw manifest values plus the `test` command. Resolved npm versions (actual installed versions vs semver ranges) are not yet tracked. -- Fields like `installed_at`, `last_updated`, `path_remap`, `rebased_at`, `core_version_at_apply`, `files_added`, and `files_modified` are planned for future versions. - ---- - -## 9. Untracked Changes - -If a user edits files directly, the system detects this via hash comparison. - -### When Detection Happens - -Before **any operation that modifies the codebase**: applying a skill, removing a skill, updating the core, replaying, or rebasing. - -### What Happens - -``` -Detected untracked changes to src/server.ts. -[1] Record these as a custom modification (recommended) -[2] Continue anyway (changes preserved, but not tracked for future replay) -[3] Abort -``` - -The system never blocks or loses work. Option 1 generates a patch and records it, making changes reproducible. Option 2 preserves the changes but they won't survive replay. - -### The Recovery Guarantee - -No matter how much a user modifies their codebase outside the system, the three-level model can always bring them back: - -1. **Git**: diff current files against base, identify what changed -2. **Claude Code**: read `state.yaml` to understand what skills were applied, compare against actual file state, identify discrepancies -3. **User**: Claude Code asks what they intended, what to keep, what to discard - -There is no unrecoverable state. - ---- - -## 10. Core Updates - -Core updates must be as programmatic as possible. The NanoClaw team is responsible for ensuring updates apply cleanly to common skill combinations. - -### Patches and Migrations - -Most core changes — bug fixes, performance improvements, new functionality — propagate automatically through the three-way merge. No special handling needed. - -**Breaking changes** — changed defaults, removed features, functionality moved to skills — require a **migration**. A migration is a skill that preserves the old behavior, authored against the new core. It's applied automatically during the update so the user's setup doesn't change. - -The maintainer's responsibility when making a breaking change: make the change in core, author a migration skill that reverts it, add the entry to `migrations.yaml`, test it. That's the cost of breaking changes. - -### `migrations.yaml` - -An append-only file in the repo root. Each entry records a breaking change and the skill that preserves the old behavior: - -```yaml -- since: 0.6.0 - skill: apple-containers@1.0.0 - description: "Preserves Apple Containers (default changed to Docker in 0.6)" - -- since: 0.7.0 - skill: add-whatsapp@2.0.0 - description: "Preserves WhatsApp (moved from core to skill in 0.7)" - -- since: 0.8.0 - skill: legacy-auth@1.0.0 - description: "Preserves legacy auth module (removed from core in 0.8)" -``` - -Migration skills are regular skills in the `skills/` directory. They have manifests, intent files, tests — everything. They're authored against the **new** core version: the modified file is the new core with the specific breaking change reverted, everything else (bug fixes, new features) identical to the new core. - -### How Migrations Work During Updates - -1. Three-way merge brings in everything from the new core — patches, breaking changes, all of it -2. Conflict resolution (normal) -3. Re-apply custom patches (normal) -4. **Update base to new core** -5. Filter `migrations.yaml` for entries where `since` > user's old `core_version` -6. **Apply each migration skill using the normal apply flow against the new base** -7. Record migration skills in `state.yaml` like any other skill -8. Run tests - -Step 6 is just the same apply function used for any skill. The migration skill merges against the new base: - -- **Base**: new core (e.g., v0.8 with Docker) -- **Current**: user's file after the update merge (new core + user's customizations preserved by the earlier merge) -- **Other**: migration skill's file (new core with Docker reverted to Apple, everything else identical) - -Three-way merge correctly keeps user's customizations, reverts the breaking change, and preserves all bug fixes. If there's a conflict, normal resolution: cache → Claude → user. - -For big version jumps (v0.5 → v0.8), all applicable migrations are applied in sequence. Migration skills are maintained against the latest core version, so they always compose correctly with the current codebase. - -### What the User Sees - -``` -Core updated: 0.5.0 → 0.8.0 - ✓ All patches applied - - Preserving your current setup: - + apple-containers@1.0.0 - + add-whatsapp@2.0.0 - + legacy-auth@1.0.0 - - Skill updates: - ✓ add-telegram 1.0.0 → 1.2.0 - - To accept new defaults: /remove-skill - ✓ All tests passing -``` - -No prompts, no choices during the update. The user's setup doesn't change. If they later want to accept a new default, they remove the migration skill. - -### What the Core Team Ships With an Update - -``` -updates/ - 0.5.0-to-0.6.0/ - migration.md # What changed, why, and how it affects skills - files/ # The new core files - file_ops: # Any renames, deletes, moves - path_remap: # Compatibility map for old skill paths - resolutions/ # Pre-computed resolutions for official skills -``` - -Plus any new migration skills added to `skills/` and entries appended to `migrations.yaml`. - -### The Maintainer's Process - -1. **Make the core change** -2. **If it's a breaking change**: author a migration skill against the new core, add entry to `migrations.yaml` -3. **Write `migration.md`** — what changed, why, what skills might be affected -4. **Test every official skill individually** against the new core (including migration skills) -5. **Test pairwise combinations** for skills that share modified files or structured operations -6. **Test curated three-skill stacks** based on popularity and overlap -7. **Resolve all conflicts** -8. **Record all resolutions** with enforced input hashes -9. **Run full test suites** -10. **Ship everything** — migration guide, migration skills, file ops, path remap, resolutions - -The bar: **patches apply silently. Breaking changes are auto-preserved via migration skills. A user should never be surprised by a change to their working setup.** - -### Update Flow (Full) - -#### Step 1: Pre-flight - -- Check for untracked changes -- Read `state.yaml` -- Load shipped resolutions -- Parse `migrations.yaml`, filter for applicable migrations - -#### Step 2: Preview - -Before modifying anything, show the user what's coming. This uses only git commands — no files are opened or changed: - -```bash -# Compute common base -BASE=$(git merge-base HEAD upstream/$BRANCH) - -# Upstream commits since last sync -git log --oneline $BASE..upstream/$BRANCH - -# Files changed upstream -git diff --name-only $BASE..upstream/$BRANCH -``` - -Present a summary grouped by impact: - -``` -Update available: 0.5.0 → 0.8.0 (12 commits) - - Source: 4 files modified (server.ts, config.ts, ...) - Skills: 2 new skills added, 1 skill updated - Config: package.json, docker-compose.yml updated - - Migrations (auto-applied to preserve your setup): - + apple-containers@1.0.0 (container default changed to Docker) - + add-whatsapp@2.0.0 (WhatsApp moved from core to skill) - - Skill updates: - add-telegram 1.0.0 → 1.2.0 - - [1] Proceed with update - [2] Abort -``` - -If the user aborts, stop here. Nothing was modified. - -#### Step 3: Backup - -Copy all files that will be modified to `.nanoclaw/backup/`. - -#### Step 4: File Operations and Path Remap - -Apply renames, deletes, moves. Record path remap in state. - -#### Step 5: Three-Way Merge - -For each core file that changed: - -```bash -git merge-file src/server.ts .nanoclaw/base/src/server.ts updates/0.5.0-to-0.6.0/files/src/server.ts -``` - -#### Step 6: Conflict Resolution - -1. Shipped resolutions (hash-verified) → automatic -2. `git rerere` local cache → automatic -3. Claude Code with `migration.md` + skill intents → resolves -4. User → only for genuine ambiguity - -#### Step 7: Re-apply Custom Patches - -```bash -git apply --3way .nanoclaw/custom/001-logging-middleware.patch -``` - -Using `--3way` allows git to fall back to three-way merge when line numbers have drifted. If `--3way` fails, escalate to Level 2. - -#### Step 8: Update Base - -`.nanoclaw/base/` replaced with new clean core. This is the **only time** the base changes. - -#### Step 9: Apply Migration Skills - -For each applicable migration (where `since` > old `core_version`), apply the migration skill using the normal apply flow against the new base. Record in `state.yaml`. - -#### Step 10: Re-apply Updated Skills - -Skills live in the repo and update alongside core files. After the update, compare the version in each skill's `manifest.yaml` on disk against the version recorded in `state.yaml`. - -For each skill where the on-disk version is newer than the recorded version: - -1. Re-apply the skill using the normal apply flow against the new base -2. The three-way merge brings in the skill's new changes while preserving user customizations -3. Re-apply any custom patches tied to the skill (`git apply --3way`) -4. Update the version in `state.yaml` - -Skills whose version hasn't changed are skipped — no action needed. - -If the user has a custom patch on a skill that changed significantly, the patch may conflict. Normal resolution: cache → Claude → user. - -#### Step 11: Re-run Structured Operations - -Recompute structured operations against the updated codebase to ensure consistency. - -#### Step 12: Validate - -- Run all skill tests — mandatory -- Compatibility report: - -``` -Core updated: 0.5.0 → 0.8.0 - ✓ All patches applied - - Migrations: - + apple-containers@1.0.0 (preserves container runtime) - + add-whatsapp@2.0.0 (WhatsApp moved to skill) - - Skill updates: - ✓ add-telegram 1.0.0 → 1.2.0 (new features applied) - ✓ custom/telegram-group-only — re-applied cleanly - - ✓ All tests passing -``` - -#### Step 13: Clean Up - -Delete `.nanoclaw/backup/`. - -### Progressive Core Slimming - -Migrations enable a clean path for slimming down the core over time. Each release can move more functionality to skills: - -- The breaking change removes the feature from core -- The migration skill preserves it for existing users -- New users start with a minimal core and add what they need -- Over time, `state.yaml` reflects exactly what each user is running - ---- - -## 11. Skill Removal (Uninstall) - -Removing a skill is not a reverse-patch operation. **Uninstall is a replay without the skill.** - -### How It Works - -1. Read `state.yaml` to get the full list of applied skills and custom modifications -2. Remove the target skill from the list -3. Backup the current codebase to `.nanoclaw/backup/` -4. **Replay from clean base** — apply each remaining skill in order, apply custom patches, using the resolution cache -5. Run all tests -6. If tests pass, delete backup and update `state.yaml` -7. If tests fail, restore from backup and report - -### Custom Patches Tied to the Removed Skill - -If the removed skill has a `custom_patch` in `state.yaml`, the user is warned: - -``` -Removing telegram will also discard custom patch: "Restrict bot responses to group chats only" -[1] Continue (discard custom patch) -[2] Abort -``` - ---- - -## 12. Rebase - -Flatten accumulated layers into a clean starting point. - -### What Rebase Does - -1. Takes the user's current actual files as the new reality -2. Updates `.nanoclaw/base/` to the current core version's clean files -3. For each applied skill, regenerates the modified file diffs against the new base -4. Updates `state.yaml` with `rebased_at` timestamp -5. Clears old custom patches (now baked in) -6. Clears stale resolution cache entries - -### When to Rebase - -- After a major core update -- When accumulated patches become unwieldy -- Before a significant new skill application -- Periodically as maintenance - -### Tradeoffs - -**Lose**: individual skill patch history, ability to cleanly remove a single old skill, old custom patches as separate artifacts - -**Gain**: clean base, simpler future merges, reduced cache size, fresh starting point - ---- - -## 13. Replay - -Given `state.yaml`, reproduce the exact installation on a fresh machine with no AI intervention (assuming all resolutions are cached). - -### Replay Flow - -```bash -# Fully programmatic — no Claude Code needed - -# 1. Install core at specified version -nanoclaw-init --version 0.5.0 - -# 2. Load shared resolutions into local rerere cache -load-resolutions .nanoclaw/resolutions/ - -# 3. For each skill in applied_skills (in order): -for skill in state.applied_skills: - # File operations - apply_file_ops(skill) - - # Copy new files - cp skills/${skill.name}/add/* . - - # Merge modified code files (with path remapping) - for file in skill.files_modified: - resolved_path = apply_remap(file, state.path_remap) - git merge-file ${resolved_path} .nanoclaw/base/${resolved_path} skills/${skill.name}/modify/${file} - # git rerere auto-resolves from shared cache if needed - - # Apply skill-specific custom patch if recorded - if skill.custom_patch: - git apply --3way ${skill.custom_patch} - -# 4. Apply all structured operations (batched) -collect_all_structured_ops(state.applied_skills) -merge_npm_dependencies → write package.json once -npm install once -merge_env_additions → write .env.example once -merge_compose_services → write docker-compose.yml once - -# 5. Apply standalone custom modifications -for custom in state.custom_modifications: - git apply --3way ${custom.patch_file} - -# 6. Run tests and verify hashes -run_tests && verify_hashes -``` - ---- - -## 14. Skill Tests - -Each skill includes integration tests that validate the skill works correctly when applied. - -### Structure - -``` -skills/ - add-whatsapp/ - tests/ - whatsapp.test.ts -``` - -### What Tests Validate - -- **Single skill on fresh core**: apply to clean codebase → tests pass → integration works -- **Skill functionality**: the feature actually works -- **Post-apply state**: files in expected state, `state.yaml` correctly updated - -### When Tests Run (Always) - -- **After applying a skill** — even if all merges were clean -- **After core update** — even if all merges were clean -- **After uninstall replay** — confirms removal didn't break remaining skills -- **In CI** — tests all official skills individually and in common combinations -- **During replay** — validates replayed state - -Clean merge ≠ working code. Tests are the only reliable signal. - -### CI Test Matrix - -Test coverage is **smart, not exhaustive**: - -- Every official skill individually against each supported core version -- **Pairwise combinations for skills that modify at least one common file or have overlapping structured operations** -- Curated three-skill stacks based on popularity and high overlap -- Test matrix auto-generated from manifest `modifies` and `structured` fields - -Each passing combination generates a verified resolution entry for the shared cache. - ---- - -## 15. Project Configuration - -### `.gitattributes` - -Ship with NanoClaw to reduce noisy merge conflicts: - -``` -* text=auto -*.ts text eol=lf -*.json text eol=lf -*.yaml text eol=lf -*.md text eol=lf -``` - ---- - -## 16. Directory Structure - -``` -project/ - src/ # The actual codebase - server.ts - config.ts - channels/ - whatsapp.ts - telegram.ts - skills/ # Skill packages (Claude Code slash commands) - add-whatsapp/ - SKILL.md - manifest.yaml - tests/ - whatsapp.test.ts - add/ - src/channels/whatsapp.ts - modify/ - src/ - server.ts - server.ts.intent.md - config.ts - config.ts.intent.md - add-telegram/ - ... - telegram-reactions/ # Layered skill - ... - .nanoclaw/ - base/ # Clean core (shared base) - src/ - server.ts - config.ts - ... - state.yaml # Full installation state - backup/ # Temporary backup during operations - custom/ # Custom patches - telegram-group-only.patch - 001-logging-middleware.patch - 001-logging-middleware.md - resolutions/ # Shared verified resolution cache - whatsapp@1.2.0+telegram@1.0.0/ - src/ - server.ts.resolution - server.ts.preimage - meta.yaml - .gitattributes -``` - ---- - -## 17. Design Principles - -1. **Use git, don't reinvent it.** `git merge-file` for code merges, `git rerere` for caching resolutions, `git apply --3way` for custom patches. -2. **Three-level resolution: git → Claude → user.** Programmatic first, AI second, human third. -3. **Clean merges aren't enough.** Tests run after every operation. Semantic conflicts survive text merges. -4. **All operations are safe.** Backup before, restore on failure. No half-applied state. -5. **One shared base.** `.nanoclaw/base/` is the clean core before any skills or customizations. It's the stable common ancestor for all three-way merges. Only updated on core updates. -6. **Code merges vs. structured operations.** Source code is three-way merged. Dependencies, env vars, and configs are aggregated programmatically. Structured operations are implicit and batched. -7. **Resolutions are learned and shared.** Maintainers resolve conflicts and ship verified resolutions with hash enforcement. `.nanoclaw/resolutions/` is the shared artifact. -8. **One skill, one happy path.** No predefined configuration options. Customization is more patching. -9. **Skills layer and compose.** Core skills provide the foundation. Extension skills add capabilities. -10. **Intent is first-class and structured.** `SKILL.md`, `.intent.md` (What, Invariants, Must-keep), and `migration.md`. -11. **State is explicit and complete.** Skills, custom patches, per-file hashes, structured outcomes, path remaps. Replay is deterministic. Drift is instant to detect. -12. **Always recoverable.** The three-level model reconstructs coherent state from any starting point. -13. **Uninstall is replay.** Replay from clean base without the skill. Backup for safety. -14. **Core updates are the maintainers' responsibility.** Test, resolve, ship. Breaking changes require a migration skill that preserves the old behavior. The cost of a breaking change is authoring and testing the migration. Users should never be surprised by a change to their setup. -15. **File operations and path remapping are first-class.** Renames, deletes, moves in manifests. Skills are never mutated — paths resolve at apply time. -16. **Skills are tested.** Integration tests per skill. CI tests pairwise by overlap. Tests run always. -17. **Deterministic serialization.** Sorted keys, consistent formatting. No noisy diffs. -18. **Rebase when needed.** Flatten layers for a clean starting point. -19. **Progressive core slimming.** Breaking changes move functionality from core to migration skills. Existing users keep what they have automatically. New users start minimal and add what they need. \ No newline at end of file diff --git a/docs/nanorepo-architecture.md b/docs/nanorepo-architecture.md deleted file mode 100644 index 1365e9e..0000000 --- a/docs/nanorepo-architecture.md +++ /dev/null @@ -1,168 +0,0 @@ -# NanoClaw Skills Architecture - -## What Skills Are For - -NanoClaw's core is intentionally minimal. Skills are how users extend it: adding channels, integrations, cross-platform support, or replacing internals entirely. Examples: add Telegram alongside WhatsApp, switch from Apple Container to Docker, add Gmail integration, add voice message transcription. Each skill modifies the actual codebase, adding channel handlers, updating the message router, changing container configuration, and adding dependencies, rather than working through a plugin API or runtime hooks. - -## Why This Architecture - -The problem: users need to combine multiple modifications to a shared codebase, keep those modifications working across core updates, and do all of this without becoming git experts or losing their custom changes. A plugin system would be simpler but constrains what skills can do. Giving skills full codebase access means they can change anything, but that creates merge conflicts, update breakage, and state tracking challenges. - -This architecture solves that by making skill application fully programmatic using standard git mechanics, with AI as a fallback for conflicts git can't resolve, and a shared resolution cache so most users never hit those conflicts at all. The result: users compose exactly the features they want, customizations survive core updates automatically, and the system is always recoverable. - -## Core Principle - -Skills are self-contained, auditable packages applied via standard git merge mechanics. Claude Code orchestrates the process — running git commands, reading skill manifests, and stepping in only when git can't resolve a conflict. The system uses existing git features (`merge-file`, `rerere`, `apply`) rather than custom merge infrastructure. - -## Three-Level Resolution Model - -Every operation follows this escalation: - -1. **Git** — deterministic. `git merge-file` merges, `git rerere` replays cached resolutions, structured operations apply without merging. No AI. Handles the vast majority of cases. -2. **Claude Code** — reads `SKILL.md`, `.intent.md`, and `state.yaml` to resolve conflicts git can't handle. Caches resolutions via `git rerere` so the same conflict never needs resolving twice. -3. **Claude Code + user input** — when Claude Code lacks sufficient context to determine intent (e.g., two features genuinely conflict at an application level), it asks the user for a decision, then uses that input to perform the resolution. Claude Code still does the work — the user provides direction, not code. - -**Important**: A clean merge doesn't guarantee working code. Semantic conflicts can produce clean text merges that break at runtime. **Tests run after every operation.** - -## Backup/Restore Safety - -Before any operation, all affected files are copied to `.nanoclaw/backup/`. On success, backup is deleted. On failure, backup is restored. Works safely for users who don't use git. - -## The Shared Base - -`.nanoclaw/base/` holds a clean copy of the core codebase. This is the single common ancestor for all three-way merges, only updated during core updates. - -## Two Types of Changes - -### Code Files (Three-Way Merge) -Source code where skills weave in logic. Merged via `git merge-file` against the shared base. Skills carry full modified files. - -### Structured Data (Deterministic Operations) -Files like `package.json`, `docker-compose.yml`, `.env.example`. Skills declare requirements in the manifest; the system applies them programmatically. Multiple skills' declarations are batched — dependencies merged, `package.json` written once, `npm install` run once. - -```yaml -structured: - npm_dependencies: - whatsapp-web.js: "^2.1.0" - env_additions: - - WHATSAPP_TOKEN - docker_compose_services: - whatsapp-redis: - image: redis:alpine - ports: ["6380:6379"] -``` - -Structured conflicts (version incompatibilities, port collisions) follow the same three-level resolution model. - -## Skill Package Structure - -A skill contains only the files it adds or modifies. Modified code files carry the **full file** (clean core + skill's changes), making `git merge-file` straightforward and auditable. - -``` -skills/add-whatsapp/ - SKILL.md # What this skill does and why - manifest.yaml # Metadata, dependencies, structured ops - tests/whatsapp.test.ts # Integration tests - add/src/channels/whatsapp.ts # New files - modify/src/server.ts # Full modified file for merge - modify/src/server.ts.intent.md # Structured intent for conflict resolution -``` - -### Intent Files -Each modified file has a `.intent.md` with structured headings: **What this skill adds**, **Key sections**, **Invariants**, and **Must-keep sections**. These give Claude Code specific guidance during conflict resolution. - -### Manifest -Declares: skill metadata, core version compatibility, files added/modified, file operations, structured operations, skill relationships (conflicts, depends, tested_with), post-apply commands, and test command. - -## Customization and Layering - -**One skill, one happy path** — a skill implements the reasonable default for 80% of users. - -**Customization is more patching.** Apply the skill, then modify via tracked patches, direct editing, or additional layered skills. Custom modifications are recorded in `state.yaml` and replayable. - -**Skills layer via `depends`.** Extension skills build on base skills (e.g., `telegram-reactions` depends on `add-telegram`). - -## File Operations - -Renames, deletes, and moves are declared in the manifest and run **before** code merges. When core renames a file, a **path remap** resolves skill references at apply time — skill packages are never mutated. - -## The Apply Flow - -1. Pre-flight checks (compatibility, dependencies, untracked changes) -2. Backup -3. File operations + path remapping -4. Copy new files -5. Merge modified code files (`git merge-file`) -6. Conflict resolution (shared cache → `git rerere` → Claude Code → Claude Code + user input) -7. Apply structured operations (batched) -8. Post-apply commands, update `state.yaml` -9. **Run tests** (mandatory, even if all merges were clean) -10. Clean up (delete backup on success, restore on failure) - -## Shared Resolution Cache - -`.nanoclaw/resolutions/` ships pre-computed, verified conflict resolutions with **hash enforcement** — a cached resolution only applies if base, current, and skill input hashes match exactly. This means most users never encounter unresolved conflicts for common skill combinations. - -### rerere Adapter -`git rerere` requires unmerged index entries that `git merge-file` doesn't create. An adapter sets up the required index state after `merge-file` produces a conflict, enabling rerere caching. This requires the project to be a git repository; users without `.git/` lose caching but not functionality. - -## State Tracking - -`.nanoclaw/state.yaml` records: core version, all applied skills (with per-file hashes for base/skill/merged), structured operation outcomes, custom patches, and path remaps. This makes drift detection instant and replay deterministic. - -## Untracked Changes - -Direct edits are detected via hash comparison before any operation. Users can record them as tracked patches, continue untracked, or abort. The three-level model can always recover coherent state from any starting point. - -## Core Updates - -Most changes propagate automatically through three-way merge. **Breaking changes** require a **migration skill** — a regular skill that preserves the old behavior, authored against the new core. Migrations are declared in `migrations.yaml` and applied automatically during updates. - -### Update Flow -1. Preview changes (git-only, no files modified) -2. Backup → file operations → three-way merge → conflict resolution -3. Re-apply custom patches (`git apply --3way`) -4. **Update base** to new core -5. Apply migration skills (preserves user's setup automatically) -6. Re-apply updated skills (version-changed skills only) -7. Re-run structured operations → run all tests → clean up - -The user sees no prompts during updates. To accept a new default later, they remove the migration skill. - -## Skill Removal - -Uninstall is **replay without the skill**: read `state.yaml`, remove the target skill, replay all remaining skills from clean base using the resolution cache. Backup for safety. - -## Rebase - -Flatten accumulated layers into a clean starting point. Updates base, regenerates diffs, clears old patches and stale cache entries. Trades individual skill history for simpler future merges. - -## Replay - -Given `state.yaml`, reproduce the exact installation on a fresh machine with no AI (assuming cached resolutions). Apply skills in order, merge, apply custom patches, batch structured operations, run tests. - -## Skill Tests - -Each skill includes integration tests. Tests run **always** — after apply, after update, after uninstall, during replay, in CI. CI tests all official skills individually and pairwise combinations for skills sharing modified files or structured operations. - -## Design Principles - -1. **Use git, don't reinvent it.** -2. **Three-level resolution: git → Claude Code → Claude Code + user input.** -3. **Clean merges aren't enough.** Tests run after every operation. -4. **All operations are safe.** Backup/restore, no half-applied state. -5. **One shared base**, only updated on core updates. -6. **Code merges vs. structured operations.** Source code is merged; configs are aggregated. -7. **Resolutions are learned and shared** with hash enforcement. -8. **One skill, one happy path.** Customization is more patching. -9. **Skills layer and compose.** -10. **Intent is first-class and structured.** -11. **State is explicit and complete.** Replay is deterministic. -12. **Always recoverable.** -13. **Uninstall is replay.** -14. **Core updates are the maintainers' responsibility.** Breaking changes require migration skills. -15. **File operations and path remapping are first-class.** -16. **Skills are tested.** CI tests pairwise by overlap. -17. **Deterministic serialization.** No noisy diffs. -18. **Rebase when needed.** -19. **Progressive core slimming** via migration skills. \ No newline at end of file diff --git a/docs/skills-as-branches.md b/docs/skills-as-branches.md deleted file mode 100644 index e1cace4..0000000 --- a/docs/skills-as-branches.md +++ /dev/null @@ -1,662 +0,0 @@ -# Skills as Branches - -## Overview - -NanoClaw skills are distributed as git branches on the upstream repository. Applying a skill is a `git merge`. Updating core is a `git merge`. Everything is standard git. - -This replaces the previous `skills-engine/` system (three-way file merging, `.nanoclaw/` state, manifest files, replay, backup/restore) with plain git operations and Claude for conflict resolution. - -## How It Works - -### Repository structure - -The upstream repo (`qwibitai/nanoclaw`) maintains: - -- `main` — core NanoClaw (no skill code) -- `skill/discord` — main + Discord integration -- `skill/telegram` — main + Telegram integration -- `skill/slack` — main + Slack integration -- `skill/gmail` — main + Gmail integration -- etc. - -Each skill branch contains all the code changes for that skill: new files, modified source files, updated `package.json` dependencies, `.env.example` additions — everything. No manifest, no structured operations, no separate `add/` and `modify/` directories. - -### Skill discovery and installation - -Skills are split into two categories: - -**Operational skills** (on `main`, always available): -- `/setup`, `/debug`, `/update-nanoclaw`, `/customize`, `/update-skills` -- These are instruction-only SKILL.md files — no code changes, just workflows -- Live in `.claude/skills/` on `main`, immediately available to every user - -**Feature skills** (in marketplace, installed on demand): -- `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc. -- Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code -- Live in the marketplace repo (`qwibitai/nanoclaw-skills`) - -Users never interact with the marketplace directly. The operational skills `/setup` and `/customize` handle plugin installation transparently: - -```bash -# Claude runs this behind the scenes — users don't see it -claude plugin install nanoclaw-skills@nanoclaw-skills --scope project -``` - -Skills are hot-loaded after `claude plugin install` — no restart needed. This means `/setup` can install the marketplace plugin, then immediately run any feature skill, all in one session. - -### Selective skill installation - -`/setup` asks users what channels they want, then only offers relevant skills: - -1. "Which messaging channels do you want to use?" → Discord, Telegram, Slack, WhatsApp -2. User picks Telegram → Claude installs the plugin and runs `/add-telegram` -3. After Telegram is set up: "Want to add Agent Swarm support for Telegram?" → offers `/add-telegram-swarm` -4. "Want to enable community skills?" → installs community marketplace plugins - -Dependent skills (e.g., `telegram-swarm` depends on `telegram`) are only offered after their parent is installed. `/customize` follows the same pattern for post-setup additions. - -### Marketplace configuration - -NanoClaw's `.claude/settings.json` registers the official marketplace: - -```json -{ - "extraKnownMarketplaces": { - "nanoclaw-skills": { - "source": { - "source": "github", - "repo": "qwibitai/nanoclaw-skills" - } - } - } -} -``` - -The marketplace repo uses Claude Code's plugin structure: - -``` -qwibitai/nanoclaw-skills/ - .claude-plugin/ - marketplace.json # Plugin catalog - plugins/ - nanoclaw-skills/ # Single plugin bundling all official skills - .claude-plugin/ - plugin.json # Plugin manifest - skills/ - add-discord/ - SKILL.md # Setup instructions; step 1 is "merge the branch" - add-telegram/ - SKILL.md - add-slack/ - SKILL.md - ... -``` - -Multiple skills are bundled in one plugin — installing `nanoclaw-skills` makes all feature skills available at once. Individual skills don't need separate installation. - -Each SKILL.md tells Claude to merge the corresponding skill branch as step 1, then walks through interactive setup (env vars, bot creation, etc.). - -### Applying a skill - -User runs `/add-discord` (discovered via marketplace). Claude follows the SKILL.md: - -1. `git fetch upstream skill/discord` -2. `git merge upstream/skill/discord` -3. Interactive setup (create bot, get token, configure env vars, etc.) - -Or manually: - -```bash -git fetch upstream skill/discord -git merge upstream/skill/discord -``` - -### Applying multiple skills - -```bash -git merge upstream/skill/discord -git merge upstream/skill/telegram -``` - -Git handles the composition. If both skills modify the same lines, it's a real conflict and Claude resolves it. - -### Updating core - -```bash -git fetch upstream main -git merge upstream/main -``` - -Since skill branches are kept merged-forward with main (see CI section), the user's merged-in skill changes and upstream changes have proper common ancestors. - -### Checking for skill updates - -Users who previously merged a skill branch can check for updates. For each `upstream/skill/*` branch, check whether the branch has commits that aren't in the user's HEAD: - -```bash -git fetch upstream -for branch in $(git branch -r | grep 'upstream/skill/'); do - # Check if user has merged this skill at some point - merge_base=$(git merge-base HEAD "$branch" 2>/dev/null) || continue - # Check if the skill branch has new commits beyond what the user has - if ! git merge-base --is-ancestor "$branch" HEAD 2>/dev/null; then - echo "$branch has updates available" - fi -done -``` - -This requires no state — it uses git history to determine which skills were previously merged and whether they have new commits. - -This logic is available in two ways: -- Built into `/update-nanoclaw` — after merging main, optionally check for skill updates -- Standalone `/update-skills` — check and merge skill updates independently - -### Conflict resolution - -At any merge step, conflicts may arise. Claude resolves them — reading the conflicted files, understanding the intent of both sides, and producing the correct result. This is what makes the branch approach viable at scale: conflict resolution that previously required human judgment is now automated. - -### Skill dependencies - -Some skills depend on other skills. E.g., `skill/telegram-swarm` requires `skill/telegram`. Dependent skill branches are branched from their parent skill branch, not from `main`. - -This means `skill/telegram-swarm` includes all of telegram's changes plus its own additions. When a user merges `skill/telegram-swarm`, they get both — no need to merge telegram separately. - -Dependencies are implicit in git history — `git merge-base --is-ancestor` determines whether one skill branch is an ancestor of another. No separate dependency file is needed. - -### Uninstalling a skill - -```bash -# Find the merge commit -git log --merges --oneline | grep discord - -# Revert it -git revert -m 1 -``` - -This creates a new commit that undoes the skill's changes. Claude can handle the whole flow. - -If the user has modified the skill's code since merging (custom changes on top), the revert might conflict — Claude resolves it. - -If the user later wants to re-apply the skill, they need to revert the revert first (git treats reverted changes as "already applied and undone"). Claude handles this too. - -## CI: Keeping Skill Branches Current - -A GitHub Action runs on every push to `main`: - -1. List all `skill/*` branches -2. For each skill branch, merge `main` into it (merge-forward, not rebase) -3. Run build and tests on the merged result -4. If tests pass, push the updated skill branch -5. If a skill fails (conflict, build error, test failure), open a GitHub issue for manual resolution - -**Why merge-forward instead of rebase:** -- No force-push — preserves history for users who already merged the skill -- Users can re-merge a skill branch to pick up skill updates (bug fixes, improvements) -- Git has proper common ancestors throughout the merge graph - -**Why this scales:** With a few hundred skills and a few commits to main per day, the CI cost is trivial. Haiku is fast and cheap. The approach that wouldn't have been feasible a year or two ago is now practical because Claude can resolve conflicts at scale. - -## Installation Flow - -### New users (recommended) - -1. Fork `qwibitai/nanoclaw` on GitHub (click the Fork button) -2. Clone your fork: - ```bash - git clone https://github.com//nanoclaw.git - cd nanoclaw - ``` -3. Run Claude Code: - ```bash - claude - ``` -4. Run `/setup` — Claude handles dependencies, authentication, container setup, service configuration, and adds `upstream` remote if not present - -Forking is recommended because it gives users a remote to push their customizations to. Clone-only works for trying things out but provides no remote backup. - -### Existing users migrating from clone - -Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` and have local customizations: - -1. Fork `qwibitai/nanoclaw` on GitHub -2. Reroute remotes: - ```bash - git remote rename origin upstream - git remote add origin https://github.com//nanoclaw.git - git push --force origin main - ``` - The `--force` is needed because the fresh fork's main is at upstream's latest, but the user wants their (possibly behind) version. The fork was just created so there's nothing to lose. -3. From this point, `origin` = their fork, `upstream` = qwibitai/nanoclaw - -### Existing users migrating from the old skills engine - -Users who previously applied skills via the `skills-engine/` system have skill code in their tree but no merge commits linking to skill branches. Git doesn't know these changes came from a skill, so merging a skill branch on top would conflict or duplicate. - -**For new skills going forward:** just merge skill branches as normal. No issue. - -**For existing old-engine skills**, two migration paths: - -**Option A: Per-skill reapply (keep your fork)** -1. For each old-engine skill: identify and revert the old changes, then merge the skill branch fresh -2. Claude assists with identifying what to revert and resolving any conflicts -3. Custom modifications (non-skill changes) are preserved - -**Option B: Fresh start (cleanest)** -1. Create a new fork from upstream -2. Merge the skill branches you want -3. Manually re-apply your custom (non-skill) changes -4. Claude assists by diffing your old fork against the new one to identify custom changes - -In both cases: -- Delete the `.nanoclaw/` directory (no longer needed) -- The `skills-engine/` code will be removed from upstream once all skills are migrated -- `/update-skills` only tracks skills applied via branch merge — old-engine skills won't appear in update checks - -## User Workflows - -### Custom changes - -Users make custom changes directly on their main branch. This is the standard fork workflow — their `main` IS their customized version. - -```bash -# Make changes -vim src/config.ts -git commit -am "change trigger word to @Bob" -git push origin main -``` - -Custom changes, skills, and core updates all coexist on their main branch. Git handles the three-way merging at each merge step because it can trace common ancestors through the merge history. - -### Applying a skill - -Run `/add-discord` in Claude Code (discovered via the marketplace plugin), or manually: - -```bash -git fetch upstream skill/discord -git merge upstream/skill/discord -# Follow setup instructions for configuration -git push origin main -``` - -If the user is behind upstream's main when they merge a skill branch, the merge might bring in some core changes too (since skill branches are merged-forward with main). This is generally fine — they get a compatible version of everything. - -### Updating core - -```bash -git fetch upstream main -git merge upstream/main -git push origin main -``` - -This is the same as the existing `/update-nanoclaw` skill's merge path. - -### Updating skills - -Run `/update-skills` or let `/update-nanoclaw` check after a core update. For each previously-merged skill branch that has new commits, Claude offers to merge the updates. - -### Contributing back to upstream - -Users who want to submit a PR to upstream: - -```bash -git fetch upstream main -git checkout -b my-fix upstream/main -# Make changes -git push origin my-fix -# Create PR from my-fix to qwibitai/nanoclaw:main -``` - -Standard fork contribution workflow. Their custom changes stay on their main and don't leak into the PR. - -## Contributing a Skill - -### Contributor flow - -1. Fork `qwibitai/nanoclaw` -2. Branch from `main` -3. Make the code changes (new channel file, modified integration points, updated package.json, .env.example additions, etc.) -4. Open a PR to `main` - -The contributor opens a normal PR — they don't need to know about skill branches or marketplace repos. They just make code changes and submit. - -### Maintainer flow - -When a skill PR is reviewed and approved: - -1. Create a `skill/` branch from the PR's commits: - ```bash - git fetch origin pull//head:skill/ - git push origin skill/ - ``` -2. Force-push to the contributor's PR branch, replacing it with a single commit that adds the contributor to `CONTRIBUTORS.md` (removing all code changes) -3. Merge the slimmed PR into `main` (just the contributor addition) -4. Add the skill's SKILL.md to the marketplace repo (`qwibitai/nanoclaw-skills`) - -This way: -- The contributor gets merge credit (their PR is merged) -- They're added to CONTRIBUTORS.md automatically by the maintainer -- The skill branch is created from their work -- `main` stays clean (no skill code) -- The contributor only had to do one thing: open a PR with code changes - -**Note:** GitHub PRs from forks have "Allow edits from maintainers" checked by default, so the maintainer can push to the contributor's PR branch. - -### Skill SKILL.md - -The contributor can optionally provide a SKILL.md (either in the PR or separately). This goes into the marketplace repo and contains: - -1. Frontmatter (name, description, triggers) -2. Step 1: Merge the skill branch -3. Steps 2-N: Interactive setup (create bot, get token, configure env vars, verify) - -If the contributor doesn't provide a SKILL.md, the maintainer writes one based on the PR. - -## Community Marketplaces - -Anyone can maintain their own fork with skill branches and their own marketplace repo. This enables a community-driven skill ecosystem without requiring write access to the upstream repo. - -### How it works - -A community contributor: - -1. Maintains a fork of NanoClaw (e.g., `alice/nanoclaw`) -2. Creates `skill/*` branches on their fork with their custom skills -3. Creates a marketplace repo (e.g., `alice/nanoclaw-skills`) with a `.claude-plugin/marketplace.json` and plugin structure - -### Adding a community marketplace - -If the community contributor is trusted, they can open a PR to add their marketplace to NanoClaw's `.claude/settings.json`: - -```json -{ - "extraKnownMarketplaces": { - "nanoclaw-skills": { - "source": { - "source": "github", - "repo": "qwibitai/nanoclaw-skills" - } - }, - "alice-nanoclaw-skills": { - "source": { - "source": "github", - "repo": "alice/nanoclaw-skills" - } - } - } -} -``` - -Once merged, all NanoClaw users automatically discover the community marketplace alongside the official one. - -### Installing community skills - -`/setup` and `/customize` ask users whether they want to enable community skills. If yes, Claude installs community marketplace plugins via `claude plugin install`: - -```bash -claude plugin install alice-skills@alice-nanoclaw-skills --scope project -``` - -Community skills are hot-loaded and immediately available — no restart needed. Dependent skills are only offered after their prerequisites are met (e.g., community Telegram add-ons only after Telegram is installed). - -Users can also browse and install community plugins manually via `/plugin`. - -### Properties of this system - -- **No gatekeeping required.** Anyone can create skills on their fork without permission. They only need approval to be listed in the auto-discovered marketplaces. -- **Multiple marketplaces coexist.** Users see skills from all trusted marketplaces in `/plugin`. -- **Community skills use the same merge pattern.** The SKILL.md just points to a different remote: - ```bash - git remote add alice https://github.com/alice/nanoclaw.git - git fetch alice skill/my-cool-feature - git merge alice/skill/my-cool-feature - ``` -- **Users can also add marketplaces manually.** Even without being listed in settings.json, users can run `/plugin marketplace add alice/nanoclaw-skills` to discover skills from any source. -- **CI is per-fork.** Each community maintainer runs their own CI to keep their skill branches merged-forward. They can use the same GitHub Action as the upstream repo. - -## Flavors - -A flavor is a curated fork of NanoClaw — a combination of skills, custom changes, and configuration tailored for a specific use case (e.g., "NanoClaw for Sales," "NanoClaw Minimal," "NanoClaw for Developers"). - -### Creating a flavor - -1. Fork `qwibitai/nanoclaw` -2. Merge in the skills you want -3. Make custom changes (trigger word, prompts, integrations, etc.) -4. Your fork's `main` IS the flavor - -### Installing a flavor - -During `/setup`, users are offered a choice of flavors before any configuration happens. The setup skill reads `flavors.yaml` from the repo (shipped with upstream, always up to date) and presents options: - -AskUserQuestion: "Start with a flavor or default NanoClaw?" -- Default NanoClaw -- NanoClaw for Sales — Gmail + Slack + CRM (maintained by alice) -- NanoClaw Minimal — Telegram-only, lightweight (maintained by bob) - -If a flavor is chosen: - -```bash -git remote add https://github.com/alice/nanoclaw.git -git fetch main -git merge /main -``` - -Then setup continues normally (dependencies, auth, container, service). - -**This choice is only offered on a fresh fork** — when the user's main matches or is close to upstream's main with no local commits. If `/setup` detects significant local changes (re-running setup on an existing install), it skips the flavor selection and goes straight to configuration. - -After installation, the user's fork has three remotes: -- `origin` — their fork (push customizations here) -- `upstream` — `qwibitai/nanoclaw` (core updates) -- `` — the flavor fork (flavor updates) - -### Updating a flavor - -```bash -git fetch main -git merge /main -``` - -The flavor maintainer keeps their fork updated (merging upstream, updating skills). Users pull flavor updates the same way they pull core updates. - -### Flavors registry - -`flavors.yaml` lives in the upstream repo: - -```yaml -flavors: - - name: NanoClaw for Sales - repo: alice/nanoclaw - description: Gmail + Slack + CRM integration, daily pipeline summaries - maintainer: alice - - - name: NanoClaw Minimal - repo: bob/nanoclaw - description: Telegram-only, no container overhead - maintainer: bob -``` - -Anyone can PR to add their flavor. The file is available locally when `/setup` runs since it's part of the cloned repo. - -### Discoverability - -- **During setup** — flavor selection is offered as part of the initial setup flow -- **`/browse-flavors` skill** — reads `flavors.yaml` and presents options at any time -- **GitHub topics** — flavor forks can tag themselves with `nanoclaw-flavor` for searchability -- **Discord / website** — community-curated lists - -## Migration - -Migration from the old skills engine to branches is complete. All feature skills now live on `skill/*` branches, and the skills engine has been removed. - -### Skill branches - -| Branch | Base | Description | -|--------|------|-------------| -| `skill/whatsapp` | `main` | WhatsApp channel | -| `skill/telegram` | `main` | Telegram channel | -| `skill/slack` | `main` | Slack channel | -| `skill/discord` | `main` | Discord channel | -| `skill/gmail` | `main` | Gmail channel | -| `skill/voice-transcription` | `skill/whatsapp` | OpenAI Whisper voice transcription | -| `skill/image-vision` | `skill/whatsapp` | Image attachment processing | -| `skill/pdf-reader` | `skill/whatsapp` | PDF attachment reading | -| `skill/local-whisper` | `skill/voice-transcription` | Local whisper.cpp transcription | -| `skill/ollama-tool` | `main` | Ollama MCP server for local models | -| `skill/apple-container` | `main` | Apple Container runtime | -| `skill/reactions` | `main` | WhatsApp emoji reactions | - -### What was removed - -- `skills-engine/` directory (entire engine) -- `scripts/apply-skill.ts`, `scripts/uninstall-skill.ts`, `scripts/rebase.ts` -- `scripts/fix-skill-drift.ts`, `scripts/validate-all-skills.ts` -- `.github/workflows/skill-drift.yml`, `.github/workflows/skill-pr.yml` -- All `add/`, `modify/`, `tests/`, and `manifest.yaml` from skill directories -- `.nanoclaw/` state directory - -Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`. - -## What Changes - -### README Quick Start - -Before: -```bash -git clone https://github.com/qwibitai/NanoClaw.git -cd NanoClaw -claude -``` - -After: -``` -1. Fork qwibitai/nanoclaw on GitHub -2. git clone https://github.com//nanoclaw.git -3. cd nanoclaw -4. claude -5. /setup -``` - -### Setup skill (`/setup`) - -Updates to the setup flow: - -- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/qwibitai/nanoclaw.git` -- Check if `origin` points to the user's fork (not qwibitai). If it points to qwibitai, guide them through the fork migration. -- **Install marketplace plugin:** `claude plugin install nanoclaw-skills@nanoclaw-skills --scope project` — makes all feature skills available (hot-loaded, no restart) -- **Ask which channels to add:** present channel options (Discord, Telegram, Slack, WhatsApp, Gmail), run corresponding `/add-*` skills for selected channels -- **Offer dependent skills:** after a channel is set up, offer relevant add-ons (e.g., Agent Swarm after Telegram, voice transcription after WhatsApp) -- **Optionally enable community marketplaces:** ask if the user wants community skills, install those marketplace plugins too - -### `.claude/settings.json` - -Marketplace configuration so the official marketplace is auto-registered: - -```json -{ - "extraKnownMarketplaces": { - "nanoclaw-skills": { - "source": { - "source": "github", - "repo": "qwibitai/nanoclaw-skills" - } - } - } -} -``` - -### Skills directory on main - -The `.claude/skills/` directory on `main` retains only operational skills (setup, debug, update-nanoclaw, customize, update-skills). Feature skills (add-discord, add-telegram, etc.) live in the marketplace repo, installed via `claude plugin install` during `/setup` or `/customize`. - -### Skills engine removal - -The following can be removed: - -- `skills-engine/` — entire directory (apply, merge, replay, state, backup, etc.) -- `scripts/apply-skill.ts` -- `scripts/uninstall-skill.ts` -- `scripts/fix-skill-drift.ts` -- `scripts/validate-all-skills.ts` -- `.nanoclaw/` — state directory -- `add/` and `modify/` subdirectories from all skill directories -- Feature skill SKILL.md files from `.claude/skills/` on main (they now live in the marketplace) - -Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`. - -### New infrastructure - -- **Marketplace repo** (`qwibitai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills -- **CI GitHub Action** — merge-forward `main` into all `skill/*` branches on every push to `main`, using Claude (Haiku) for conflict resolution -- **`/update-skills` skill** — checks for and applies skill branch updates using git history -- **`CONTRIBUTORS.md`** — tracks skill contributors - -### Update skill (`/update-nanoclaw`) - -The update skill gets simpler with the branch-based approach. The old skills engine required replaying all applied skills after merging core updates — that entire step disappears. Skill changes are already in the user's git history, so `git merge upstream/main` just works. - -**What stays the same:** -- Preflight (clean working tree, upstream remote) -- Backup branch + tag -- Preview (git log, git diff, file buckets) -- Merge/cherry-pick/rebase options -- Conflict preview (dry-run merge) -- Conflict resolution -- Build + test validation -- Rollback instructions - -**What's removed:** -- Skill replay step (was needed by the old skills engine to re-apply skills after core update) -- Re-running structured operations (npm deps, env vars — these are part of git history now) - -**What's added:** -- Optional step at the end: "Check for skill updates?" which runs the `/update-skills` logic -- This checks whether any previously-merged skill branches have new commits (bug fixes, improvements to the skill itself — not just merge-forwards from main) - -**Why users don't need to re-merge skills after a core update:** -When the user merged a skill branch, those changes became part of their git history. When they later merge `upstream/main`, git performs a normal three-way merge — the skill changes in their tree are untouched, and only core changes are brought in. The merge-forward CI ensures skill branches stay compatible with latest main, but that's for new users applying the skill fresh. Existing users who already merged the skill don't need to do anything. - -Users only need to re-merge a skill branch if the skill itself was updated (not just merged-forward with main). The `/update-skills` check detects this. - -## Discord Announcement - -### For existing users - -> **Skills are now git branches** -> -> We've simplified how skills work in NanoClaw. Instead of a custom skills engine, skills are now git branches that you merge in. -> -> **What this means for you:** -> - Applying a skill: `git fetch upstream skill/discord && git merge upstream/skill/discord` -> - Updating core: `git fetch upstream main && git merge upstream/main` -> - Checking for skill updates: `/update-skills` -> - No more `.nanoclaw/` state directory or skills engine -> -> **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to. -> -> **If you currently have a clone with local changes**, migrate to a fork: -> 1. Fork `qwibitai/nanoclaw` on GitHub -> 2. Run: -> ``` -> git remote rename origin upstream -> git remote add origin https://github.com//nanoclaw.git -> git push --force origin main -> ``` -> This works even if you're way behind — just push your current state. -> -> **If you previously applied skills via the old system**, your code changes are already in your working tree — nothing to redo. You can delete the `.nanoclaw/` directory. Future skills and updates use the branch-based approach. -> -> **Discovering skills:** Skills are now available through Claude Code's plugin marketplace. Run `/plugin` in Claude Code to browse and install available skills. - -### For skill contributors - -> **Contributing skills** -> -> To contribute a skill: -> 1. Fork `qwibitai/nanoclaw` -> 2. Branch from `main` and make your code changes -> 3. Open a regular PR -> -> That's it. We'll create a `skill/` branch from your PR, add you to CONTRIBUTORS.md, and add the SKILL.md to the marketplace. CI automatically keeps skill branches merged-forward with `main` using Claude to resolve any conflicts. -> -> **Want to run your own skill marketplace?** Maintain skill branches on your fork and create a marketplace repo. Open a PR to add it to NanoClaw's auto-discovered marketplaces — or users can add it manually via `/plugin marketplace add`. diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 28e97a7..5882f16 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -1,58 +1,7 @@ -# Andy +# Global Memory -You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. +This file is for mutable memory shared across Claude groups. -## What You Can Do +Use it for durable facts, preferences, and shared context that may change over time. -- Answer questions and have conversations -- Search the web and fetch content from URLs -- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open ` to start, then `agent-browser snapshot -i` to see interactive elements) -- Read and write files in your workspace -- Run bash commands in your sandbox -- Schedule tasks to run later or on a recurring basis -- Send messages back to the chat - -## Communication - -Your output is sent to the user or group. - -You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work. - -### Internal thoughts - -If part of your output is internal reasoning rather than something for the user, wrap it in `` tags: - -``` -Compiled all three reports, ready to summarize. - -Here are the key findings from the research... -``` - -Text inside `` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `` to avoid sending it again. - -### Sub-agents and teammates - -When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. - -## Your Workspace - -Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist. - -## Memory - -The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. - -When you learn something important: -- Create files for structured data (e.g., `customers.md`, `preferences.md`) -- Split files larger than 500 lines into folders -- Keep an index in your memory for the files you create - -## 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**. +Do not store platform-wide operating rules here. Those now live in `prompts/claude-platform.md`. diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 11e846b..aea778e 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -1,246 +1,5 @@ -# Andy +# Main Group Memory -You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. +This file is for mutable memory and admin context specific to the main group. -## What You Can Do - -- Answer questions and have conversations -- Search the web and fetch content from URLs -- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open ` to start, then `agent-browser snapshot -i` to see interactive elements) -- Read and write files in your workspace -- Run bash commands in your sandbox -- Schedule tasks to run later or on a recurring basis -- Send messages back to the chat - -## Communication - -Your output is sent to the user or group. - -You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work. - -### Internal thoughts - -If part of your output is internal reasoning rather than something for the user, wrap it in `` tags: - -``` -Compiled all three reports, ready to summarize. - -Here are the key findings from the research... -``` - -Text inside `` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `` to avoid sending it again. - -### Sub-agents and teammates - -When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. - -## Memory - -The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. - -When you learn something important: -- Create files for structured data (e.g., `customers.md`, `preferences.md`) -- Split files larger than 500 lines into folders -- Keep an index in your memory for the files you create - -## WhatsApp Formatting (and other messaging apps) - -Do NOT use markdown headings (##) in WhatsApp messages. Only use: -- *Bold* (single asterisks) (NEVER **double asterisks**) -- _Italic_ (underscores) -- • Bullets (bullet points) -- ```Code blocks``` (triple backticks) - -Keep messages clean and readable for WhatsApp. - ---- - -## Admin Context - -This is the **main channel**, which has elevated privileges. - -## Container Mounts - -Main has read-only access to the project and read-write access to its group folder: - -| Container Path | Host Path | Access | -|----------------|-----------|--------| -| `/workspace/project` | Project root | read-only | -| `/workspace/group` | `groups/main/` | read-write | - -Key paths inside the container: -- `/workspace/project/store/messages.db` - SQLite database -- `/workspace/project/store/messages.db` (registered_groups table) - Group config -- `/workspace/project/groups/` - All group folders - ---- - -## Managing Groups - -### Finding Available Groups - -Available groups are provided in `/workspace/ipc/available_groups.json`: - -```json -{ - "groups": [ - { - "jid": "120363336345536173@g.us", - "name": "Family Chat", - "lastActivity": "2026-01-31T12:00:00.000Z", - "isRegistered": false - } - ], - "lastSync": "2026-01-31T12:00:00.000Z" -} -``` - -Groups are ordered by most recent activity. The list is synced from WhatsApp daily. - -If a group the user mentions isn't in the list, request a fresh sync: - -```bash -echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json -``` - -Then wait a moment and re-read `available_groups.json`. - -**Fallback**: Query the SQLite database directly: - -```bash -sqlite3 /workspace/project/store/messages.db " - SELECT jid, name, last_message_time - FROM chats - WHERE jid LIKE '%@g.us' AND jid != '__group_sync__' - ORDER BY last_message_time DESC - LIMIT 10; -" -``` - -### Registered Groups Config - -Groups are registered in the SQLite `registered_groups` table: - -```json -{ - "1234567890-1234567890@g.us": { - "name": "Family Chat", - "folder": "whatsapp_family-chat", - "trigger": "@Andy", - "added_at": "2024-01-31T12:00:00.000Z" - } -} -``` - -Fields: -- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.) -- **name**: Display name for the group -- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory -- **trigger**: The trigger word (usually same as global, but could differ) -- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed -- **isMain**: Whether this is the main control group (elevated privileges, no trigger required) -- **added_at**: ISO timestamp when registered - -### Trigger Behavior - -- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically -- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats) -- **Other groups** (default): Messages must start with `@AssistantName` to be processed - -### Adding a Group - -1. Query the database to find the group's JID -2. Use the `register_group` MCP tool with the JID, name, folder, and trigger -3. Optionally include `containerConfig` for additional mounts -4. The group folder is created automatically: `/workspace/project/groups/{folder-name}/` -5. Optionally create an initial `CLAUDE.md` for the group - -Folder naming convention — channel prefix with underscore separator: -- WhatsApp "Family Chat" → `whatsapp_family-chat` -- Telegram "Dev Team" → `telegram_dev-team` -- Discord "General" → `discord_general` -- Slack "Engineering" → `slack_engineering` -- Use lowercase, hyphens for the group name part - -#### Adding Additional Directories for a Group - -Groups can have extra directories mounted. Add `containerConfig` to their entry: - -```json -{ - "1234567890@g.us": { - "name": "Dev Team", - "folder": "dev-team", - "trigger": "@Andy", - "added_at": "2026-01-31T12:00:00Z", - "containerConfig": { - "additionalMounts": [ - { - "hostPath": "~/projects/webapp", - "containerPath": "webapp", - "readonly": false - } - ] - } - } -} -``` - -The directory will appear at `/workspace/extra/webapp` in that group's container. - -#### Sender Allowlist - -After registering a group, explain the sender allowlist feature to the user: - -> This group can be configured with a sender allowlist to control who can interact with me. There are two modes: -> -> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}. -> - **Drop mode**: Messages from non-allowed senders are not stored at all. -> -> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that? - -If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host: - -```json -{ - "default": { "allow": "*", "mode": "trigger" }, - "chats": { - "": { - "allow": ["sender-id-1", "sender-id-2"], - "mode": "trigger" - } - }, - "logDenied": true -} -``` - -Notes: -- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist. -- If the config file doesn't exist or is invalid, all senders are allowed (fail-open) -- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container - -### Removing a Group - -1. Read `/workspace/project/data/registered_groups.json` -2. Remove the entry for that group -3. Write the updated JSON back -4. The group folder and its files remain (don't delete them) - -### Listing Groups - -Read `/workspace/project/data/registered_groups.json` and format it nicely. - ---- - -## Global Memory - -You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar. - ---- - -## Scheduling for Other Groups - -When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`: -- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")` - -The task will run in that group's context with access to their files and memory. +Keep platform-wide operating rules in `prompts/claude-platform.md`. diff --git a/package.json b/package.json index e5e98e5..e913b09 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "build:runners": "cd container/agent-runner && npm install && npm run build && cd ../codex-runner && npm install && npm run build", + "build:runners": "npm --prefix runners/agent-runner install && npm --prefix runners/agent-runner run build && npm --prefix runners/codex-runner install && npm --prefix runners/codex-runner run build", "start": "node dist/index.js", "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", diff --git a/prompts/claude-paired-room.md b/prompts/claude-paired-room.md new file mode 100644 index 0000000..fc002d2 --- /dev/null +++ b/prompts/claude-paired-room.md @@ -0,0 +1,20 @@ +# Claude Paired Room Rules + +This room has both Claude and Codex. +Both of you can read the same room conversation and respond in the same thread. + +Your default role is review, test planning, verification, and risk checking. + +Discussion and design debate are shared responsibilities. You can challenge Codex, refine its approach, and propose alternatives when they are stronger. + +Keep coordination with Codex public by default. Use `` only for content that truly needs to stay hidden from the room. + +When Codex is already implementing, prefer: +- clarifying requirements +- surfacing edge cases and regressions +- proposing focused tests +- reviewing results and calling out risks + +Let Codex take the lead on implementation in most cases. + +You can still implement when the user explicitly asks you to, when Codex is blocked, or when a small targeted patch is the fastest way to verify a point. diff --git a/prompts/claude-platform.md b/prompts/claude-platform.md new file mode 100644 index 0000000..8a3f180 --- /dev/null +++ b/prompts/claude-platform.md @@ -0,0 +1,46 @@ +# Claude Platform Rules + +You are Andy, a personal assistant. + +## Communication + +Your output is sent directly to the user or Discord group. + +You also have a `send_message` tool, which sends a message immediately while you are still working. Use it when you want to acknowledge a request before starting longer work. + +### Internal thoughts + +Use `` only for genuinely hidden content. + +If part of your output is internal reasoning rather than something for the user, wrap it in `` tags: + +```text +Compiled all three reports, ready to summarize. + +Here are the key findings from the research... +``` + +Text inside `` tags is logged but not sent to the user. + +Prefer public replies for coordination, status updates, review comments, and anything Codex or the user should react to. + +### Sub-agents and teammates + +When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to. + +## Memory + +The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context. + +When you learn something important: +- Create files for structured data when that is genuinely useful +- Split files larger than 500 lines into smaller folders or documents +- Keep an index if you start building a larger memory structure + +## Message formatting + +Do not use markdown headings in chat replies. Keep messages clean and readable for Discord. + +- Use concise paragraphs or simple lists +- Use fenced code blocks when showing code +- Prefer plain links over markdown link syntax diff --git a/prompts/codex-paired-room.md b/prompts/codex-paired-room.md new file mode 100644 index 0000000..99cdfb8 --- /dev/null +++ b/prompts/codex-paired-room.md @@ -0,0 +1,18 @@ +# Codex Paired Room Rules + +This room has both Claude and Codex. +Both of you can read the same room conversation and respond in the same thread. + +Your default role is implementation, debugging, command execution, and concrete code changes. + +Take the lead on implementation in this room unless the user explicitly redirects the work. + +Discussion and design debate are shared responsibilities. Engage with Claude critically and evaluate its feedback on the merits. + +Treat Claude's feedback as input to inspect, test, and reason through, not as something to accept automatically. + +When Claude is already reviewing or testing, prefer: +- making the code change +- running commands and checks +- narrowing the bug or failure +- reporting concrete results back to the room diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md new file mode 100644 index 0000000..527695e --- /dev/null +++ b/prompts/codex-platform.md @@ -0,0 +1,28 @@ +# Codex Platform Rules + +You are 코덱스, a participant in a Discord chat. + +## Core rules + +- Respond directly to messages. Do not provide reply suggestions or draft responses for someone else to send. +- Respond in Korean. +- When coding, debugging, or file work is needed, do it directly. + +## Communication + +Your output is sent directly to the Discord group. + +- Keep answers concise unless more detail is genuinely needed +- Give conclusions and concrete next steps, not hidden reasoning +- Use code blocks for commands or code when helpful +- Do not claim you will keep watching, monitor later, report back later, or continue tracking unless you actually scheduled an EJClaw task with `watch_ci` or `schedule_task` +- If no task was scheduled, do not imply that background tracking is active. If future follow-up is needed, tell the user to ping you again or explicitly ask for scheduling +- When you do schedule background follow-up, mention that it was scheduled. Include the task ID only when it is useful for later reference + +## Working style + +- Prefer reading the current workspace before making assumptions +- Modify only what is needed for the task +- Verify changes when you can instead of claiming they should work +- For CI/status/watch requests that require future follow-up, schedule `watch_ci` +- Use generic `schedule_task` for reminders or other non-CI recurring work diff --git a/container/agent-runner/package-lock.json b/runners/agent-runner/package-lock.json similarity index 99% rename from container/agent-runner/package-lock.json rename to runners/agent-runner/package-lock.json index 2c6696e..89cee2c 100644 --- a/container/agent-runner/package-lock.json +++ b/runners/agent-runner/package-lock.json @@ -724,7 +724,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -929,7 +928,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz", "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1509,7 +1507,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/container/agent-runner/package.json b/runners/agent-runner/package.json similarity index 100% rename from container/agent-runner/package.json rename to runners/agent-runner/package.json diff --git a/container/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts similarity index 94% rename from container/agent-runner/src/index.ts rename to runners/agent-runner/src/index.ts index 6600a8b..1e1f941 100644 --- a/container/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -1,10 +1,10 @@ /** * NanoClaw Agent Runner - * Runs inside a container, receives config via stdin, outputs result to stdout + * Runs as a child process, receives config via stdin, outputs result to stdout * * Input protocol: - * Stdin: Full ContainerInput JSON (read until EOF, like before) - * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/ + * Stdin: Full RunnerInput JSON (read until EOF, like before) + * IPC: Follow-up messages written as JSON files to $NANOCLAW_IPC_DIR/input/ * Files: {type:"message", text:"..."}.json — polled and consumed * Sentinel: /workspace/ipc/input/_close — signals session end * @@ -59,11 +59,9 @@ interface SDKUserMessage { session_id: string; } -// Paths configurable via env vars (defaults to container paths for backwards compat) +// Paths configurable via env vars. const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; -const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global'; -const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra'; // Optional: override cwd (agent works in this directory instead of GROUP_DIR) const WORK_DIR = process.env.NANOCLAW_WORK_DIR || ''; @@ -450,13 +448,6 @@ async function runQuery( let messageCount = 0; let resultCount = 0; - // Load global CLAUDE.md as additional system context (shared across all groups) - const globalClaudeMdPath = path.join(GLOBAL_DIR, 'CLAUDE.md'); - let globalClaudeMd: string | undefined; - if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { - globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); - } - // Discover additional directories const extraDirs: string[] = []; @@ -465,17 +456,6 @@ async function runQuery( if (WORK_DIR && WORK_DIR !== GROUP_DIR) { extraDirs.push(GROUP_DIR); log(`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`); - } else { - // Only scan EXTRA_BASE when no WORK_DIR override (legacy container mount support) - const extraBase = EXTRA_BASE; - if (extraBase && fs.existsSync(extraBase)) { - for (const entry of fs.readdirSync(extraBase)) { - const fullPath = path.join(extraBase, entry); - if (fs.statSync(fullPath).isDirectory()) { - extraDirs.push(fullPath); - } - } - } } if (extraDirs.length > 0) { log(`Additional directories: ${extraDirs.join(', ')}`); @@ -507,9 +487,6 @@ async function runQuery( 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', diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts similarity index 100% rename from container/agent-runner/src/ipc-mcp-stdio.ts rename to runners/agent-runner/src/ipc-mcp-stdio.ts diff --git a/container/agent-runner/tsconfig.json b/runners/agent-runner/tsconfig.json similarity index 100% rename from container/agent-runner/tsconfig.json rename to runners/agent-runner/tsconfig.json diff --git a/runners/codex-runner/package-lock.json b/runners/codex-runner/package-lock.json new file mode 100644 index 0000000..4f62306 --- /dev/null +++ b/runners/codex-runner/package-lock.json @@ -0,0 +1,184 @@ +{ + "name": "nanoclaw-codex-runner", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nanoclaw-codex-runner", + "version": "1.0.0", + "dependencies": { + "@openai/codex-sdk": "^0.115.0" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } + }, + "node_modules/@openai/codex": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0.tgz", + "integrity": "sha512-uu689DHUzvuPcb39hJ+7fqy++TAvY32w9VggDpcz3HS0Sx0WadWoAPPcMK547P2T6AqfMsAtA8kspkR3tqErOg==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.115.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.115.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.115.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.115.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.115.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.115.0-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.115.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-arm64.tgz", + "integrity": "sha512-wTWV+YlDTL0y0mL+FMmbzhilm+dPkbIZTe/lH3LwBzcEMhgSGLsPdZLfAiMND4wFE21HS7H0pjMogNQEMLQmqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.115.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-x64.tgz", + "integrity": "sha512-EPzgymU4CFp83qjv29wXFwhWib3zC8g6SLTJGh2OpcJiOYyLY0bO53FB+QchL1jC9gm1uLyD5j5F3ddI0AbjIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.115.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-arm64.tgz", + "integrity": "sha512-40+SCyI+LvVx/iX30qH7dTQzWt9MZxDaK2E6YRB4hoYej5UALrhkFNzweHa5uvqvhmqGZCuagZOYB8285eGCgw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.115.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-x64.tgz", + "integrity": "sha512-+6eRd2p4KMrhQvuF7XpjYIdCA2Ok2LbhOq+ywZdLpHbmwQ/Yynd0gJ/Q90xCeo2vloCwl9WsbsiVVSahG5FyWg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-sdk": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.115.0.tgz", + "integrity": "sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==", + "license": "Apache-2.0", + "dependencies": { + "@openai/codex": "0.115.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.115.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-arm64.tgz", + "integrity": "sha512-yaYhQ0kPL9Kithmrr1vh5A4c+gqAMhMZeA/htTtkpWW8RQkmwgcYYpX/Ky2cEzu0w51pvxQQy63LgHftc+pg1Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.115.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-x64.tgz", + "integrity": "sha512-wtYf2HJCB+p+Uj7o1W08fRgZMzKCVGRQ4YdSfLRNfF4wwPqX5XsK9blsv7brukn5J9lclYxagiR6qGvbfHbD7w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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" + } + } +} diff --git a/container/codex-runner/package.json b/runners/codex-runner/package.json similarity index 84% rename from container/codex-runner/package.json rename to runners/codex-runner/package.json index 2a623e2..a4c2d1d 100644 --- a/container/codex-runner/package.json +++ b/runners/codex-runner/package.json @@ -8,7 +8,9 @@ "build": "tsc", "start": "node dist/index.js" }, - "dependencies": {}, + "dependencies": { + "@openai/codex-sdk": "^0.115.0" + }, "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" diff --git a/runners/codex-runner/src/app-server-client.ts b/runners/codex-runner/src/app-server-client.ts new file mode 100644 index 0000000..757210f --- /dev/null +++ b/runners/codex-runner/src/app-server-client.ts @@ -0,0 +1,444 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; +import { createRequire } from 'module'; +import path from 'path'; + +import { + createInitialAppServerTurnState, + getAppServerTurnResult, + isAppServerTurnFinished, + reduceAppServerTurnState, + type AppServerTurnEvent, + type AppServerTurnState, +} from './app-server-state.js'; + +export interface AppServerInputItemText { + type: 'text'; + text: string; +} + +export interface AppServerInputItemLocalImage { + type: 'localImage'; + path: string; +} + +export type AppServerInputItem = + | AppServerInputItemText + | AppServerInputItemLocalImage; + +export interface CodexAppServerThreadOptions { + cwd: string; + model?: string; +} + +export interface CodexAppServerTurnOptions { + cwd: string; + model?: string; + effort?: string; +} + +export interface CodexAppServerTurnResult { + state: AppServerTurnState; + result: string | null; +} + +interface JsonRpcResponse { + id: number; + result?: unknown; + error?: { + code?: number; + message?: string; + data?: unknown; + }; +} + +interface JsonRpcNotification { + method: string; + params?: Record; +} + +interface JsonRpcServerRequest extends JsonRpcNotification { + id: number; +} + +interface PendingRequest { + method: string; + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; +} + +interface ActiveTurn { + threadId: string; + state: AppServerTurnState; + resolve: (value: CodexAppServerTurnResult) => void; + reject: (reason?: unknown) => void; +} + +export interface CodexAppServerClientOptions { + cwd: string; + env?: NodeJS.ProcessEnv; + log: (message: string) => void; +} + +export class CodexAppServerClient { + private readonly cwd: string; + private readonly env: NodeJS.ProcessEnv; + private readonly log: (message: string) => void; + private readonly pending = new Map(); + private readonly require = createRequire(import.meta.url); + private nextId = 1; + private stdoutBuffer = ''; + private activeTurn: ActiveTurn | null = null; + private proc: ChildProcessWithoutNullStreams | null = null; + + constructor(options: CodexAppServerClientOptions) { + this.cwd = options.cwd; + this.env = options.env || process.env; + this.log = options.log; + } + + async start(): Promise { + if (this.proc) return; + + const codexPackagePath = this.require.resolve('@openai/codex/package.json'); + const codexBin = path.join(path.dirname(codexPackagePath), 'bin', 'codex.js'); + + this.proc = spawn(process.execPath, [codexBin, 'app-server'], { + cwd: this.cwd, + env: this.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + this.proc.stdout.setEncoding('utf8'); + this.proc.stdout.on('data', (chunk: string) => { + this.stdoutBuffer += chunk; + const lines = this.stdoutBuffer.split('\n'); + this.stdoutBuffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + this.handleStdoutLine(trimmed); + } + }); + + this.proc.stderr.setEncoding('utf8'); + this.proc.stderr.on('data', (chunk: string) => { + for (const line of chunk.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + this.log(`[app-server] ${trimmed}`); + } + }); + + this.proc.on('close', (code) => { + const error = new Error( + `Codex app-server exited with code ${code ?? 'unknown'}`, + ); + this.rejectAll(error); + }); + + this.proc.on('error', (error) => { + this.rejectAll(error); + }); + + await this.request('initialize', { + clientInfo: { + name: 'nanoclaw_codex_runner', + title: 'NanoClaw Codex Runner', + version: '1.0.0', + }, + capabilities: { + experimentalApi: true, + optOutNotificationMethods: [ + 'item/agentMessage/delta', + 'item/plan/delta', + 'item/reasoning/textDelta', + 'item/reasoning/summaryTextDelta', + 'item/reasoning/summaryPartAdded', + ], + }, + }); + this.notify('initialized', {}); + } + + async close(): Promise { + if (!this.proc) return; + const proc = this.proc; + this.proc = null; + try { + proc.kill('SIGTERM'); + } catch { + /* ignore */ + } + } + + async startOrResumeThread( + sessionId: string | undefined, + options: CodexAppServerThreadOptions, + ): Promise { + const params = { + cwd: options.cwd, + model: options.model, + approvalPolicy: 'never', + sandbox: 'danger-full-access', + serviceName: 'nanoclaw', + }; + + const result = sessionId + ? await this.request('thread/resume', { + threadId: sessionId, + ...params, + }) + : await this.request('thread/start', params); + + const thread = (result as { thread?: { id?: string } }).thread; + if (!thread?.id) { + throw new Error('Codex app-server did not return a thread id.'); + } + return thread.id; + } + + async startTurn( + threadId: string, + input: AppServerInputItem[], + options: CodexAppServerTurnOptions, + ): Promise<{ + turnId: string; + steer: (nextInput: AppServerInputItem[]) => Promise; + interrupt: () => Promise; + wait: () => Promise; + }> { + if (this.activeTurn) { + throw new Error('A Codex app-server turn is already active.'); + } + + const turnPromise = new Promise((resolve, reject) => { + this.activeTurn = { + threadId, + state: createInitialAppServerTurnState(), + resolve, + reject, + }; + }); + + let turnId = ''; + try { + const response = (await this.request('turn/start', { + threadId, + input, + cwd: options.cwd, + approvalPolicy: 'never', + sandboxPolicy: { + type: 'dangerFullAccess', + networkAccess: true, + }, + model: options.model, + effort: options.effort, + summary: 'concise', + })) as { turn?: { id?: string; status?: string } }; + + turnId = response.turn?.id || ''; + if (!turnId) { + throw new Error('Codex app-server did not return a turn id.'); + } + + const activeTurn = this.activeTurn as ActiveTurn | null; + if (activeTurn !== null) { + activeTurn.state = reduceAppServerTurnState(activeTurn.state, { + method: 'turn/started', + params: { + turn: { + id: turnId, + status: response.turn?.status || 'inProgress', + }, + }, + }); + } + } catch (error) { + this.activeTurn = null; + throw error; + } + + return { + turnId, + steer: async (nextInput) => { + await this.request('turn/steer', { + threadId, + input: nextInput, + expectedTurnId: turnId, + }); + }, + interrupt: async () => { + await this.request('turn/interrupt', { + threadId, + turnId, + }); + }, + wait: async () => turnPromise, + }; + } + + async startCompaction(threadId: string): Promise { + if (this.activeTurn) { + throw new Error('A Codex app-server turn is already active.'); + } + + const turnPromise = new Promise((resolve, reject) => { + this.activeTurn = { + threadId, + state: createInitialAppServerTurnState(), + resolve, + reject, + }; + }); + + try { + await this.request('thread/compact/start', { threadId }); + } catch (error) { + this.activeTurn = null; + throw error; + } + + return turnPromise; + } + + private handleStdoutLine(line: string): void { + let message: JsonRpcResponse | JsonRpcNotification | JsonRpcServerRequest; + try { + message = JSON.parse(line); + } catch { + this.log(`[app-server] non-JSON stdout: ${line}`); + return; + } + + if ( + typeof (message as JsonRpcResponse).id === 'number' && + ('result' in message || 'error' in message) && + !('method' in message) + ) { + this.handleResponse(message as JsonRpcResponse); + return; + } + + if ( + typeof (message as JsonRpcServerRequest).id === 'number' && + typeof (message as JsonRpcServerRequest).method === 'string' + ) { + this.handleServerRequest(message as JsonRpcServerRequest); + return; + } + + if (typeof (message as JsonRpcNotification).method === 'string') { + this.handleNotification(message as JsonRpcNotification); + } + } + + private handleResponse(message: JsonRpcResponse): void { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + + if (message.error) { + pending.reject( + new Error( + message.error.message || + `${pending.method} failed with JSON-RPC error ${message.error.code ?? 'unknown'}`, + ), + ); + return; + } + + pending.resolve(message.result); + } + + private handleServerRequest(message: JsonRpcServerRequest): void { + if (message.method.endsWith('/requestApproval')) { + this.respond(message.id, 'acceptForSession'); + return; + } + + this.respondError( + message.id, + -32601, + `NanoClaw does not handle server request ${message.method}`, + ); + } + + private handleNotification(message: JsonRpcNotification): void { + if (!this.activeTurn) return; + + this.activeTurn.state = reduceAppServerTurnState( + this.activeTurn.state, + message as AppServerTurnEvent, + ); + + if (!isAppServerTurnFinished(this.activeTurn.state)) { + return; + } + + const activeTurn = this.activeTurn; + this.activeTurn = null; + activeTurn.resolve({ + state: activeTurn.state, + result: getAppServerTurnResult(activeTurn.state), + }); + } + + private rejectAll(error: unknown): void { + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + + if (this.activeTurn) { + const activeTurn = this.activeTurn; + this.activeTurn = null; + activeTurn.reject(error); + } + } + + private request(method: string, params?: unknown): Promise { + const id = this.nextId++; + const payload = { + jsonrpc: '2.0', + id, + method, + params, + }; + + return new Promise((resolve, reject) => { + this.pending.set(id, { method, resolve, reject }); + this.write(payload); + }); + } + + private notify(method: string, params?: unknown): void { + this.write({ + jsonrpc: '2.0', + method, + params, + }); + } + + private respond(id: number, result: unknown): void { + this.write({ + jsonrpc: '2.0', + id, + result, + }); + } + + private respondError(id: number, code: number, message: string): void { + this.write({ + jsonrpc: '2.0', + id, + error: { code, message }, + }); + } + + private write(message: Record): void { + if (!this.proc?.stdin.writable) { + throw new Error('Codex app-server stdin is not writable.'); + } + this.proc.stdin.write(JSON.stringify(message) + '\n'); + } +} diff --git a/runners/codex-runner/src/app-server-state.ts b/runners/codex-runner/src/app-server-state.ts new file mode 100644 index 0000000..69a5f35 --- /dev/null +++ b/runners/codex-runner/src/app-server-state.ts @@ -0,0 +1,174 @@ +export interface AppServerAgentMessageItem { + type: 'agentMessage'; + text?: string | null; + phase?: 'commentary' | 'final_answer' | string | null; +} + +export interface AppServerContextCompactionItem { + type: 'contextCompaction'; + id?: string; +} + +export type AppServerItem = + | AppServerAgentMessageItem + | AppServerContextCompactionItem + | { + type: string; + [key: string]: unknown; + }; + +export interface AppServerTurnState { + turnId?: string; + status: 'pending' | 'inProgress' | 'completed' | 'failed' | 'interrupted'; + finalAnswer: string | null; + latestAgentMessage: string | null; + errorMessage: string | null; + compactionCompleted: boolean; +} + +export type AppServerTurnEvent = + | { + method: 'turn/started'; + params?: { turn?: { id?: string | null; status?: string | null } }; + } + | { + method: 'turn/completed'; + params?: { + turn?: { + id?: string | null; + status?: string | null; + error?: + | { message?: string | null } + | string + | null; + }; + }; + } + | { + method: 'item/completed'; + params?: { item?: AppServerItem | null }; + } + | { + method: 'error'; + params?: { + error?: { + message?: string | null; + codexErrorInfo?: { + httpStatusCode?: number | null; + type?: string | null; + } | null; + } | null; + }; + }; + +export function createInitialAppServerTurnState(): AppServerTurnState { + return { + status: 'pending', + finalAnswer: null, + latestAgentMessage: null, + errorMessage: null, + compactionCompleted: false, + }; +} + +export function reduceAppServerTurnState( + state: AppServerTurnState, + event: AppServerTurnEvent, +): AppServerTurnState { + if (event.method === 'turn/started') { + return { + ...state, + turnId: event.params?.turn?.id || state.turnId, + status: 'inProgress', + }; + } + + if (event.method === 'item/completed') { + const item = event.params?.item; + if (!item) return state; + + if (item.type === 'agentMessage') { + const text = + typeof item.text === 'string' && item.text.trim().length > 0 + ? item.text + : null; + if (!text) return state; + + if (item.phase === 'final_answer') { + return { + ...state, + finalAnswer: text, + latestAgentMessage: text, + }; + } + + return { + ...state, + latestAgentMessage: text, + }; + } + + if (item.type === 'contextCompaction') { + return { + ...state, + compactionCompleted: true, + }; + } + + return state; + } + + if (event.method === 'error') { + const error = event.params?.error; + const message = + typeof error?.message === 'string' && error.message.trim().length > 0 + ? error.message.trim() + : 'Codex app-server turn failed.'; + const httpStatusCode = error?.codexErrorInfo?.httpStatusCode; + + return { + ...state, + errorMessage: + typeof httpStatusCode === 'number' + ? `${message} (HTTP ${httpStatusCode})` + : message, + }; + } + + if (event.method === 'turn/completed') { + const turn = event.params?.turn; + const status = + turn?.status === 'completed' || + turn?.status === 'failed' || + turn?.status === 'interrupted' + ? turn.status + : 'completed'; + const turnError = + typeof turn?.error === 'string' + ? turn.error + : turn?.error?.message || null; + + return { + ...state, + turnId: turn?.id || state.turnId, + status, + errorMessage: turnError || state.errorMessage, + }; + } + + return state; +} + +export function isAppServerTurnFinished(state: AppServerTurnState): boolean { + return ( + state.status === 'completed' || + state.status === 'failed' || + state.status === 'interrupted' + ); +} + +export function getAppServerTurnResult( + state: AppServerTurnState, +): string | null { + return state.finalAnswer || state.latestAgentMessage; +} diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts new file mode 100644 index 0000000..6dc6a2e --- /dev/null +++ b/runners/codex-runner/src/index.ts @@ -0,0 +1,631 @@ +/** + * NanoClaw Codex Runner + * + * Default runtime is Codex app-server, with SDK fallback available via + * CODEX_RUNTIME=sdk or automatic fallback when app-server startup fails. + * + * Input protocol: + * Stdin: Full ContainerInput JSON (read until EOF) + * IPC: Follow-up messages as JSON files in $NANOCLAW_IPC_DIR/input/ + * Sentinel: _close — signals session end + * + * Stdout protocol: + * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. + */ + +import { Codex, type Thread, type ThreadOptions, type UserInput } from '@openai/codex-sdk'; +import fs from 'fs'; +import path from 'path'; + +import { + CodexAppServerClient, + type AppServerInputItem, +} from './app-server-client.js'; + +// ── Types ────────────────────────────────────────────────────────── + +interface ContainerInput { + prompt: string; + sessionId?: string; + groupFolder: string; + chatJid: string; + isMain: boolean; + isScheduledTask?: boolean; + assistantName?: string; + agentType?: string; +} + +interface ContainerOutput { + status: 'success' | 'error'; + result: string | null; + newSessionId?: string; + error?: string; +} + +// ── Constants ────────────────────────────────────────────────────── + +const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; +const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; +const WORK_DIR = process.env.NANOCLAW_WORK_DIR || ''; +const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); +const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); +const IPC_POLL_MS = 500; +const MAX_TURNS = 100; +const CODEX_RUNTIME = (process.env.CODEX_RUNTIME || 'app-server').toLowerCase(); + +const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; +const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; + +const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR; +const CODEX_MODEL = process.env.CODEX_MODEL || ''; +const CODEX_EFFORT = process.env.CODEX_EFFORT || ''; + +let closeRequested = false; + +// ── Helpers ──────────────────────────────────────────────────────── + +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(`[codex-runner] ${message}`); +} + +async function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk: string) => { + data += chunk; + }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', reject); + }); +} + +function consumeCloseSentinel(): boolean { + if (closeRequested) return true; + if (!fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) return false; + + try { + fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); + } catch { + /* ignore */ + } + closeRequested = true; + return true; +} + +function drainIpcInput(): string[] { + try { + fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); + const files = fs + .readdirSync(IPC_INPUT_DIR) + .filter((file) => file.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 []; + } +} + +function waitForIpcMessage(): Promise { + return new Promise((resolve) => { + const poll = () => { + if (consumeCloseSentinel()) { + resolve(null); + return; + } + const messages = drainIpcInput(); + if (messages.length > 0) { + resolve(messages.join('\n')); + return; + } + setTimeout(poll, IPC_POLL_MS); + }; + poll(); + }); +} + +function extractImagePaths(text: string): { cleanText: string; imagePaths: string[] } { + const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g; + const imagePaths: string[] = []; + let match: RegExpExecArray | null; + while ((match = imagePattern.exec(text)) !== null) { + imagePaths.push(match[1].trim()); + } + + return { + cleanText: text.replace(imagePattern, '').trim(), + imagePaths, + }; +} + +function parseSdkInput(text: string): string | UserInput[] { + const { cleanText, imagePaths } = extractImagePaths(text); + if (imagePaths.length === 0) return text; + + const input: UserInput[] = []; + if (cleanText) { + input.push({ type: 'text', text: cleanText }); + } + for (const imgPath of imagePaths) { + if (fs.existsSync(imgPath)) { + input.push({ type: 'local_image', path: imgPath }); + log(`Adding image input: ${imgPath}`); + } else { + log(`Image not found, skipping: ${imgPath}`); + } + } + return input.length > 0 ? input : text; +} + +function parseAppServerInput(text: string): AppServerInputItem[] { + const { cleanText, imagePaths } = extractImagePaths(text); + const input: AppServerInputItem[] = []; + + if (cleanText) { + input.push({ type: 'text', text: cleanText }); + } + + for (const imgPath of imagePaths) { + if (fs.existsSync(imgPath)) { + input.push({ type: 'localImage', path: imgPath }); + log(`Adding image input: ${imgPath}`); + } else { + log(`Image not found, skipping: ${imgPath}`); + } + } + + if (input.length === 0) { + input.push({ type: 'text', text }); + } + + return input; +} + +function getThreadOptions(): ThreadOptions { + const threadOptions: ThreadOptions = { + workingDirectory: EFFECTIVE_CWD, + approvalPolicy: 'never', + sandboxMode: 'danger-full-access', + networkAccessEnabled: true, + webSearchMode: 'live', + }; + if (CODEX_MODEL) threadOptions.model = CODEX_MODEL; + if (CODEX_EFFORT) { + threadOptions.modelReasoningEffort = + CODEX_EFFORT as ThreadOptions['modelReasoningEffort']; + } + return threadOptions; +} + +async function executeSdkTurn( + thread: Thread, + input: string | UserInput[], +): Promise<{ result: string; error?: string }> { + const ac = new AbortController(); + + let turnSeconds = 0; + const sentinel = setInterval(() => { + if (consumeCloseSentinel()) { + log('Close sentinel detected during SDK turn, aborting'); + ac.abort(); + return; + } + turnSeconds += 5; + if (turnSeconds % 60 === 0) { + log(`Turn in progress... (${Math.round(turnSeconds / 60)}min)`); + } + }, 5000); + + try { + const turn = await thread.run(input, { signal: ac.signal }); + return { result: turn.finalResponse }; + } catch (err) { + if (ac.signal.aborted) { + return { result: '' }; + } + return { + result: '', + error: err instanceof Error ? err.message : String(err), + }; + } finally { + clearInterval(sentinel); + } +} + +async function executeAppServerTurn( + client: CodexAppServerClient, + threadId: string, + prompt: string, +): Promise<{ result: string; error?: string }> { + const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), { + cwd: EFFECTIVE_CWD, + model: CODEX_MODEL || undefined, + effort: CODEX_EFFORT || undefined, + }); + + let elapsedMs = 0; + let polling = true; + const pollDuringTurn = async () => { + if (!polling) return; + + if (consumeCloseSentinel()) { + log('Close sentinel detected during app-server turn, interrupting'); + polling = false; + try { + await activeTurn.interrupt(); + } catch (err) { + log( + `Failed to interrupt active turn: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + return; + } + + const messages = drainIpcInput(); + if (messages.length > 0) { + const merged = messages.join('\n'); + log(`Steering active turn with ${messages.length} queued message(s)`); + try { + await activeTurn.steer(parseAppServerInput(merged)); + } catch (err) { + log( + `turn/steer failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + elapsedMs += IPC_POLL_MS; + if (elapsedMs > 0 && elapsedMs % 60000 === 0) { + log(`Turn in progress... (${Math.round(elapsedMs / 60000)}min)`); + } + setTimeout(() => void pollDuringTurn(), IPC_POLL_MS); + }; + + setTimeout(() => void pollDuringTurn(), IPC_POLL_MS); + + try { + const { state, result } = await activeTurn.wait(); + if (state.status === 'completed') { + return { result: result || '' }; + } + if (state.status === 'interrupted' && consumeCloseSentinel()) { + return { result: result || '' }; + } + return { + result: result || '', + error: state.errorMessage || `Codex turn finished with status ${state.status}`, + }; + } finally { + polling = false; + } +} + +async function runSdkSession( + containerInput: ContainerInput, + prompt: string, +): Promise { + const threadOptions = getThreadOptions(); + const codex = new Codex(); + + let thread: Thread; + if (containerInput.sessionId) { + thread = codex.resumeThread(containerInput.sessionId, threadOptions); + log(`Thread resuming (session: ${containerInput.sessionId})`); + } else { + thread = codex.startThread(threadOptions); + log('Thread started (new session)'); + } + + let turnCount = 0; + while (true) { + turnCount++; + if (turnCount > MAX_TURNS) { + log(`Turn limit reached (${MAX_TURNS}), exiting`); + writeOutput({ + status: 'success', + result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]', + newSessionId: thread.id || undefined, + }); + break; + } + + const input = parseSdkInput(prompt); + log(`Starting SDK turn ${turnCount}/${MAX_TURNS}...`); + + let { result, error } = await executeSdkTurn(thread, input); + + if (error && turnCount === 1 && containerInput.sessionId) { + log(`Resume may have failed, retrying with new thread: ${error}`); + thread = codex.startThread(threadOptions); + ({ result, error } = await executeSdkTurn(thread, input)); + } + + if (consumeCloseSentinel()) { + if (result) { + writeOutput({ + status: 'success', + result, + newSessionId: thread.id || undefined, + }); + } + log('Close sentinel detected, exiting SDK runtime'); + break; + } + + if (error) { + log(`SDK turn error: ${error}`); + writeOutput({ + status: 'error', + result: result || null, + newSessionId: thread.id || undefined, + error, + }); + } else { + writeOutput({ + status: 'success', + result: result || null, + newSessionId: thread.id || undefined, + }); + } + + log('SDK turn done, waiting for next IPC message...'); + + const nextMessage = await waitForIpcMessage(); + if (nextMessage === null) { + log('Close sentinel received, exiting SDK runtime'); + break; + } + + log(`Got new SDK message (${nextMessage.length} chars)`); + prompt = nextMessage; + } +} + +async function runAppServerCompact( + client: CodexAppServerClient, + threadId: string | undefined, +): Promise { + if (!threadId) { + writeOutput({ + status: 'success', + result: '현재 활성 Codex 세션이 없어 compact를 건너뜁니다.', + }); + return; + } + + const { state } = await client.startCompaction(threadId); + if (state.status === 'failed') { + writeOutput({ + status: 'error', + result: null, + newSessionId: threadId, + error: state.errorMessage || 'Conversation compaction failed.', + }); + return; + } + + writeOutput({ + status: 'success', + result: state.compactionCompleted + ? 'Conversation compacted.' + : 'Compaction requested but contextCompaction was not observed.', + newSessionId: threadId, + }); +} + +async function runAppServerSession( + containerInput: ContainerInput, + prompt: string, +): Promise { + const client = new CodexAppServerClient({ + cwd: EFFECTIVE_CWD, + env: process.env, + log, + }); + + await client.start(); + + let threadId: string | undefined; + try { + try { + threadId = await client.startOrResumeThread(containerInput.sessionId, { + cwd: EFFECTIVE_CWD, + model: CODEX_MODEL || undefined, + }); + log( + containerInput.sessionId + ? `App-server thread resumed (${threadId})` + : `App-server thread started (${threadId})`, + ); + } catch (err) { + if (!containerInput.sessionId) throw err; + log( + `App-server resume failed, retrying with new thread: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + threadId = await client.startOrResumeThread(undefined, { + cwd: EFFECTIVE_CWD, + model: CODEX_MODEL || undefined, + }); + log(`App-server thread restarted (${threadId})`); + } + + const trimmedPrompt = prompt.trim(); + if (trimmedPrompt === '/compact') { + await runAppServerCompact(client, threadId); + return; + } + + let turnCount = 0; + while (true) { + turnCount++; + if (turnCount > MAX_TURNS) { + log(`Turn limit reached (${MAX_TURNS}), exiting`); + writeOutput({ + status: 'success', + result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]', + newSessionId: threadId, + }); + break; + } + + log(`Starting app-server turn ${turnCount}/${MAX_TURNS}...`); + const { result, error } = await executeAppServerTurn(client, threadId, prompt); + + if (consumeCloseSentinel()) { + if (result) { + writeOutput({ + status: 'success', + result, + newSessionId: threadId, + }); + } + log('Close sentinel detected, exiting app-server runtime'); + break; + } + + if (error) { + log(`App-server turn error: ${error}`); + writeOutput({ + status: 'error', + result: result || null, + newSessionId: threadId, + error, + }); + } else { + writeOutput({ + status: 'success', + result: result || null, + newSessionId: threadId, + }); + } + + log('App-server turn done, waiting for next IPC message...'); + + const nextMessage = await waitForIpcMessage(); + if (nextMessage === null) { + log('Close sentinel received, exiting app-server runtime'); + break; + } + + log(`Got new app-server message (${nextMessage.length} chars)`); + prompt = nextMessage; + } + } finally { + await client.close(); + } +} + +function shouldUseAppServer(): boolean { + return CODEX_RUNTIME !== 'sdk'; +} + +// ── Main ────────────────────────────────────────────────────────── + +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); + } + + fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); + closeRequested = false; + try { + fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); + } catch { + /* ignore */ + } + + let prompt = containerInput.prompt; + if (containerInput.isScheduledTask) { + prompt = `[SCHEDULED TASK]\n\n${prompt}`; + } + const pending = drainIpcInput(); + if (pending.length > 0) { + prompt += '\n' + pending.join('\n'); + } + + const preferAppServer = shouldUseAppServer(); + try { + if (preferAppServer) { + try { + log(`Runtime selected: app-server (${CODEX_RUNTIME})`); + await runAppServerSession(containerInput, prompt); + return; + } catch (err) { + if (CODEX_RUNTIME === 'app-server') { + log( + `App-server runtime failed, falling back to SDK: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } else { + throw err; + } + } + } + + log('Runtime selected: sdk'); + await runSdkSession(containerInput, prompt); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log(`Runner error: ${errorMessage}`); + writeOutput({ + status: 'error', + result: null, + error: errorMessage, + }); + } +} + +main(); diff --git a/container/codex-runner/tsconfig.json b/runners/codex-runner/tsconfig.json similarity index 100% rename from container/codex-runner/tsconfig.json rename to runners/codex-runner/tsconfig.json diff --git a/container/skills/agent-browser/SKILL.md b/runners/skills/agent-browser/SKILL.md similarity index 100% rename from container/skills/agent-browser/SKILL.md rename to runners/skills/agent-browser/SKILL.md diff --git a/setup/environment.test.ts b/setup/environment.test.ts index deda62f..bc95f28 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -6,7 +6,7 @@ import Database from 'better-sqlite3'; /** * Tests for the environment check step. * - * Verifies: config detection, Docker/AC detection, DB queries. + * Verifies: config detection, platform helpers, DB queries. */ describe('environment detection', () => { @@ -28,7 +28,7 @@ describe('registered groups DB query', () => { folder TEXT NOT NULL UNIQUE, trigger_pattern TEXT NOT NULL, added_at TEXT NOT NULL, - container_config TEXT, + agent_config TEXT, requires_trigger INTEGER DEFAULT 1 )`); }); @@ -96,7 +96,7 @@ describe('credentials detection', () => { }); }); -describe('Docker detection logic', () => { +describe('platform command detection', () => { it('commandExists returns boolean', async () => { const { commandExists } = await import('./platform.js'); expect(typeof commandExists('docker')).toBe('boolean'); @@ -104,18 +104,3 @@ describe('Docker detection logic', () => { }); }); -describe('channel auth detection', () => { - it('detects non-empty auth directory', () => { - const hasAuth = (authDir: string) => { - try { - return fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; - } catch { - return false; - } - }; - - // Non-existent directory - expect(hasAuth('/tmp/nonexistent_auth_dir_xyz')).toBe(false); - }); -}); - diff --git a/setup/groups.ts b/setup/groups.ts index 6697029..941b81c 100644 --- a/setup/groups.ts +++ b/setup/groups.ts @@ -1,10 +1,7 @@ /** - * Step: groups — Fetch group metadata from messaging platforms, write to DB. - * WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating). - * Other channels discover group names at runtime — this step auto-skips for them. - * Replaces 05-sync-groups.sh + 05b-list-groups.sh + * Step: groups — List known Discord groups from the local chat metadata store. + * Discord channel names are discovered at runtime, so the sync path is a no-op. */ -import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -28,7 +25,6 @@ function parseArgs(args: string[]): { list: boolean; limit: number } { } export async function run(args: string[]): Promise { - const projectRoot = process.cwd(); const { list, limit } = parseArgs(args); if (list) { @@ -36,7 +32,7 @@ export async function run(args: string[]): Promise { return; } - await syncGroups(projectRoot); + await syncGroups(); } async function listGroups(limit: number): Promise { @@ -51,9 +47,9 @@ async function listGroups(limit: number): Promise { const rows = db .prepare( `SELECT jid, name FROM chats - WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid - ORDER BY last_message_time DESC - LIMIT ?`, + WHERE jid LIKE 'dc:%' AND is_group = 1 AND jid <> '__group_sync__' AND name <> jid + ORDER BY last_message_time DESC + LIMIT ?`, ) .all(limit) as Array<{ jid: string; name: string }>; db.close(); @@ -63,167 +59,33 @@ async function listGroups(limit: number): Promise { } } -async function syncGroups(projectRoot: string): Promise { - // Only WhatsApp needs an upfront group sync; other channels resolve names at runtime. - // Detect WhatsApp by checking for auth credentials on disk. - const authDir = path.join(projectRoot, 'store', 'auth'); - const hasWhatsAppAuth = - fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; - - if (!hasWhatsAppAuth) { - logger.info('WhatsApp auth not found — skipping group sync'); - emitStatus('SYNC_GROUPS', { - BUILD: 'skipped', - SYNC: 'skipped', - GROUPS_IN_DB: 0, - REASON: 'whatsapp_not_configured', - STATUS: 'success', - LOG: 'logs/setup.log', - }); - return; - } - - // Build TypeScript first - logger.info('Building TypeScript'); - let buildOk = false; - try { - execSync('npm run build', { - cwd: projectRoot, - stdio: ['ignore', 'pipe', 'pipe'], - }); - buildOk = true; - logger.info('Build succeeded'); - } catch { - logger.error('Build failed'); - emitStatus('SYNC_GROUPS', { - BUILD: 'failed', - SYNC: 'skipped', - GROUPS_IN_DB: 0, - STATUS: 'failed', - ERROR: 'build_failed', - LOG: 'logs/setup.log', - }); - process.exit(1); - } - - // Run sync script via a temp file to avoid shell escaping issues with node -e - logger.info('Fetching group metadata'); - let syncOk = false; - try { - const syncScript = ` -import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys'; -import pino from 'pino'; -import path from 'path'; -import fs from 'fs'; -import Database from 'better-sqlite3'; - -const logger = pino({ level: 'silent' }); -const authDir = path.join('store', 'auth'); -const dbPath = path.join('store', 'messages.db'); - -if (!fs.existsSync(authDir)) { - console.error('NO_AUTH'); - process.exit(1); -} - -const db = new Database(dbPath); -db.pragma('journal_mode = WAL'); -db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)'); - -const upsert = db.prepare( - 'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name' -); - -const { state, saveCreds } = await useMultiFileAuthState(authDir); - -const sock = makeWASocket({ - auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) }, - printQRInTerminal: false, - logger, - browser: Browsers.macOS('Chrome'), -}); - -const timeout = setTimeout(() => { - console.error('TIMEOUT'); - process.exit(1); -}, 30000); - -sock.ev.on('creds.update', saveCreds); - -sock.ev.on('connection.update', async (update) => { - if (update.connection === 'open') { - try { - const groups = await sock.groupFetchAllParticipating(); - const now = new Date().toISOString(); - let count = 0; - for (const [jid, metadata] of Object.entries(groups)) { - if (metadata.subject) { - upsert.run(jid, metadata.subject, now); - count++; - } - } - console.log('SYNCED:' + count); - } catch (err) { - console.error('FETCH_ERROR:' + err.message); - } finally { - clearTimeout(timeout); - sock.end(undefined); - db.close(); - process.exit(0); - } - } else if (update.connection === 'close') { - clearTimeout(timeout); - console.error('CONNECTION_CLOSED'); - process.exit(1); - } -}); -`; - - const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs'); - fs.writeFileSync(tmpScript, syncScript, 'utf-8'); - try { - const output = execSync(`node ${tmpScript}`, { - cwd: projectRoot, - encoding: 'utf-8', - timeout: 45000, - stdio: ['ignore', 'pipe', 'pipe'], - }); - syncOk = output.includes('SYNCED:'); - logger.info({ output: output.trim() }, 'Sync output'); - } finally { - try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ } - } - } catch (err) { - logger.error({ err }, 'Sync failed'); - } - - // Count groups in DB using better-sqlite3 (no sqlite3 CLI) - let groupsInDb = 0; +async function syncGroups(): Promise { const dbPath = path.join(STORE_DIR, 'messages.db'); + let groupsInDb = 0; + if (fs.existsSync(dbPath)) { try { const db = new Database(dbPath, { readonly: true }); const row = db .prepare( - "SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'", + `SELECT COUNT(*) as count FROM chats + WHERE jid LIKE 'dc:%' AND is_group = 1 AND jid <> '__group_sync__' AND name <> jid`, ) .get() as { count: number }; groupsInDb = row.count; db.close(); - } catch { - // DB may not exist yet + } catch (err) { + logger.warn({ err }, 'Failed to count Discord groups during setup'); } } - const status = syncOk ? 'success' : 'failed'; - + logger.info({ groupsInDb }, 'Discord groups are discovered at runtime'); emitStatus('SYNC_GROUPS', { - BUILD: buildOk ? 'success' : 'failed', - SYNC: syncOk ? 'success' : 'failed', + BUILD: 'skipped', + SYNC: 'skipped', GROUPS_IN_DB: groupsInDb, - STATUS: status, + REASON: 'discord_runtime_sync', + STATUS: 'success', LOG: 'logs/setup.log', }); - - if (status === 'failed') process.exit(1); } diff --git a/setup/index.ts b/setup/index.ts index 8757880..39c55e4 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -10,10 +10,9 @@ const STEPS: Record< () => Promise<{ run: (args: string[]) => Promise }> > = { environment: () => import('./environment.js'), - runners: () => import('./container.js'), + runners: () => import('./runners.js'), groups: () => import('./groups.js'), register: () => import('./register.js'), - mounts: () => import('./mounts.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), }; diff --git a/setup/mounts.ts b/setup/mounts.ts deleted file mode 100644 index eb2a5f6..0000000 --- a/setup/mounts.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Step: mounts — Write mount allowlist config file. - * Replaces 07-configure-mounts.sh - */ -import fs from 'fs'; -import path from 'path'; -import os from 'os'; - -import { logger } from '../src/logger.js'; -import { isRoot } from './platform.js'; -import { emitStatus } from './status.js'; - -function parseArgs(args: string[]): { empty: boolean; json: string } { - let empty = false; - let json = ''; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--empty') empty = true; - if (args[i] === '--json' && args[i + 1]) { - json = args[i + 1]; - i++; - } - } - return { empty, json }; -} - -export async function run(args: string[]): Promise { - const { empty, json } = parseArgs(args); - const homeDir = os.homedir(); - const configDir = path.join(homeDir, '.config', 'nanoclaw'); - const configFile = path.join(configDir, 'mount-allowlist.json'); - - if (isRoot()) { - logger.warn( - 'Running as root — mount allowlist will be written to root home directory', - ); - } - - fs.mkdirSync(configDir, { recursive: true }); - - let allowedRoots = 0; - let nonMainReadOnly = 'true'; - - if (empty) { - logger.info('Writing empty mount allowlist'); - const emptyConfig = { - allowedRoots: [], - blockedPatterns: [], - nonMainReadOnly: true, - }; - fs.writeFileSync(configFile, JSON.stringify(emptyConfig, null, 2) + '\n'); - } else if (json) { - // Validate JSON with JSON.parse (not piped through shell) - let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean }; - try { - parsed = JSON.parse(json); - } catch { - logger.error('Invalid JSON input'); - emitStatus('CONFIGURE_MOUNTS', { - PATH: configFile, - ALLOWED_ROOTS: 0, - NON_MAIN_READ_ONLY: 'unknown', - STATUS: 'failed', - ERROR: 'invalid_json', - LOG: 'logs/setup.log', - }); - process.exit(4); - return; // unreachable but satisfies TS - } - - fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n'); - allowedRoots = Array.isArray(parsed.allowedRoots) - ? parsed.allowedRoots.length - : 0; - nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true'; - } else { - // Read from stdin - logger.info('Reading mount allowlist from stdin'); - const input = fs.readFileSync(0, 'utf-8'); - let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean }; - try { - parsed = JSON.parse(input); - } catch { - logger.error('Invalid JSON from stdin'); - emitStatus('CONFIGURE_MOUNTS', { - PATH: configFile, - ALLOWED_ROOTS: 0, - NON_MAIN_READ_ONLY: 'unknown', - STATUS: 'failed', - ERROR: 'invalid_json', - LOG: 'logs/setup.log', - }); - process.exit(4); - return; - } - - fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n'); - allowedRoots = Array.isArray(parsed.allowedRoots) - ? parsed.allowedRoots.length - : 0; - nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true'; - } - - logger.info( - { configFile, allowedRoots, nonMainReadOnly }, - 'Allowlist configured', - ); - - emitStatus('CONFIGURE_MOUNTS', { - PATH: configFile, - ALLOWED_ROOTS: allowedRoots, - NON_MAIN_READ_ONLY: nonMainReadOnly, - STATUS: 'success', - LOG: 'logs/setup.log', - }); -} diff --git a/setup/register.test.ts b/setup/register.test.ts index d47d95c..256f10e 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -17,7 +17,7 @@ function createTestDb(): Database.Database { folder TEXT NOT NULL UNIQUE, trigger_pattern TEXT NOT NULL, added_at TEXT NOT NULL, - container_config TEXT, + agent_config TEXT, requires_trigger INTEGER DEFAULT 1, is_main INTEGER DEFAULT 0 )`); @@ -34,7 +34,7 @@ describe('parameterized SQL registration', () => { it('registers a group with parameterized query', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) + (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '123@g.us', @@ -67,7 +67,7 @@ describe('parameterized SQL registration', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) + (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '456@g.us', @@ -92,7 +92,7 @@ describe('parameterized SQL registration', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) + (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1); @@ -113,10 +113,10 @@ describe('parameterized SQL registration', () => { it('handles requiresTrigger=false', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) + (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( - '789@s.whatsapp.net', + 'dc:789', 'Personal', 'main', '@Andy', @@ -126,7 +126,7 @@ describe('parameterized SQL registration', () => { const row = db .prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?') - .get('789@s.whatsapp.net') as { requires_trigger: number }; + .get('dc:789') as { requires_trigger: number }; expect(row.requires_trigger).toBe(0); }); @@ -134,12 +134,12 @@ describe('parameterized SQL registration', () => { it('stores is_main flag', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) + (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, ).run( - '789@s.whatsapp.net', + 'dc:789', 'Personal', - 'whatsapp_main', + 'discord_main', '@Andy', '2024-01-01T00:00:00.000Z', 0, @@ -148,7 +148,7 @@ describe('parameterized SQL registration', () => { const row = db .prepare('SELECT is_main FROM registered_groups WHERE jid = ?') - .get('789@s.whatsapp.net') as { is_main: number }; + .get('dc:789') as { is_main: number }; expect(row.is_main).toBe(1); }); @@ -156,12 +156,12 @@ describe('parameterized SQL registration', () => { it('defaults is_main to 0', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) + (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '123@g.us', 'Some Group', - 'whatsapp_some-group', + 'discord_some-group', '@Andy', '2024-01-01T00:00:00.000Z', 1, @@ -177,7 +177,7 @@ describe('parameterized SQL registration', () => { it('upserts on conflict', () => { const stmt = db.prepare( `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) + (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ); diff --git a/setup/register.ts b/setup/register.ts index 03ea7df..0adda1f 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -1,7 +1,7 @@ /** * Step: register — Write channel registration config, create group folders. * - * Accepts --channel to specify the messaging platform (whatsapp, telegram, slack, discord). + * NanoClaw is Discord-only, so registrations must target Discord channel IDs. * Uses parameterized SQL queries to prevent injection. */ import fs from 'fs'; @@ -9,7 +9,7 @@ import path from 'path'; import Database from 'better-sqlite3'; -import { STORE_DIR } from '../src/config.js'; +import { SERVICE_AGENT_TYPE, STORE_DIR } from '../src/config.js'; import { isValidGroupFolder } from '../src/group-folder.js'; import { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; @@ -31,7 +31,7 @@ function parseArgs(args: string[]): RegisterArgs { name: '', trigger: '', folder: '', - channel: 'whatsapp', // backward-compat: pre-refactor installs omit --channel + channel: 'discord', requiresTrigger: true, isMain: false, assistantName: 'Andy', @@ -91,10 +91,18 @@ export async function run(args: string[]): Promise { process.exit(4); } + if (parsed.channel !== 'discord') { + emitStatus('REGISTER_CHANNEL', { + STATUS: 'failed', + ERROR: 'unsupported_channel', + LOG: 'logs/setup.log', + }); + process.exit(4); + } + logger.info(parsed, 'Registering channel'); - // Ensure data and store directories exist (store/ may not exist on - // fresh installs that skip WhatsApp auth, which normally creates it) + // Ensure data and store directories exist for fresh installs. fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true }); fs.mkdirSync(STORE_DIR, { recursive: true }); @@ -106,22 +114,40 @@ export async function run(args: string[]): Promise { const db = new Database(dbPath); // Ensure schema exists db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( - jid TEXT PRIMARY KEY, + jid TEXT NOT NULL, name TEXT NOT NULL, - folder TEXT NOT NULL UNIQUE, + folder TEXT NOT NULL, trigger_pattern TEXT NOT NULL, added_at TEXT NOT NULL, - container_config TEXT, + agent_config TEXT, requires_trigger INTEGER DEFAULT 1, - is_main INTEGER DEFAULT 0 + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) )`); + try { + db.exec( + `ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`, + ); + } catch { + /* column already exists */ + } + + try { + db.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`); + } catch { + /* column already exists */ + } + const isMainInt = parsed.isMain ? 1 : 0; db.prepare( `INSERT OR REPLACE INTO registered_groups - (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, + (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, NULL)`, ).run( parsed.jid, parsed.name, @@ -130,6 +156,7 @@ export async function run(args: string[]): Promise { timestamp, requiresTriggerInt, isMainInt, + SERVICE_AGENT_TYPE, ); db.close(); diff --git a/setup/container.ts b/setup/runners.ts similarity index 90% rename from setup/container.ts rename to setup/runners.ts index cdcb92d..f0264e8 100644 --- a/setup/container.ts +++ b/setup/runners.ts @@ -1,6 +1,5 @@ /** - * Step: runners — Build agent runners (no container needed). - * Agents run as direct host processes. + * Step: runners — Build the Claude and Codex runner packages. */ import { execSync } from 'child_process'; import fs from 'fs'; @@ -29,14 +28,14 @@ export async function run(_args: string[]): Promise { // Verify runner entry points exist const agentRunner = path.join( projectRoot, - 'container', + 'runners', 'agent-runner', 'dist', 'index.js', ); const codexRunner = path.join( projectRoot, - 'container', + 'runners', 'codex-runner', 'dist', 'index.js', diff --git a/setup/verify.ts b/setup/verify.ts index 5b1eba6..5857b35 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -6,7 +6,6 @@ */ import { execSync } from 'child_process'; import fs from 'fs'; -import os from 'os'; import path from 'path'; import Database from 'better-sqlite3'; @@ -14,18 +13,12 @@ import Database from 'better-sqlite3'; import { STORE_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { logger } from '../src/logger.js'; -import { - getPlatform, - getServiceManager, - hasSystemd, - isRoot, -} from './platform.js'; +import { getPlatform, getServiceManager, isRoot } from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); const platform = getPlatform(); - const homeDir = os.homedir(); logger.info('Starting verification'); @@ -93,31 +86,10 @@ export async function run(_args: string[]): Promise { } // 3. Check channel auth (detect configured channels by credentials) - const envVars = readEnvFile([ - 'TELEGRAM_BOT_TOKEN', - 'SLACK_BOT_TOKEN', - 'SLACK_APP_TOKEN', - 'DISCORD_BOT_TOKEN', - ]); + const envVars = readEnvFile(['DISCORD_BOT_TOKEN']); const channelAuth: Record = {}; - // WhatsApp: check for auth credentials on disk - const authDir = path.join(projectRoot, 'store', 'auth'); - if (fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0) { - channelAuth.whatsapp = 'authenticated'; - } - - // Token-based channels: check .env - if (process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN) { - channelAuth.telegram = 'configured'; - } - if ( - (process.env.SLACK_BOT_TOKEN || envVars.SLACK_BOT_TOKEN) && - (process.env.SLACK_APP_TOKEN || envVars.SLACK_APP_TOKEN) - ) { - channelAuth.slack = 'configured'; - } if (process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) { channelAuth.discord = 'configured'; } @@ -141,16 +113,6 @@ export async function run(_args: string[]): Promise { } } - // 5. Check mount allowlist - let mountAllowlist = 'missing'; - if ( - fs.existsSync( - path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'), - ) - ) { - mountAllowlist = 'configured'; - } - // Determine overall status const status = service === 'running' && @@ -168,7 +130,6 @@ export async function run(_args: string[]): Promise { CONFIGURED_CHANNELS: configuredChannels.join(','), CHANNEL_AUTH: JSON.stringify(channelAuth), REGISTERED_GROUPS: registeredGroups, - MOUNT_ALLOWLIST: mountAllowlist, STATUS: status, LOG: 'logs/setup.log', }); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index da252e9..3ba7227 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -18,6 +18,11 @@ import { import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { logger } from './logger.js'; import { readEnvFile } from './env.js'; +import { isPairedRoomJid } from './db.js'; +import { + readPairedRoomPrompt, + readPlatformPrompt, +} from './platform-prompts.js'; import { RegisteredGroup } from './types.js'; // Sentinel markers for robust output parsing (must match agent-runner) @@ -29,6 +34,7 @@ export interface AgentInput { sessionId?: string; groupFolder: string; chatJid: string; + runId?: string; isMain: boolean; isScheduledTask?: boolean; assistantName?: string; @@ -49,6 +55,7 @@ export interface AgentOutput { function prepareGroupEnvironment( group: RegisteredGroup, isMain: boolean, + chatJid: string, ): { env: Record; groupDir: string; runnerDir: string } { const projectRoot = process.cwd(); const groupDir = resolveGroupFolderPath(group.folder); @@ -82,14 +89,14 @@ function prepareGroupEnvironment( } // Sync skills into each group's .claude/ session dir - // Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) container/skills/ + // Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) runners/skills/ const workDirClaude = group.workDir ? path.join(group.workDir, '.claude') : null; const skillSources = [ path.join(os.homedir(), '.claude', 'skills'), ...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []), - path.join(projectRoot, 'container', 'skills'), + path.join(projectRoot, 'runners', 'skills'), ]; const skillsDst = path.join(groupSessionsDir, 'skills'); for (const src of skillSources) { @@ -114,21 +121,36 @@ function prepareGroupEnvironment( // Global memory directory (for non-main groups) const globalDir = path.join(GROUPS_DIR, 'global'); + const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md'); + const isPairedRoom = isPairedRoomJid(chatJid); - // Additional mount directories (validated) - const extraDirs: string[] = []; - if (group.agentConfig?.additionalMounts) { - for (const mount of group.agentConfig.additionalMounts) { - if (fs.existsSync(mount.hostPath)) { - extraDirs.push(mount.hostPath); - } - } + const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot); + const claudePairedRoomPrompt = isPairedRoom + ? readPairedRoomPrompt('claude-code', projectRoot) + : undefined; + const globalClaudeMemory = + !isMain && fs.existsSync(globalClaudeMdPath) + ? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim() + : undefined; + const sessionClaudeMd = [ + claudePlatformPrompt, + claudePairedRoomPrompt, + globalClaudeMemory, + ] + .filter((value): value is string => Boolean(value)) + .join('\n\n---\n\n') + .trim(); + const sessionClaudeMdPath = path.join(groupSessionsDir, 'CLAUDE.md'); + if (sessionClaudeMd) { + fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n'); + } else if (fs.existsSync(sessionClaudeMdPath)) { + fs.unlinkSync(sessionClaudeMdPath); } // Determine runner directory const agentType = group.agentType || 'claude-code'; const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner'; - const runnerDir = path.join(projectRoot, 'container', runnerDirName); + const runnerDir = path.join(projectRoot, 'runners', runnerDirName); // Build environment variables for the runner process const envVars = readEnvFile([ @@ -170,7 +192,6 @@ function prepareGroupEnvironment( NANOCLAW_GROUP_DIR: groupDir, NANOCLAW_IPC_DIR: groupIpcDir, NANOCLAW_GLOBAL_DIR: globalDir, - NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '', // Working directory override (agent uses this as cwd instead of group dir) ...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}), // MCP server context @@ -214,18 +235,32 @@ function prepareGroupEnvironment( const authSrc = path.join(hostCodexDir, 'auth.json'); const authDst = path.join(sessionCodexDir, 'auth.json'); if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst); - for (const file of ['config.toml', 'config.json', 'instructions.md']) { + for (const file of ['config.toml', 'config.json']) { const src = path.join(hostCodexDir, file); const dst = path.join(sessionCodexDir, file); if (fs.existsSync(src)) { fs.copyFileSync(src, dst); } } + const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md'); + const codexPlatformPrompt = readPlatformPrompt('codex', projectRoot); + const codexPairedRoomPrompt = isPairedRoom + ? readPairedRoomPrompt('codex', projectRoot) + : undefined; + const sessionAgents = [codexPlatformPrompt, codexPairedRoomPrompt] + .filter((value): value is string => Boolean(value)) + .join('\n\n---\n\n') + .trim(); + if (sessionAgents) { + fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n'); + } else if (fs.existsSync(sessionAgentsPath)) { + fs.unlinkSync(sessionAgentsPath); + } // Sync skills into Codex session dir - // SSOT: ~/.claude/skills/ (shared with Claude Code) + container/skills/ + // SSOT: ~/.claude/skills/ (shared with Claude Code) + runners/skills/ const codexSkillSources = [ path.join(os.homedir(), '.claude', 'skills'), - path.join(projectRoot, 'container', 'skills'), + path.join(projectRoot, 'runners', 'skills'), ]; const codexSkillsDst = path.join(sessionCodexDir, 'skills'); for (const src of codexSkillSources) { @@ -245,7 +280,7 @@ function prepareGroupEnvironment( // Inject nanoclaw MCP server into Codex config.toml const mcpServerPath = path.join( projectRoot, - 'container', + 'runners', 'agent-runner', 'dist', 'ipc-mcp-stdio.js', @@ -323,17 +358,22 @@ export async function runAgentProcess( const { env, groupDir, runnerDir } = prepareGroupEnvironment( group, input.isMain, + input.chatJid, ); + if (input.runId) { + env.NANOCLAW_RUN_ID = input.runId; + } const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); - const processName = `nanoclaw-${safeName}-${Date.now()}`; + const processSuffix = input.runId || `${Date.now()}`; + const processName = `nanoclaw-${safeName}-${processSuffix}`; // Check if runner is built const distEntry = path.join(runnerDir, 'dist', 'index.js'); if (!fs.existsSync(distEntry)) { logger.error( - { runnerDir }, - 'Runner not built. Run: cd container/agent-runner && npm install && npm run build', + { runnerDir, chatJid: input.chatJid, runId: input.runId }, + 'Runner not built. Run: cd runners/agent-runner && npm install && npm run build', ); return { status: 'error', @@ -345,6 +385,9 @@ export async function runAgentProcess( logger.info( { group: group.name, + chatJid: input.chatJid, + groupFolder: input.groupFolder, + runId: input.runId, processName, agentType: group.agentType || 'claude-code', isMain: input.isMain, @@ -386,7 +429,12 @@ export async function runAgentProcess( stdout += chunk.slice(0, remaining); stdoutTruncated = true; logger.warn( - { group: group.name, size: stdout.length }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + size: stdout.length, + }, 'Agent stdout truncated due to size limit', ); } else { @@ -416,7 +464,12 @@ export async function runAgentProcess( outputChain = outputChain.then(() => onOutput(parsed)); } catch (err) { logger.warn( - { group: group.name, error: err }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + error: err, + }, 'Failed to parse streamed output chunk', ); } @@ -424,22 +477,6 @@ export async function runAgentProcess( } }); - proc.stderr.on('data', (data) => { - const chunk = data.toString(); - const lines = chunk.trim().split('\n'); - for (const line of lines) { - if (line) logger.debug({ agent: group.folder }, line); - } - if (stderrTruncated) return; - const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length; - if (chunk.length > remaining) { - stderr += chunk.slice(0, remaining); - stderrTruncated = true; - } else { - stderr += chunk; - } - }); - let timedOut = false; let hadStreamingOutput = false; const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT; @@ -448,7 +485,12 @@ export async function runAgentProcess( const killOnTimeout = () => { timedOut = true; logger.error( - { group: group.name, processName }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + processName, + }, 'Agent timeout, sending SIGTERM', ); proc.kill('SIGTERM'); @@ -465,6 +507,35 @@ export async function runAgentProcess( timeout = setTimeout(killOnTimeout, timeoutMs); }; + proc.stderr.on('data', (data) => { + const chunk = data.toString(); + const lines = chunk.trim().split('\n'); + for (const line of lines) { + if (!line) continue; + if (line.includes('Turn in progress')) { + logger.info( + { group: group.name, chatJid: input.chatJid, runId: input.runId }, + line.replace(/^\[.*?\]\s*/, ''), + ); + } else { + logger.debug( + { agent: group.folder, chatJid: input.chatJid, runId: input.runId }, + line, + ); + } + } + // Stderr activity means agent is alive — reset timeout + resetTimeout(); + if (stderrTruncated) return; + const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length; + if (chunk.length > remaining) { + stderr += chunk.slice(0, remaining); + stderrTruncated = true; + } else { + stderr += chunk; + } + }); + proc.on('close', (code) => { clearTimeout(timeout); const duration = Date.now() - startTime; @@ -472,11 +543,13 @@ export async function runAgentProcess( if (timedOut) { const ts = new Date().toISOString().replace(/[:.]/g, '-'); fs.writeFileSync( - path.join(logsDir, `agent-${ts}.log`), + path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`), [ `=== Agent Run Log (TIMEOUT) ===`, `Timestamp: ${new Date().toISOString()}`, `Group: ${group.name}`, + `ChatJid: ${input.chatJid}`, + `RunId: ${input.runId || 'n/a'}`, `Process: ${processName}`, `Duration: ${duration}ms`, `Exit Code: ${code}`, @@ -486,7 +559,14 @@ export async function runAgentProcess( if (hadStreamingOutput) { logger.info( - { group: group.name, processName, duration, code }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + processName, + duration, + code, + }, 'Agent timed out after output (idle cleanup)', ); outputChain.then(() => { @@ -504,7 +584,10 @@ export async function runAgentProcess( } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logFile = path.join(logsDir, `agent-${timestamp}.log`); + const logFile = path.join( + logsDir, + `agent-${input.runId || 'adhoc'}-${timestamp}.log`, + ); const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; @@ -512,6 +595,9 @@ export async function runAgentProcess( `=== Agent Run Log ===`, `Timestamp: ${new Date().toISOString()}`, `Group: ${group.name}`, + `ChatJid: ${input.chatJid}`, + `GroupFolder: ${input.groupFolder}`, + `RunId: ${input.runId || 'n/a'}`, `IsMain: ${input.isMain}`, `AgentType: ${group.agentType || 'claude-code'}`, `Duration: ${duration}ms`, @@ -542,7 +628,14 @@ export async function runAgentProcess( if (code !== 0) { logger.error( - { group: group.name, code, duration, logFile }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + code, + duration, + logFile, + }, 'Agent exited with error', ); resolve({ @@ -556,7 +649,13 @@ export async function runAgentProcess( if (onOutput) { outputChain.then(() => { logger.info( - { group: group.name, duration, newSessionId }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + duration, + newSessionId, + }, 'Agent completed (streaming mode)', ); resolve({ status: 'success', result: null, newSessionId }); @@ -579,13 +678,24 @@ export async function runAgentProcess( } const output: AgentOutput = JSON.parse(jsonLine); logger.info( - { group: group.name, duration, status: output.status }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + duration, + status: output.status, + }, 'Agent completed', ); resolve(output); } catch (err) { logger.error( - { group: group.name, error: err }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + error: err, + }, 'Failed to parse agent output', ); resolve({ @@ -599,7 +709,13 @@ export async function runAgentProcess( proc.on('error', (err) => { clearTimeout(timeout); logger.error( - { group: group.name, processName, error: err }, + { + group: group.name, + chatJid: input.chatJid, + runId: input.runId, + processName, + error: err, + }, 'Agent spawn error', ); resolve({ @@ -644,7 +760,7 @@ export function writeGroupsSnapshot( groupFolder: string, isMain: boolean, groups: AvailableGroup[], - registeredJids: Set, + _registeredJids?: Set, ): void { const groupIpcDir = resolveGroupIpcPath(groupFolder); fs.mkdirSync(groupIpcDir, { recursive: true }); diff --git a/src/channels/index.ts b/src/channels/index.ts index 3916e5e..f53c9ca 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -1,13 +1,4 @@ // Channel self-registration barrel file. // Each import triggers the channel module's registerChannel() call. -// discord import './discord.js'; - -// gmail - -// slack - -// telegram - -// whatsapp diff --git a/src/config.ts b/src/config.ts index a3ae02e..f6351aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,15 +1,34 @@ import os from 'os'; import path from 'path'; +import type { AgentType } from './types.js'; import { readEnvFile } from './env.js'; -const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']); +const envConfig = readEnvFile([ + 'ASSISTANT_NAME', + 'ASSISTANT_HAS_OWN_NUMBER', + 'SERVICE_ID', + 'SERVICE_AGENT_TYPE', + 'SESSION_COMMAND_ALLOWED_SENDERS', + 'USAGE_DASHBOARD', +]); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; export const ASSISTANT_HAS_OWN_NUMBER = (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; +const ASSISTANT_SLUG = ASSISTANT_NAME.trim().toLowerCase(); +const rawServiceAgentType = + process.env.SERVICE_AGENT_TYPE || envConfig.SERVICE_AGENT_TYPE; +export const SERVICE_ID = + process.env.SERVICE_ID || envConfig.SERVICE_ID || ASSISTANT_SLUG; +export const SERVICE_AGENT_TYPE: AgentType = + rawServiceAgentType === 'codex' || rawServiceAgentType === 'claude-code' + ? rawServiceAgentType + : ASSISTANT_SLUG === 'codex' + ? 'codex' + : 'claude-code'; export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; @@ -62,8 +81,23 @@ export const TRIGGER_PATTERN = new RegExp( export const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || ''; export const STATUS_UPDATE_INTERVAL = 10000; // 10s export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes +export const USAGE_DASHBOARD_ENABLED = + (process.env.USAGE_DASHBOARD || envConfig.USAGE_DASHBOARD) === 'true'; // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default export const TIMEZONE = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; + +const SESSION_COMMAND_ALLOWED_SENDERS = new Set( + (process.env.SESSION_COMMAND_ALLOWED_SENDERS || + envConfig.SESSION_COMMAND_ALLOWED_SENDERS || + '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), +); + +export function isSessionCommandSenderAllowed(sender: string): boolean { + return SESSION_COMMAND_ALLOWED_SENDERS.has(sender); +} diff --git a/src/db.test.ts b/src/db.test.ts index b40a3a7..58ed3a6 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -455,25 +455,25 @@ describe('message query LIMIT', () => { describe('registered group isMain', () => { it('persists isMain=true through set/get round-trip', () => { - setRegisteredGroup('main@s.whatsapp.net', { + setRegisteredGroup('dc:main', { name: 'Main Chat', - folder: 'whatsapp_main', + folder: 'discord_main', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', isMain: true, }); const groups = getAllRegisteredGroups(); - const group = groups['main@s.whatsapp.net']; + const group = groups['dc:main']; expect(group).toBeDefined(); expect(group.isMain).toBe(true); - expect(group.folder).toBe('whatsapp_main'); + expect(group.folder).toBe('discord_main'); }); it('omits isMain for non-main groups', () => { setRegisteredGroup('group@g.us', { name: 'Family Chat', - folder: 'whatsapp_family-chat', + folder: 'discord_family-chat', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }); diff --git a/src/db.ts b/src/db.ts index 4973c8d..f535a87 100644 --- a/src/db.ts +++ b/src/db.ts @@ -2,11 +2,18 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; -import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; +import { + ASSISTANT_NAME, + DATA_DIR, + SERVICE_AGENT_TYPE, + SERVICE_ID, + STORE_DIR, +} from './config.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; import { NewMessage, + AgentType, RegisteredGroup, ScheduledTask, TaskRunLog, @@ -70,17 +77,24 @@ function createSchema(database: Database.Database): void { value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( - group_folder TEXT PRIMARY KEY, - session_id TEXT NOT NULL + group_folder TEXT NOT NULL, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + session_id TEXT NOT NULL, + PRIMARY KEY (group_folder, agent_type) ); CREATE TABLE IF NOT EXISTS registered_groups ( - jid TEXT PRIMARY KEY, + jid TEXT NOT NULL, name TEXT NOT NULL, - folder TEXT NOT NULL UNIQUE, + folder TEXT NOT NULL, trigger_pattern TEXT NOT NULL, added_at TEXT NOT NULL, - container_config TEXT, - requires_trigger INTEGER DEFAULT 1 + agent_config TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) ); `); @@ -106,33 +120,137 @@ function createSchema(database: Database.Database): void { /* column already exists */ } - // Add is_main column if it doesn't exist (migration for existing DBs) - try { - database.exec( - `ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`, + // Migrate registered_groups to composite keys so Claude/Codex can share a jid/folder. + const registeredGroupsSql = ( + database + .prepare( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`, + ) + .get() as { sql?: string } | undefined + )?.sql; + if ( + registeredGroupsSql && + !registeredGroupsSql.includes('PRIMARY KEY (jid, agent_type)') + ) { + const registeredGroupCols = database + .prepare('PRAGMA table_info(registered_groups)') + .all() as Array<{ name: string }>; + const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main'); + const hasAgentType = registeredGroupCols.some( + (col) => col.name === 'agent_type', ); + const hasWorkDir = registeredGroupCols.some((col) => col.name === 'work_dir'); + const hasAgentConfig = registeredGroupCols.some( + (col) => col.name === 'agent_config', + ); + const hasContainerConfig = registeredGroupCols.some( + (col) => col.name === 'container_config', + ); + + database.exec(` + CREATE TABLE registered_groups_new ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + `); + + database.exec(` + INSERT INTO registered_groups_new ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) + SELECT + jid, + name, + folder, + trigger_pattern, + added_at, + ${ + hasAgentConfig + ? 'agent_config' + : hasContainerConfig + ? 'container_config' + : 'NULL' + }, + requires_trigger, + ${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"}, + ${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"}, + ${hasWorkDir ? 'work_dir' : 'NULL'} + FROM registered_groups; + `); + + database.exec(` + DROP TABLE registered_groups; + ALTER TABLE registered_groups_new RENAME TO registered_groups; + `); + } else { // Backfill: existing rows with folder = 'main' are the main group database.exec( - `UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`, + `UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`, ); - } catch { - /* column already exists */ } - // Add agent_type column if it doesn't exist (migration for Codex support) - try { + const registeredGroupCols = database + .prepare('PRAGMA table_info(registered_groups)') + .all() as Array<{ name: string }>; + const hasAgentConfig = registeredGroupCols.some( + (col) => col.name === 'agent_config', + ); + const hasContainerConfig = registeredGroupCols.some( + (col) => col.name === 'container_config', + ); + if (!hasAgentConfig) { + database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`); + } + if (hasContainerConfig) { database.exec( - `ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`, + `UPDATE registered_groups + SET agent_config = COALESCE(agent_config, container_config) + WHERE container_config IS NOT NULL`, ); - } catch { - /* column already exists */ } - // Add work_dir column if it doesn't exist (migration for per-group working directory) - try { - database.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`); - } catch { - /* column already exists */ + // Migrate sessions table to composite PK (group_folder, agent_type) + const sessionCols = database + .prepare('PRAGMA table_info(sessions)') + .all() as Array<{ name: string }>; + if (!sessionCols.some((col) => col.name === 'agent_type')) { + database.exec(` + CREATE TABLE sessions_new ( + group_folder TEXT NOT NULL, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + session_id TEXT NOT NULL, + PRIMARY KEY (group_folder, agent_type) + ); + `); + database + .prepare( + `INSERT INTO sessions_new (group_folder, agent_type, session_id) + SELECT group_folder, ?, session_id FROM sessions`, + ) + .run(SERVICE_AGENT_TYPE); + database.exec(` + DROP TABLE sessions; + ALTER TABLE sessions_new RENAME TO sessions; + `); } // Add channel and is_group columns if they don't exist (migration for existing DBs) @@ -162,6 +280,8 @@ export function initDatabase(): void { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); createSchema(db); // Migrate from JSON files if they exist @@ -214,20 +334,6 @@ export function storeChatMetadata( } } -/** - * Update chat name without changing timestamp for existing chats. - * New chats get the current time as their initial timestamp. - * Used during group metadata sync. - */ -export function updateChatName(chatJid: string, name: string): void { - db.prepare( - ` - INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) - ON CONFLICT(jid) DO UPDATE SET name = excluded.name - `, - ).run(chatJid, name, new Date().toISOString()); -} - export interface ChatInfo { jid: string; name: string; @@ -251,27 +357,6 @@ export function getAllChats(): ChatInfo[] { .all() as ChatInfo[]; } -/** - * Get timestamp of last group metadata sync. - */ -export function getLastGroupSync(): string | null { - // Store sync time in a special chat entry - const row = db - .prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`) - .get() as { last_message_time: string } | undefined; - return row?.last_message_time || null; -} - -/** - * Record that group metadata was synced. - */ -export function setLastGroupSync(): void { - const now = new Date().toISOString(); - db.prepare( - `INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`, - ).run(now); -} - /** * Store a message with full content. * Only call this for registered groups where message history is needed. @@ -291,33 +376,6 @@ export function storeMessage(msg: NewMessage): void { ); } -/** - * Store a message directly. - */ -export function storeMessageDirect(msg: { - id: string; - chat_jid: string; - sender: string; - sender_name: string; - content: string; - timestamp: string; - is_from_me: boolean; - is_bot_message?: boolean; -}): void { - db.prepare( - `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - msg.id, - msg.chat_jid, - msg.sender, - msg.sender_name, - msg.content, - msg.timestamp, - msg.is_from_me ? 1 : 0, - msg.is_bot_message ? 1 : 0, - ); -} - export function getNewMessages( jids: string[], lastTimestamp: string, @@ -327,9 +385,9 @@ export function getNewMessages( if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp }; const placeholders = jids.map(() => '?').join(','); - // Filter bot messages using both the is_bot_message flag AND the content - // prefix as a backstop for messages written before the migration ran. - // Subquery takes the N most recent, outer query re-sorts chronologically. + // Filter legacy prefixed outbound messages as a backstop for rows written + // before explicit bot flags existed. Self-message filtering is channel-specific + // and happens in message-runtime so cross-bot collaboration still works. const sql = ` SELECT * FROM ( SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message @@ -360,9 +418,9 @@ export function getMessagesSince( botPrefix: string, limit: number = 200, ): NewMessage[] { - // Filter bot messages using both the is_bot_message flag AND the content - // prefix as a backstop for messages written before the migration ran. - // Subquery takes the N most recent, outer query re-sorts chronologically. + // Filter legacy prefixed outbound messages as a backstop for rows written + // before explicit bot flags existed. Self-message filtering is channel-specific + // and happens in message-runtime so cross-bot collaboration still works. const sql = ` SELECT * FROM ( SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message @@ -527,37 +585,65 @@ export function logTaskRun(log: TaskRunLog): void { // --- Router state accessors --- export function getRouterState(key: string): string | undefined { + const prefixedKey = `${SERVICE_ID}:${key}`; const row = db + .prepare('SELECT value FROM router_state WHERE key = ?') + .get(prefixedKey) as { value: string } | undefined; + if (row) return row.value; + + // Lazy migration: read unprefixed key and migrate to prefixed + const old = db .prepare('SELECT value FROM router_state WHERE key = ?') .get(key) as { value: string } | undefined; - return row?.value; + if (old) { + db.prepare( + 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', + ).run(prefixedKey, old.value); + db.prepare('DELETE FROM router_state WHERE key = ?').run(key); + return old.value; + } + return undefined; } export function setRouterState(key: string, value: string): void { + const prefixedKey = `${SERVICE_ID}:${key}`; db.prepare( 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', - ).run(key, value); + ).run(prefixedKey, value); } // --- Session accessors --- export function getSession(groupFolder: string): string | undefined { const row = db - .prepare('SELECT session_id FROM sessions WHERE group_folder = ?') - .get(groupFolder) as { session_id: string } | undefined; + .prepare( + 'SELECT session_id FROM sessions WHERE group_folder = ? AND agent_type = ?', + ) + .get(groupFolder, SERVICE_AGENT_TYPE) as { session_id: string } | undefined; return row?.session_id; } export function setSession(groupFolder: string, sessionId: string): void { db.prepare( - 'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', - ).run(groupFolder, sessionId); + 'INSERT OR REPLACE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)', + ).run(groupFolder, SERVICE_AGENT_TYPE, sessionId); +} + +export function deleteSession(groupFolder: string): void { + db.prepare( + 'DELETE FROM sessions WHERE group_folder = ? AND agent_type = ?', + ).run(groupFolder, SERVICE_AGENT_TYPE); } export function getAllSessions(): Record { const rows = db - .prepare('SELECT group_folder, session_id FROM sessions') - .all() as Array<{ group_folder: string; session_id: string }>; + .prepare( + 'SELECT group_folder, session_id FROM sessions WHERE agent_type = ?', + ) + .all(SERVICE_AGENT_TYPE) as Array<{ + group_folder: string; + session_id: string; + }>; const result: Record = {}; for (const row of rows) { result[row.group_folder] = row.session_id; @@ -574,15 +660,15 @@ export function getRegisteredGroup( .prepare('SELECT * FROM registered_groups WHERE jid = ?') .get(jid) as | { - jid: string; - name: string; - folder: string; - trigger_pattern: string; - added_at: string; - container_config: string | null; - requires_trigger: number | null; - is_main: number | null; - agent_type: string | null; + jid: string; + name: string; + folder: string; + trigger_pattern: string; + added_at: string; + agent_config: string | null; + requires_trigger: number | null; + is_main: number | null; + agent_type: string | null; work_dir: string | null; } | undefined; @@ -600,8 +686,8 @@ export function getRegisteredGroup( folder: row.folder, trigger: row.trigger_pattern, added_at: row.added_at, - agentConfig: row.container_config - ? JSON.parse(row.container_config) + agentConfig: row.agent_config + ? JSON.parse(row.agent_config) : undefined, requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, @@ -616,7 +702,7 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); } db.prepare( - `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, agent_type, work_dir) + `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( jid, @@ -632,17 +718,25 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { ); } -export function getAllRegisteredGroups(): Record { - const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{ +export function getAllRegisteredGroups( + agentTypeFilter?: string, +): Record { + const rows = ( + agentTypeFilter + ? db + .prepare('SELECT * FROM registered_groups WHERE agent_type = ?') + .all(agentTypeFilter) + : db.prepare('SELECT * FROM registered_groups').all() + ) as Array<{ jid: string; - name: string; - folder: string; - trigger_pattern: string; - added_at: string; - container_config: string | null; - requires_trigger: number | null; - is_main: number | null; - agent_type: string | null; + name: string; + folder: string; + trigger_pattern: string; + added_at: string; + agent_config: string | null; + requires_trigger: number | null; + is_main: number | null; + agent_type: string | null; work_dir: string | null; }>; const result: Record = {}; @@ -659,8 +753,8 @@ export function getAllRegisteredGroups(): Record { folder: row.folder, trigger: row.trigger_pattern, added_at: row.added_at, - agentConfig: row.container_config - ? JSON.parse(row.container_config) + agentConfig: row.agent_config + ? JSON.parse(row.agent_config) : undefined, requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, @@ -672,6 +766,28 @@ export function getAllRegisteredGroups(): Record { return result; } +export function getRegisteredAgentTypesForJid(jid: string): AgentType[] { + if (!db) return []; + + const rows = db + .prepare('SELECT agent_type FROM registered_groups WHERE jid = ?') + .all(jid) as Array<{ agent_type: string | null }>; + + const types = new Set(); + for (const row of rows) { + const agentType = row.agent_type as AgentType | null; + if (agentType === 'claude-code' || agentType === 'codex') { + types.add(agentType); + } + } + return [...types]; +} + +export function isPairedRoomJid(jid: string): boolean { + const types = getRegisteredAgentTypesForJid(jid); + return types.includes('claude-code') && types.includes('codex'); +} + // --- JSON migration --- function migrateJsonState(): void { diff --git a/src/group-folder.test.ts b/src/group-folder.test.ts index b88d268..99c4b0c 100644 --- a/src/group-folder.test.ts +++ b/src/group-folder.test.ts @@ -2,6 +2,7 @@ import path from 'path'; import { describe, expect, it } from 'vitest'; +import { DATA_DIR, GROUPS_DIR } from './config.js'; import { isValidGroupFolder, resolveGroupFolderPath, @@ -24,16 +25,12 @@ describe('group folder validation', () => { it('resolves safe paths under groups directory', () => { const resolved = resolveGroupFolderPath('family-chat'); - expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe( - true, - ); + expect(resolved).toBe(path.resolve(GROUPS_DIR, 'family-chat')); }); it('resolves safe paths under data ipc directory', () => { const resolved = resolveGroupIpcPath('family-chat'); - expect( - resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`), - ).toBe(true); + expect(resolved).toBe(path.resolve(DATA_DIR, 'ipc', 'family-chat')); }); it('throws for unsafe folder names', () => { diff --git a/src/index.ts b/src/index.ts index 4066ddc..fc385c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { ASSISTANT_NAME, IDLE_TIMEOUT, POLL_INTERVAL, + isSessionCommandSenderAllowed, STATUS_CHANNEL_ID, STATUS_UPDATE_INTERVAL, TIMEZONE, @@ -37,6 +38,7 @@ import { initDatabase, setRegisteredGroup, setRouterState, + deleteSession, setSession, storeChatMetadata, storeMessage, @@ -94,6 +96,11 @@ function saveState(): void { setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); } +function clearSession(groupFolder: string): void { + delete sessions[groupFolder]; + deleteSession(groupFolder); +} + function registerGroup(jid: string, group: RegisteredGroup): void { let groupDir: string; try { @@ -182,11 +189,13 @@ async function processGroupMessages(chatJid: string): Promise { runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput), closeStdin: () => queue.closeStdin(chatJid), + clearSession: () => clearSession(group.folder), advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); }, formatMessages, + isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender), canSenderInteract: (msg) => { const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim()); const reqTrigger = !isMainGroup && group.requiresTrigger !== false; @@ -921,6 +930,7 @@ async function startMessageLoop(): Promise { isSessionCommandAllowed( isMainGroup, loopCmdMsg.is_from_me === true, + isSessionCommandSenderAllowed(loopCmdMsg.sender), ) ) { queue.closeStdin(chatJid); diff --git a/src/platform-prompts.ts b/src/platform-prompts.ts new file mode 100644 index 0000000..e3c15c7 --- /dev/null +++ b/src/platform-prompts.ts @@ -0,0 +1,60 @@ +import fs from 'fs'; +import path from 'path'; + +import type { AgentType } from './types.js'; + +const PLATFORM_PROMPT_FILES: Record = { + 'claude-code': 'claude-platform.md', + codex: 'codex-platform.md', +}; + +const PAIRED_ROOM_PROMPT_FILES: Record = { + 'claude-code': 'claude-paired-room.md', + codex: 'codex-paired-room.md', +}; + +export function getPlatformPromptsDir(projectRoot = process.cwd()): string { + return path.join(projectRoot, 'prompts'); +} + +export function getPlatformPromptPath( + agentType: AgentType, + projectRoot = process.cwd(), +): string { + return path.join( + getPlatformPromptsDir(projectRoot), + PLATFORM_PROMPT_FILES[agentType], + ); +} + +export function readPlatformPrompt( + agentType: AgentType, + projectRoot = process.cwd(), +): string | undefined { + const promptPath = getPlatformPromptPath(agentType, projectRoot); + if (!fs.existsSync(promptPath)) return undefined; + + const prompt = fs.readFileSync(promptPath, 'utf-8').trim(); + return prompt || undefined; +} + +export function getPairedRoomPromptPath( + agentType: AgentType, + projectRoot = process.cwd(), +): string { + return path.join( + getPlatformPromptsDir(projectRoot), + PAIRED_ROOM_PROMPT_FILES[agentType], + ); +} + +export function readPairedRoomPrompt( + agentType: AgentType, + projectRoot = process.cwd(), +): string | undefined { + const promptPath = getPairedRoomPromptPath(agentType, projectRoot); + if (!fs.existsSync(promptPath)) return undefined; + + const prompt = fs.readFileSync(promptPath, 'utf-8').trim(); + return prompt || undefined; +} diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 4a3cbfe..5a683aa 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -82,8 +82,10 @@ function makeDeps( setTyping: vi.fn().mockResolvedValue(undefined), runAgent: vi.fn().mockResolvedValue('success'), closeStdin: vi.fn(), + clearSession: vi.fn(), advanceCursor: vi.fn(), formatMessages: vi.fn().mockReturnValue(''), + isAdminSender: vi.fn().mockReturnValue(false), canSenderInteract: vi.fn().mockReturnValue(true), ...overrides, }; diff --git a/src/session-commands.ts b/src/session-commands.ts index 1a3b2fb..946aa6d 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -1,5 +1,14 @@ import type { NewMessage } from './types.js'; import { logger } from './logger.js'; +import { formatOutbound } from './router.js'; + +const SESSION_COMMAND_CONTROL_PATTERNS = [ + /^Current session cleared\. The next message will start a new conversation\.$/, + /^Session commands require admin access\.$/, + /^Failed to process messages before \/compact\. Try again\.$/, + /^\/compact failed\. The session is unchanged\.$/, + /^Conversation compacted\.$/, +]; /** * Extract a session slash command from a message, stripping the trigger prefix if present. @@ -12,18 +21,27 @@ export function extractSessionCommand( let text = content.trim(); text = text.replace(triggerPattern, '').trim(); if (text === '/compact') return '/compact'; + if (text === '/clear') return '/clear'; return null; } /** * Check if a session command sender is authorized. - * Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group. + * Allowed: main group (any sender), or trusted/admin sender in any group. */ export function isSessionCommandAllowed( isMainGroup: boolean, isFromMe: boolean, + isAdminSender: boolean = false, ): boolean { - return isMainGroup || isFromMe; + return isMainGroup || isFromMe || isAdminSender; +} + +export function isSessionCommandControlMessage(content: string): boolean { + const trimmed = content.trim(); + return SESSION_COMMAND_CONTROL_PATTERNS.some((pattern) => + pattern.test(trimmed), + ); } /** Minimal agent result interface — matches the subset of AgentOutput used here. */ @@ -41,8 +59,10 @@ export interface SessionCommandDeps { onOutput: (result: AgentResult) => Promise, ) => Promise<'success' | 'error'>; closeStdin: () => void; + clearSession: () => void; advanceCursor: (timestamp: string) => void; formatMessages: (msgs: NewMessage[], timezone: string) => string; + isAdminSender: (msg: NewMessage) => boolean; /** Whether the denied sender would normally be allowed to interact (for denial messages). */ canSenderInteract: (msg: NewMessage) => boolean; } @@ -50,7 +70,7 @@ export interface SessionCommandDeps { function resultToText(result: string | object | null | undefined): string { if (!result) return ''; const raw = typeof result === 'string' ? result : JSON.stringify(result); - return raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); + return formatOutbound(raw); } /** @@ -63,6 +83,7 @@ export async function handleSessionCommand(opts: { missedMessages: NewMessage[]; isMainGroup: boolean; groupName: string; + runId?: string; triggerPattern: RegExp; timezone: string; deps: SessionCommandDeps; @@ -71,6 +92,7 @@ export async function handleSessionCommand(opts: { missedMessages, isMainGroup, groupName, + runId, triggerPattern, timezone, deps, @@ -85,7 +107,13 @@ export async function handleSessionCommand(opts: { if (!command || !cmdMsg) return { handled: false }; - if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) { + if ( + !isSessionCommandAllowed( + isMainGroup, + cmdMsg.is_from_me === true, + deps.isAdminSender(cmdMsg), + ) + ) { // DENIED: send denial if the sender would normally be allowed to interact, // then silently consume the command by advancing the cursor past it. // Trade-off: other messages in the same batch are also consumed (cursor is @@ -98,7 +126,17 @@ export async function handleSessionCommand(opts: { } // AUTHORIZED: process pre-compact messages first, then run the command - logger.info({ group: groupName, command }, 'Session command'); + logger.info({ group: groupName, runId, command }, 'Session command'); + + if (command === '/clear') { + deps.closeStdin(); + deps.clearSession(); + deps.advanceCursor(cmdMsg.timestamp); + await deps.sendMessage( + 'Current session cleared. The next message will start a new conversation.', + ); + return { handled: true, success: true }; + } const cmdIndex = missedMessages.indexOf(cmdMsg); const preCompactMsgs = missedMessages.slice(0, cmdIndex); @@ -125,7 +163,7 @@ export async function handleSessionCommand(opts: { if (preResult === 'error' || hadPreError) { logger.warn( - { group: groupName }, + { group: groupName, runId }, 'Pre-compact processing failed, aborting session command', ); await deps.sendMessage( diff --git a/src/types.ts b/src/types.ts index 316b284..e93593d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,4 @@ -export interface AdditionalMount { - hostPath: string; // Absolute path on host (supports ~ for home) - containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value} - readonly?: boolean; // Default: true for safety -} - export interface AgentConfig { - additionalMounts?: AdditionalMount[]; timeout?: number; // Default: 300000 (5 minutes) // Per-group model/effort overrides (take precedence over global env vars) codexModel?: string; @@ -78,6 +71,8 @@ export interface Channel { sendMessage(jid: string, text: string): Promise; isConnected(): boolean; ownsJid(jid: string): boolean; + // Optional: whether a stored inbound message was authored by this channel's own bot/user. + isOwnMessage?(msg: NewMessage): boolean; disconnect(): Promise; // Optional: typing indicator. Channels that support it implement it. setTyping?(jid: string, isTyping: boolean): Promise;