From 5118239ceaf98aaabc503576f6160bbc331d3be8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 10 Mar 2026 00:18:25 +0200 Subject: [PATCH] feat: skills as branches, channels as forks Replace the custom skills engine with standard git operations. Feature skills are now git branches (on upstream or channel forks) applied via `git merge`. Channels are separate fork repos. - Remove skills-engine/ (6,300+ lines), apply/uninstall/rebase scripts - Remove old skill format (add/, modify/, manifest.yaml) from all skills - Remove old CI (skill-drift.yml, skill-pr.yml) - Add merge-forward CI for upstream skill branches - Add fork notification (repository_dispatch to channel forks) - Add marketplace config (.claude/settings.json) - Add /update-skills operational skill - Update /setup and /customize for marketplace plugin install - Add docs/skills-as-branches.md architecture doc Channel forks created: nanoclaw-whatsapp (with 5 skill branches), nanoclaw-telegram, nanoclaw-discord, nanoclaw-slack, nanoclaw-gmail. Upstream retains: skill/ollama-tool, skill/apple-container, skill/compact. Co-Authored-By: Claude Opus 4.6 --- .claude/settings.json | 10 + .claude/skills/add-compact/SKILL.md | 139 -- .../add/src/session-commands.test.ts | 214 ---- .../add-compact/add/src/session-commands.ts | 143 --- .claude/skills/add-compact/manifest.yaml | 16 - .../container/agent-runner/src/index.ts | 688 ---------- .../agent-runner/src/index.ts.intent.md | 29 - .../skills/add-compact/modify/src/index.ts | 640 ---------- .../add-compact/modify/src/index.ts.intent.md | 25 - .../add-compact/tests/add-compact.test.ts | 188 --- .claude/skills/add-discord/SKILL.md | 206 --- .../add/src/channels/discord.test.ts | 776 ------------ .../add-discord/add/src/channels/discord.ts | 250 ---- .claude/skills/add-discord/manifest.yaml | 17 - .../add-discord/modify/src/channels/index.ts | 13 - .../modify/src/channels/index.ts.intent.md | 7 - .../skills/add-discord/tests/discord.test.ts | 69 - .claude/skills/add-gmail/SKILL.md | 242 ---- .../add-gmail/add/src/channels/gmail.test.ts | 74 -- .../add-gmail/add/src/channels/gmail.ts | 352 ------ .claude/skills/add-gmail/manifest.yaml | 17 - .../container/agent-runner/src/index.ts | 593 --------- .../agent-runner/src/index.ts.intent.md | 32 - .../add-gmail/modify/src/channels/index.ts | 13 - .../modify/src/channels/index.ts.intent.md | 7 - .../add-gmail/modify/src/container-runner.ts | 661 ---------- .../modify/src/container-runner.ts.intent.md | 37 - .claude/skills/add-gmail/tests/gmail.test.ts | 98 -- .claude/skills/add-image-vision/SKILL.md | 70 -- .../add-image-vision/add/src/image.test.ts | 89 -- .../skills/add-image-vision/add/src/image.ts | 63 - .claude/skills/add-image-vision/manifest.yaml | 20 - .../container/agent-runner/src/index.ts | 626 --------- .../agent-runner/src/index.ts.intent.md | 23 - .../modify/src/channels/whatsapp.test.ts | 1117 ----------------- .../src/channels/whatsapp.test.ts.intent.md | 21 - .../modify/src/channels/whatsapp.ts | 419 ------- .../modify/src/channels/whatsapp.ts.intent.md | 23 - .../modify/src/container-runner.ts | 703 ----------- .../modify/src/container-runner.ts.intent.md | 15 - .../add-image-vision/modify/src/index.ts | 590 --------- .../modify/src/index.ts.intent.md | 24 - .../tests/image-vision.test.ts | 297 ----- .claude/skills/add-ollama-tool/SKILL.md | 152 --- .../agent-runner/src/ollama-mcp-stdio.ts | 147 --- .../add/scripts/ollama-watch.sh | 41 - .claude/skills/add-ollama-tool/manifest.yaml | 17 - .../container/agent-runner/src/index.ts | 593 --------- .../agent-runner/src/index.ts.intent.md | 23 - .../modify/src/container-runner.ts | 708 ----------- .../modify/src/container-runner.ts.intent.md | 18 - .claude/skills/add-pdf-reader/SKILL.md | 100 -- .../add/container/skills/pdf-reader/SKILL.md | 94 -- .../container/skills/pdf-reader/pdf-reader | 203 --- .claude/skills/add-pdf-reader/manifest.yaml | 17 - .../modify/container/Dockerfile | 74 -- .../modify/container/Dockerfile.intent.md | 23 - .../modify/src/channels/whatsapp.test.ts | 1069 ---------------- .../src/channels/whatsapp.test.ts.intent.md | 22 - .../modify/src/channels/whatsapp.ts | 429 ------- .../modify/src/channels/whatsapp.ts.intent.md | 29 - .../add-pdf-reader/tests/pdf-reader.test.ts | 171 --- .claude/skills/add-reactions/SKILL.md | 103 -- .../add/container/skills/reactions/SKILL.md | 63 - .../add/scripts/migrate-reactions.ts | 57 - .../add/src/status-tracker.test.ts | 450 ------- .../add-reactions/add/src/status-tracker.ts | 324 ----- .claude/skills/add-reactions/manifest.yaml | 23 - .../agent-runner/src/ipc-mcp-stdio.ts | 440 ------- .../modify/src/channels/whatsapp.test.ts | 952 -------------- .../modify/src/channels/whatsapp.ts | 457 ------- .../add-reactions/modify/src/db.test.ts | 715 ----------- .claude/skills/add-reactions/modify/src/db.ts | 801 ------------ .../modify/src/group-queue.test.ts | 510 -------- .../skills/add-reactions/modify/src/index.ts | 726 ----------- .../add-reactions/modify/src/ipc-auth.test.ts | 807 ------------ .../skills/add-reactions/modify/src/ipc.ts | 446 ------- .../skills/add-reactions/modify/src/types.ts | 111 -- .claude/skills/add-slack/SKILL.md | 215 ---- .claude/skills/add-slack/SLACK_SETUP.md | 149 --- .../add-slack/add/src/channels/slack.test.ts | 851 ------------- .../add-slack/add/src/channels/slack.ts | 300 ----- .claude/skills/add-slack/manifest.yaml | 18 - .../add-slack/modify/src/channels/index.ts | 13 - .../modify/src/channels/index.ts.intent.md | 7 - .claude/skills/add-slack/tests/slack.test.ts | 100 -- .claude/skills/add-telegram/SKILL.md | 231 ---- .../add/src/channels/telegram.test.ts | 932 -------------- .../add-telegram/add/src/channels/telegram.ts | 257 ---- .claude/skills/add-telegram/manifest.yaml | 17 - .../add-telegram/modify/src/channels/index.ts | 13 - .../modify/src/channels/index.ts.intent.md | 7 - .../add-telegram/tests/telegram.test.ts | 69 - .../skills/add-voice-transcription/SKILL.md | 141 --- .../add/src/transcription.ts | 98 -- .../add-voice-transcription/manifest.yaml | 17 - .../modify/src/channels/whatsapp.test.ts | 967 -------------- .../src/channels/whatsapp.test.ts.intent.md | 27 - .../modify/src/channels/whatsapp.ts | 366 ------ .../modify/src/channels/whatsapp.ts.intent.md | 27 - .../tests/voice-transcription.test.ts | 123 -- .claude/skills/add-whatsapp/SKILL.md | 361 ------ .../add-whatsapp/add/setup/whatsapp-auth.ts | 368 ------ .../add/src/channels/whatsapp.test.ts | 950 -------------- .../add-whatsapp/add/src/channels/whatsapp.ts | 398 ------ .../add-whatsapp/add/src/whatsapp-auth.ts | 180 --- .claude/skills/add-whatsapp/manifest.yaml | 23 - .../skills/add-whatsapp/modify/setup/index.ts | 60 - .../modify/setup/index.ts.intent.md | 1 - .../add-whatsapp/modify/src/channels/index.ts | 13 - .../modify/src/channels/index.ts.intent.md | 7 - .../add-whatsapp/tests/whatsapp.test.ts | 70 -- .../convert-to-apple-container/SKILL.md | 183 --- .../convert-to-apple-container/manifest.yaml | 15 - .../modify/container/Dockerfile | 68 - .../modify/container/Dockerfile.intent.md | 31 - .../modify/container/build.sh | 23 - .../modify/container/build.sh.intent.md | 17 - .../modify/src/container-runner.ts | 701 ----------- .../modify/src/container-runner.ts.intent.md | 37 - .../modify/src/container-runtime.test.ts | 177 --- .../modify/src/container-runtime.ts | 99 -- .../modify/src/container-runtime.ts.intent.md | 41 - .../tests/convert-to-apple-container.test.ts | 69 - .claude/skills/customize/SKILL.md | 13 +- .claude/skills/setup/SKILL.md | 67 +- .claude/skills/update-nanoclaw/SKILL.md | 17 +- .claude/skills/update-skills/SKILL.md | 130 ++ .claude/skills/use-local-whisper/SKILL.md | 128 -- .../skills/use-local-whisper/manifest.yaml | 12 - .../modify/src/transcription.ts | 95 -- .../modify/src/transcription.ts.intent.md | 39 - .../tests/use-local-whisper.test.ts | 115 -- .github/workflows/merge-forward-skills.yml | 158 +++ .github/workflows/skill-drift.yml | 102 -- .github/workflows/skill-pr.yml | 151 --- CLAUDE.md | 2 +- README.md | 16 +- docs/skills-as-branches.md | 662 ++++++++++ scripts/apply-skill.ts | 24 - scripts/fix-skill-drift.ts | 266 ---- scripts/rebase.ts | 21 - scripts/run-migrations.ts | 10 +- scripts/uninstall-skill.ts | 39 - scripts/validate-all-skills.ts | 252 ---- skills-engine/__tests__/apply.test.ts | 157 --- skills-engine/__tests__/backup.test.ts | 87 -- skills-engine/__tests__/constants.test.ts | 41 - skills-engine/__tests__/customize.test.ts | 146 --- skills-engine/__tests__/file-ops.test.ts | 169 --- skills-engine/__tests__/lock.test.ts | 60 - skills-engine/__tests__/manifest.test.ts | 355 ------ skills-engine/__tests__/merge.test.ts | 71 -- skills-engine/__tests__/path-remap.test.ts | 172 --- skills-engine/__tests__/rebase.test.ts | 389 ------ skills-engine/__tests__/replay.test.ts | 297 ----- .../__tests__/run-migrations.test.ts | 235 ---- skills-engine/__tests__/state.test.ts | 122 -- skills-engine/__tests__/structured.test.ts | 243 ---- skills-engine/__tests__/test-helpers.ts | 108 -- skills-engine/__tests__/uninstall.test.ts | 259 ---- skills-engine/apply.ts | 384 ------ skills-engine/backup.ts | 65 - skills-engine/constants.ts | 16 - skills-engine/customize.ts | 152 --- skills-engine/file-ops.ts | 191 --- skills-engine/fs-utils.ts | 21 - skills-engine/index.ts | 67 - skills-engine/init.ts | 101 -- skills-engine/lock.ts | 106 -- skills-engine/manifest.ts | 104 -- skills-engine/merge.ts | 39 - skills-engine/migrate.ts | 70 -- skills-engine/path-remap.ts | 125 -- skills-engine/rebase.ts | 257 ---- skills-engine/replay.ts | 270 ---- skills-engine/state.ts | 119 -- skills-engine/structured.ts | 201 --- skills-engine/tsconfig.json | 16 - skills-engine/types.ts | 95 -- skills-engine/uninstall.ts | 231 ---- vitest.config.ts | 2 +- 182 files changed, 1065 insertions(+), 36205 deletions(-) create mode 100644 .claude/settings.json delete mode 100644 .claude/skills/add-compact/SKILL.md delete mode 100644 .claude/skills/add-compact/add/src/session-commands.test.ts delete mode 100644 .claude/skills/add-compact/add/src/session-commands.ts delete mode 100644 .claude/skills/add-compact/manifest.yaml delete mode 100644 .claude/skills/add-compact/modify/container/agent-runner/src/index.ts delete mode 100644 .claude/skills/add-compact/modify/container/agent-runner/src/index.ts.intent.md delete mode 100644 .claude/skills/add-compact/modify/src/index.ts delete mode 100644 .claude/skills/add-compact/modify/src/index.ts.intent.md delete mode 100644 .claude/skills/add-compact/tests/add-compact.test.ts delete mode 100644 .claude/skills/add-discord/SKILL.md delete mode 100644 .claude/skills/add-discord/add/src/channels/discord.test.ts delete mode 100644 .claude/skills/add-discord/add/src/channels/discord.ts delete mode 100644 .claude/skills/add-discord/manifest.yaml delete mode 100644 .claude/skills/add-discord/modify/src/channels/index.ts delete mode 100644 .claude/skills/add-discord/modify/src/channels/index.ts.intent.md delete mode 100644 .claude/skills/add-discord/tests/discord.test.ts delete mode 100644 .claude/skills/add-gmail/SKILL.md delete mode 100644 .claude/skills/add-gmail/add/src/channels/gmail.test.ts delete mode 100644 .claude/skills/add-gmail/add/src/channels/gmail.ts delete mode 100644 .claude/skills/add-gmail/manifest.yaml delete mode 100644 .claude/skills/add-gmail/modify/container/agent-runner/src/index.ts delete mode 100644 .claude/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md delete mode 100644 .claude/skills/add-gmail/modify/src/channels/index.ts delete mode 100644 .claude/skills/add-gmail/modify/src/channels/index.ts.intent.md delete mode 100644 .claude/skills/add-gmail/modify/src/container-runner.ts delete mode 100644 .claude/skills/add-gmail/modify/src/container-runner.ts.intent.md delete mode 100644 .claude/skills/add-gmail/tests/gmail.test.ts delete mode 100644 .claude/skills/add-image-vision/SKILL.md delete mode 100644 .claude/skills/add-image-vision/add/src/image.test.ts delete mode 100644 .claude/skills/add-image-vision/add/src/image.ts delete mode 100644 .claude/skills/add-image-vision/manifest.yaml delete mode 100644 .claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts delete mode 100644 .claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts.intent.md delete mode 100644 .claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts delete mode 100644 .claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts.intent.md delete mode 100644 .claude/skills/add-image-vision/modify/src/channels/whatsapp.ts delete mode 100644 .claude/skills/add-image-vision/modify/src/channels/whatsapp.ts.intent.md delete mode 100644 .claude/skills/add-image-vision/modify/src/container-runner.ts delete mode 100644 .claude/skills/add-image-vision/modify/src/container-runner.ts.intent.md delete mode 100644 .claude/skills/add-image-vision/modify/src/index.ts delete mode 100644 .claude/skills/add-image-vision/modify/src/index.ts.intent.md delete mode 100644 .claude/skills/add-image-vision/tests/image-vision.test.ts delete mode 100644 .claude/skills/add-ollama-tool/SKILL.md delete mode 100644 .claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts delete mode 100755 .claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh delete mode 100644 .claude/skills/add-ollama-tool/manifest.yaml delete mode 100644 .claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts delete mode 100644 .claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md delete mode 100644 .claude/skills/add-ollama-tool/modify/src/container-runner.ts delete mode 100644 .claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md delete mode 100644 .claude/skills/add-pdf-reader/SKILL.md delete mode 100644 .claude/skills/add-pdf-reader/add/container/skills/pdf-reader/SKILL.md delete mode 100755 .claude/skills/add-pdf-reader/add/container/skills/pdf-reader/pdf-reader delete mode 100644 .claude/skills/add-pdf-reader/manifest.yaml delete mode 100644 .claude/skills/add-pdf-reader/modify/container/Dockerfile delete mode 100644 .claude/skills/add-pdf-reader/modify/container/Dockerfile.intent.md delete mode 100644 .claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts delete mode 100644 .claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts.intent.md delete mode 100644 .claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts delete mode 100644 .claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts.intent.md delete mode 100644 .claude/skills/add-pdf-reader/tests/pdf-reader.test.ts delete mode 100644 .claude/skills/add-reactions/SKILL.md delete mode 100644 .claude/skills/add-reactions/add/container/skills/reactions/SKILL.md delete mode 100644 .claude/skills/add-reactions/add/scripts/migrate-reactions.ts delete mode 100644 .claude/skills/add-reactions/add/src/status-tracker.test.ts delete mode 100644 .claude/skills/add-reactions/add/src/status-tracker.ts delete mode 100644 .claude/skills/add-reactions/manifest.yaml delete mode 100644 .claude/skills/add-reactions/modify/container/agent-runner/src/ipc-mcp-stdio.ts delete mode 100644 .claude/skills/add-reactions/modify/src/channels/whatsapp.test.ts delete mode 100644 .claude/skills/add-reactions/modify/src/channels/whatsapp.ts delete mode 100644 .claude/skills/add-reactions/modify/src/db.test.ts delete mode 100644 .claude/skills/add-reactions/modify/src/db.ts delete mode 100644 .claude/skills/add-reactions/modify/src/group-queue.test.ts delete mode 100644 .claude/skills/add-reactions/modify/src/index.ts delete mode 100644 .claude/skills/add-reactions/modify/src/ipc-auth.test.ts delete mode 100644 .claude/skills/add-reactions/modify/src/ipc.ts delete mode 100644 .claude/skills/add-reactions/modify/src/types.ts delete mode 100644 .claude/skills/add-slack/SKILL.md delete mode 100644 .claude/skills/add-slack/SLACK_SETUP.md delete mode 100644 .claude/skills/add-slack/add/src/channels/slack.test.ts delete mode 100644 .claude/skills/add-slack/add/src/channels/slack.ts delete mode 100644 .claude/skills/add-slack/manifest.yaml delete mode 100644 .claude/skills/add-slack/modify/src/channels/index.ts delete mode 100644 .claude/skills/add-slack/modify/src/channels/index.ts.intent.md delete mode 100644 .claude/skills/add-slack/tests/slack.test.ts delete mode 100644 .claude/skills/add-telegram/SKILL.md delete mode 100644 .claude/skills/add-telegram/add/src/channels/telegram.test.ts delete mode 100644 .claude/skills/add-telegram/add/src/channels/telegram.ts delete mode 100644 .claude/skills/add-telegram/manifest.yaml delete mode 100644 .claude/skills/add-telegram/modify/src/channels/index.ts delete mode 100644 .claude/skills/add-telegram/modify/src/channels/index.ts.intent.md delete mode 100644 .claude/skills/add-telegram/tests/telegram.test.ts delete mode 100644 .claude/skills/add-voice-transcription/SKILL.md delete mode 100644 .claude/skills/add-voice-transcription/add/src/transcription.ts delete mode 100644 .claude/skills/add-voice-transcription/manifest.yaml delete mode 100644 .claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts delete mode 100644 .claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md delete mode 100644 .claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts delete mode 100644 .claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts.intent.md delete mode 100644 .claude/skills/add-voice-transcription/tests/voice-transcription.test.ts delete mode 100644 .claude/skills/add-whatsapp/SKILL.md delete mode 100644 .claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts delete mode 100644 .claude/skills/add-whatsapp/add/src/channels/whatsapp.test.ts delete mode 100644 .claude/skills/add-whatsapp/add/src/channels/whatsapp.ts delete mode 100644 .claude/skills/add-whatsapp/add/src/whatsapp-auth.ts delete mode 100644 .claude/skills/add-whatsapp/manifest.yaml delete mode 100644 .claude/skills/add-whatsapp/modify/setup/index.ts delete mode 100644 .claude/skills/add-whatsapp/modify/setup/index.ts.intent.md delete mode 100644 .claude/skills/add-whatsapp/modify/src/channels/index.ts delete mode 100644 .claude/skills/add-whatsapp/modify/src/channels/index.ts.intent.md delete mode 100644 .claude/skills/add-whatsapp/tests/whatsapp.test.ts delete mode 100644 .claude/skills/convert-to-apple-container/SKILL.md delete mode 100644 .claude/skills/convert-to-apple-container/manifest.yaml delete mode 100644 .claude/skills/convert-to-apple-container/modify/container/Dockerfile delete mode 100644 .claude/skills/convert-to-apple-container/modify/container/Dockerfile.intent.md delete mode 100644 .claude/skills/convert-to-apple-container/modify/container/build.sh delete mode 100644 .claude/skills/convert-to-apple-container/modify/container/build.sh.intent.md delete mode 100644 .claude/skills/convert-to-apple-container/modify/src/container-runner.ts delete mode 100644 .claude/skills/convert-to-apple-container/modify/src/container-runner.ts.intent.md delete mode 100644 .claude/skills/convert-to-apple-container/modify/src/container-runtime.test.ts delete mode 100644 .claude/skills/convert-to-apple-container/modify/src/container-runtime.ts delete mode 100644 .claude/skills/convert-to-apple-container/modify/src/container-runtime.ts.intent.md delete mode 100644 .claude/skills/convert-to-apple-container/tests/convert-to-apple-container.test.ts create mode 100644 .claude/skills/update-skills/SKILL.md delete mode 100644 .claude/skills/use-local-whisper/SKILL.md delete mode 100644 .claude/skills/use-local-whisper/manifest.yaml delete mode 100644 .claude/skills/use-local-whisper/modify/src/transcription.ts delete mode 100644 .claude/skills/use-local-whisper/modify/src/transcription.ts.intent.md delete mode 100644 .claude/skills/use-local-whisper/tests/use-local-whisper.test.ts create mode 100644 .github/workflows/merge-forward-skills.yml delete mode 100644 .github/workflows/skill-drift.yml delete mode 100644 .github/workflows/skill-pr.yml create mode 100644 docs/skills-as-branches.md delete mode 100644 scripts/apply-skill.ts delete mode 100644 scripts/fix-skill-drift.ts delete mode 100644 scripts/rebase.ts delete mode 100644 scripts/uninstall-skill.ts delete mode 100644 scripts/validate-all-skills.ts delete mode 100644 skills-engine/__tests__/apply.test.ts delete mode 100644 skills-engine/__tests__/backup.test.ts delete mode 100644 skills-engine/__tests__/constants.test.ts delete mode 100644 skills-engine/__tests__/customize.test.ts delete mode 100644 skills-engine/__tests__/file-ops.test.ts delete mode 100644 skills-engine/__tests__/lock.test.ts delete mode 100644 skills-engine/__tests__/manifest.test.ts delete mode 100644 skills-engine/__tests__/merge.test.ts delete mode 100644 skills-engine/__tests__/path-remap.test.ts delete mode 100644 skills-engine/__tests__/rebase.test.ts delete mode 100644 skills-engine/__tests__/replay.test.ts delete mode 100644 skills-engine/__tests__/run-migrations.test.ts delete mode 100644 skills-engine/__tests__/state.test.ts delete mode 100644 skills-engine/__tests__/structured.test.ts delete mode 100644 skills-engine/__tests__/test-helpers.ts delete mode 100644 skills-engine/__tests__/uninstall.test.ts delete mode 100644 skills-engine/apply.ts delete mode 100644 skills-engine/backup.ts delete mode 100644 skills-engine/constants.ts delete mode 100644 skills-engine/customize.ts delete mode 100644 skills-engine/file-ops.ts delete mode 100644 skills-engine/fs-utils.ts delete mode 100644 skills-engine/index.ts delete mode 100644 skills-engine/init.ts delete mode 100644 skills-engine/lock.ts delete mode 100644 skills-engine/manifest.ts delete mode 100644 skills-engine/merge.ts delete mode 100644 skills-engine/migrate.ts delete mode 100644 skills-engine/path-remap.ts delete mode 100644 skills-engine/rebase.ts delete mode 100644 skills-engine/replay.ts delete mode 100644 skills-engine/state.ts delete mode 100644 skills-engine/structured.ts delete mode 100644 skills-engine/tsconfig.json delete mode 100644 skills-engine/types.ts delete mode 100644 skills-engine/uninstall.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f859a6d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "extraKnownMarketplaces": { + "nanoclaw-skills": { + "source": { + "source": "github", + "repo": "qwibitai/nanoclaw-skills" + } + } + } +} diff --git a/.claude/skills/add-compact/SKILL.md b/.claude/skills/add-compact/SKILL.md deleted file mode 100644 index 1b75152..0000000 --- a/.claude/skills/add-compact/SKILL.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -name: add-compact -description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only. ---- - -# Add /compact Command - -Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts. - -**Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context. - -## Phase 1: Pre-flight - -Read `.nanoclaw/state.yaml`. If `add-compact` is in `applied_skills`, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-compact -``` - -This deterministically: -- Adds `src/session-commands.ts` (extract and authorize session commands) -- Adds `src/session-commands.test.ts` (unit tests for command parsing and auth) -- Three-way merges session command interception into `src/index.ts` (both `processGroupMessages` and `startMessageLoop`) -- Three-way merges slash command handling into `container/agent-runner/src/index.ts` -- Records application in `.nanoclaw/state.yaml` - -If merge conflicts occur, read the intent files: -- `modify/src/index.ts.intent.md` -- `modify/container/agent-runner/src/index.ts.intent.md` - -### Validate - -```bash -npm test -npm run build -``` - -### Rebuild container - -```bash -./container/build.sh -``` - -### Restart service - -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 3: Verify - -### Integration Test - -1. Start NanoClaw in dev mode: `npm run dev` -2. From the **main group** (self-chat), send exactly: `/compact` -3. Verify: - - The agent acknowledges compaction (e.g., "Conversation compacted.") - - The session continues — send a follow-up message and verify the agent responds coherently - - A conversation archive is written to `groups/{folder}/conversations/` (by the PreCompact hook) - - Container logs show `Compact boundary observed` (confirms SDK actually compacted) - - If `compact_boundary` was NOT observed, the response says "compact_boundary was not observed" -4. From a **non-main group** as a non-admin user, send: `@ /compact` -5. Verify: - - The bot responds with "Session commands require admin access." - - No compaction occurs, no container is spawned for the command -6. From a **non-main group** as the admin (device owner / `is_from_me`), send: `@ /compact` -7. Verify: - - Compaction proceeds normally (same behavior as main group) -8. While an **active container** is running for the main group, send `/compact` -9. Verify: - - The active container is signaled to close (authorized senders only — untrusted senders cannot kill in-flight work) - - Compaction proceeds via a new container once the active one exits - - The command is not dropped (no cursor race) -10. Send a normal message, then `/compact`, then another normal message in quick succession (same polling batch): -11. Verify: - - Pre-compact messages are sent to the agent first (check container logs for two `runAgent` calls) - - Compaction proceeds after pre-compact messages are processed - - Messages **after** `/compact` in the batch are preserved (cursor advances to `/compact`'s timestamp only) and processed on the next poll cycle -12. From a **non-main group** as a non-admin user, send `@ /compact`: -13. Verify: - - Denial message is sent ("Session commands require admin access.") - - The `/compact` is consumed (cursor advanced) — it does NOT replay on future polls - - Other messages in the same batch are also consumed (cursor is a high-water mark — this is an accepted tradeoff for the narrow edge case of denied `/compact` + other messages in the same polling interval) - - No container is killed or interrupted -14. From a **non-main group** (with `requiresTrigger` enabled) as a non-admin user, send bare `/compact` (no trigger prefix): -15. Verify: - - No denial message is sent (trigger policy prevents untrusted bot responses) - - The `/compact` is consumed silently - - Note: in groups where `requiresTrigger` is `false`, a denial message IS sent because the sender is considered reachable -16. After compaction, verify **no auto-compaction** behavior — only manual `/compact` triggers it - -### Validation on Fresh Clone - -```bash -git clone /tmp/nanoclaw-test -cd /tmp/nanoclaw-test -claude # then run /add-compact -npm run build -npm test -./container/build.sh -# Manual: send /compact from main group, verify compaction + continuation -# Manual: send @ /compact from non-main as non-admin, verify denial -# Manual: send @ /compact from non-main as admin, verify allowed -# Manual: verify no auto-compaction behavior -``` - -## Security Constraints - -- **Main-group or trusted/admin sender only.** The main group is the user's private self-chat and is trusted (see `docs/SECURITY.md`). Non-main groups are untrusted — a careless or malicious user could wipe the agent's short-term memory. However, the device owner (`is_from_me`) is always trusted and can compact from any group. -- **No auto-compaction.** This skill implements manual compaction only. Automatic threshold-based compaction is a separate concern and should be a separate skill. -- **No config file.** NanoClaw's philosophy is customization through code changes, not configuration sprawl. -- **Transcript archived before compaction.** The existing `PreCompact` hook in the agent-runner archives the full transcript to `conversations/` before the SDK compacts it. -- **Session continues after compaction.** This is not a destructive reset. The conversation continues with summarized context. - -## What This Does NOT Do - -- No automatic compaction threshold (add separately if desired) -- No `/clear` command (separate skill, separate semantics — `/clear` is a destructive reset) -- No cross-group compaction (each group's session is isolated) -- No changes to the container image, Dockerfile, or build script - -## Troubleshooting - -- **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied. -- **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded. -- **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt. diff --git a/.claude/skills/add-compact/add/src/session-commands.test.ts b/.claude/skills/add-compact/add/src/session-commands.test.ts deleted file mode 100644 index 7cbc680..0000000 --- a/.claude/skills/add-compact/add/src/session-commands.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js'; -import type { NewMessage } from './types.js'; -import type { SessionCommandDeps } from './session-commands.js'; - -describe('extractSessionCommand', () => { - const trigger = /^@Andy\b/i; - - it('detects bare /compact', () => { - expect(extractSessionCommand('/compact', trigger)).toBe('/compact'); - }); - - it('detects /compact with trigger prefix', () => { - expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact'); - }); - - it('rejects /compact with extra text', () => { - expect(extractSessionCommand('/compact now please', trigger)).toBeNull(); - }); - - it('rejects partial matches', () => { - expect(extractSessionCommand('/compaction', trigger)).toBeNull(); - }); - - it('rejects regular messages', () => { - expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull(); - }); - - it('handles whitespace', () => { - expect(extractSessionCommand(' /compact ', trigger)).toBe('/compact'); - }); - - it('is case-sensitive for the command', () => { - expect(extractSessionCommand('/Compact', trigger)).toBeNull(); - }); -}); - -describe('isSessionCommandAllowed', () => { - it('allows main group regardless of sender', () => { - expect(isSessionCommandAllowed(true, false)).toBe(true); - }); - - it('allows trusted/admin sender (is_from_me) in non-main group', () => { - expect(isSessionCommandAllowed(false, true)).toBe(true); - }); - - it('denies untrusted sender in non-main group', () => { - expect(isSessionCommandAllowed(false, false)).toBe(false); - }); - - it('allows trusted sender in main group', () => { - expect(isSessionCommandAllowed(true, true)).toBe(true); - }); -}); - -function makeMsg(content: string, overrides: Partial = {}): NewMessage { - return { - id: 'msg-1', - chat_jid: 'group@test', - sender: 'user@test', - sender_name: 'User', - content, - timestamp: '100', - ...overrides, - }; -} - -function makeDeps(overrides: Partial = {}): SessionCommandDeps { - return { - sendMessage: vi.fn().mockResolvedValue(undefined), - setTyping: vi.fn().mockResolvedValue(undefined), - runAgent: vi.fn().mockResolvedValue('success'), - closeStdin: vi.fn(), - advanceCursor: vi.fn(), - formatMessages: vi.fn().mockReturnValue(''), - canSenderInteract: vi.fn().mockReturnValue(true), - ...overrides, - }; -} - -const trigger = /^@Andy\b/i; - -describe('handleSessionCommand', () => { - it('returns handled:false when no session command found', async () => { - const deps = makeDeps(); - const result = await handleSessionCommand({ - missedMessages: [makeMsg('hello')], - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - expect(result.handled).toBe(false); - }); - - it('handles authorized /compact in main group', async () => { - const deps = makeDeps(); - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/compact')], - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - expect(result).toEqual({ handled: true, success: true }); - expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function)); - expect(deps.advanceCursor).toHaveBeenCalledWith('100'); - }); - - it('sends denial to interactable sender in non-main group', async () => { - const deps = makeDeps(); - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/compact', { is_from_me: false })], - isMainGroup: false, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - expect(result).toEqual({ handled: true, success: true }); - expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.'); - expect(deps.runAgent).not.toHaveBeenCalled(); - expect(deps.advanceCursor).toHaveBeenCalledWith('100'); - }); - - it('silently consumes denied command when sender cannot interact', async () => { - const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) }); - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/compact', { is_from_me: false })], - isMainGroup: false, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - expect(result).toEqual({ handled: true, success: true }); - expect(deps.sendMessage).not.toHaveBeenCalled(); - expect(deps.advanceCursor).toHaveBeenCalledWith('100'); - }); - - it('processes pre-compact messages before /compact', async () => { - const deps = makeDeps(); - const msgs = [ - makeMsg('summarize this', { timestamp: '99' }), - makeMsg('/compact', { timestamp: '100' }), - ]; - const result = await handleSessionCommand({ - missedMessages: msgs, - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - expect(result).toEqual({ handled: true, success: true }); - expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC'); - // Two runAgent calls: pre-compact + /compact - expect(deps.runAgent).toHaveBeenCalledTimes(2); - expect(deps.runAgent).toHaveBeenCalledWith('', expect.any(Function)); - expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function)); - }); - - it('allows is_from_me sender in non-main group', async () => { - const deps = makeDeps(); - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/compact', { is_from_me: true })], - isMainGroup: false, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - expect(result).toEqual({ handled: true, success: true }); - expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function)); - }); - - it('reports failure when command-stage runAgent returns error without streamed status', async () => { - // runAgent resolves 'error' but callback never gets status: 'error' - const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => { - await onOutput({ status: 'success', result: null }); - return 'error'; - })}); - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/compact')], - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - expect(result).toEqual({ handled: true, success: true }); - expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed')); - }); - - it('returns success:false on pre-compact failure with no output', async () => { - const deps = makeDeps({ runAgent: vi.fn().mockResolvedValue('error') }); - const msgs = [ - makeMsg('summarize this', { timestamp: '99' }), - makeMsg('/compact', { timestamp: '100' }), - ]; - const result = await handleSessionCommand({ - missedMessages: msgs, - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - expect(result).toEqual({ handled: true, success: false }); - expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process')); - }); -}); diff --git a/.claude/skills/add-compact/add/src/session-commands.ts b/.claude/skills/add-compact/add/src/session-commands.ts deleted file mode 100644 index 69ea041..0000000 --- a/.claude/skills/add-compact/add/src/session-commands.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { NewMessage } from './types.js'; -import { logger } from './logger.js'; - -/** - * Extract a session slash command from a message, stripping the trigger prefix if present. - * Returns the slash command (e.g., '/compact') or null if not a session command. - */ -export function extractSessionCommand(content: string, triggerPattern: RegExp): string | null { - let text = content.trim(); - text = text.replace(triggerPattern, '').trim(); - if (text === '/compact') return '/compact'; - 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. - */ -export function isSessionCommandAllowed(isMainGroup: boolean, isFromMe: boolean): boolean { - return isMainGroup || isFromMe; -} - -/** Minimal agent result interface — matches the subset of ContainerOutput used here. */ -export interface AgentResult { - status: 'success' | 'error'; - result?: string | object | null; -} - -/** Dependencies injected by the orchestrator. */ -export interface SessionCommandDeps { - sendMessage: (text: string) => Promise; - setTyping: (typing: boolean) => Promise; - runAgent: ( - prompt: string, - onOutput: (result: AgentResult) => Promise, - ) => Promise<'success' | 'error'>; - closeStdin: () => void; - advanceCursor: (timestamp: string) => void; - formatMessages: (msgs: NewMessage[], timezone: string) => string; - /** Whether the denied sender would normally be allowed to interact (for denial messages). */ - canSenderInteract: (msg: NewMessage) => boolean; -} - -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(); -} - -/** - * Handle session command interception in processGroupMessages. - * Scans messages for a session command, handles auth + execution. - * Returns { handled: true, success } if a command was found; { handled: false } otherwise. - * success=false means the caller should retry (cursor was not advanced). - */ -export async function handleSessionCommand(opts: { - missedMessages: NewMessage[]; - isMainGroup: boolean; - groupName: string; - triggerPattern: RegExp; - timezone: string; - deps: SessionCommandDeps; -}): Promise<{ handled: false } | { handled: true; success: boolean }> { - const { missedMessages, isMainGroup, groupName, triggerPattern, timezone, deps } = opts; - - const cmdMsg = missedMessages.find( - (m) => extractSessionCommand(m.content, triggerPattern) !== null, - ); - const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null; - - if (!command || !cmdMsg) return { handled: false }; - - if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) { - // 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 - // a high-water mark). Acceptable for this narrow edge case. - if (deps.canSenderInteract(cmdMsg)) { - await deps.sendMessage('Session commands require admin access.'); - } - deps.advanceCursor(cmdMsg.timestamp); - return { handled: true, success: true }; - } - - // AUTHORIZED: process pre-compact messages first, then run the command - logger.info({ group: groupName, command }, 'Session command'); - - const cmdIndex = missedMessages.indexOf(cmdMsg); - const preCompactMsgs = missedMessages.slice(0, cmdIndex); - - // Send pre-compact messages to the agent so they're in the session context. - if (preCompactMsgs.length > 0) { - const prePrompt = deps.formatMessages(preCompactMsgs, timezone); - let hadPreError = false; - let preOutputSent = false; - - const preResult = await deps.runAgent(prePrompt, async (result) => { - if (result.status === 'error') hadPreError = true; - const text = resultToText(result.result); - if (text) { - await deps.sendMessage(text); - preOutputSent = true; - } - // Close stdin on session-update marker — emitted after query completes, - // so all results (including multi-result runs) are already written. - if (result.status === 'success' && result.result === null) { - deps.closeStdin(); - } - }); - - if (preResult === 'error' || hadPreError) { - logger.warn({ group: groupName }, 'Pre-compact processing failed, aborting session command'); - await deps.sendMessage(`Failed to process messages before ${command}. Try again.`); - if (preOutputSent) { - // Output was already sent — don't retry or it will duplicate. - // Advance cursor past pre-compact messages, leave command pending. - deps.advanceCursor(preCompactMsgs[preCompactMsgs.length - 1].timestamp); - return { handled: true, success: true }; - } - return { handled: true, success: false }; - } - } - - // Forward the literal slash command as the prompt (no XML formatting) - await deps.setTyping(true); - - let hadCmdError = false; - const cmdOutput = await deps.runAgent(command, async (result) => { - if (result.status === 'error') hadCmdError = true; - const text = resultToText(result.result); - if (text) await deps.sendMessage(text); - }); - - // Advance cursor to the command — messages AFTER it remain pending for next poll. - deps.advanceCursor(cmdMsg.timestamp); - await deps.setTyping(false); - - if (cmdOutput === 'error' || hadCmdError) { - await deps.sendMessage(`${command} failed. The session is unchanged.`); - } - - return { handled: true, success: true }; -} diff --git a/.claude/skills/add-compact/manifest.yaml b/.claude/skills/add-compact/manifest.yaml deleted file mode 100644 index 3ac9b31..0000000 --- a/.claude/skills/add-compact/manifest.yaml +++ /dev/null @@ -1,16 +0,0 @@ -skill: add-compact -version: 1.0.0 -description: "Add /compact command for manual context compaction via Claude Agent SDK" -core_version: 1.2.10 -adds: - - src/session-commands.ts - - src/session-commands.test.ts -modifies: - - src/index.ts - - container/agent-runner/src/index.ts -structured: - npm_dependencies: {} - env_additions: [] -conflicts: [] -depends: [] -test: "npx vitest run --config vitest.skills.config.ts .claude/skills/add-compact/tests/add-compact.test.ts" diff --git a/.claude/skills/add-compact/modify/container/agent-runner/src/index.ts b/.claude/skills/add-compact/modify/container/agent-runner/src/index.ts deleted file mode 100644 index a8f4c3b..0000000 --- a/.claude/skills/add-compact/modify/container/agent-runner/src/index.ts +++ /dev/null @@ -1,688 +0,0 @@ -/** - * NanoClaw Agent Runner - * Runs inside a container, 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/ - * Files: {type:"message", text:"..."}.json — polled and consumed - * Sentinel: /workspace/ipc/input/_close — signals session end - * - * Stdout protocol: - * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. - * Multiple results may be emitted (one per agent teams result). - * Final marker after loop ends signals completion. - */ - -import fs from 'fs'; -import path from 'path'; -import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; -import { fileURLToPath } from 'url'; - -interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - secrets?: Record; -} - -interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface SessionEntry { - sessionId: string; - fullPath: string; - summary: string; - firstPrompt: string; -} - -interface SessionsIndex { - entries: SessionEntry[]; -} - -interface SDKUserMessage { - type: 'user'; - message: { role: 'user'; content: string }; - parent_tool_use_id: null; - session_id: string; -} - -const IPC_INPUT_DIR = '/workspace/ipc/input'; -const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); -const IPC_POLL_MS = 500; - -/** - * Push-based async iterable for streaming user messages to the SDK. - * Keeps the iterable alive until end() is called, preventing isSingleUserTurn. - */ -class MessageStream { - private queue: SDKUserMessage[] = []; - private waiting: (() => void) | null = null; - private done = false; - - push(text: string): void { - this.queue.push({ - type: 'user', - message: { role: 'user', content: text }, - parent_tool_use_id: null, - session_id: '', - }); - this.waiting?.(); - } - - end(): void { - this.done = true; - this.waiting?.(); - } - - async *[Symbol.asyncIterator](): AsyncGenerator { - while (true) { - while (this.queue.length > 0) { - yield this.queue.shift()!; - } - if (this.done) return; - await new Promise(r => { this.waiting = r; }); - this.waiting = null; - } - } -} - -async function readStdin(): Promise { - return new Promise((resolve, reject) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', reject); - }); -} - -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -function writeOutput(output: ContainerOutput): void { - console.log(OUTPUT_START_MARKER); - console.log(JSON.stringify(output)); - console.log(OUTPUT_END_MARKER); -} - -function log(message: string): void { - console.error(`[agent-runner] ${message}`); -} - -function getSessionSummary(sessionId: string, transcriptPath: string): string | null { - const projectDir = path.dirname(transcriptPath); - const indexPath = path.join(projectDir, 'sessions-index.json'); - - if (!fs.existsSync(indexPath)) { - log(`Sessions index not found at ${indexPath}`); - return null; - } - - try { - const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); - const entry = index.entries.find(e => e.sessionId === sessionId); - if (entry?.summary) { - return entry.summary; - } - } catch (err) { - log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); - } - - return null; -} - -/** - * Archive the full transcript to conversations/ before compaction. - */ -function createPreCompactHook(assistantName?: string): HookCallback { - return async (input, _toolUseId, _context) => { - const preCompact = input as PreCompactHookInput; - const transcriptPath = preCompact.transcript_path; - const sessionId = preCompact.session_id; - - if (!transcriptPath || !fs.existsSync(transcriptPath)) { - log('No transcript found for archiving'); - return {}; - } - - try { - const content = fs.readFileSync(transcriptPath, 'utf-8'); - const messages = parseTranscript(content); - - if (messages.length === 0) { - log('No messages to archive'); - return {}; - } - - const summary = getSessionSummary(sessionId, transcriptPath); - const name = summary ? sanitizeFilename(summary) : generateFallbackName(); - - const conversationsDir = '/workspace/group/conversations'; - fs.mkdirSync(conversationsDir, { recursive: true }); - - const date = new Date().toISOString().split('T')[0]; - const filename = `${date}-${name}.md`; - const filePath = path.join(conversationsDir, filename); - - const markdown = formatTranscriptMarkdown(messages, summary, assistantName); - fs.writeFileSync(filePath, markdown); - - log(`Archived conversation to ${filePath}`); - } catch (err) { - log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); - } - - return {}; - }; -} - -// Secrets to strip from Bash tool subprocess environments. -// These are needed by claude-code for API auth but should never -// be visible to commands Kit runs. -const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; - -function createSanitizeBashHook(): HookCallback { - return async (input, _toolUseId, _context) => { - const preInput = input as PreToolUseHookInput; - const command = (preInput.tool_input as { command?: string })?.command; - if (!command) return {}; - - const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `; - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - updatedInput: { - ...(preInput.tool_input as Record), - command: unsetPrefix + command, - }, - }, - }; - }; -} - -function sanitizeFilename(summary: string): string { - return summary - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 50); -} - -function generateFallbackName(): string { - const time = new Date(); - return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; -} - -interface ParsedMessage { - role: 'user' | 'assistant'; - content: string; -} - -function parseTranscript(content: string): ParsedMessage[] { - const messages: ParsedMessage[] = []; - - for (const line of content.split('\n')) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - if (entry.type === 'user' && entry.message?.content) { - const text = typeof entry.message.content === 'string' - ? entry.message.content - : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); - if (text) messages.push({ role: 'user', content: text }); - } else if (entry.type === 'assistant' && entry.message?.content) { - const textParts = entry.message.content - .filter((c: { type: string }) => c.type === 'text') - .map((c: { text: string }) => c.text); - const text = textParts.join(''); - if (text) messages.push({ role: 'assistant', content: text }); - } - } catch { - } - } - - return messages; -} - -function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { - const now = new Date(); - const formatDateTime = (d: Date) => d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }); - - const lines: string[] = []; - lines.push(`# ${title || 'Conversation'}`); - lines.push(''); - lines.push(`Archived: ${formatDateTime(now)}`); - lines.push(''); - lines.push('---'); - lines.push(''); - - for (const msg of messages) { - const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant'); - const content = msg.content.length > 2000 - ? msg.content.slice(0, 2000) + '...' - : msg.content; - lines.push(`**${sender}**: ${content}`); - lines.push(''); - } - - return lines.join('\n'); -} - -/** - * Check for _close sentinel. - */ -function shouldClose(): boolean { - if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - return true; - } - return false; -} - -/** - * Drain all pending IPC input messages. - * Returns messages found, or empty array. - */ -function drainIpcInput(): string[] { - try { - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - const files = fs.readdirSync(IPC_INPUT_DIR) - .filter(f => f.endsWith('.json')) - .sort(); - - const messages: string[] = []; - for (const file of files) { - const filePath = path.join(IPC_INPUT_DIR, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - fs.unlinkSync(filePath); - if (data.type === 'message' && data.text) { - messages.push(data.text); - } - } catch (err) { - log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`); - try { fs.unlinkSync(filePath); } catch { /* ignore */ } - } - } - return messages; - } catch (err) { - log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); - return []; - } -} - -/** - * Wait for a new IPC message or _close sentinel. - * Returns the messages as a single string, or null if _close. - */ -function waitForIpcMessage(): Promise { - return new Promise((resolve) => { - const poll = () => { - if (shouldClose()) { - resolve(null); - return; - } - const messages = drainIpcInput(); - if (messages.length > 0) { - resolve(messages.join('\n')); - return; - } - setTimeout(poll, IPC_POLL_MS); - }; - poll(); - }); -} - -/** - * Run a single query and stream results via writeOutput. - * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, - * allowing agent teams subagents to run to completion. - * Also pipes IPC messages into the stream during the query. - */ -async function runQuery( - prompt: string, - sessionId: string | undefined, - mcpServerPath: string, - containerInput: ContainerInput, - sdkEnv: Record, - resumeAt?: string, -): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> { - const stream = new MessageStream(); - stream.push(prompt); - - // Poll IPC for follow-up messages and _close sentinel during the query - let ipcPolling = true; - let closedDuringQuery = false; - const pollIpcDuringQuery = () => { - if (!ipcPolling) return; - if (shouldClose()) { - log('Close sentinel detected during query, ending stream'); - closedDuringQuery = true; - stream.end(); - ipcPolling = false; - return; - } - const messages = drainIpcInput(); - for (const text of messages) { - log(`Piping IPC message into active query (${text.length} chars)`); - stream.push(text); - } - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - }; - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - - let newSessionId: string | undefined; - let lastAssistantUuid: string | undefined; - let messageCount = 0; - let resultCount = 0; - - // Load global CLAUDE.md as additional system context (shared across all groups) - const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; - let globalClaudeMd: string | undefined; - if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { - globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); - } - - // Discover additional directories mounted at /workspace/extra/* - // These are passed to the SDK so their CLAUDE.md files are loaded automatically - const extraDirs: string[] = []; - const extraBase = '/workspace/extra'; - if (fs.existsSync(extraBase)) { - for (const entry of fs.readdirSync(extraBase)) { - const fullPath = path.join(extraBase, entry); - if (fs.statSync(fullPath).isDirectory()) { - extraDirs.push(fullPath); - } - } - } - if (extraDirs.length > 0) { - log(`Additional directories: ${extraDirs.join(', ')}`); - } - - for await (const message of query({ - prompt: stream, - options: { - cwd: '/workspace/group', - additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, - resume: sessionId, - resumeSessionAt: resumeAt, - systemPrompt: globalClaudeMd - ? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd } - : undefined, - allowedTools: [ - 'Bash', - 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'Task', 'TaskOutput', 'TaskStop', - 'TeamCreate', 'TeamDelete', 'SendMessage', - 'TodoWrite', 'ToolSearch', 'Skill', - 'NotebookEdit', - 'mcp__nanoclaw__*' - ], - env: sdkEnv, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - settingSources: ['project', 'user'], - mcpServers: { - nanoclaw: { - command: 'node', - args: [mcpServerPath], - env: { - NANOCLAW_CHAT_JID: containerInput.chatJid, - NANOCLAW_GROUP_FOLDER: containerInput.groupFolder, - NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', - }, - }, - }, - hooks: { - PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], - PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], - }, - } - })) { - messageCount++; - const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; - log(`[msg #${messageCount}] type=${msgType}`); - - if (message.type === 'assistant' && 'uuid' in message) { - lastAssistantUuid = (message as { uuid: string }).uuid; - } - - if (message.type === 'system' && message.subtype === 'init') { - newSessionId = message.session_id; - log(`Session initialized: ${newSessionId}`); - } - - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { - const tn = message as { task_id: string; status: string; summary: string }; - log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); - } - - if (message.type === 'result') { - resultCount++; - const textResult = 'result' in message ? (message as { result?: string }).result : null; - log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`); - writeOutput({ - status: 'success', - result: textResult || null, - newSessionId - }); - } - } - - ipcPolling = false; - log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`); - return { newSessionId, lastAssistantUuid, closedDuringQuery }; -} - -async function main(): Promise { - let containerInput: ContainerInput; - - try { - const stdinData = await readStdin(); - containerInput = JSON.parse(stdinData); - // Delete the temp file the entrypoint wrote — it contains secrets - 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); - } - - // Build SDK env: merge secrets into process.env for the SDK only. - // Secrets never touch process.env itself, so Bash subprocesses can't see them. - const sdkEnv: Record = { ...process.env }; - for (const [key, value] of Object.entries(containerInput.secrets || {})) { - sdkEnv[key] = value; - } - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); - - let sessionId = containerInput.sessionId; - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - - // Clean up stale _close sentinel from previous container runs - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - - // Build initial prompt (drain any pending IPC messages too) - let prompt = containerInput.prompt; - if (containerInput.isScheduledTask) { - prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`; - } - const pending = drainIpcInput(); - if (pending.length > 0) { - log(`Draining ${pending.length} pending IPC messages into initial prompt`); - prompt += '\n' + pending.join('\n'); - } - - // --- Slash command handling --- - // Only known session slash commands are handled here. This prevents - // accidental interception of user prompts that happen to start with '/'. - const KNOWN_SESSION_COMMANDS = new Set(['/compact']); - const trimmedPrompt = prompt.trim(); - const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(trimmedPrompt); - - if (isSessionSlashCommand) { - log(`Handling session command: ${trimmedPrompt}`); - let slashSessionId: string | undefined; - let compactBoundarySeen = false; - let hadError = false; - let resultEmitted = false; - - try { - for await (const message of query({ - prompt: trimmedPrompt, - options: { - cwd: '/workspace/group', - resume: sessionId, - systemPrompt: undefined, - allowedTools: [], - env: sdkEnv, - permissionMode: 'bypassPermissions' as const, - allowDangerouslySkipPermissions: true, - settingSources: ['project', 'user'] as const, - hooks: { - PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], - }, - }, - })) { - const msgType = message.type === 'system' - ? `system/${(message as { subtype?: string }).subtype}` - : message.type; - log(`[slash-cmd] type=${msgType}`); - - if (message.type === 'system' && message.subtype === 'init') { - slashSessionId = message.session_id; - log(`Session after slash command: ${slashSessionId}`); - } - - // Observe compact_boundary to confirm compaction completed - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { - compactBoundarySeen = true; - log('Compact boundary observed — compaction completed'); - } - - if (message.type === 'result') { - const resultSubtype = (message as { subtype?: string }).subtype; - const textResult = 'result' in message ? (message as { result?: string }).result : null; - - if (resultSubtype?.startsWith('error')) { - hadError = true; - writeOutput({ - status: 'error', - result: null, - error: textResult || 'Session command failed.', - newSessionId: slashSessionId, - }); - } else { - writeOutput({ - status: 'success', - result: textResult || 'Conversation compacted.', - newSessionId: slashSessionId, - }); - } - resultEmitted = true; - } - } - } catch (err) { - hadError = true; - const errorMsg = err instanceof Error ? err.message : String(err); - log(`Slash command error: ${errorMsg}`); - writeOutput({ status: 'error', result: null, error: errorMsg }); - } - - log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`); - - // Warn if compact_boundary was never observed — compaction may not have occurred - if (!hadError && !compactBoundarySeen) { - log('WARNING: compact_boundary was not observed. Compaction may not have completed.'); - } - - // Only emit final session marker if no result was emitted yet and no error occurred - if (!resultEmitted && !hadError) { - writeOutput({ - status: 'success', - result: compactBoundarySeen - ? 'Conversation compacted.' - : 'Compaction requested but compact_boundary was not observed.', - newSessionId: slashSessionId, - }); - } else if (!hadError) { - // Emit session-only marker so host updates session tracking - writeOutput({ status: 'success', result: null, newSessionId: slashSessionId }); - } - return; - } - // --- End slash command handling --- - - // Query loop: run query → wait for IPC message → run new query → repeat - let resumeAt: string | undefined; - try { - while (true) { - log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`); - - const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt); - if (queryResult.newSessionId) { - sessionId = queryResult.newSessionId; - } - if (queryResult.lastAssistantUuid) { - resumeAt = queryResult.lastAssistantUuid; - } - - // If _close was consumed during the query, exit immediately. - // Don't emit a session-update marker (it would reset the host's - // idle timer and cause a 30-min delay before the next _close). - if (queryResult.closedDuringQuery) { - log('Close sentinel consumed during query, exiting'); - break; - } - - // Emit session update so host can track it - writeOutput({ status: 'success', result: null, newSessionId: sessionId }); - - log('Query ended, waiting for next IPC message...'); - - // Wait for the next message or _close sentinel - const nextMessage = await waitForIpcMessage(); - if (nextMessage === null) { - log('Close sentinel received, exiting'); - break; - } - - log(`Got new message (${nextMessage.length} chars), starting new query`); - prompt = nextMessage; - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - log(`Agent error: ${errorMessage}`); - writeOutput({ - status: 'error', - result: null, - newSessionId: sessionId, - error: errorMessage - }); - process.exit(1); - } -} - -main(); diff --git a/.claude/skills/add-compact/modify/container/agent-runner/src/index.ts.intent.md b/.claude/skills/add-compact/modify/container/agent-runner/src/index.ts.intent.md deleted file mode 100644 index 2538ca6..0000000 --- a/.claude/skills/add-compact/modify/container/agent-runner/src/index.ts.intent.md +++ /dev/null @@ -1,29 +0,0 @@ -# Intent: container/agent-runner/src/index.ts - -## What Changed -- Added `KNOWN_SESSION_COMMANDS` whitelist (`/compact`) -- Added slash command handling block in `main()` between prompt building and query loop -- Slash commands use `query()` with string prompt (not MessageStream), `allowedTools: []`, no mcpServers -- Tracks `compactBoundarySeen`, `hadError`, `resultEmitted` flags -- Observes `compact_boundary` system event to confirm compaction -- PreCompact hook still registered for transcript archival -- Error subtype checking: `resultSubtype?.startsWith('error')` emits `status: 'error'` -- Container exits after slash command completes (no IPC wait loop) - -## Key Sections -- **KNOWN_SESSION_COMMANDS** (before query loop): Set containing `/compact` -- **Slash command block** (after prompt building, before query loop): Detects session command, runs query with minimal options, handles result/error/boundary events -- **Existing query loop**: Unchanged - -## Invariants (must-keep) -- ContainerInput/ContainerOutput interfaces -- readStdin, writeOutput, log utilities -- OUTPUT_START_MARKER / OUTPUT_END_MARKER protocol -- MessageStream class with push/end/asyncIterator -- IPC polling (drainIpcInput, waitForIpcMessage, shouldClose) -- runQuery function with all existing logic -- createPreCompactHook for transcript archival -- createSanitizeBashHook for secret stripping -- parseTranscript, formatTranscriptMarkdown helpers -- main() stdin parsing, SDK env setup, query loop -- SECRET_ENV_VARS list diff --git a/.claude/skills/add-compact/modify/src/index.ts b/.claude/skills/add-compact/modify/src/index.ts deleted file mode 100644 index d7df95c..0000000 --- a/.claude/skills/add-compact/modify/src/index.ts +++ /dev/null @@ -1,640 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { - ASSISTANT_NAME, - IDLE_TIMEOUT, - POLL_INTERVAL, - TIMEZONE, - TRIGGER_PATTERN, -} from './config.js'; -import './channels/index.js'; -import { - getChannelFactory, - getRegisteredChannelNames, -} from './channels/registry.js'; -import { - ContainerOutput, - runContainerAgent, - writeGroupsSnapshot, - writeTasksSnapshot, -} from './container-runner.js'; -import { - cleanupOrphans, - ensureContainerRuntimeRunning, -} from './container-runtime.js'; -import { - getAllChats, - getAllRegisteredGroups, - getAllSessions, - getAllTasks, - getMessagesSince, - getNewMessages, - getRegisteredGroup, - getRouterState, - initDatabase, - setRegisteredGroup, - setRouterState, - setSession, - storeChatMetadata, - storeMessage, -} from './db.js'; -import { GroupQueue } from './group-queue.js'; -import { resolveGroupFolderPath } from './group-folder.js'; -import { startIpcWatcher } from './ipc.js'; -import { findChannel, formatMessages, formatOutbound } from './router.js'; -import { - isSenderAllowed, - isTriggerAllowed, - loadSenderAllowlist, - shouldDropMessage, -} from './sender-allowlist.js'; -import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js'; -import { startSchedulerLoop } from './task-scheduler.js'; -import { Channel, NewMessage, RegisteredGroup } from './types.js'; -import { logger } from './logger.js'; - -// Re-export for backwards compatibility during refactor -export { escapeXml, formatMessages } from './router.js'; - -let lastTimestamp = ''; -let sessions: Record = {}; -let registeredGroups: Record = {}; -let lastAgentTimestamp: Record = {}; -let messageLoopRunning = false; - -const channels: Channel[] = []; -const queue = new GroupQueue(); - -function loadState(): void { - lastTimestamp = getRouterState('last_timestamp') || ''; - const agentTs = getRouterState('last_agent_timestamp'); - try { - lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {}; - } catch { - logger.warn('Corrupted last_agent_timestamp in DB, resetting'); - lastAgentTimestamp = {}; - } - sessions = getAllSessions(); - registeredGroups = getAllRegisteredGroups(); - logger.info( - { groupCount: Object.keys(registeredGroups).length }, - 'State loaded', - ); -} - -function saveState(): void { - setRouterState('last_timestamp', lastTimestamp); - setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); -} - -function registerGroup(jid: string, group: RegisteredGroup): void { - let groupDir: string; - try { - groupDir = resolveGroupFolderPath(group.folder); - } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Rejecting group registration with invalid folder', - ); - return; - } - - registeredGroups[jid] = group; - setRegisteredGroup(jid, group); - - // Create group folder - fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); - - logger.info( - { jid, name: group.name, folder: group.folder }, - 'Group registered', - ); -} - -/** - * Get available groups list for the agent. - * Returns groups ordered by most recent activity. - */ -export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] { - const chats = getAllChats(); - const registeredJids = new Set(Object.keys(registeredGroups)); - - return chats - .filter((c) => c.jid !== '__group_sync__' && c.is_group) - .map((c) => ({ - jid: c.jid, - name: c.name, - lastActivity: c.last_message_time, - isRegistered: registeredJids.has(c.jid), - })); -} - -/** @internal - exported for testing */ -export function _setRegisteredGroups( - groups: Record, -): void { - registeredGroups = groups; -} - -/** - * Process all pending messages for a group. - * Called by the GroupQueue when it's this group's turn. - */ -async function processGroupMessages(chatJid: string): Promise { - const group = registeredGroups[chatJid]; - if (!group) return true; - - const channel = findChannel(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - return true; - } - - const isMainGroup = group.isMain === true; - - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const missedMessages = getMessagesSince( - chatJid, - sinceTimestamp, - ASSISTANT_NAME, - ); - - if (missedMessages.length === 0) return true; - - // --- Session command interception (before trigger check) --- - const cmdResult = await handleSessionCommand({ - missedMessages, - isMainGroup, - groupName: group.name, - triggerPattern: TRIGGER_PATTERN, - timezone: TIMEZONE, - deps: { - sendMessage: (text) => channel.sendMessage(chatJid, text), - setTyping: (typing) => channel.setTyping?.(chatJid, typing) ?? Promise.resolve(), - runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput), - closeStdin: () => queue.closeStdin(chatJid), - advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); }, - formatMessages, - canSenderInteract: (msg) => { - const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim()); - const reqTrigger = !isMainGroup && group.requiresTrigger !== false; - return isMainGroup || !reqTrigger || (hasTrigger && ( - msg.is_from_me || - isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist()) - )); - }, - }, - }); - if (cmdResult.handled) return cmdResult.success; - // --- End session command interception --- - - // For non-main groups, check if trigger is required and present - if (!isMainGroup && group.requiresTrigger !== false) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = missedMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) { - return true; - } - } - - const prompt = formatMessages(missedMessages, TIMEZONE); - - // Advance cursor so the piping path in startMessageLoop won't re-fetch - // these messages. Save the old cursor so we can roll back on error. - const previousCursor = lastAgentTimestamp[chatJid] || ''; - lastAgentTimestamp[chatJid] = - missedMessages[missedMessages.length - 1].timestamp; - saveState(); - - logger.info( - { group: group.name, messageCount: missedMessages.length }, - 'Processing messages', - ); - - // Track idle timer for closing stdin when agent is idle - let idleTimer: ReturnType | null = null; - - const resetIdleTimer = () => { - if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(() => { - logger.debug( - { group: group.name }, - 'Idle timeout, closing container stdin', - ); - queue.closeStdin(chatJid); - }, IDLE_TIMEOUT); - }; - - await channel.setTyping?.(chatJid, true); - let hadError = false; - let outputSentToUser = false; - - const output = await runAgent(group, prompt, chatJid, async (result) => { - // Streaming output callback — called for each agent result - if (result.result) { - const raw = - typeof result.result === 'string' - ? result.result - : JSON.stringify(result.result); - // Strip ... blocks — agent uses these for internal reasoning - const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); - logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); - if (text) { - await channel.sendMessage(chatJid, text); - outputSentToUser = true; - } - // Only reset idle timer on actual results, not session-update markers (result: null) - resetIdleTimer(); - } - - if (result.status === 'success') { - queue.notifyIdle(chatJid); - } - - if (result.status === 'error') { - hadError = true; - } - }); - - await channel.setTyping?.(chatJid, false); - if (idleTimer) clearTimeout(idleTimer); - - if (output === 'error' || hadError) { - // If we already sent output to the user, don't roll back the cursor — - // the user got their response and re-processing would send duplicates. - if (outputSentToUser) { - logger.warn( - { group: group.name }, - 'Agent error after output was sent, skipping cursor rollback to prevent duplicates', - ); - return true; - } - // Roll back cursor so retries can re-process these messages - lastAgentTimestamp[chatJid] = previousCursor; - saveState(); - logger.warn( - { group: group.name }, - 'Agent error, rolled back message cursor for retry', - ); - return false; - } - - return true; -} - -async function runAgent( - group: RegisteredGroup, - prompt: string, - chatJid: string, - onOutput?: (output: ContainerOutput) => Promise, -): Promise<'success' | 'error'> { - const isMain = group.isMain === true; - const sessionId = sessions[group.folder]; - - // Update tasks snapshot for container to read (filtered by group) - const tasks = getAllTasks(); - writeTasksSnapshot( - group.folder, - isMain, - tasks.map((t) => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run, - })), - ); - - // Update available groups snapshot (main group only can see all groups) - const availableGroups = getAvailableGroups(); - writeGroupsSnapshot( - group.folder, - isMain, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); - - // Wrap onOutput to track session ID from streamed results - const wrappedOnOutput = onOutput - ? async (output: ContainerOutput) => { - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - await onOutput(output); - } - : undefined; - - try { - const output = await runContainerAgent( - group, - { - prompt, - sessionId, - groupFolder: group.folder, - chatJid, - isMain, - assistantName: ASSISTANT_NAME, - }, - (proc, containerName) => - queue.registerProcess(chatJid, proc, containerName, group.folder), - wrappedOnOutput, - ); - - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - - if (output.status === 'error') { - logger.error( - { group: group.name, error: output.error }, - 'Container agent error', - ); - return 'error'; - } - - return 'success'; - } catch (err) { - logger.error({ group: group.name, err }, 'Agent error'); - return 'error'; - } -} - -async function startMessageLoop(): Promise { - if (messageLoopRunning) { - logger.debug('Message loop already running, skipping duplicate start'); - return; - } - messageLoopRunning = true; - - logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); - - while (true) { - try { - const jids = Object.keys(registeredGroups); - const { messages, newTimestamp } = getNewMessages( - jids, - lastTimestamp, - ASSISTANT_NAME, - ); - - if (messages.length > 0) { - logger.info({ count: messages.length }, 'New messages'); - - // Advance the "seen" cursor for all messages immediately - lastTimestamp = newTimestamp; - saveState(); - - // Deduplicate by group - const messagesByGroup = new Map(); - for (const msg of messages) { - const existing = messagesByGroup.get(msg.chat_jid); - if (existing) { - existing.push(msg); - } else { - messagesByGroup.set(msg.chat_jid, [msg]); - } - } - - for (const [chatJid, groupMessages] of messagesByGroup) { - const group = registeredGroups[chatJid]; - if (!group) continue; - - const channel = findChannel(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - continue; - } - - const isMainGroup = group.isMain === true; - - // --- Session command interception (message loop) --- - // Scan ALL messages in the batch for a session command. - const loopCmdMsg = groupMessages.find( - (m) => extractSessionCommand(m.content, TRIGGER_PATTERN) !== null, - ); - - if (loopCmdMsg) { - // Only close active container if the sender is authorized — otherwise an - // untrusted user could kill in-flight work by sending /compact (DoS). - // closeStdin no-ops internally when no container is active. - if (isSessionCommandAllowed(isMainGroup, loopCmdMsg.is_from_me === true)) { - queue.closeStdin(chatJid); - } - // Enqueue so processGroupMessages handles auth + cursor advancement. - // Don't pipe via IPC — slash commands need a fresh container with - // string prompt (not MessageStream) for SDK recognition. - queue.enqueueMessageCheck(chatJid); - continue; - } - // --- End session command interception --- - - const needsTrigger = !isMainGroup && group.requiresTrigger !== false; - - // For non-main groups, only act on trigger messages. - // Non-trigger messages accumulate in DB and get pulled as - // context when a trigger eventually arrives. - if (needsTrigger) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = groupMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || - isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) continue; - } - - // Pull all messages since lastAgentTimestamp so non-trigger - // context that accumulated between triggers is included. - const allPending = getMessagesSince( - chatJid, - lastAgentTimestamp[chatJid] || '', - ASSISTANT_NAME, - ); - const messagesToSend = - allPending.length > 0 ? allPending : groupMessages; - const formatted = formatMessages(messagesToSend, TIMEZONE); - - if (queue.sendMessage(chatJid, formatted)) { - logger.debug( - { chatJid, count: messagesToSend.length }, - 'Piped messages to active container', - ); - lastAgentTimestamp[chatJid] = - messagesToSend[messagesToSend.length - 1].timestamp; - saveState(); - // Show typing indicator while the container processes the piped message - channel - .setTyping?.(chatJid, true) - ?.catch((err) => - logger.warn({ chatJid, err }, 'Failed to set typing indicator'), - ); - } else { - // No active container — enqueue for a new one - queue.enqueueMessageCheck(chatJid); - } - } - } - } catch (err) { - logger.error({ err }, 'Error in message loop'); - } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - } -} - -/** - * Startup recovery: check for unprocessed messages in registered groups. - * Handles crash between advancing lastTimestamp and processing messages. - */ -function recoverPendingMessages(): void { - for (const [chatJid, group] of Object.entries(registeredGroups)) { - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); - if (pending.length > 0) { - logger.info( - { group: group.name, pendingCount: pending.length }, - 'Recovery: found unprocessed messages', - ); - queue.enqueueMessageCheck(chatJid); - } - } -} - -function ensureContainerSystemRunning(): void { - ensureContainerRuntimeRunning(); - cleanupOrphans(); -} - -async function main(): Promise { - ensureContainerSystemRunning(); - initDatabase(); - logger.info('Database initialized'); - loadState(); - - // Graceful shutdown handlers - const shutdown = async (signal: string) => { - logger.info({ signal }, 'Shutdown signal received'); - await queue.shutdown(10000); - for (const ch of channels) await ch.disconnect(); - process.exit(0); - }; - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); - - // Channel callbacks (shared by all channels) - const channelOpts = { - onMessage: (chatJid: string, msg: NewMessage) => { - // Sender allowlist drop mode: discard messages from denied senders before storing - if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { - const cfg = loadSenderAllowlist(); - if ( - shouldDropMessage(chatJid, cfg) && - !isSenderAllowed(chatJid, msg.sender, cfg) - ) { - if (cfg.logDenied) { - logger.debug( - { chatJid, sender: msg.sender }, - 'sender-allowlist: dropping message (drop mode)', - ); - } - return; - } - } - storeMessage(msg); - }, - onChatMetadata: ( - chatJid: string, - timestamp: string, - name?: string, - channel?: string, - isGroup?: boolean, - ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup), - registeredGroups: () => registeredGroups, - }; - - // Create and connect all registered channels. - // Each channel self-registers via the barrel import above. - // Factories return null when credentials are missing, so unconfigured channels are skipped. - for (const channelName of getRegisteredChannelNames()) { - const factory = getChannelFactory(channelName)!; - const channel = factory(channelOpts); - if (!channel) { - logger.warn( - { channel: channelName }, - 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.', - ); - continue; - } - channels.push(channel); - await channel.connect(); - } - if (channels.length === 0) { - logger.fatal('No channels connected'); - process.exit(1); - } - - // Start subsystems (independently of connection handler) - startSchedulerLoop({ - registeredGroups: () => registeredGroups, - getSessions: () => sessions, - queue, - onProcess: (groupJid, proc, containerName, groupFolder) => - queue.registerProcess(groupJid, proc, containerName, groupFolder), - sendMessage: async (jid, rawText) => { - const channel = findChannel(channels, jid); - if (!channel) { - logger.warn({ jid }, 'No channel owns JID, cannot send message'); - return; - } - const text = formatOutbound(rawText); - if (text) await channel.sendMessage(jid, text); - }, - }); - startIpcWatcher({ - sendMessage: (jid, text) => { - const channel = findChannel(channels, jid); - if (!channel) throw new Error(`No channel for JID: ${jid}`); - return channel.sendMessage(jid, text); - }, - registeredGroups: () => registeredGroups, - registerGroup, - syncGroups: async (force: boolean) => { - await Promise.all( - channels - .filter((ch) => ch.syncGroups) - .map((ch) => ch.syncGroups!(force)), - ); - }, - getAvailableGroups, - writeGroupsSnapshot: (gf, im, ag, rj) => - writeGroupsSnapshot(gf, im, ag, rj), - }); - queue.setProcessMessagesFn(processGroupMessages); - recoverPendingMessages(); - startMessageLoop().catch((err) => { - logger.fatal({ err }, 'Message loop crashed unexpectedly'); - process.exit(1); - }); -} - -// Guard: only run when executed directly, not when imported by tests -const isDirectRun = - process.argv[1] && - new URL(import.meta.url).pathname === - new URL(`file://${process.argv[1]}`).pathname; - -if (isDirectRun) { - main().catch((err) => { - logger.error({ err }, 'Failed to start NanoClaw'); - process.exit(1); - }); -} diff --git a/.claude/skills/add-compact/modify/src/index.ts.intent.md b/.claude/skills/add-compact/modify/src/index.ts.intent.md deleted file mode 100644 index 0f915d7..0000000 --- a/.claude/skills/add-compact/modify/src/index.ts.intent.md +++ /dev/null @@ -1,25 +0,0 @@ -# Intent: src/index.ts - -## What Changed -- Added `import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js'` -- Added `handleSessionCommand()` call in `processGroupMessages()` between `missedMessages.length === 0` check and trigger check -- Added session command interception in `startMessageLoop()` between `isMainGroup` check and `needsTrigger` block - -## Key Sections -- **Imports** (top of file): extractSessionCommand, handleSessionCommand, isSessionCommandAllowed from session-commands -- **processGroupMessages**: Calls `handleSessionCommand()` with deps (sendMessage, runAgent, closeStdin, advanceCursor, formatMessages, canSenderInteract), returns early if handled -- **startMessageLoop**: Session command detection, auth-gated closeStdin (prevents DoS), enqueue for processGroupMessages - -## Invariants (must-keep) -- State management (lastTimestamp, sessions, registeredGroups, lastAgentTimestamp) -- loadState/saveState functions -- registerGroup function with folder validation -- getAvailableGroups function -- processGroupMessages trigger logic, cursor management, idle timer, error rollback with duplicate prevention -- runAgent task/group snapshot writes, session tracking, wrappedOnOutput -- startMessageLoop with dedup-by-group and piping logic -- recoverPendingMessages startup recovery -- main() with channel setup, scheduler, IPC watcher, queue -- ensureContainerSystemRunning using container-runtime abstraction -- Graceful shutdown with queue.shutdown -- Sender allowlist integration (drop mode, trigger check) diff --git a/.claude/skills/add-compact/tests/add-compact.test.ts b/.claude/skills/add-compact/tests/add-compact.test.ts deleted file mode 100644 index 396d57b..0000000 --- a/.claude/skills/add-compact/tests/add-compact.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -const SKILL_DIR = path.resolve(__dirname, '..'); - -describe('add-compact skill package', () => { - describe('manifest', () => { - let content: string; - - beforeAll(() => { - content = fs.readFileSync(path.join(SKILL_DIR, 'manifest.yaml'), 'utf-8'); - }); - - it('has a valid manifest.yaml', () => { - expect(fs.existsSync(path.join(SKILL_DIR, 'manifest.yaml'))).toBe(true); - expect(content).toContain('skill: add-compact'); - expect(content).toContain('version: 1.0.0'); - }); - - it('has no npm dependencies', () => { - expect(content).toContain('npm_dependencies: {}'); - }); - - it('has no env_additions', () => { - expect(content).toContain('env_additions: []'); - }); - - it('lists all add files', () => { - expect(content).toContain('src/session-commands.ts'); - expect(content).toContain('src/session-commands.test.ts'); - }); - - it('lists all modify files', () => { - expect(content).toContain('src/index.ts'); - expect(content).toContain('container/agent-runner/src/index.ts'); - }); - - it('has no dependencies', () => { - expect(content).toContain('depends: []'); - }); - }); - - describe('add/ files', () => { - it('includes src/session-commands.ts with required exports', () => { - const filePath = path.join(SKILL_DIR, 'add', 'src', 'session-commands.ts'); - expect(fs.existsSync(filePath)).toBe(true); - - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('export function extractSessionCommand'); - expect(content).toContain('export function isSessionCommandAllowed'); - expect(content).toContain('export async function handleSessionCommand'); - expect(content).toContain("'/compact'"); - }); - - it('includes src/session-commands.test.ts with test cases', () => { - const filePath = path.join(SKILL_DIR, 'add', 'src', 'session-commands.test.ts'); - expect(fs.existsSync(filePath)).toBe(true); - - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('extractSessionCommand'); - expect(content).toContain('isSessionCommandAllowed'); - expect(content).toContain('detects bare /compact'); - expect(content).toContain('denies untrusted sender'); - }); - }); - - describe('modify/ files exist', () => { - const modifyFiles = [ - 'src/index.ts', - 'container/agent-runner/src/index.ts', - ]; - - for (const file of modifyFiles) { - it(`includes modify/${file}`, () => { - const filePath = path.join(SKILL_DIR, 'modify', file); - expect(fs.existsSync(filePath)).toBe(true); - }); - } - }); - - describe('intent files exist', () => { - const intentFiles = [ - 'src/index.ts.intent.md', - 'container/agent-runner/src/index.ts.intent.md', - ]; - - for (const file of intentFiles) { - it(`includes modify/${file}`, () => { - const filePath = path.join(SKILL_DIR, 'modify', file); - expect(fs.existsSync(filePath)).toBe(true); - }); - } - }); - - describe('modify/src/index.ts', () => { - let content: string; - - beforeAll(() => { - content = fs.readFileSync( - path.join(SKILL_DIR, 'modify', 'src', 'index.ts'), - 'utf-8', - ); - }); - - it('imports session command helpers', () => { - expect(content).toContain("import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js'"); - }); - - it('uses const for missedMessages', () => { - expect(content).toMatch(/const missedMessages = getMessagesSince/); - }); - - it('delegates to handleSessionCommand in processGroupMessages', () => { - expect(content).toContain('Session command interception (before trigger check)'); - expect(content).toContain('handleSessionCommand('); - expect(content).toContain('cmdResult.handled'); - expect(content).toContain('cmdResult.success'); - }); - - it('passes deps to handleSessionCommand', () => { - expect(content).toContain('sendMessage:'); - expect(content).toContain('setTyping:'); - expect(content).toContain('runAgent:'); - expect(content).toContain('closeStdin:'); - expect(content).toContain('advanceCursor:'); - expect(content).toContain('formatMessages'); - expect(content).toContain('canSenderInteract:'); - }); - - it('has session command interception in startMessageLoop', () => { - expect(content).toContain('Session command interception (message loop)'); - expect(content).toContain('queue.enqueueMessageCheck(chatJid)'); - }); - - it('preserves core index.ts structure', () => { - expect(content).toContain('processGroupMessages'); - expect(content).toContain('startMessageLoop'); - expect(content).toContain('async function main()'); - expect(content).toContain('recoverPendingMessages'); - expect(content).toContain('ensureContainerSystemRunning'); - }); - }); - - describe('modify/container/agent-runner/src/index.ts', () => { - let content: string; - - beforeAll(() => { - content = fs.readFileSync( - path.join(SKILL_DIR, 'modify', 'container', 'agent-runner', 'src', 'index.ts'), - 'utf-8', - ); - }); - - it('defines KNOWN_SESSION_COMMANDS whitelist', () => { - expect(content).toContain("KNOWN_SESSION_COMMANDS"); - expect(content).toContain("'/compact'"); - }); - - it('uses query() with string prompt for slash commands', () => { - expect(content).toContain('prompt: trimmedPrompt'); - expect(content).toContain('allowedTools: []'); - }); - - it('observes compact_boundary system event', () => { - expect(content).toContain('compactBoundarySeen'); - expect(content).toContain("'compact_boundary'"); - expect(content).toContain('Compact boundary observed'); - }); - - it('handles error subtypes', () => { - expect(content).toContain("resultSubtype?.startsWith('error')"); - }); - - it('registers PreCompact hook for slash commands', () => { - expect(content).toContain('createPreCompactHook(containerInput.assistantName)'); - }); - - it('preserves core agent-runner structure', () => { - expect(content).toContain('async function runQuery'); - expect(content).toContain('class MessageStream'); - expect(content).toContain('function writeOutput'); - expect(content).toContain('function createPreCompactHook'); - expect(content).toContain('function createSanitizeBashHook'); - expect(content).toContain('async function main'); - }); - }); -}); diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md deleted file mode 100644 index 0522bd1..0000000 --- a/.claude/skills/add-discord/SKILL.md +++ /dev/null @@ -1,206 +0,0 @@ -# Add Discord Channel - -This skill adds Discord support to NanoClaw using the skills engine for deterministic code changes, then walks through interactive setup. - -## Phase 1: Pre-flight - -### Check if already applied - -Read `.nanoclaw/state.yaml`. If `discord` is in `applied_skills`, 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 Discord 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 - -Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md. - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist yet: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -Or call `initSkillsSystem()` from `skills-engine/migrate.ts`. - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-discord -``` - -This deterministically: -- Adds `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`) -- Adds `src/channels/discord.test.ts` (unit tests with discord.js mock) -- Appends `import './discord.js'` to the channel barrel file `src/channels/index.ts` -- Installs the `discord.js` npm dependency -- Records the application in `.nanoclaw/state.yaml` - -If the apply reports merge conflicts, read the intent file: -- `modify/src/channels/index.ts.intent.md` — what changed and invariants - -### Validate code changes - -```bash -npm test -npm run build -``` - -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 - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -## Phase 4: Registration - -### Get Channel ID - -Tell the user: - -> To get the channel ID for registration: -> -> 1. In Discord, go to **User Settings** > **Advanced** > Enable **Developer Mode** -> 2. Right-click the text channel you want the bot to respond in -> 3. Click **Copy Channel ID** -> -> The channel ID will be a long number like `1234567890123456`. - -Wait for the user to provide the channel ID (format: `dc:1234567890123456`). - -### Register the channel - -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-discord/add/src/channels/discord.test.ts b/.claude/skills/add-discord/add/src/channels/discord.test.ts deleted file mode 100644 index 5dbfb50..0000000 --- a/.claude/skills/add-discord/add/src/channels/discord.test.ts +++ /dev/null @@ -1,776 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// --- Mocks --- - -// Mock registry (registerChannel runs at import time) -vi.mock('./registry.js', () => ({ registerChannel: vi.fn() })); - -// Mock env reader (used by the factory, not needed in unit tests) -vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); - -// Mock config -vi.mock('../config.js', () => ({ - ASSISTANT_NAME: 'Andy', - TRIGGER_PATTERN: /^@Andy\b/i, -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// --- discord.js mock --- - -type Handler = (...args: any[]) => any; - -const clientRef = vi.hoisted(() => ({ current: null as any })); - -vi.mock('discord.js', () => { - const Events = { - MessageCreate: 'messageCreate', - ClientReady: 'ready', - Error: 'error', - }; - - const GatewayIntentBits = { - Guilds: 1, - GuildMessages: 2, - MessageContent: 4, - DirectMessages: 8, - }; - - class MockClient { - eventHandlers = new Map(); - user: any = { id: '999888777', tag: 'Andy#1234' }; - private _ready = false; - - constructor(_opts: any) { - clientRef.current = this; - } - - on(event: string, handler: Handler) { - const existing = this.eventHandlers.get(event) || []; - existing.push(handler); - this.eventHandlers.set(event, existing); - return this; - } - - once(event: string, handler: Handler) { - return this.on(event, handler); - } - - async login(_token: string) { - this._ready = true; - // Fire the ready event - const readyHandlers = this.eventHandlers.get('ready') || []; - for (const h of readyHandlers) { - h({ user: this.user }); - } - } - - isReady() { - return this._ready; - } - - channels = { - fetch: vi.fn().mockResolvedValue({ - send: vi.fn().mockResolvedValue(undefined), - sendTyping: vi.fn().mockResolvedValue(undefined), - }), - }; - - destroy() { - this._ready = false; - } - } - - // Mock TextChannel type - class TextChannel {} - - return { - Client: MockClient, - Events, - GatewayIntentBits, - TextChannel, - }; -}); - -import { DiscordChannel, DiscordChannelOpts } from './discord.js'; - -// --- Test helpers --- - -function createTestOpts( - overrides?: Partial, -): DiscordChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: vi.fn(() => ({ - 'dc:1234567890123456': { - name: 'Test Server #general', - folder: 'test-server', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - ...overrides, - }; -} - -function createMessage(overrides: { - channelId?: string; - content?: string; - authorId?: string; - authorUsername?: string; - authorDisplayName?: string; - memberDisplayName?: string; - isBot?: boolean; - guildName?: string; - channelName?: string; - messageId?: string; - createdAt?: Date; - attachments?: Map; - reference?: { messageId?: string }; - mentionsBotId?: boolean; -}) { - const channelId = overrides.channelId ?? '1234567890123456'; - const authorId = overrides.authorId ?? '55512345'; - const botId = '999888777'; // matches mock client user id - - const mentionsMap = new Map(); - if (overrides.mentionsBotId) { - mentionsMap.set(botId, { id: botId }); - } - - return { - channelId, - id: overrides.messageId ?? 'msg_001', - content: overrides.content ?? 'Hello everyone', - createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'), - author: { - id: authorId, - username: overrides.authorUsername ?? 'alice', - displayName: overrides.authorDisplayName ?? 'Alice', - bot: overrides.isBot ?? false, - }, - member: overrides.memberDisplayName - ? { displayName: overrides.memberDisplayName } - : null, - guild: overrides.guildName - ? { name: overrides.guildName } - : null, - channel: { - name: overrides.channelName ?? 'general', - messages: { - fetch: vi.fn().mockResolvedValue({ - author: { username: 'Bob', displayName: 'Bob' }, - member: { displayName: 'Bob' }, - }), - }, - }, - mentions: { - users: mentionsMap, - }, - attachments: overrides.attachments ?? new Map(), - reference: overrides.reference ?? null, - }; -} - -function currentClient() { - return clientRef.current; -} - -async function triggerMessage(message: any) { - const handlers = currentClient().eventHandlers.get('messageCreate') || []; - for (const h of handlers) await h(message); -} - -// --- Tests --- - -describe('DiscordChannel', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when client is ready', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - await channel.connect(); - - expect(channel.isConnected()).toBe(true); - }); - - it('registers message handlers on connect', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - await channel.connect(); - - expect(currentClient().eventHandlers.has('messageCreate')).toBe(true); - expect(currentClient().eventHandlers.has('error')).toBe(true); - expect(currentClient().eventHandlers.has('ready')).toBe(true); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - await channel.connect(); - expect(channel.isConnected()).toBe(true); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - }); - - it('isConnected() returns false before connect', () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - expect(channel.isConnected()).toBe(false); - }); - }); - - // --- Text message handling --- - - describe('text message handling', () => { - it('delivers message for registered channel', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hello everyone', - guildName: 'Test Server', - channelName: 'general', - }); - await triggerMessage(msg); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.any(String), - 'Test Server #general', - 'discord', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - id: 'msg_001', - chat_jid: 'dc:1234567890123456', - sender: '55512345', - sender_name: 'Alice', - content: 'Hello everyone', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered channels', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - channelId: '9999999999999999', - content: 'Unknown channel', - guildName: 'Other Server', - }); - await triggerMessage(msg); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'dc:9999999999999999', - expect.any(String), - expect.any(String), - 'discord', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores bot messages', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ isBot: true, content: 'I am a bot' }); - await triggerMessage(msg); - - expect(opts.onMessage).not.toHaveBeenCalled(); - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - }); - - it('uses member displayName when available (server nickname)', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hi', - memberDisplayName: 'Alice Nickname', - authorDisplayName: 'Alice Global', - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ sender_name: 'Alice Nickname' }), - ); - }); - - it('falls back to author displayName when no member', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hi', - memberDisplayName: undefined, - authorDisplayName: 'Alice Global', - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ sender_name: 'Alice Global' }), - ); - }); - - it('uses sender name for DM chats (no guild)', async () => { - const opts = createTestOpts({ - registeredGroups: vi.fn(() => ({ - 'dc:1234567890123456': { - name: 'DM', - folder: 'dm', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hello', - guildName: undefined, - authorDisplayName: 'Alice', - }); - await triggerMessage(msg); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.any(String), - 'Alice', - 'discord', - false, - ); - }); - - it('uses guild name + channel name for server messages', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hello', - guildName: 'My Server', - channelName: 'bot-chat', - }); - await triggerMessage(msg); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.any(String), - 'My Server #bot-chat', - 'discord', - true, - ); - }); - }); - - // --- @mention translation --- - - describe('@mention translation', () => { - it('translates <@botId> mention to trigger format', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: '<@999888777> what time is it?', - mentionsBotId: true, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '@Andy what time is it?', - }), - ); - }); - - it('does not translate if message already matches trigger', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: '@Andy hello <@999888777>', - mentionsBotId: true, - guildName: 'Server', - }); - await triggerMessage(msg); - - // Should NOT prepend @Andy — already starts with trigger - // But the <@botId> should still be stripped - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '@Andy hello', - }), - ); - }); - - it('does not translate when bot is not mentioned', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'hello everyone', - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: 'hello everyone', - }), - ); - }); - - it('handles <@!botId> (nickname mention format)', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: '<@!999888777> check this', - mentionsBotId: true, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '@Andy check this', - }), - ); - }); - }); - - // --- Attachments --- - - describe('attachments', () => { - it('stores image attachment with placeholder', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - ['att1', { name: 'photo.png', contentType: 'image/png' }], - ]); - const msg = createMessage({ - content: '', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '[Image: photo.png]', - }), - ); - }); - - it('stores video attachment with placeholder', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - ['att1', { name: 'clip.mp4', contentType: 'video/mp4' }], - ]); - const msg = createMessage({ - content: '', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '[Video: clip.mp4]', - }), - ); - }); - - it('stores file attachment with placeholder', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - ['att1', { name: 'report.pdf', contentType: 'application/pdf' }], - ]); - const msg = createMessage({ - content: '', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '[File: report.pdf]', - }), - ); - }); - - it('includes text content with attachments', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - ['att1', { name: 'photo.jpg', contentType: 'image/jpeg' }], - ]); - const msg = createMessage({ - content: 'Check this out', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: 'Check this out\n[Image: photo.jpg]', - }), - ); - }); - - it('handles multiple attachments', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - ['att1', { name: 'a.png', contentType: 'image/png' }], - ['att2', { name: 'b.txt', contentType: 'text/plain' }], - ]); - const msg = createMessage({ - content: '', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '[Image: a.png]\n[File: b.txt]', - }), - ); - }); - }); - - // --- Reply context --- - - describe('reply context', () => { - it('includes reply author in content', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'I agree with that', - reference: { messageId: 'original_msg_id' }, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '[Reply to Bob] I agree with that', - }), - ); - }); - }); - - // --- sendMessage --- - - describe('sendMessage', () => { - it('sends message via channel', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - await channel.sendMessage('dc:1234567890123456', 'Hello'); - - const fetchedChannel = await currentClient().channels.fetch('1234567890123456'); - expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456'); - }); - - it('strips dc: prefix from JID', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - await channel.sendMessage('dc:9876543210', 'Test'); - - expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210'); - }); - - it('handles send failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - currentClient().channels.fetch.mockRejectedValueOnce( - new Error('Channel not found'), - ); - - // Should not throw - await expect( - channel.sendMessage('dc:1234567890123456', 'Will fail'), - ).resolves.toBeUndefined(); - }); - - it('does nothing when client is not initialized', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - // Don't connect — client is null - await channel.sendMessage('dc:1234567890123456', 'No client'); - - // No error, no API call - }); - - it('splits messages exceeding 2000 characters', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const mockChannel = { - send: vi.fn().mockResolvedValue(undefined), - sendTyping: vi.fn(), - }; - currentClient().channels.fetch.mockResolvedValue(mockChannel); - - const longText = 'x'.repeat(3000); - await channel.sendMessage('dc:1234567890123456', longText); - - expect(mockChannel.send).toHaveBeenCalledTimes(2); - expect(mockChannel.send).toHaveBeenNthCalledWith(1, 'x'.repeat(2000)); - expect(mockChannel.send).toHaveBeenNthCalledWith(2, 'x'.repeat(1000)); - }); - }); - - // --- ownsJid --- - - describe('ownsJid', () => { - it('owns dc: JIDs', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.ownsJid('dc:1234567890123456')).toBe(true); - }); - - it('does not own WhatsApp group JIDs', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(false); - }); - - it('does not own Telegram JIDs', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.ownsJid('tg:123456789')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); - }); - - // --- setTyping --- - - describe('setTyping', () => { - it('sends typing indicator when isTyping is true', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const mockChannel = { - send: vi.fn(), - sendTyping: vi.fn().mockResolvedValue(undefined), - }; - currentClient().channels.fetch.mockResolvedValue(mockChannel); - - await channel.setTyping('dc:1234567890123456', true); - - expect(mockChannel.sendTyping).toHaveBeenCalled(); - }); - - it('does nothing when isTyping is false', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - await channel.setTyping('dc:1234567890123456', false); - - // channels.fetch should NOT be called - expect(currentClient().channels.fetch).not.toHaveBeenCalled(); - }); - - it('does nothing when client is not initialized', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - // Don't connect - await channel.setTyping('dc:1234567890123456', true); - - // No error - }); - }); - - // --- Channel properties --- - - describe('channel properties', () => { - it('has name "discord"', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.name).toBe('discord'); - }); - }); -}); diff --git a/.claude/skills/add-discord/add/src/channels/discord.ts b/.claude/skills/add-discord/add/src/channels/discord.ts deleted file mode 100644 index 13f07ba..0000000 --- a/.claude/skills/add-discord/add/src/channels/discord.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js'; - -import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; -import { readEnvFile } from '../env.js'; -import { logger } from '../logger.js'; -import { registerChannel, ChannelOpts } from './registry.js'; -import { - Channel, - OnChatMetadata, - OnInboundMessage, - RegisteredGroup, -} from '../types.js'; - -export interface DiscordChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -export class DiscordChannel implements Channel { - name = 'discord'; - - private client: Client | null = null; - private opts: DiscordChannelOpts; - private botToken: string; - - constructor(botToken: string, opts: DiscordChannelOpts) { - this.botToken = botToken; - this.opts = opts; - } - - async connect(): Promise { - this.client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - GatewayIntentBits.DirectMessages, - ], - }); - - this.client.on(Events.MessageCreate, async (message: Message) => { - // Ignore bot messages (including own) - if (message.author.bot) return; - - const channelId = message.channelId; - const chatJid = `dc:${channelId}`; - let content = message.content; - const timestamp = message.createdAt.toISOString(); - const senderName = - message.member?.displayName || - message.author.displayName || - message.author.username; - const sender = message.author.id; - const msgId = message.id; - - // Determine chat name - let chatName: string; - if (message.guild) { - const textChannel = message.channel as TextChannel; - chatName = `${message.guild.name} #${textChannel.name}`; - } else { - chatName = senderName; - } - - // Translate Discord @bot mentions into TRIGGER_PATTERN format. - // Discord mentions look like <@botUserId> — these won't match - // TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger - // when the bot is @mentioned. - if (this.client?.user) { - const botId = this.client.user.id; - const isBotMentioned = - message.mentions.users.has(botId) || - content.includes(`<@${botId}>`) || - content.includes(`<@!${botId}>`); - - if (isBotMentioned) { - // Strip the <@botId> mention to avoid visual clutter - content = content - .replace(new RegExp(`<@!?${botId}>`, 'g'), '') - .trim(); - // Prepend trigger if not already present - if (!TRIGGER_PATTERN.test(content)) { - content = `@${ASSISTANT_NAME} ${content}`; - } - } - } - - // Handle attachments — store placeholders so the agent knows something was sent - if (message.attachments.size > 0) { - const attachmentDescriptions = [...message.attachments.values()].map((att) => { - const contentType = att.contentType || ''; - if (contentType.startsWith('image/')) { - return `[Image: ${att.name || 'image'}]`; - } else if (contentType.startsWith('video/')) { - return `[Video: ${att.name || 'video'}]`; - } else if (contentType.startsWith('audio/')) { - return `[Audio: ${att.name || 'audio'}]`; - } else { - return `[File: ${att.name || 'file'}]`; - } - }); - if (content) { - content = `${content}\n${attachmentDescriptions.join('\n')}`; - } else { - content = attachmentDescriptions.join('\n'); - } - } - - // Handle reply context — include who the user is replying to - if (message.reference?.messageId) { - try { - const repliedTo = await message.channel.messages.fetch( - message.reference.messageId, - ); - const replyAuthor = - repliedTo.member?.displayName || - repliedTo.author.displayName || - repliedTo.author.username; - content = `[Reply to ${replyAuthor}] ${content}`; - } catch { - // Referenced message may have been deleted - } - } - - // Store chat metadata for discovery - const isGroup = message.guild !== null; - this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup); - - // Only deliver full message for registered groups - const group = this.opts.registeredGroups()[chatJid]; - if (!group) { - logger.debug( - { chatJid, chatName }, - 'Message from unregistered Discord channel', - ); - return; - } - - // Deliver message — startMessageLoop() will pick it up - this.opts.onMessage(chatJid, { - id: msgId, - chat_jid: chatJid, - sender, - sender_name: senderName, - content, - timestamp, - is_from_me: false, - }); - - logger.info( - { chatJid, chatName, sender: senderName }, - 'Discord message stored', - ); - }); - - // Handle errors gracefully - this.client.on(Events.Error, (err) => { - logger.error({ err: err.message }, 'Discord client error'); - }); - - return new Promise((resolve) => { - this.client!.once(Events.ClientReady, (readyClient) => { - logger.info( - { username: readyClient.user.tag, id: readyClient.user.id }, - 'Discord bot connected', - ); - console.log(`\n Discord bot: ${readyClient.user.tag}`); - console.log( - ` Use /chatid command or check channel IDs in Discord settings\n`, - ); - resolve(); - }); - - this.client!.login(this.botToken); - }); - } - - async sendMessage(jid: string, text: string): Promise { - if (!this.client) { - logger.warn('Discord client not initialized'); - return; - } - - try { - const channelId = jid.replace(/^dc:/, ''); - const channel = await this.client.channels.fetch(channelId); - - if (!channel || !('send' in channel)) { - logger.warn({ jid }, 'Discord channel not found or not text-based'); - return; - } - - const textChannel = channel as TextChannel; - - // Discord has a 2000 character limit per message — split if needed - const MAX_LENGTH = 2000; - if (text.length <= MAX_LENGTH) { - await textChannel.send(text); - } else { - for (let i = 0; i < text.length; i += MAX_LENGTH) { - await textChannel.send(text.slice(i, i + MAX_LENGTH)); - } - } - logger.info({ jid, length: text.length }, 'Discord message sent'); - } catch (err) { - logger.error({ jid, err }, 'Failed to send Discord message'); - } - } - - isConnected(): boolean { - return this.client !== null && this.client.isReady(); - } - - ownsJid(jid: string): boolean { - return jid.startsWith('dc:'); - } - - async disconnect(): Promise { - if (this.client) { - this.client.destroy(); - this.client = null; - logger.info('Discord bot stopped'); - } - } - - async setTyping(jid: string, isTyping: boolean): Promise { - if (!this.client || !isTyping) return; - try { - const channelId = jid.replace(/^dc:/, ''); - const channel = await this.client.channels.fetch(channelId); - if (channel && 'sendTyping' in channel) { - await (channel as TextChannel).sendTyping(); - } - } catch (err) { - logger.debug({ jid, err }, 'Failed to send Discord typing indicator'); - } - } -} - -registerChannel('discord', (opts: ChannelOpts) => { - const envVars = readEnvFile(['DISCORD_BOT_TOKEN']); - const token = - process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN || ''; - if (!token) { - logger.warn('Discord: DISCORD_BOT_TOKEN not set'); - return null; - } - return new DiscordChannel(token, opts); -}); diff --git a/.claude/skills/add-discord/manifest.yaml b/.claude/skills/add-discord/manifest.yaml deleted file mode 100644 index c5bec61..0000000 --- a/.claude/skills/add-discord/manifest.yaml +++ /dev/null @@ -1,17 +0,0 @@ -skill: discord -version: 1.0.0 -description: "Discord Bot integration via discord.js" -core_version: 0.1.0 -adds: - - src/channels/discord.ts - - src/channels/discord.test.ts -modifies: - - src/channels/index.ts -structured: - npm_dependencies: - discord.js: "^14.18.0" - env_additions: - - DISCORD_BOT_TOKEN -conflicts: [] -depends: [] -test: "npx vitest run src/channels/discord.test.ts" diff --git a/.claude/skills/add-discord/modify/src/channels/index.ts b/.claude/skills/add-discord/modify/src/channels/index.ts deleted file mode 100644 index 3916e5e..0000000 --- a/.claude/skills/add-discord/modify/src/channels/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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/.claude/skills/add-discord/modify/src/channels/index.ts.intent.md b/.claude/skills/add-discord/modify/src/channels/index.ts.intent.md deleted file mode 100644 index baba3f5..0000000 --- a/.claude/skills/add-discord/modify/src/channels/index.ts.intent.md +++ /dev/null @@ -1,7 +0,0 @@ -# Intent: Add Discord channel import - -Add `import './discord.js';` to the channel barrel file so the Discord -module self-registers with the channel registry on startup. - -This is an append-only change — existing import lines for other channels -must be preserved. diff --git a/.claude/skills/add-discord/tests/discord.test.ts b/.claude/skills/add-discord/tests/discord.test.ts deleted file mode 100644 index b51411c..0000000 --- a/.claude/skills/add-discord/tests/discord.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('discord skill package', () => { - const skillDir = path.resolve(__dirname, '..'); - - it('has a valid manifest', () => { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - expect(fs.existsSync(manifestPath)).toBe(true); - - const content = fs.readFileSync(manifestPath, 'utf-8'); - expect(content).toContain('skill: discord'); - expect(content).toContain('version: 1.0.0'); - expect(content).toContain('discord.js'); - }); - - it('has all files declared in adds', () => { - const channelFile = path.join( - skillDir, - 'add', - 'src', - 'channels', - 'discord.ts', - ); - expect(fs.existsSync(channelFile)).toBe(true); - - const content = fs.readFileSync(channelFile, 'utf-8'); - expect(content).toContain('class DiscordChannel'); - expect(content).toContain('implements Channel'); - expect(content).toContain("registerChannel('discord'"); - - // Test file for the channel - const testFile = path.join( - skillDir, - 'add', - 'src', - 'channels', - 'discord.test.ts', - ); - expect(fs.existsSync(testFile)).toBe(true); - - const testContent = fs.readFileSync(testFile, 'utf-8'); - expect(testContent).toContain("describe('DiscordChannel'"); - }); - - it('has all files declared in modifies', () => { - // Channel barrel file - const indexFile = path.join( - skillDir, - 'modify', - 'src', - 'channels', - 'index.ts', - ); - expect(fs.existsSync(indexFile)).toBe(true); - - const indexContent = fs.readFileSync(indexFile, 'utf-8'); - expect(indexContent).toContain("import './discord.js'"); - }); - - it('has intent files for modified files', () => { - expect( - fs.existsSync( - path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'), - ), - ).toBe(true); - }); -}); diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md deleted file mode 100644 index b8d2c25..0000000 --- a/.claude/skills/add-gmail/SKILL.md +++ /dev/null @@ -1,242 +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 - -Read `.nanoclaw/state.yaml`. If `gmail` is in `applied_skills`, 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 - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist yet: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -### Path A: Tool-only (user chose "No") - -Do NOT run the full apply script. Only two source files need changes. This avoids adding dead code (`gmail.ts`, `gmail.test.ts`, index.ts channel logic, routing tests, `googleapis` dependency). - -#### 1. Mount Gmail credentials in container - -Apply the changes described in `modify/src/container-runner.ts.intent.md` to `src/container-runner.ts`: import `os`, add a conditional read-write mount of `~/.gmail-mcp` to `/home/node/.gmail-mcp` in `buildVolumeMounts()` after the session mounts. - -#### 2. Add Gmail MCP server to agent runner - -Apply the changes described in `modify/container/agent-runner/src/index.ts.intent.md` to `container/agent-runner/src/index.ts`: add `gmail` MCP server (`npx -y @gongrzhe/server-gmail-autoauth-mcp`) and `'mcp__gmail__*'` to `allowedTools`. - -#### 3. Record in state - -Add `gmail` to `.nanoclaw/state.yaml` under `applied_skills` with `mode: tool-only`. - -#### 4. Validate - -```bash -npm run build -``` - -Build must be clean before proceeding. Skip to Phase 3. - -### Path B: Channel mode (user chose "Yes") - -Run the full skills engine to apply all code changes: - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-gmail -``` - -This deterministically: - -- Adds `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`) -- Adds `src/channels/gmail.test.ts` (unit tests) -- Appends `import './gmail.js'` to the channel barrel file `src/channels/index.ts` -- Three-way merges Gmail credentials mount into `src/container-runner.ts` (~/.gmail-mcp -> /home/node/.gmail-mcp) -- Three-way merges Gmail MCP server into `container/agent-runner/src/index.ts` (@gongrzhe/server-gmail-autoauth-mcp) -- Installs the `googleapis` npm dependency -- Records the application in `.nanoclaw/state.yaml` - -If the apply reports merge conflicts, read the intent files: - -- `modify/src/channels/index.ts.intent.md` — what changed for the barrel file -- `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts -- `modify/container/agent-runner/src/index.ts.intent.md` — what changed for agent-runner - -#### Add email handling instructions - -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 - -```bash -npm test -npm run build -``` - -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. Remove `gmail` from `.nanoclaw/state.yaml` -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. Remove `gmail` from `.nanoclaw/state.yaml` -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-gmail/add/src/channels/gmail.test.ts b/.claude/skills/add-gmail/add/src/channels/gmail.test.ts deleted file mode 100644 index afdb15b..0000000 --- a/.claude/skills/add-gmail/add/src/channels/gmail.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock registry (registerChannel runs at import time) -vi.mock('./registry.js', () => ({ registerChannel: vi.fn() })); - -import { GmailChannel, GmailChannelOpts } from './gmail.js'; - -function makeOpts(overrides?: Partial): GmailChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: () => ({}), - ...overrides, - }; -} - -describe('GmailChannel', () => { - let channel: GmailChannel; - - beforeEach(() => { - channel = new GmailChannel(makeOpts()); - }); - - describe('ownsJid', () => { - it('returns true for gmail: prefixed JIDs', () => { - expect(channel.ownsJid('gmail:abc123')).toBe(true); - expect(channel.ownsJid('gmail:thread-id-456')).toBe(true); - }); - - it('returns false for non-gmail JIDs', () => { - expect(channel.ownsJid('12345@g.us')).toBe(false); - expect(channel.ownsJid('tg:123')).toBe(false); - expect(channel.ownsJid('dc:456')).toBe(false); - expect(channel.ownsJid('user@s.whatsapp.net')).toBe(false); - }); - }); - - describe('name', () => { - it('is gmail', () => { - expect(channel.name).toBe('gmail'); - }); - }); - - describe('isConnected', () => { - it('returns false before connect', () => { - expect(channel.isConnected()).toBe(false); - }); - }); - - describe('disconnect', () => { - it('sets connected to false', async () => { - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - }); - }); - - describe('constructor options', () => { - it('accepts custom poll interval', () => { - const ch = new GmailChannel(makeOpts(), 30000); - expect(ch.name).toBe('gmail'); - }); - - it('defaults to unread query when no filter configured', () => { - const ch = new GmailChannel(makeOpts()); - const query = (ch as unknown as { buildQuery: () => string }).buildQuery(); - expect(query).toBe('is:unread category:primary'); - }); - - it('defaults with no options provided', () => { - const ch = new GmailChannel(makeOpts()); - expect(ch.name).toBe('gmail'); - }); - }); -}); diff --git a/.claude/skills/add-gmail/add/src/channels/gmail.ts b/.claude/skills/add-gmail/add/src/channels/gmail.ts deleted file mode 100644 index 131f55a..0000000 --- a/.claude/skills/add-gmail/add/src/channels/gmail.ts +++ /dev/null @@ -1,352 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import { google, gmail_v1 } from 'googleapis'; -import { OAuth2Client } from 'google-auth-library'; - -// isMain flag is used instead of MAIN_GROUP_FOLDER constant -import { logger } from '../logger.js'; -import { registerChannel, ChannelOpts } from './registry.js'; -import { - Channel, - OnChatMetadata, - OnInboundMessage, - RegisteredGroup, -} from '../types.js'; - -export interface GmailChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -interface ThreadMeta { - sender: string; - senderName: string; - subject: string; - messageId: string; // RFC 2822 Message-ID for In-Reply-To -} - -export class GmailChannel implements Channel { - name = 'gmail'; - - private oauth2Client: OAuth2Client | null = null; - private gmail: gmail_v1.Gmail | null = null; - private opts: GmailChannelOpts; - private pollIntervalMs: number; - private pollTimer: ReturnType | null = null; - private processedIds = new Set(); - private threadMeta = new Map(); - private consecutiveErrors = 0; - private userEmail = ''; - - constructor(opts: GmailChannelOpts, pollIntervalMs = 60000) { - this.opts = opts; - this.pollIntervalMs = pollIntervalMs; - } - - async connect(): Promise { - const credDir = path.join(os.homedir(), '.gmail-mcp'); - const keysPath = path.join(credDir, 'gcp-oauth.keys.json'); - const tokensPath = path.join(credDir, 'credentials.json'); - - if (!fs.existsSync(keysPath) || !fs.existsSync(tokensPath)) { - logger.warn( - 'Gmail credentials not found in ~/.gmail-mcp/. Skipping Gmail channel. Run /add-gmail to set up.', - ); - return; - } - - const keys = JSON.parse(fs.readFileSync(keysPath, 'utf-8')); - const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); - - const clientConfig = keys.installed || keys.web || keys; - const { client_id, client_secret, redirect_uris } = clientConfig; - this.oauth2Client = new google.auth.OAuth2( - client_id, - client_secret, - redirect_uris?.[0], - ); - this.oauth2Client.setCredentials(tokens); - - // Persist refreshed tokens - this.oauth2Client.on('tokens', (newTokens) => { - try { - const current = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); - Object.assign(current, newTokens); - fs.writeFileSync(tokensPath, JSON.stringify(current, null, 2)); - logger.debug('Gmail OAuth tokens refreshed'); - } catch (err) { - logger.warn({ err }, 'Failed to persist refreshed Gmail tokens'); - } - }); - - this.gmail = google.gmail({ version: 'v1', auth: this.oauth2Client }); - - // Verify connection - const profile = await this.gmail.users.getProfile({ userId: 'me' }); - this.userEmail = profile.data.emailAddress || ''; - logger.info({ email: this.userEmail }, 'Gmail channel connected'); - - // Start polling with error backoff - const schedulePoll = () => { - const backoffMs = this.consecutiveErrors > 0 - ? Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveErrors), 30 * 60 * 1000) - : this.pollIntervalMs; - this.pollTimer = setTimeout(() => { - this.pollForMessages() - .catch((err) => logger.error({ err }, 'Gmail poll error')) - .finally(() => { - if (this.gmail) schedulePoll(); - }); - }, backoffMs); - }; - - // Initial poll - await this.pollForMessages(); - schedulePoll(); - } - - async sendMessage(jid: string, text: string): Promise { - if (!this.gmail) { - logger.warn('Gmail not initialized'); - return; - } - - const threadId = jid.replace(/^gmail:/, ''); - const meta = this.threadMeta.get(threadId); - - if (!meta) { - logger.warn({ jid }, 'No thread metadata for reply, cannot send'); - return; - } - - const subject = meta.subject.startsWith('Re:') - ? meta.subject - : `Re: ${meta.subject}`; - - const headers = [ - `To: ${meta.sender}`, - `From: ${this.userEmail}`, - `Subject: ${subject}`, - `In-Reply-To: ${meta.messageId}`, - `References: ${meta.messageId}`, - 'Content-Type: text/plain; charset=utf-8', - '', - text, - ].join('\r\n'); - - const encodedMessage = Buffer.from(headers) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - try { - await this.gmail.users.messages.send({ - userId: 'me', - requestBody: { - raw: encodedMessage, - threadId, - }, - }); - logger.info({ to: meta.sender, threadId }, 'Gmail reply sent'); - } catch (err) { - logger.error({ jid, err }, 'Failed to send Gmail reply'); - } - } - - isConnected(): boolean { - return this.gmail !== null; - } - - ownsJid(jid: string): boolean { - return jid.startsWith('gmail:'); - } - - async disconnect(): Promise { - if (this.pollTimer) { - clearTimeout(this.pollTimer); - this.pollTimer = null; - } - this.gmail = null; - this.oauth2Client = null; - logger.info('Gmail channel stopped'); - } - - // --- Private --- - - private buildQuery(): string { - return 'is:unread category:primary'; - } - - private async pollForMessages(): Promise { - if (!this.gmail) return; - - try { - const query = this.buildQuery(); - const res = await this.gmail.users.messages.list({ - userId: 'me', - q: query, - maxResults: 10, - }); - - const messages = res.data.messages || []; - - for (const stub of messages) { - if (!stub.id || this.processedIds.has(stub.id)) continue; - this.processedIds.add(stub.id); - - await this.processMessage(stub.id); - } - - // Cap processed ID set to prevent unbounded growth - if (this.processedIds.size > 5000) { - const ids = [...this.processedIds]; - this.processedIds = new Set(ids.slice(ids.length - 2500)); - } - - this.consecutiveErrors = 0; - } catch (err) { - this.consecutiveErrors++; - const backoffMs = Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveErrors), 30 * 60 * 1000); - logger.error({ err, consecutiveErrors: this.consecutiveErrors, nextPollMs: backoffMs }, 'Gmail poll failed'); - } - } - - private async processMessage(messageId: string): Promise { - if (!this.gmail) return; - - const msg = await this.gmail.users.messages.get({ - userId: 'me', - id: messageId, - format: 'full', - }); - - const headers = msg.data.payload?.headers || []; - const getHeader = (name: string) => - headers.find((h) => h.name?.toLowerCase() === name.toLowerCase()) - ?.value || ''; - - const from = getHeader('From'); - const subject = getHeader('Subject'); - const rfc2822MessageId = getHeader('Message-ID'); - const threadId = msg.data.threadId || messageId; - const timestamp = new Date( - parseInt(msg.data.internalDate || '0', 10), - ).toISOString(); - - // Extract sender name and email - const senderMatch = from.match(/^(.+?)\s*<(.+?)>$/); - const senderName = senderMatch ? senderMatch[1].replace(/"/g, '') : from; - const senderEmail = senderMatch ? senderMatch[2] : from; - - // Skip emails from self (our own replies) - if (senderEmail === this.userEmail) return; - - // Extract body text - const body = this.extractTextBody(msg.data.payload); - - if (!body) { - logger.debug({ messageId, subject }, 'Skipping email with no text body'); - return; - } - - const chatJid = `gmail:${threadId}`; - - // Cache thread metadata for replies - this.threadMeta.set(threadId, { - sender: senderEmail, - senderName, - subject, - messageId: rfc2822MessageId, - }); - - // Store chat metadata for group discovery - this.opts.onChatMetadata(chatJid, timestamp, subject, 'gmail', false); - - // Find the main group to deliver the email notification - const groups = this.opts.registeredGroups(); - const mainEntry = Object.entries(groups).find( - ([, g]) => g.isMain === true, - ); - - if (!mainEntry) { - logger.debug( - { chatJid, subject }, - 'No main group registered, skipping email', - ); - return; - } - - const mainJid = mainEntry[0]; - const content = `[Email from ${senderName} <${senderEmail}>]\nSubject: ${subject}\n\n${body}`; - - this.opts.onMessage(mainJid, { - id: messageId, - chat_jid: mainJid, - sender: senderEmail, - sender_name: senderName, - content, - timestamp, - is_from_me: false, - }); - - // Mark as read - try { - await this.gmail.users.messages.modify({ - userId: 'me', - id: messageId, - requestBody: { removeLabelIds: ['UNREAD'] }, - }); - } catch (err) { - logger.warn({ messageId, err }, 'Failed to mark email as read'); - } - - logger.info( - { mainJid, from: senderName, subject }, - 'Gmail email delivered to main group', - ); - } - - private extractTextBody( - payload: gmail_v1.Schema$MessagePart | undefined, - ): string { - if (!payload) return ''; - - // Direct text/plain body - if (payload.mimeType === 'text/plain' && payload.body?.data) { - return Buffer.from(payload.body.data, 'base64').toString('utf-8'); - } - - // Multipart: search parts recursively - if (payload.parts) { - // Prefer text/plain - for (const part of payload.parts) { - if (part.mimeType === 'text/plain' && part.body?.data) { - return Buffer.from(part.body.data, 'base64').toString('utf-8'); - } - } - // Recurse into nested multipart - for (const part of payload.parts) { - const text = this.extractTextBody(part); - if (text) return text; - } - } - - return ''; - } -} - -registerChannel('gmail', (opts: ChannelOpts) => { - const credDir = path.join(os.homedir(), '.gmail-mcp'); - if ( - !fs.existsSync(path.join(credDir, 'gcp-oauth.keys.json')) || - !fs.existsSync(path.join(credDir, 'credentials.json')) - ) { - logger.warn('Gmail: credentials not found in ~/.gmail-mcp/'); - return null; - } - return new GmailChannel(opts); -}); diff --git a/.claude/skills/add-gmail/manifest.yaml b/.claude/skills/add-gmail/manifest.yaml deleted file mode 100644 index 1123c56..0000000 --- a/.claude/skills/add-gmail/manifest.yaml +++ /dev/null @@ -1,17 +0,0 @@ -skill: gmail -version: 1.0.0 -description: "Gmail integration via Google APIs" -core_version: 0.1.0 -adds: - - src/channels/gmail.ts - - src/channels/gmail.test.ts -modifies: - - src/channels/index.ts - - src/container-runner.ts - - container/agent-runner/src/index.ts -structured: - npm_dependencies: - googleapis: "^144.0.0" -conflicts: [] -depends: [] -test: "npx vitest run src/channels/gmail.test.ts" diff --git a/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts b/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts deleted file mode 100644 index 4d98033..0000000 --- a/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts +++ /dev/null @@ -1,593 +0,0 @@ -/** - * NanoClaw Agent Runner - * Runs inside a container, 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/ - * Files: {type:"message", text:"..."}.json — polled and consumed - * Sentinel: /workspace/ipc/input/_close — signals session end - * - * Stdout protocol: - * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. - * Multiple results may be emitted (one per agent teams result). - * Final marker after loop ends signals completion. - */ - -import fs from 'fs'; -import path from 'path'; -import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; -import { fileURLToPath } from 'url'; - -interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - secrets?: Record; -} - -interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface SessionEntry { - sessionId: string; - fullPath: string; - summary: string; - firstPrompt: string; -} - -interface SessionsIndex { - entries: SessionEntry[]; -} - -interface SDKUserMessage { - type: 'user'; - message: { role: 'user'; content: string }; - parent_tool_use_id: null; - session_id: string; -} - -const IPC_INPUT_DIR = '/workspace/ipc/input'; -const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); -const IPC_POLL_MS = 500; - -/** - * Push-based async iterable for streaming user messages to the SDK. - * Keeps the iterable alive until end() is called, preventing isSingleUserTurn. - */ -class MessageStream { - private queue: SDKUserMessage[] = []; - private waiting: (() => void) | null = null; - private done = false; - - push(text: string): void { - this.queue.push({ - type: 'user', - message: { role: 'user', content: text }, - parent_tool_use_id: null, - session_id: '', - }); - this.waiting?.(); - } - - end(): void { - this.done = true; - this.waiting?.(); - } - - async *[Symbol.asyncIterator](): AsyncGenerator { - while (true) { - while (this.queue.length > 0) { - yield this.queue.shift()!; - } - if (this.done) return; - await new Promise(r => { this.waiting = r; }); - this.waiting = null; - } - } -} - -async function readStdin(): Promise { - return new Promise((resolve, reject) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', reject); - }); -} - -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -function writeOutput(output: ContainerOutput): void { - console.log(OUTPUT_START_MARKER); - console.log(JSON.stringify(output)); - console.log(OUTPUT_END_MARKER); -} - -function log(message: string): void { - console.error(`[agent-runner] ${message}`); -} - -function getSessionSummary(sessionId: string, transcriptPath: string): string | null { - const projectDir = path.dirname(transcriptPath); - const indexPath = path.join(projectDir, 'sessions-index.json'); - - if (!fs.existsSync(indexPath)) { - log(`Sessions index not found at ${indexPath}`); - return null; - } - - try { - const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); - const entry = index.entries.find(e => e.sessionId === sessionId); - if (entry?.summary) { - return entry.summary; - } - } catch (err) { - log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); - } - - return null; -} - -/** - * Archive the full transcript to conversations/ before compaction. - */ -function createPreCompactHook(assistantName?: string): HookCallback { - return async (input, _toolUseId, _context) => { - const preCompact = input as PreCompactHookInput; - const transcriptPath = preCompact.transcript_path; - const sessionId = preCompact.session_id; - - if (!transcriptPath || !fs.existsSync(transcriptPath)) { - log('No transcript found for archiving'); - return {}; - } - - try { - const content = fs.readFileSync(transcriptPath, 'utf-8'); - const messages = parseTranscript(content); - - if (messages.length === 0) { - log('No messages to archive'); - return {}; - } - - const summary = getSessionSummary(sessionId, transcriptPath); - const name = summary ? sanitizeFilename(summary) : generateFallbackName(); - - const conversationsDir = '/workspace/group/conversations'; - fs.mkdirSync(conversationsDir, { recursive: true }); - - const date = new Date().toISOString().split('T')[0]; - const filename = `${date}-${name}.md`; - const filePath = path.join(conversationsDir, filename); - - const markdown = formatTranscriptMarkdown(messages, summary, assistantName); - fs.writeFileSync(filePath, markdown); - - log(`Archived conversation to ${filePath}`); - } catch (err) { - log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); - } - - return {}; - }; -} - -// Secrets to strip from Bash tool subprocess environments. -// These are needed by claude-code for API auth but should never -// be visible to commands Kit runs. -const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; - -function createSanitizeBashHook(): HookCallback { - return async (input, _toolUseId, _context) => { - const preInput = input as PreToolUseHookInput; - const command = (preInput.tool_input as { command?: string })?.command; - if (!command) return {}; - - const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `; - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - updatedInput: { - ...(preInput.tool_input as Record), - command: unsetPrefix + command, - }, - }, - }; - }; -} - -function sanitizeFilename(summary: string): string { - return summary - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 50); -} - -function generateFallbackName(): string { - const time = new Date(); - return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; -} - -interface ParsedMessage { - role: 'user' | 'assistant'; - content: string; -} - -function parseTranscript(content: string): ParsedMessage[] { - const messages: ParsedMessage[] = []; - - for (const line of content.split('\n')) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - if (entry.type === 'user' && entry.message?.content) { - const text = typeof entry.message.content === 'string' - ? entry.message.content - : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); - if (text) messages.push({ role: 'user', content: text }); - } else if (entry.type === 'assistant' && entry.message?.content) { - const textParts = entry.message.content - .filter((c: { type: string }) => c.type === 'text') - .map((c: { text: string }) => c.text); - const text = textParts.join(''); - if (text) messages.push({ role: 'assistant', content: text }); - } - } catch { - } - } - - return messages; -} - -function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { - const now = new Date(); - const formatDateTime = (d: Date) => d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }); - - const lines: string[] = []; - lines.push(`# ${title || 'Conversation'}`); - lines.push(''); - lines.push(`Archived: ${formatDateTime(now)}`); - lines.push(''); - lines.push('---'); - lines.push(''); - - for (const msg of messages) { - const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant'); - const content = msg.content.length > 2000 - ? msg.content.slice(0, 2000) + '...' - : msg.content; - lines.push(`**${sender}**: ${content}`); - lines.push(''); - } - - return lines.join('\n'); -} - -/** - * Check for _close sentinel. - */ -function shouldClose(): boolean { - if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - return true; - } - return false; -} - -/** - * Drain all pending IPC input messages. - * Returns messages found, or empty array. - */ -function drainIpcInput(): string[] { - try { - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - const files = fs.readdirSync(IPC_INPUT_DIR) - .filter(f => f.endsWith('.json')) - .sort(); - - const messages: string[] = []; - for (const file of files) { - const filePath = path.join(IPC_INPUT_DIR, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - fs.unlinkSync(filePath); - if (data.type === 'message' && data.text) { - messages.push(data.text); - } - } catch (err) { - log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`); - try { fs.unlinkSync(filePath); } catch { /* ignore */ } - } - } - return messages; - } catch (err) { - log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); - return []; - } -} - -/** - * Wait for a new IPC message or _close sentinel. - * Returns the messages as a single string, or null if _close. - */ -function waitForIpcMessage(): Promise { - return new Promise((resolve) => { - const poll = () => { - if (shouldClose()) { - resolve(null); - return; - } - const messages = drainIpcInput(); - if (messages.length > 0) { - resolve(messages.join('\n')); - return; - } - setTimeout(poll, IPC_POLL_MS); - }; - poll(); - }); -} - -/** - * Run a single query and stream results via writeOutput. - * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, - * allowing agent teams subagents to run to completion. - * Also pipes IPC messages into the stream during the query. - */ -async function runQuery( - prompt: string, - sessionId: string | undefined, - mcpServerPath: string, - containerInput: ContainerInput, - sdkEnv: Record, - resumeAt?: string, -): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> { - const stream = new MessageStream(); - stream.push(prompt); - - // Poll IPC for follow-up messages and _close sentinel during the query - let ipcPolling = true; - let closedDuringQuery = false; - const pollIpcDuringQuery = () => { - if (!ipcPolling) return; - if (shouldClose()) { - log('Close sentinel detected during query, ending stream'); - closedDuringQuery = true; - stream.end(); - ipcPolling = false; - return; - } - const messages = drainIpcInput(); - for (const text of messages) { - log(`Piping IPC message into active query (${text.length} chars)`); - stream.push(text); - } - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - }; - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - - let newSessionId: string | undefined; - let lastAssistantUuid: string | undefined; - let messageCount = 0; - let resultCount = 0; - - // Load global CLAUDE.md as additional system context (shared across all groups) - const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; - let globalClaudeMd: string | undefined; - if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { - globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); - } - - // Discover additional directories mounted at /workspace/extra/* - // These are passed to the SDK so their CLAUDE.md files are loaded automatically - const extraDirs: string[] = []; - const extraBase = '/workspace/extra'; - if (fs.existsSync(extraBase)) { - for (const entry of fs.readdirSync(extraBase)) { - const fullPath = path.join(extraBase, entry); - if (fs.statSync(fullPath).isDirectory()) { - extraDirs.push(fullPath); - } - } - } - if (extraDirs.length > 0) { - log(`Additional directories: ${extraDirs.join(', ')}`); - } - - for await (const message of query({ - prompt: stream, - options: { - cwd: '/workspace/group', - additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, - resume: sessionId, - resumeSessionAt: resumeAt, - systemPrompt: globalClaudeMd - ? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd } - : undefined, - allowedTools: [ - 'Bash', - 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'Task', 'TaskOutput', 'TaskStop', - 'TeamCreate', 'TeamDelete', 'SendMessage', - 'TodoWrite', 'ToolSearch', 'Skill', - 'NotebookEdit', - 'mcp__nanoclaw__*', - 'mcp__gmail__*', - ], - env: sdkEnv, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - settingSources: ['project', 'user'], - mcpServers: { - nanoclaw: { - command: 'node', - args: [mcpServerPath], - env: { - NANOCLAW_CHAT_JID: containerInput.chatJid, - NANOCLAW_GROUP_FOLDER: containerInput.groupFolder, - NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', - }, - }, - gmail: { - command: 'npx', - args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'], - }, - }, - hooks: { - PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], - PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], - }, - } - })) { - messageCount++; - const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; - log(`[msg #${messageCount}] type=${msgType}`); - - if (message.type === 'assistant' && 'uuid' in message) { - lastAssistantUuid = (message as { uuid: string }).uuid; - } - - if (message.type === 'system' && message.subtype === 'init') { - newSessionId = message.session_id; - log(`Session initialized: ${newSessionId}`); - } - - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { - const tn = message as { task_id: string; status: string; summary: string }; - log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); - } - - if (message.type === 'result') { - resultCount++; - const textResult = 'result' in message ? (message as { result?: string }).result : null; - log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`); - writeOutput({ - status: 'success', - result: textResult || null, - newSessionId - }); - } - } - - ipcPolling = false; - log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`); - return { newSessionId, lastAssistantUuid, closedDuringQuery }; -} - -async function main(): Promise { - let containerInput: ContainerInput; - - try { - const stdinData = await readStdin(); - containerInput = JSON.parse(stdinData); - // Delete the temp file the entrypoint wrote — it contains secrets - 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); - } - - // Build SDK env: merge secrets into process.env for the SDK only. - // Secrets never touch process.env itself, so Bash subprocesses can't see them. - const sdkEnv: Record = { ...process.env }; - for (const [key, value] of Object.entries(containerInput.secrets || {})) { - sdkEnv[key] = value; - } - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); - - let sessionId = containerInput.sessionId; - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - - // Clean up stale _close sentinel from previous container runs - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - - // Build initial prompt (drain any pending IPC messages too) - let prompt = containerInput.prompt; - if (containerInput.isScheduledTask) { - prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`; - } - const pending = drainIpcInput(); - if (pending.length > 0) { - log(`Draining ${pending.length} pending IPC messages into initial prompt`); - prompt += '\n' + pending.join('\n'); - } - - // Query loop: run query → wait for IPC message → run new query → repeat - let resumeAt: string | undefined; - try { - while (true) { - log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`); - - const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt); - if (queryResult.newSessionId) { - sessionId = queryResult.newSessionId; - } - if (queryResult.lastAssistantUuid) { - resumeAt = queryResult.lastAssistantUuid; - } - - // If _close was consumed during the query, exit immediately. - // Don't emit a session-update marker (it would reset the host's - // idle timer and cause a 30-min delay before the next _close). - if (queryResult.closedDuringQuery) { - log('Close sentinel consumed during query, exiting'); - break; - } - - // Emit session update so host can track it - writeOutput({ status: 'success', result: null, newSessionId: sessionId }); - - log('Query ended, waiting for next IPC message...'); - - // Wait for the next message or _close sentinel - const nextMessage = await waitForIpcMessage(); - if (nextMessage === null) { - log('Close sentinel received, exiting'); - break; - } - - log(`Got new message (${nextMessage.length} chars), starting new query`); - prompt = nextMessage; - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - log(`Agent error: ${errorMessage}`); - writeOutput({ - status: 'error', - result: null, - newSessionId: sessionId, - error: errorMessage - }); - process.exit(1); - } -} - -main(); diff --git a/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md b/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md deleted file mode 100644 index 3d24be7..0000000 --- a/.claude/skills/add-gmail/modify/container/agent-runner/src/index.ts.intent.md +++ /dev/null @@ -1,32 +0,0 @@ -# Intent: container/agent-runner/src/index.ts modifications - -## What changed -Added Gmail MCP server to the agent's available tools so it can read and send emails. - -## Key sections - -### mcpServers (inside runQuery → query() call) -- Added: `gmail` MCP server alongside the existing `nanoclaw` server: - ``` - gmail: { - command: 'npx', - args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'], - }, - ``` - -### allowedTools (inside runQuery → query() call) -- Added: `'mcp__gmail__*'` to allow all Gmail MCP tools - -## Invariants -- The `nanoclaw` MCP server configuration is unchanged -- All existing allowed tools are preserved -- The query loop, IPC handling, MessageStream, and all other logic is untouched -- Hooks (PreCompact, sanitize Bash) are unchanged -- Output protocol (markers) is unchanged - -## Must-keep -- The `nanoclaw` MCP server with its environment variables -- All existing allowedTools entries -- The hook system (PreCompact, PreToolUse sanitize) -- The IPC input/close sentinel handling -- The MessageStream class and query loop diff --git a/.claude/skills/add-gmail/modify/src/channels/index.ts b/.claude/skills/add-gmail/modify/src/channels/index.ts deleted file mode 100644 index 53df423..0000000 --- a/.claude/skills/add-gmail/modify/src/channels/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Channel self-registration barrel file. -// Each import triggers the channel module's registerChannel() call. - -// discord - -// gmail -import './gmail.js'; - -// slack - -// telegram - -// whatsapp diff --git a/.claude/skills/add-gmail/modify/src/channels/index.ts.intent.md b/.claude/skills/add-gmail/modify/src/channels/index.ts.intent.md deleted file mode 100644 index 3b0518d..0000000 --- a/.claude/skills/add-gmail/modify/src/channels/index.ts.intent.md +++ /dev/null @@ -1,7 +0,0 @@ -# Intent: Add Gmail channel import - -Add `import './gmail.js';` to the channel barrel file so the Gmail -module self-registers with the channel registry on startup. - -This is an append-only change — existing import lines for other channels -must be preserved. diff --git a/.claude/skills/add-gmail/modify/src/container-runner.ts b/.claude/skills/add-gmail/modify/src/container-runner.ts deleted file mode 100644 index 7221338..0000000 --- a/.claude/skills/add-gmail/modify/src/container-runner.ts +++ /dev/null @@ -1,661 +0,0 @@ -/** - * Container Runner for NanoClaw - * Spawns agent execution in containers and handles IPC - */ -import { ChildProcess, exec, spawn } from 'child_process'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import { - CONTAINER_IMAGE, - CONTAINER_MAX_OUTPUT_SIZE, - CONTAINER_TIMEOUT, - DATA_DIR, - GROUPS_DIR, - IDLE_TIMEOUT, - TIMEZONE, -} from './config.js'; -import { readEnvFile } from './env.js'; -import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; -import { logger } from './logger.js'; -import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js'; -import { validateAdditionalMounts } from './mount-security.js'; -import { RegisteredGroup } from './types.js'; - -// Sentinel markers for robust output parsing (must match agent-runner) -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -export interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - secrets?: Record; -} - -export interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface VolumeMount { - hostPath: string; - containerPath: string; - readonly: boolean; -} - -function buildVolumeMounts( - group: RegisteredGroup, - isMain: boolean, -): VolumeMount[] { - const mounts: VolumeMount[] = []; - const projectRoot = process.cwd(); - const homeDir = os.homedir(); - const groupDir = resolveGroupFolderPath(group.folder); - - if (isMain) { - // Main gets the project root read-only. Writable paths the agent needs - // (group folder, IPC, .claude/) are mounted separately below. - // Read-only prevents the agent from modifying host application code - // (src/, dist/, package.json, etc.) which would bypass the sandbox - // entirely on next restart. - mounts.push({ - hostPath: projectRoot, - containerPath: '/workspace/project', - readonly: true, - }); - - // Main also gets its group folder as the working directory - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - } else { - // Other groups only get their own folder - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - - // Global memory directory (read-only for non-main) - // Only directory mounts are supported, not file mounts - const globalDir = path.join(GROUPS_DIR, 'global'); - if (fs.existsSync(globalDir)) { - mounts.push({ - hostPath: globalDir, - containerPath: '/workspace/global', - readonly: true, - }); - } - } - - // Per-group Claude sessions directory (isolated from other groups) - // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - '.claude', - ); - fs.mkdirSync(groupSessionsDir, { recursive: true }); - const settingsFile = path.join(groupSessionsDir, 'settings.json'); - if (!fs.existsSync(settingsFile)) { - fs.writeFileSync(settingsFile, JSON.stringify({ - env: { - // Enable agent swarms (subagent orchestration) - // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - // Load CLAUDE.md from additional mounted directories - // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - // Enable Claude's memory feature (persists user preferences between sessions) - // https://code.claude.com/docs/en/memory#manage-auto-memory - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, null, 2) + '\n'); - } - - // Sync skills from container/skills/ into each group's .claude/skills/ - const skillsSrc = path.join(process.cwd(), 'container', 'skills'); - const skillsDst = path.join(groupSessionsDir, 'skills'); - if (fs.existsSync(skillsSrc)) { - for (const skillDir of fs.readdirSync(skillsSrc)) { - const srcDir = path.join(skillsSrc, skillDir); - if (!fs.statSync(srcDir).isDirectory()) continue; - const dstDir = path.join(skillsDst, skillDir); - fs.cpSync(srcDir, dstDir, { recursive: true }); - } - } - mounts.push({ - hostPath: groupSessionsDir, - containerPath: '/home/node/.claude', - readonly: false, - }); - - // Gmail credentials directory (for Gmail MCP inside the container) - const gmailDir = path.join(homeDir, '.gmail-mcp'); - if (fs.existsSync(gmailDir)) { - mounts.push({ - hostPath: gmailDir, - containerPath: '/home/node/.gmail-mcp', - readonly: false, // MCP may need to refresh OAuth tokens - }); - } - - // Per-group IPC namespace: each group gets its own IPC directory - // This prevents cross-group privilege escalation via IPC - const groupIpcDir = resolveGroupIpcPath(group.folder); - fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); - mounts.push({ - hostPath: groupIpcDir, - containerPath: '/workspace/ipc', - readonly: false, - }); - - // Copy agent-runner source into a per-group writable location so agents - // can customize it (add tools, change behavior) without affecting other - // groups. Recompiled on container startup via entrypoint.sh. - const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); - const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src'); - if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); - } - mounts.push({ - hostPath: groupAgentRunnerDir, - containerPath: '/app/src', - readonly: false, - }); - - // Additional mounts validated against external allowlist (tamper-proof from containers) - if (group.containerConfig?.additionalMounts) { - const validatedMounts = validateAdditionalMounts( - group.containerConfig.additionalMounts, - group.name, - isMain, - ); - mounts.push(...validatedMounts); - } - - return mounts; -} - -/** - * Read allowed secrets from .env for passing to the container via stdin. - * Secrets are never written to disk or mounted as files. - */ -function readSecrets(): Record { - return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']); -} - -function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] { - const args: string[] = ['run', '-i', '--rm', '--name', containerName]; - - // Pass host timezone so container's local time matches the user's - args.push('-e', `TZ=${TIMEZONE}`); - - // Run as host user so bind-mounted files are accessible. - // Skip when running as root (uid 0), as the container's node user (uid 1000), - // or when getuid is unavailable (native Windows without WSL). - const hostUid = process.getuid?.(); - const hostGid = process.getgid?.(); - if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { - args.push('--user', `${hostUid}:${hostGid}`); - args.push('-e', 'HOME=/home/node'); - } - - for (const mount of mounts) { - if (mount.readonly) { - args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); - } else { - args.push('-v', `${mount.hostPath}:${mount.containerPath}`); - } - } - - args.push(CONTAINER_IMAGE); - - return args; -} - -export async function runContainerAgent( - group: RegisteredGroup, - input: ContainerInput, - onProcess: (proc: ChildProcess, containerName: string) => void, - onOutput?: (output: ContainerOutput) => Promise, -): Promise { - const startTime = Date.now(); - - const groupDir = resolveGroupFolderPath(group.folder); - fs.mkdirSync(groupDir, { recursive: true }); - - const mounts = buildVolumeMounts(group, input.isMain); - const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); - const containerName = `nanoclaw-${safeName}-${Date.now()}`; - const containerArgs = buildContainerArgs(mounts, containerName); - - logger.debug( - { - group: group.name, - containerName, - mounts: mounts.map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ), - containerArgs: containerArgs.join(' '), - }, - 'Container mount configuration', - ); - - logger.info( - { - group: group.name, - containerName, - mountCount: mounts.length, - isMain: input.isMain, - }, - 'Spawning container agent', - ); - - const logsDir = path.join(groupDir, 'logs'); - fs.mkdirSync(logsDir, { recursive: true }); - - return new Promise((resolve) => { - const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - onProcess(container, containerName); - - let stdout = ''; - let stderr = ''; - let stdoutTruncated = false; - let stderrTruncated = false; - - // Pass secrets via stdin (never written to disk or mounted as files) - input.secrets = readSecrets(); - container.stdin.write(JSON.stringify(input)); - container.stdin.end(); - // Remove secrets from input so they don't appear in logs - delete input.secrets; - - // Streaming output: parse OUTPUT_START/END marker pairs as they arrive - let parseBuffer = ''; - let newSessionId: string | undefined; - let outputChain = Promise.resolve(); - - container.stdout.on('data', (data) => { - const chunk = data.toString(); - - // Always accumulate for logging - if (!stdoutTruncated) { - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length; - if (chunk.length > remaining) { - stdout += chunk.slice(0, remaining); - stdoutTruncated = true; - logger.warn( - { group: group.name, size: stdout.length }, - 'Container stdout truncated due to size limit', - ); - } else { - stdout += chunk; - } - } - - // Stream-parse for output markers - if (onOutput) { - parseBuffer += chunk; - let startIdx: number; - while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { - const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); - if (endIdx === -1) break; // Incomplete pair, wait for more data - - const jsonStr = parseBuffer - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); - parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); - - try { - const parsed: ContainerOutput = JSON.parse(jsonStr); - if (parsed.newSessionId) { - newSessionId = parsed.newSessionId; - } - hadStreamingOutput = true; - // Activity detected — reset the hard timeout - resetTimeout(); - // Call onOutput for all markers (including null results) - // so idle timers start even for "silent" query completions. - outputChain = outputChain.then(() => onOutput(parsed)); - } catch (err) { - logger.warn( - { group: group.name, error: err }, - 'Failed to parse streamed output chunk', - ); - } - } - } - }); - - container.stderr.on('data', (data) => { - const chunk = data.toString(); - const lines = chunk.trim().split('\n'); - for (const line of lines) { - if (line) logger.debug({ container: group.folder }, line); - } - // Don't reset timeout on stderr — SDK writes debug logs continuously. - // Timeout only resets on actual output (OUTPUT_MARKER in stdout). - if (stderrTruncated) return; - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length; - if (chunk.length > remaining) { - stderr += chunk.slice(0, remaining); - stderrTruncated = true; - logger.warn( - { group: group.name, size: stderr.length }, - 'Container stderr truncated due to size limit', - ); - } else { - stderr += chunk; - } - }); - - let timedOut = false; - let hadStreamingOutput = false; - const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; - // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the - // graceful _close sentinel has time to trigger before the hard kill fires. - const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000); - - const killOnTimeout = () => { - timedOut = true; - logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully'); - exec(stopContainer(containerName), { timeout: 15000 }, (err) => { - if (err) { - logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing'); - container.kill('SIGKILL'); - } - }); - }; - - let timeout = setTimeout(killOnTimeout, timeoutMs); - - // Reset the timeout whenever there's activity (streaming output) - const resetTimeout = () => { - clearTimeout(timeout); - timeout = setTimeout(killOnTimeout, timeoutMs); - }; - - container.on('close', (code) => { - clearTimeout(timeout); - const duration = Date.now() - startTime; - - if (timedOut) { - const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const timeoutLog = path.join(logsDir, `container-${ts}.log`); - fs.writeFileSync(timeoutLog, [ - `=== Container Run Log (TIMEOUT) ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `Container: ${containerName}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Had Streaming Output: ${hadStreamingOutput}`, - ].join('\n')); - - // Timeout after output = idle cleanup, not failure. - // The agent already sent its response; this is just the - // container being reaped after the idle period expired. - if (hadStreamingOutput) { - logger.info( - { group: group.name, containerName, duration, code }, - 'Container timed out after output (idle cleanup)', - ); - outputChain.then(() => { - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - logger.error( - { group: group.name, containerName, duration, code }, - 'Container timed out with no output', - ); - - resolve({ - status: 'error', - result: null, - error: `Container timed out after ${configTimeout}ms`, - }); - return; - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logFile = path.join(logsDir, `container-${timestamp}.log`); - const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; - - const logLines = [ - `=== Container Run Log ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `IsMain: ${input.isMain}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Stdout Truncated: ${stdoutTruncated}`, - `Stderr Truncated: ${stderrTruncated}`, - ``, - ]; - - const isError = code !== 0; - - if (isVerbose || isError) { - logLines.push( - `=== Input ===`, - JSON.stringify(input, null, 2), - ``, - `=== Container Args ===`, - containerArgs.join(' '), - ``, - `=== Mounts ===`, - mounts - .map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ) - .join('\n'), - ``, - `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, - stderr, - ``, - `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, - stdout, - ); - } else { - logLines.push( - `=== Input Summary ===`, - `Prompt length: ${input.prompt.length} chars`, - `Session ID: ${input.sessionId || 'new'}`, - ``, - `=== Mounts ===`, - mounts - .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) - .join('\n'), - ``, - ); - } - - fs.writeFileSync(logFile, logLines.join('\n')); - logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); - - if (code !== 0) { - logger.error( - { - group: group.name, - code, - duration, - stderr, - stdout, - logFile, - }, - 'Container exited with error', - ); - - resolve({ - status: 'error', - result: null, - error: `Container exited with code ${code}: ${stderr.slice(-200)}`, - }); - return; - } - - // Streaming mode: wait for output chain to settle, return completion marker - if (onOutput) { - outputChain.then(() => { - logger.info( - { group: group.name, duration, newSessionId }, - 'Container completed (streaming mode)', - ); - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - // Legacy mode: parse the last output marker pair from accumulated stdout - try { - // Extract JSON between sentinel markers for robust parsing - const startIdx = stdout.indexOf(OUTPUT_START_MARKER); - const endIdx = stdout.indexOf(OUTPUT_END_MARKER); - - let jsonLine: string; - if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - jsonLine = stdout - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); - } else { - // Fallback: last non-empty line (backwards compatibility) - const lines = stdout.trim().split('\n'); - jsonLine = lines[lines.length - 1]; - } - - const output: ContainerOutput = JSON.parse(jsonLine); - - logger.info( - { - group: group.name, - duration, - status: output.status, - hasResult: !!output.result, - }, - 'Container completed', - ); - - resolve(output); - } catch (err) { - logger.error( - { - group: group.name, - stdout, - stderr, - error: err, - }, - 'Failed to parse container output', - ); - - resolve({ - status: 'error', - result: null, - error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, - }); - } - }); - - container.on('error', (err) => { - clearTimeout(timeout); - logger.error({ group: group.name, containerName, error: err }, 'Container spawn error'); - resolve({ - status: 'error', - result: null, - error: `Container spawn error: ${err.message}`, - }); - }); - }); -} - -export function writeTasksSnapshot( - groupFolder: string, - isMain: boolean, - tasks: Array<{ - id: string; - groupFolder: string; - prompt: string; - schedule_type: string; - schedule_value: string; - status: string; - next_run: string | null; - }>, -): void { - // Write filtered tasks to the group's IPC directory - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all tasks, others only see their own - const filteredTasks = isMain - ? tasks - : tasks.filter((t) => t.groupFolder === groupFolder); - - const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); - fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); -} - -export interface AvailableGroup { - jid: string; - name: string; - lastActivity: string; - isRegistered: boolean; -} - -/** - * Write available groups snapshot for the container to read. - * Only main group can see all available groups (for activation). - * Non-main groups only see their own registration status. - */ -export function writeGroupsSnapshot( - groupFolder: string, - isMain: boolean, - groups: AvailableGroup[], - registeredJids: Set, -): void { - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all groups; others see nothing (they can't activate groups) - const visibleGroups = isMain ? groups : []; - - const groupsFile = path.join(groupIpcDir, 'available_groups.json'); - fs.writeFileSync( - groupsFile, - JSON.stringify( - { - groups: visibleGroups, - lastSync: new Date().toISOString(), - }, - null, - 2, - ), - ); -} diff --git a/.claude/skills/add-gmail/modify/src/container-runner.ts.intent.md b/.claude/skills/add-gmail/modify/src/container-runner.ts.intent.md deleted file mode 100644 index a9847a9..0000000 --- a/.claude/skills/add-gmail/modify/src/container-runner.ts.intent.md +++ /dev/null @@ -1,37 +0,0 @@ -# Intent: src/container-runner.ts modifications - -## What changed -Added a volume mount for Gmail OAuth credentials (`~/.gmail-mcp/`) so the Gmail MCP server inside the container can authenticate with Google. - -## Key sections - -### buildVolumeMounts() -- Added: Gmail credentials mount after the `.claude` sessions mount: - ``` - const gmailDir = path.join(homeDir, '.gmail-mcp'); - if (fs.existsSync(gmailDir)) { - mounts.push({ - hostPath: gmailDir, - containerPath: '/home/node/.gmail-mcp', - readonly: false, // MCP may need to refresh OAuth tokens - }); - } - ``` -- Uses `os.homedir()` to resolve the home directory -- Mount is read-write because the Gmail MCP server needs to refresh OAuth tokens -- Mount is conditional — only added if `~/.gmail-mcp/` exists on the host - -### Imports -- Added: `os` import for `os.homedir()` - -## Invariants -- All existing mounts are unchanged -- Mount ordering is preserved (Gmail added after session mounts, before additional mounts) -- The `buildContainerArgs`, `runContainerAgent`, and all other functions are untouched -- Additional mount validation via `validateAdditionalMounts` is unchanged - -## Must-keep -- All existing volume mounts (project root, group dir, global, sessions, IPC, agent-runner, additional) -- The mount security model (allowlist validation for additional mounts) -- The `readSecrets` function and stdin-based secret passing -- Container lifecycle (spawn, timeout, output parsing) diff --git a/.claude/skills/add-gmail/tests/gmail.test.ts b/.claude/skills/add-gmail/tests/gmail.test.ts deleted file mode 100644 index 79e8ecb..0000000 --- a/.claude/skills/add-gmail/tests/gmail.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('add-gmail skill package', () => { - const skillDir = path.resolve(__dirname, '..'); - - it('has a valid manifest', () => { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - expect(fs.existsSync(manifestPath)).toBe(true); - - const content = fs.readFileSync(manifestPath, 'utf-8'); - expect(content).toContain('skill: gmail'); - expect(content).toContain('version: 1.0.0'); - expect(content).toContain('googleapis'); - }); - - it('has channel file with self-registration', () => { - const channelFile = path.join( - skillDir, - 'add', - 'src', - 'channels', - 'gmail.ts', - ); - expect(fs.existsSync(channelFile)).toBe(true); - - const content = fs.readFileSync(channelFile, 'utf-8'); - expect(content).toContain('class GmailChannel'); - expect(content).toContain('implements Channel'); - expect(content).toContain("registerChannel('gmail'"); - }); - - it('has channel barrel file modification', () => { - const indexFile = path.join( - skillDir, - 'modify', - 'src', - 'channels', - 'index.ts', - ); - expect(fs.existsSync(indexFile)).toBe(true); - - const indexContent = fs.readFileSync(indexFile, 'utf-8'); - expect(indexContent).toContain("import './gmail.js'"); - }); - - it('has intent files for modified files', () => { - expect( - fs.existsSync( - path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'), - ), - ).toBe(true); - }); - - it('has container-runner mount modification', () => { - const crFile = path.join( - skillDir, - 'modify', - 'src', - 'container-runner.ts', - ); - expect(fs.existsSync(crFile)).toBe(true); - - const content = fs.readFileSync(crFile, 'utf-8'); - expect(content).toContain('.gmail-mcp'); - }); - - it('has agent-runner Gmail MCP server modification', () => { - const arFile = path.join( - skillDir, - 'modify', - 'container', - 'agent-runner', - 'src', - 'index.ts', - ); - expect(fs.existsSync(arFile)).toBe(true); - - const content = fs.readFileSync(arFile, 'utf-8'); - expect(content).toContain('mcp__gmail__*'); - expect(content).toContain('@gongrzhe/server-gmail-autoauth-mcp'); - }); - - it('has test file for the channel', () => { - const testFile = path.join( - skillDir, - 'add', - 'src', - 'channels', - 'gmail.test.ts', - ); - expect(fs.existsSync(testFile)).toBe(true); - - const testContent = fs.readFileSync(testFile, 'utf-8'); - expect(testContent).toContain("describe('GmailChannel'"); - }); -}); diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md deleted file mode 100644 index 7ba621e..0000000 --- a/.claude/skills/add-image-vision/SKILL.md +++ /dev/null @@ -1,70 +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 `.nanoclaw/state.yaml` for `add-image-vision` — skip if already applied -2. Confirm `sharp` is installable (native bindings require build tools) - -## Phase 2: Apply Code Changes - -1. Initialize the skills system if not already done: - ```bash - npx tsx -e "import { initNanoclawDir } from './skills-engine/init.ts'; initNanoclawDir();" - ``` - -2. Apply the skill: - ```bash - npx tsx skills-engine/apply-skill.ts add-image-vision - ``` - -3. Install new dependency: - ```bash - npm install sharp - ``` - -4. Validate: - ```bash - npm run typecheck - npm test - ``` - -## 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-image-vision/add/src/image.test.ts b/.claude/skills/add-image-vision/add/src/image.test.ts deleted file mode 100644 index 6164a78..0000000 --- a/.claude/skills/add-image-vision/add/src/image.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import fs from 'fs'; - -// Mock sharp -vi.mock('sharp', () => { - const mockSharp = vi.fn(() => ({ - resize: vi.fn().mockReturnThis(), - jpeg: vi.fn().mockReturnThis(), - toBuffer: vi.fn().mockResolvedValue(Buffer.from('resized-image-data')), - })); - return { default: mockSharp }; -}); - -vi.mock('fs'); - -import { processImage, parseImageReferences, isImageMessage } from './image.js'; - -describe('image processing', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(fs.mkdirSync).mockReturnValue(undefined); - vi.mocked(fs.writeFileSync).mockReturnValue(undefined); - }); - - describe('isImageMessage', () => { - it('returns true for image messages', () => { - const msg = { message: { imageMessage: { mimetype: 'image/jpeg' } } }; - expect(isImageMessage(msg as any)).toBe(true); - }); - - it('returns false for non-image messages', () => { - const msg = { message: { conversation: 'hello' } }; - expect(isImageMessage(msg as any)).toBe(false); - }); - - it('returns false for null message', () => { - const msg = { message: null }; - expect(isImageMessage(msg as any)).toBe(false); - }); - }); - - describe('processImage', () => { - it('resizes and saves image, returns content string', async () => { - const buffer = Buffer.from('raw-image-data'); - const result = await processImage(buffer, '/tmp/groups/test', 'Check this out'); - - expect(result).not.toBeNull(); - expect(result!.content).toMatch(/^\[Image: attachments\/img-\d+-[a-z0-9]+\.jpg\] Check this out$/); - expect(result!.relativePath).toMatch(/^attachments\/img-\d+-[a-z0-9]+\.jpg$/); - expect(fs.mkdirSync).toHaveBeenCalled(); - expect(fs.writeFileSync).toHaveBeenCalled(); - }); - - it('returns content without caption when none provided', async () => { - const buffer = Buffer.from('raw-image-data'); - const result = await processImage(buffer, '/tmp/groups/test', ''); - - expect(result).not.toBeNull(); - expect(result!.content).toMatch(/^\[Image: attachments\/img-\d+-[a-z0-9]+\.jpg\]$/); - }); - - it('returns null on empty buffer', async () => { - const result = await processImage(Buffer.alloc(0), '/tmp/groups/test', ''); - - expect(result).toBeNull(); - }); - }); - - describe('parseImageReferences', () => { - it('extracts image paths from message content', () => { - const messages = [ - { content: '[Image: attachments/img-123.jpg] hello' }, - { content: 'plain text' }, - { content: '[Image: attachments/img-456.jpg]' }, - ]; - const refs = parseImageReferences(messages as any); - - expect(refs).toEqual([ - { relativePath: 'attachments/img-123.jpg', mediaType: 'image/jpeg' }, - { relativePath: 'attachments/img-456.jpg', mediaType: 'image/jpeg' }, - ]); - }); - - it('returns empty array when no images', () => { - const messages = [{ content: 'just text' }]; - expect(parseImageReferences(messages as any)).toEqual([]); - }); - }); -}); diff --git a/.claude/skills/add-image-vision/add/src/image.ts b/.claude/skills/add-image-vision/add/src/image.ts deleted file mode 100644 index 574110f..0000000 --- a/.claude/skills/add-image-vision/add/src/image.ts +++ /dev/null @@ -1,63 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import sharp from 'sharp'; -import type { WAMessage } from '@whiskeysockets/baileys'; - -const MAX_DIMENSION = 1024; -const IMAGE_REF_PATTERN = /\[Image: (attachments\/[^\]]+)\]/g; - -export interface ProcessedImage { - content: string; - relativePath: string; -} - -export interface ImageAttachment { - relativePath: string; - mediaType: string; -} - -export function isImageMessage(msg: WAMessage): boolean { - return !!msg.message?.imageMessage; -} - -export async function processImage( - buffer: Buffer, - groupDir: string, - caption: string, -): Promise { - if (!buffer || buffer.length === 0) return null; - - const resized = await sharp(buffer) - .resize(MAX_DIMENSION, MAX_DIMENSION, { fit: 'inside', withoutEnlargement: true }) - .jpeg({ quality: 85 }) - .toBuffer(); - - const attachDir = path.join(groupDir, 'attachments'); - fs.mkdirSync(attachDir, { recursive: true }); - - const filename = `img-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.jpg`; - const filePath = path.join(attachDir, filename); - fs.writeFileSync(filePath, resized); - - const relativePath = `attachments/${filename}`; - const content = caption - ? `[Image: ${relativePath}] ${caption}` - : `[Image: ${relativePath}]`; - - return { content, relativePath }; -} - -export function parseImageReferences( - messages: Array<{ content: string }>, -): ImageAttachment[] { - const refs: ImageAttachment[] = []; - for (const msg of messages) { - let match: RegExpExecArray | null; - IMAGE_REF_PATTERN.lastIndex = 0; - while ((match = IMAGE_REF_PATTERN.exec(msg.content)) !== null) { - // Always JPEG — processImage() normalizes all images to .jpg - refs.push({ relativePath: match[1], mediaType: 'image/jpeg' }); - } - } - return refs; -} diff --git a/.claude/skills/add-image-vision/manifest.yaml b/.claude/skills/add-image-vision/manifest.yaml deleted file mode 100644 index f611011..0000000 --- a/.claude/skills/add-image-vision/manifest.yaml +++ /dev/null @@ -1,20 +0,0 @@ -skill: add-image-vision -version: 1.1.0 -description: "Add image vision to NanoClaw agents via WhatsApp image attachments" -core_version: 1.2.8 -adds: - - src/image.ts - - src/image.test.ts -modifies: - - src/channels/whatsapp.ts - - src/channels/whatsapp.test.ts - - src/container-runner.ts - - src/index.ts - - container/agent-runner/src/index.ts -structured: - npm_dependencies: - sharp: "^0.34.5" - env_additions: [] -conflicts: [] -depends: [] -test: "npx vitest run --config vitest.skills.config.ts .claude/skills/add-image-vision/tests/image-vision.test.ts" diff --git a/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts b/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts deleted file mode 100644 index c08fc34..0000000 --- a/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts +++ /dev/null @@ -1,626 +0,0 @@ -/** - * NanoClaw Agent Runner - * Runs inside a container, 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/ - * Files: {type:"message", text:"..."}.json — polled and consumed - * Sentinel: /workspace/ipc/input/_close — signals session end - * - * Stdout protocol: - * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. - * Multiple results may be emitted (one per agent teams result). - * Final marker after loop ends signals completion. - */ - -import fs from 'fs'; -import path from 'path'; -import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; -import { fileURLToPath } from 'url'; - -interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - secrets?: Record; - imageAttachments?: Array<{ relativePath: string; mediaType: string }>; -} - -interface ImageContentBlock { - type: 'image'; - source: { type: 'base64'; media_type: string; data: string }; -} -interface TextContentBlock { - type: 'text'; - text: string; -} -type ContentBlock = ImageContentBlock | TextContentBlock; - -interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface SessionEntry { - sessionId: string; - fullPath: string; - summary: string; - firstPrompt: string; -} - -interface SessionsIndex { - entries: SessionEntry[]; -} - -interface SDKUserMessage { - type: 'user'; - message: { role: 'user'; content: string | ContentBlock[] }; - parent_tool_use_id: null; - session_id: string; -} - -const IPC_INPUT_DIR = '/workspace/ipc/input'; -const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); -const IPC_POLL_MS = 500; - -/** - * Push-based async iterable for streaming user messages to the SDK. - * Keeps the iterable alive until end() is called, preventing isSingleUserTurn. - */ -class MessageStream { - private queue: SDKUserMessage[] = []; - private waiting: (() => void) | null = null; - private done = false; - - push(text: string): void { - this.queue.push({ - type: 'user', - message: { role: 'user', content: text }, - parent_tool_use_id: null, - session_id: '', - }); - this.waiting?.(); - } - - pushMultimodal(content: ContentBlock[]): void { - this.queue.push({ - type: 'user', - message: { role: 'user', content }, - parent_tool_use_id: null, - session_id: '', - }); - this.waiting?.(); - } - - end(): void { - this.done = true; - this.waiting?.(); - } - - async *[Symbol.asyncIterator](): AsyncGenerator { - while (true) { - while (this.queue.length > 0) { - yield this.queue.shift()!; - } - if (this.done) return; - await new Promise(r => { this.waiting = r; }); - this.waiting = null; - } - } -} - -async function readStdin(): Promise { - return new Promise((resolve, reject) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', reject); - }); -} - -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -function writeOutput(output: ContainerOutput): void { - console.log(OUTPUT_START_MARKER); - console.log(JSON.stringify(output)); - console.log(OUTPUT_END_MARKER); -} - -function log(message: string): void { - console.error(`[agent-runner] ${message}`); -} - -function getSessionSummary(sessionId: string, transcriptPath: string): string | null { - const projectDir = path.dirname(transcriptPath); - const indexPath = path.join(projectDir, 'sessions-index.json'); - - if (!fs.existsSync(indexPath)) { - log(`Sessions index not found at ${indexPath}`); - return null; - } - - try { - const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); - const entry = index.entries.find(e => e.sessionId === sessionId); - if (entry?.summary) { - return entry.summary; - } - } catch (err) { - log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); - } - - return null; -} - -/** - * Archive the full transcript to conversations/ before compaction. - */ -function createPreCompactHook(assistantName?: string): HookCallback { - return async (input, _toolUseId, _context) => { - const preCompact = input as PreCompactHookInput; - const transcriptPath = preCompact.transcript_path; - const sessionId = preCompact.session_id; - - if (!transcriptPath || !fs.existsSync(transcriptPath)) { - log('No transcript found for archiving'); - return {}; - } - - try { - const content = fs.readFileSync(transcriptPath, 'utf-8'); - const messages = parseTranscript(content); - - if (messages.length === 0) { - log('No messages to archive'); - return {}; - } - - const summary = getSessionSummary(sessionId, transcriptPath); - const name = summary ? sanitizeFilename(summary) : generateFallbackName(); - - const conversationsDir = '/workspace/group/conversations'; - fs.mkdirSync(conversationsDir, { recursive: true }); - - const date = new Date().toISOString().split('T')[0]; - const filename = `${date}-${name}.md`; - const filePath = path.join(conversationsDir, filename); - - const markdown = formatTranscriptMarkdown(messages, summary, assistantName); - fs.writeFileSync(filePath, markdown); - - log(`Archived conversation to ${filePath}`); - } catch (err) { - log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); - } - - return {}; - }; -} - -// Secrets to strip from Bash tool subprocess environments. -// These are needed by claude-code for API auth but should never -// be visible to commands Kit runs. -const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; - -function createSanitizeBashHook(): HookCallback { - return async (input, _toolUseId, _context) => { - const preInput = input as PreToolUseHookInput; - const command = (preInput.tool_input as { command?: string })?.command; - if (!command) return {}; - - const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `; - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - updatedInput: { - ...(preInput.tool_input as Record), - command: unsetPrefix + command, - }, - }, - }; - }; -} - -function sanitizeFilename(summary: string): string { - return summary - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 50); -} - -function generateFallbackName(): string { - const time = new Date(); - return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; -} - -interface ParsedMessage { - role: 'user' | 'assistant'; - content: string; -} - -function parseTranscript(content: string): ParsedMessage[] { - const messages: ParsedMessage[] = []; - - for (const line of content.split('\n')) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - if (entry.type === 'user' && entry.message?.content) { - const text = typeof entry.message.content === 'string' - ? entry.message.content - : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); - if (text) messages.push({ role: 'user', content: text }); - } else if (entry.type === 'assistant' && entry.message?.content) { - const textParts = entry.message.content - .filter((c: { type: string }) => c.type === 'text') - .map((c: { text: string }) => c.text); - const text = textParts.join(''); - if (text) messages.push({ role: 'assistant', content: text }); - } - } catch { - } - } - - return messages; -} - -function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { - const now = new Date(); - const formatDateTime = (d: Date) => d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }); - - const lines: string[] = []; - lines.push(`# ${title || 'Conversation'}`); - lines.push(''); - lines.push(`Archived: ${formatDateTime(now)}`); - lines.push(''); - lines.push('---'); - lines.push(''); - - for (const msg of messages) { - const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant'); - const content = msg.content.length > 2000 - ? msg.content.slice(0, 2000) + '...' - : msg.content; - lines.push(`**${sender}**: ${content}`); - lines.push(''); - } - - return lines.join('\n'); -} - -/** - * Check for _close sentinel. - */ -function shouldClose(): boolean { - if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - return true; - } - return false; -} - -/** - * Drain all pending IPC input messages. - * Returns messages found, or empty array. - */ -function drainIpcInput(): string[] { - try { - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - const files = fs.readdirSync(IPC_INPUT_DIR) - .filter(f => f.endsWith('.json')) - .sort(); - - const messages: string[] = []; - for (const file of files) { - const filePath = path.join(IPC_INPUT_DIR, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - fs.unlinkSync(filePath); - if (data.type === 'message' && data.text) { - messages.push(data.text); - } - } catch (err) { - log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`); - try { fs.unlinkSync(filePath); } catch { /* ignore */ } - } - } - return messages; - } catch (err) { - log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); - return []; - } -} - -/** - * Wait for a new IPC message or _close sentinel. - * Returns the messages as a single string, or null if _close. - */ -function waitForIpcMessage(): Promise { - return new Promise((resolve) => { - const poll = () => { - if (shouldClose()) { - resolve(null); - return; - } - const messages = drainIpcInput(); - if (messages.length > 0) { - resolve(messages.join('\n')); - return; - } - setTimeout(poll, IPC_POLL_MS); - }; - poll(); - }); -} - -/** - * Run a single query and stream results via writeOutput. - * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, - * allowing agent teams subagents to run to completion. - * Also pipes IPC messages into the stream during the query. - */ -async function runQuery( - prompt: string, - sessionId: string | undefined, - mcpServerPath: string, - containerInput: ContainerInput, - sdkEnv: Record, - resumeAt?: string, -): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> { - const stream = new MessageStream(); - stream.push(prompt); - - // Load image attachments and send as multimodal content blocks - if (containerInput.imageAttachments?.length) { - const blocks: ContentBlock[] = []; - for (const img of containerInput.imageAttachments) { - const imgPath = path.join('/workspace/group', img.relativePath); - try { - const data = fs.readFileSync(imgPath).toString('base64'); - blocks.push({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data } }); - } catch (err) { - log(`Failed to load image: ${imgPath}`); - } - } - if (blocks.length > 0) { - stream.pushMultimodal(blocks); - } - } - - // Poll IPC for follow-up messages and _close sentinel during the query - let ipcPolling = true; - let closedDuringQuery = false; - const pollIpcDuringQuery = () => { - if (!ipcPolling) return; - if (shouldClose()) { - log('Close sentinel detected during query, ending stream'); - closedDuringQuery = true; - stream.end(); - ipcPolling = false; - return; - } - const messages = drainIpcInput(); - for (const text of messages) { - log(`Piping IPC message into active query (${text.length} chars)`); - stream.push(text); - } - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - }; - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - - let newSessionId: string | undefined; - let lastAssistantUuid: string | undefined; - let messageCount = 0; - let resultCount = 0; - - // Load global CLAUDE.md as additional system context (shared across all groups) - const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; - let globalClaudeMd: string | undefined; - if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { - globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); - } - - // Discover additional directories mounted at /workspace/extra/* - // These are passed to the SDK so their CLAUDE.md files are loaded automatically - const extraDirs: string[] = []; - const extraBase = '/workspace/extra'; - if (fs.existsSync(extraBase)) { - for (const entry of fs.readdirSync(extraBase)) { - const fullPath = path.join(extraBase, entry); - if (fs.statSync(fullPath).isDirectory()) { - extraDirs.push(fullPath); - } - } - } - if (extraDirs.length > 0) { - log(`Additional directories: ${extraDirs.join(', ')}`); - } - - for await (const message of query({ - prompt: stream, - options: { - cwd: '/workspace/group', - additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, - resume: sessionId, - resumeSessionAt: resumeAt, - systemPrompt: globalClaudeMd - ? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd } - : undefined, - allowedTools: [ - 'Bash', - 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'Task', 'TaskOutput', 'TaskStop', - 'TeamCreate', 'TeamDelete', 'SendMessage', - 'TodoWrite', 'ToolSearch', 'Skill', - 'NotebookEdit', - 'mcp__nanoclaw__*' - ], - env: sdkEnv, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - settingSources: ['project', 'user'], - mcpServers: { - nanoclaw: { - command: 'node', - args: [mcpServerPath], - env: { - NANOCLAW_CHAT_JID: containerInput.chatJid, - NANOCLAW_GROUP_FOLDER: containerInput.groupFolder, - NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', - }, - }, - }, - hooks: { - PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], - PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], - }, - } - })) { - messageCount++; - const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; - log(`[msg #${messageCount}] type=${msgType}`); - - if (message.type === 'assistant' && 'uuid' in message) { - lastAssistantUuid = (message as { uuid: string }).uuid; - } - - if (message.type === 'system' && message.subtype === 'init') { - newSessionId = message.session_id; - log(`Session initialized: ${newSessionId}`); - } - - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { - const tn = message as { task_id: string; status: string; summary: string }; - log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); - } - - if (message.type === 'result') { - resultCount++; - const textResult = 'result' in message ? (message as { result?: string }).result : null; - log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`); - writeOutput({ - status: 'success', - result: textResult || null, - newSessionId - }); - } - } - - ipcPolling = false; - log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`); - return { newSessionId, lastAssistantUuid, closedDuringQuery }; -} - -async function main(): Promise { - let containerInput: ContainerInput; - - try { - const stdinData = await readStdin(); - containerInput = JSON.parse(stdinData); - // Delete the temp file the entrypoint wrote — it contains secrets - 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); - } - - // Build SDK env: merge secrets into process.env for the SDK only. - // Secrets never touch process.env itself, so Bash subprocesses can't see them. - const sdkEnv: Record = { ...process.env }; - for (const [key, value] of Object.entries(containerInput.secrets || {})) { - sdkEnv[key] = value; - } - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); - - let sessionId = containerInput.sessionId; - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - - // Clean up stale _close sentinel from previous container runs - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - - // Build initial prompt (drain any pending IPC messages too) - let prompt = containerInput.prompt; - if (containerInput.isScheduledTask) { - prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`; - } - const pending = drainIpcInput(); - if (pending.length > 0) { - log(`Draining ${pending.length} pending IPC messages into initial prompt`); - prompt += '\n' + pending.join('\n'); - } - - // Query loop: run query → wait for IPC message → run new query → repeat - let resumeAt: string | undefined; - try { - while (true) { - log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`); - - const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt); - if (queryResult.newSessionId) { - sessionId = queryResult.newSessionId; - } - if (queryResult.lastAssistantUuid) { - resumeAt = queryResult.lastAssistantUuid; - } - - // If _close was consumed during the query, exit immediately. - // Don't emit a session-update marker (it would reset the host's - // idle timer and cause a 30-min delay before the next _close). - if (queryResult.closedDuringQuery) { - log('Close sentinel consumed during query, exiting'); - break; - } - - // Emit session update so host can track it - writeOutput({ status: 'success', result: null, newSessionId: sessionId }); - - log('Query ended, waiting for next IPC message...'); - - // Wait for the next message or _close sentinel - const nextMessage = await waitForIpcMessage(); - if (nextMessage === null) { - log('Close sentinel received, exiting'); - break; - } - - log(`Got new message (${nextMessage.length} chars), starting new query`); - prompt = nextMessage; - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - log(`Agent error: ${errorMessage}`); - writeOutput({ - status: 'error', - result: null, - newSessionId: sessionId, - error: errorMessage - }); - process.exit(1); - } -} - -main(); diff --git a/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts.intent.md b/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts.intent.md deleted file mode 100644 index bf659bb..0000000 --- a/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts.intent.md +++ /dev/null @@ -1,23 +0,0 @@ -# Intent: container/agent-runner/src/index.ts - -## What Changed -- Added `imageAttachments?` field to ContainerInput interface -- Added `ImageContentBlock`, `TextContentBlock`, `ContentBlock` type definitions -- Changed `SDKUserMessage.message.content` type from `string` to `string | ContentBlock[]` -- Added `pushMultimodal(content: ContentBlock[])` method to MessageStream class -- In `runQuery`: image loading logic reads attachments from disk, base64-encodes, sends as multimodal content blocks - -## Key Sections -- **Types** (top of file): New content block interfaces, updated SDKUserMessage -- **MessageStream class**: New pushMultimodal method -- **runQuery function**: Image loading block - -## Invariants (must-keep) -- All IPC protocol logic (input polling, close sentinel, message stream) -- MessageStream push/end/asyncIterator (text messages still work) -- readStdin, writeOutput, log functions -- Session management (getSessionSummary, sessions index) -- PreCompact hook (transcript archiving) -- Bash sanitization hook -- SDK query options structure (mcpServers, hooks, permissions) -- Query loop in main() (query -> wait for IPC -> repeat) diff --git a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts deleted file mode 100644 index 9014758..0000000 --- a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts +++ /dev/null @@ -1,1117 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { EventEmitter } from 'events'; - -// --- Mocks --- - -// Mock config -vi.mock('../config.js', () => ({ - STORE_DIR: '/tmp/nanoclaw-test-store', - ASSISTANT_NAME: 'Andy', - ASSISTANT_HAS_OWN_NUMBER: false, - GROUPS_DIR: '/tmp/test-groups', -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// Mock db -vi.mock('../db.js', () => ({ - getLastGroupSync: vi.fn(() => null), - setLastGroupSync: vi.fn(), - updateChatName: vi.fn(), -})); - -// Mock image module -vi.mock('../image.js', () => ({ - isImageMessage: vi.fn().mockReturnValue(false), - processImage: vi.fn().mockResolvedValue({ content: '[Image: attachments/test.jpg]', relativePath: 'attachments/test.jpg' }), -})); - -// Mock fs -vi.mock('fs', async () => { - const actual = await vi.importActual('fs'); - return { - ...actual, - default: { - ...actual, - existsSync: vi.fn(() => true), - mkdirSync: vi.fn(), - }, - }; -}); - -// Mock child_process (used for osascript notification) -vi.mock('child_process', () => ({ - exec: vi.fn(), -})); - -// Build a fake WASocket that's an EventEmitter with the methods we need -function createFakeSocket() { - const ev = new EventEmitter(); - const sock = { - ev: { - on: (event: string, handler: (...args: unknown[]) => void) => { - ev.on(event, handler); - }, - }, - user: { - id: '1234567890:1@s.whatsapp.net', - lid: '9876543210:1@lid', - }, - sendMessage: vi.fn().mockResolvedValue(undefined), - sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), - groupFetchAllParticipating: vi.fn().mockResolvedValue({}), - updateMediaMessage: vi.fn().mockResolvedValue(undefined), - end: vi.fn(), - // Expose the event emitter for triggering events in tests - _ev: ev, - }; - return sock; -} - -let fakeSocket: ReturnType; - -// Mock Baileys -vi.mock('@whiskeysockets/baileys', () => { - return { - default: vi.fn(() => fakeSocket), - Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) }, - DisconnectReason: { - loggedOut: 401, - badSession: 500, - connectionClosed: 428, - connectionLost: 408, - connectionReplaced: 440, - timedOut: 408, - restartRequired: 515, - }, - fetchLatestWaWebVersion: vi - .fn() - .mockResolvedValue({ version: [2, 3000, 0] }), - downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from('image-data')), - normalizeMessageContent: vi.fn((content: unknown) => content), - makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), - useMultiFileAuthState: vi.fn().mockResolvedValue({ - state: { - creds: {}, - keys: {}, - }, - saveCreds: vi.fn(), - }), - }; -}); - -import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js'; -import { downloadMediaMessage } from '@whiskeysockets/baileys'; -import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js'; -import { isImageMessage, processImage } from '../image.js'; - -// --- Test helpers --- - -function createTestOpts( - overrides?: Partial, -): WhatsAppChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: vi.fn(() => ({ - 'registered@g.us': { - name: 'Test Group', - folder: 'test-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - ...overrides, - }; -} - -function triggerConnection(state: string, extra?: Record) { - fakeSocket._ev.emit('connection.update', { connection: state, ...extra }); -} - -function triggerDisconnect(statusCode: number) { - fakeSocket._ev.emit('connection.update', { - connection: 'close', - lastDisconnect: { - error: { output: { statusCode } }, - }, - }); -} - -async function triggerMessages(messages: unknown[]) { - fakeSocket._ev.emit('messages.upsert', { messages }); - // Flush microtasks so the async messages.upsert handler completes - await new Promise((r) => setTimeout(r, 0)); -} - -// --- Tests --- - -describe('WhatsAppChannel', () => { - beforeEach(() => { - fakeSocket = createFakeSocket(); - vi.mocked(getLastGroupSync).mockReturnValue(null); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - /** - * Helper: start connect, flush microtasks so event handlers are registered, - * then trigger the connection open event. Returns the resolved promise. - */ - async function connectChannel(channel: WhatsAppChannel): Promise { - const p = channel.connect(); - // Flush microtasks so connectInternal completes its await and registers handlers - await new Promise((r) => setTimeout(r, 0)); - triggerConnection('open'); - return p; - } - - // --- Version fetch --- - - describe('version fetch', () => { - it('connects with fetched version', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - await connectChannel(channel); - - const { fetchLatestWaWebVersion } = - await import('@whiskeysockets/baileys'); - expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({}); - }); - - it('falls back gracefully when version fetch fails', async () => { - const { fetchLatestWaWebVersion } = - await import('@whiskeysockets/baileys'); - vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce( - new Error('network error'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - await connectChannel(channel); - - // Should still connect successfully despite fetch failure - expect(channel.isConnected()).toBe(true); - }); - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when connection opens', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - }); - - it('sets up LID to phone mapping on open', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The channel should have mapped the LID from sock.user - // We can verify by sending a message from a LID JID - // and checking the translated JID in the callback - }); - - it('flushes outgoing queue on reconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect - (channel as any).connected = false; - - // Queue a message while disconnected - await channel.sendMessage('test@g.us', 'Queued message'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - - // Reconnect - (channel as any).connected = true; - await (channel as any).flushOutgoingQueue(); - - // Group messages get prefixed when flushed - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { - text: 'Andy: Queued message', - }); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - expect(fakeSocket.end).toHaveBeenCalled(); - }); - }); - - // --- QR code and auth --- - - describe('authentication', () => { - it('exits process when QR code is emitted (no auth state)', async () => { - vi.useFakeTimers(); - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Start connect but don't await (it won't resolve - process exits) - channel.connect().catch(() => {}); - - // Flush microtasks so connectInternal registers handlers - await vi.advanceTimersByTimeAsync(0); - - // Emit QR code event - fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' }); - - // Advance timer past the 1000ms setTimeout before exit - await vi.advanceTimersByTimeAsync(1500); - - expect(mockExit).toHaveBeenCalledWith(1); - mockExit.mockRestore(); - vi.useRealTimers(); - }); - }); - - // --- Reconnection behavior --- - - describe('reconnection', () => { - it('reconnects on non-loggedOut disconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - - // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428) - triggerDisconnect(428); - - expect(channel.isConnected()).toBe(false); - // The channel should attempt to reconnect (calls connectInternal again) - }); - - it('exits on loggedOut disconnect', async () => { - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with loggedOut reason (401) - triggerDisconnect(401); - - expect(channel.isConnected()).toBe(false); - expect(mockExit).toHaveBeenCalledWith(0); - mockExit.mockRestore(); - }); - - it('retries reconnection after 5s on failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with stream error 515 - triggerDisconnect(515); - - // The channel sets a 5s retry — just verify it doesn't crash - await new Promise((r) => setTimeout(r, 100)); - }); - }); - - // --- Message handling --- - - describe('message handling', () => { - it('delivers message for registered group', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-1', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello Andy' }, - pushName: 'Alice', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - id: 'msg-1', - content: 'Hello Andy', - sender_name: 'Alice', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered groups', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-2', - remoteJid: 'unregistered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello' }, - pushName: 'Bob', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'unregistered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores status@broadcast messages', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-3', - remoteJid: 'status@broadcast', - fromMe: false, - }, - message: { conversation: 'Status update' }, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores messages with no content', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-4', - remoteJid: 'registered@g.us', - fromMe: false, - }, - message: null, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('extracts text from extendedTextMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-5', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - extendedTextMessage: { text: 'A reply message' }, - }, - pushName: 'Charlie', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'A reply message' }), - ); - }); - - it('extracts caption from imageMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-6', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - imageMessage: { - caption: 'Check this photo', - mimetype: 'image/jpeg', - }, - }, - pushName: 'Diana', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Check this photo' }), - ); - }); - - it('extracts caption from videoMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-7', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' }, - }, - pushName: 'Eve', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Watch this' }), - ); - }); - - it('handles message with no extractable text (e.g. voice note without caption)', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-8', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, - }, - pushName: 'Frank', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Skipped — no text content to process - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('uses sender JID when pushName is absent', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-9', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'No push name' }, - // pushName is undefined - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ sender_name: '5551234' }), - ); - }); - - it('downloads and processes image attachments', async () => { - vi.mocked(isImageMessage).mockReturnValue(true); - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-img-1', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - imageMessage: { - caption: 'Check this', - mimetype: 'image/jpeg', - }, - }, - pushName: 'Alice', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(downloadMediaMessage).toHaveBeenCalled(); - expect(processImage).toHaveBeenCalled(); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - content: '[Image: attachments/test.jpg]', - }), - ); - - vi.mocked(isImageMessage).mockReturnValue(false); - }); - - it('handles image without caption', async () => { - vi.mocked(isImageMessage).mockReturnValue(true); - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-img-2', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - imageMessage: { - mimetype: 'image/jpeg', - }, - }, - pushName: 'Bob', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(processImage).toHaveBeenCalledWith( - expect.any(Buffer), - expect.any(String), - '', - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - content: '[Image: attachments/test.jpg]', - }), - ); - - vi.mocked(isImageMessage).mockReturnValue(false); - }); - - it('handles image download failure gracefully', async () => { - vi.mocked(isImageMessage).mockReturnValue(true); - vi.mocked(downloadMediaMessage).mockRejectedValueOnce( - new Error('Download failed'), - ); - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-img-3', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - imageMessage: { - caption: 'Will fail', - mimetype: 'image/jpeg', - }, - }, - pushName: 'Charlie', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Image download failed but caption is still there as content - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - content: 'Will fail', - }), - ); - - vi.mocked(isImageMessage).mockReturnValue(false); - }); - - it('falls back to caption when processImage returns null', async () => { - vi.mocked(isImageMessage).mockReturnValue(true); - vi.mocked(processImage).mockResolvedValueOnce(null); - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-img-4', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - imageMessage: { - caption: 'Fallback caption', - mimetype: 'image/jpeg', - }, - }, - pushName: 'Diana', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // processImage returned null, so original caption content is used - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - content: 'Fallback caption', - }), - ); - - vi.mocked(isImageMessage).mockReturnValue(false); - }); - }); - - // --- LID ↔ JID translation --- - - describe('LID to JID translation', () => { - it('translates known LID to phone JID', async () => { - const opts = createTestOpts({ - registeredGroups: vi.fn(() => ({ - '1234567890@s.whatsapp.net': { - name: 'Self Chat', - folder: 'self-chat', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net' - // Send a message from the LID - await triggerMessages([ - { - key: { - id: 'msg-lid', - remoteJid: '9876543210@lid', - fromMe: false, - }, - message: { conversation: 'From LID' }, - pushName: 'Self', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Should be translated to phone JID - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '1234567890@s.whatsapp.net', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - - it('passes through non-LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-normal', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Normal JID' }, - pushName: 'Grace', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - }); - - it('passes through unknown LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-unknown-lid', - remoteJid: '0000000000@lid', - fromMe: false, - }, - message: { conversation: 'Unknown LID' }, - pushName: 'Unknown', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Unknown LID passes through unchanged - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '0000000000@lid', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - }); - - // --- Outgoing message queue --- - - describe('outgoing message queue', () => { - it('sends message directly when connected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('test@g.us', 'Hello'); - // Group messages get prefixed with assistant name - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { - text: 'Andy: Hello', - }); - }); - - it('prefixes direct chat messages on shared number', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('123@s.whatsapp.net', 'Hello'); - // Shared number: DMs also get prefixed (needed for self-chat distinction) - expect(fakeSocket.sendMessage).toHaveBeenCalledWith( - '123@s.whatsapp.net', - { text: 'Andy: Hello' }, - ); - }); - - it('queues message when disconnected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Don't connect — channel starts disconnected - await channel.sendMessage('test@g.us', 'Queued'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - }); - - it('queues message on send failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Make sendMessage fail - fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error')); - - await channel.sendMessage('test@g.us', 'Will fail'); - - // Should not throw, message queued for retry - // The queue should have the message - }); - - it('flushes multiple queued messages in order', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Queue messages while disconnected - await channel.sendMessage('test@g.us', 'First'); - await channel.sendMessage('test@g.us', 'Second'); - await channel.sendMessage('test@g.us', 'Third'); - - // Connect — flush happens automatically on open - await connectChannel(channel); - - // Give the async flush time to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3); - // Group messages get prefixed - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { - text: 'Andy: First', - }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { - text: 'Andy: Second', - }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { - text: 'Andy: Third', - }); - }); - }); - - // --- Group metadata sync --- - - describe('group metadata sync', () => { - it('syncs group metadata on first connection', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Group One' }, - 'group2@g.us': { subject: 'Group Two' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Wait for async sync to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One'); - expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two'); - expect(setLastGroupSync).toHaveBeenCalled(); - }); - - it('skips sync when synced recently', async () => { - // Last sync was 1 hour ago (within 24h threshold) - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled(); - }); - - it('forces sync regardless of cache', async () => { - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group@g.us': { subject: 'Forced Group' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.syncGroupMetadata(true); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group'); - }); - - it('handles group sync failure gracefully', async () => { - fakeSocket.groupFetchAllParticipating.mockRejectedValue( - new Error('Network timeout'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Should not throw - await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined(); - }); - - it('skips groups with no subject', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Has Subject' }, - 'group2@g.us': { subject: '' }, - 'group3@g.us': {}, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Clear any calls from the automatic sync on connect - vi.mocked(updateChatName).mockClear(); - - await channel.syncGroupMetadata(true); - - expect(updateChatName).toHaveBeenCalledTimes(1); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject'); - }); - }); - - // --- JID ownership --- - - describe('ownsJid', () => { - it('owns @g.us JIDs (WhatsApp groups)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(true); - }); - - it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true); - }); - - it('does not own Telegram JIDs', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('tg:12345')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); - }); - - // --- Typing indicator --- - - describe('setTyping', () => { - it('sends composing presence when typing', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', true); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( - 'composing', - 'test@g.us', - ); - }); - - it('sends paused presence when stopping', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', false); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( - 'paused', - 'test@g.us', - ); - }); - - it('handles typing indicator failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed')); - - // Should not throw - await expect( - channel.setTyping('test@g.us', true), - ).resolves.toBeUndefined(); - }); - }); - - // --- Channel properties --- - - describe('channel properties', () => { - it('has name "whatsapp"', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.name).toBe('whatsapp'); - }); - - it('does not expose prefixAssistantName (prefix handled internally)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect('prefixAssistantName' in channel).toBe(false); - }); - }); -}); diff --git a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts.intent.md b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts.intent.md deleted file mode 100644 index 2c96eec..0000000 --- a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts.intent.md +++ /dev/null @@ -1,21 +0,0 @@ -# Intent: src/channels/whatsapp.test.ts - -## What Changed -- Added `GROUPS_DIR` to config mock -- Added `../image.js` mock (isImageMessage defaults false, processImage returns stub) -- Added `updateMediaMessage` to fake socket (needed by downloadMediaMessage) -- Added `normalizeMessageContent` to Baileys mock (pass-through) -- Added `downloadMediaMessage` to Baileys mock (returns Buffer) -- Added imports for `downloadMediaMessage`, `isImageMessage`, `processImage` -- Added image test cases: downloads/processes, no caption, download failure, processImage null fallback - -## Key Sections -- **Mock setup** (top of file): New image mock, extended Baileys mock, extended fakeSocket -- **Message handling tests**: Image test cases - -## Invariants (must-keep) -- All existing test sections and describe blocks -- Existing mock structure (config, logger, db, fs, child_process, Baileys) -- Test helpers (createTestOpts, triggerConnection, triggerDisconnect, triggerMessages, connectChannel) -- Connection lifecycle, authentication, reconnection, LID translation tests -- Outgoing queue, group metadata sync, JID ownership, typing indicator tests diff --git a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts deleted file mode 100644 index cee13f7..0000000 --- a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { exec } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import makeWASocket, { - Browsers, - DisconnectReason, - downloadMediaMessage, - WASocket, - fetchLatestWaWebVersion, - makeCacheableSignalKeyStore, - normalizeMessageContent, - useMultiFileAuthState, -} from '@whiskeysockets/baileys'; - -import { - ASSISTANT_HAS_OWN_NUMBER, - ASSISTANT_NAME, - GROUPS_DIR, - STORE_DIR, -} from '../config.js'; -import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js'; -import { isImageMessage, processImage } from '../image.js'; -import { logger } from '../logger.js'; -import { - Channel, - OnInboundMessage, - OnChatMetadata, - RegisteredGroup, -} from '../types.js'; -import { registerChannel, ChannelOpts } from './registry.js'; - -const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours - -export interface WhatsAppChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -export class WhatsAppChannel implements Channel { - name = 'whatsapp'; - - private sock!: WASocket; - private connected = false; - private lidToPhoneMap: Record = {}; - private outgoingQueue: Array<{ jid: string; text: string }> = []; - private flushing = false; - private groupSyncTimerStarted = false; - - private opts: WhatsAppChannelOpts; - - constructor(opts: WhatsAppChannelOpts) { - this.opts = opts; - } - - async connect(): Promise { - return new Promise((resolve, reject) => { - this.connectInternal(resolve).catch(reject); - }); - } - - private async connectInternal(onFirstOpen?: () => void): Promise { - const authDir = path.join(STORE_DIR, 'auth'); - fs.mkdirSync(authDir, { recursive: true }); - - const { state, saveCreds } = await useMultiFileAuthState(authDir); - - const { version } = await fetchLatestWaWebVersion({}).catch((err) => { - logger.warn( - { err }, - 'Failed to fetch latest WA Web version, using default', - ); - return { version: undefined }; - }); - this.sock = makeWASocket({ - version, - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, logger), - }, - printQRInTerminal: false, - logger, - browser: Browsers.macOS('Chrome'), - }); - - this.sock.ev.on('connection.update', (update) => { - const { connection, lastDisconnect, qr } = update; - - if (qr) { - const msg = - 'WhatsApp authentication required. Run /setup in Claude Code.'; - logger.error(msg); - exec( - `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`, - ); - setTimeout(() => process.exit(1), 1000); - } - - if (connection === 'close') { - this.connected = false; - const reason = ( - lastDisconnect?.error as { output?: { statusCode?: number } } - )?.output?.statusCode; - const shouldReconnect = reason !== DisconnectReason.loggedOut; - logger.info( - { - reason, - shouldReconnect, - queuedMessages: this.outgoingQueue.length, - }, - 'Connection closed', - ); - - if (shouldReconnect) { - this.scheduleReconnect(1); - } else { - logger.info('Logged out. Run /setup to re-authenticate.'); - process.exit(0); - } - } else if (connection === 'open') { - this.connected = true; - logger.info('Connected to WhatsApp'); - - // Announce availability so WhatsApp relays subsequent presence updates (typing indicators) - this.sock.sendPresenceUpdate('available').catch((err) => { - logger.warn({ err }, 'Failed to send presence update'); - }); - - // Build LID to phone mapping from auth state for self-chat translation - if (this.sock.user) { - const phoneUser = this.sock.user.id.split(':')[0]; - const lidUser = this.sock.user.lid?.split(':')[0]; - if (lidUser && phoneUser) { - this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`; - logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set'); - } - } - - // Flush any messages queued while disconnected - this.flushOutgoingQueue().catch((err) => - logger.error({ err }, 'Failed to flush outgoing queue'), - ); - - // Sync group metadata on startup (respects 24h cache) - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Initial group sync failed'), - ); - // Set up daily sync timer (only once) - if (!this.groupSyncTimerStarted) { - this.groupSyncTimerStarted = true; - setInterval(() => { - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Periodic group sync failed'), - ); - }, GROUP_SYNC_INTERVAL_MS); - } - - // Signal first connection to caller - if (onFirstOpen) { - onFirstOpen(); - onFirstOpen = undefined; - } - } - }); - - this.sock.ev.on('creds.update', saveCreds); - - this.sock.ev.on('messages.upsert', async ({ messages }) => { - for (const msg of messages) { - try { - if (!msg.message) continue; - // Unwrap container types (viewOnceMessageV2, ephemeralMessage, - // editedMessage, etc.) so that conversation, extendedTextMessage, - // imageMessage, etc. are accessible at the top level. - const normalized = normalizeMessageContent(msg.message); - if (!normalized) continue; - const rawJid = msg.key.remoteJid; - if (!rawJid || rawJid === 'status@broadcast') continue; - - // Translate LID JID to phone JID if applicable - const chatJid = await this.translateJid(rawJid); - - const timestamp = new Date( - Number(msg.messageTimestamp) * 1000, - ).toISOString(); - - // Always notify about chat metadata for group discovery - const isGroup = chatJid.endsWith('@g.us'); - this.opts.onChatMetadata( - chatJid, - timestamp, - undefined, - 'whatsapp', - isGroup, - ); - - // Only deliver full message for registered groups - const groups = this.opts.registeredGroups(); - if (groups[chatJid]) { - let content = - normalized.conversation || - normalized.extendedTextMessage?.text || - normalized.imageMessage?.caption || - normalized.videoMessage?.caption || - ''; - - // Image attachment handling - if (isImageMessage(msg)) { - try { - const buffer = await downloadMediaMessage(msg, 'buffer', {}); - const groupDir = path.join(GROUPS_DIR, groups[chatJid].folder); - const caption = normalized?.imageMessage?.caption ?? ''; - const result = await processImage(buffer as Buffer, groupDir, caption); - if (result) { - content = result.content; - } - } catch (err) { - logger.warn({ err, jid: chatJid }, 'Image - download failed'); - } - } - - // Skip protocol messages with no text content (encryption keys, read receipts, etc.) - if (!content) continue; - - const sender = msg.key.participant || msg.key.remoteJid || ''; - const senderName = msg.pushName || sender.split('@')[0]; - - const fromMe = msg.key.fromMe || false; - // Detect bot messages: with own number, fromMe is reliable - // since only the bot sends from that number. - // With shared number, bot messages carry the assistant name prefix - // (even in DMs/self-chat) so we check for that. - const isBotMessage = ASSISTANT_HAS_OWN_NUMBER - ? fromMe - : content.startsWith(`${ASSISTANT_NAME}:`); - - this.opts.onMessage(chatJid, { - id: msg.key.id || '', - chat_jid: chatJid, - sender, - sender_name: senderName, - content, - timestamp, - is_from_me: fromMe, - is_bot_message: isBotMessage, - }); - } - } catch (err) { - logger.error( - { err, remoteJid: msg.key?.remoteJid }, - 'Error processing incoming message', - ); - } - } - }); - } - - async sendMessage(jid: string, text: string): Promise { - // Prefix bot messages with assistant name so users know who's speaking. - // On a shared number, prefix is also needed in DMs (including self-chat) - // to distinguish bot output from user messages. - // Skip only when the assistant has its own dedicated phone number. - const prefixed = ASSISTANT_HAS_OWN_NUMBER - ? text - : `${ASSISTANT_NAME}: ${text}`; - - if (!this.connected) { - this.outgoingQueue.push({ jid, text: prefixed }); - logger.info( - { jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, - 'WA disconnected, message queued', - ); - return; - } - try { - await this.sock.sendMessage(jid, { text: prefixed }); - logger.info({ jid, length: prefixed.length }, 'Message sent'); - } catch (err) { - // If send fails, queue it for retry on reconnect - this.outgoingQueue.push({ jid, text: prefixed }); - logger.warn( - { jid, err, queueSize: this.outgoingQueue.length }, - 'Failed to send, message queued', - ); - } - } - - isConnected(): boolean { - return this.connected; - } - - ownsJid(jid: string): boolean { - return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net'); - } - - async disconnect(): Promise { - this.connected = false; - this.sock?.end(undefined); - } - - async setTyping(jid: string, isTyping: boolean): Promise { - try { - const status = isTyping ? 'composing' : 'paused'; - logger.debug({ jid, status }, 'Sending presence update'); - await this.sock.sendPresenceUpdate(status, jid); - } catch (err) { - logger.debug({ jid, err }, 'Failed to update typing status'); - } - } - - async syncGroups(force: boolean): Promise { - return this.syncGroupMetadata(force); - } - - /** - * Sync group metadata from WhatsApp. - * Fetches all participating groups and stores their names in the database. - * Called on startup, daily, and on-demand via IPC. - */ - async syncGroupMetadata(force = false): Promise { - if (!force) { - const lastSync = getLastGroupSync(); - if (lastSync) { - const lastSyncTime = new Date(lastSync).getTime(); - if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) { - logger.debug({ lastSync }, 'Skipping group sync - synced recently'); - return; - } - } - } - - try { - logger.info('Syncing group metadata from WhatsApp...'); - const groups = await this.sock.groupFetchAllParticipating(); - - let count = 0; - for (const [jid, metadata] of Object.entries(groups)) { - if (metadata.subject) { - updateChatName(jid, metadata.subject); - count++; - } - } - - setLastGroupSync(); - logger.info({ count }, 'Group metadata synced'); - } catch (err) { - logger.error({ err }, 'Failed to sync group metadata'); - } - } - - private scheduleReconnect(attempt: number): void { - const delayMs = Math.min(5000 * Math.pow(2, attempt - 1), 300000); - logger.info({ attempt, delayMs }, 'Reconnecting...'); - setTimeout(() => { - this.connectInternal().catch((err) => { - logger.error({ err, attempt }, 'Reconnection attempt failed'); - this.scheduleReconnect(attempt + 1); - }); - }, delayMs); - } - - private async translateJid(jid: string): Promise { - if (!jid.endsWith('@lid')) return jid; - const lidUser = jid.split('@')[0].split(':')[0]; - - // Check local cache first - const cached = this.lidToPhoneMap[lidUser]; - if (cached) { - logger.debug( - { lidJid: jid, phoneJid: cached }, - 'Translated LID to phone JID (cached)', - ); - return cached; - } - - // Query Baileys' signal repository for the mapping - try { - const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid); - if (pn) { - const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; - this.lidToPhoneMap[lidUser] = phoneJid; - logger.info( - { lidJid: jid, phoneJid }, - 'Translated LID to phone JID (signalRepository)', - ); - return phoneJid; - } - } catch (err) { - logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository'); - } - - return jid; - } - - private async flushOutgoingQueue(): Promise { - if (this.flushing || this.outgoingQueue.length === 0) return; - this.flushing = true; - try { - logger.info( - { count: this.outgoingQueue.length }, - 'Flushing outgoing message queue', - ); - while (this.outgoingQueue.length > 0) { - const item = this.outgoingQueue.shift()!; - // Send directly — queued items are already prefixed by sendMessage - await this.sock.sendMessage(item.jid, { text: item.text }); - logger.info( - { jid: item.jid, length: item.text.length }, - 'Queued message sent', - ); - } - } finally { - this.flushing = false; - } - } -} - -registerChannel('whatsapp', (opts: ChannelOpts) => new WhatsAppChannel(opts)); diff --git a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts.intent.md b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts.intent.md deleted file mode 100644 index bed8467..0000000 --- a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts.intent.md +++ /dev/null @@ -1,23 +0,0 @@ -# Intent: src/channels/whatsapp.ts - -## What Changed -- Added `downloadMediaMessage` import from Baileys -- Added `normalizeMessageContent` import from Baileys for unwrapping container types -- Added `GROUPS_DIR` to config import -- Added `isImageMessage`, `processImage` imports from `../image.js` -- Uses `normalizeMessageContent(msg.message)` to unwrap viewOnce, ephemeral, edited messages -- Changed `const content =` to `let content =` (allows mutation by image handler) -- Added image download/process block between content extraction and `!content` guard - -## Key Sections -- **Imports** (top of file): New imports for downloadMediaMessage, normalizeMessageContent, isImageMessage, processImage, GROUPS_DIR -- **messages.upsert handler** (inside `connectInternal`): normalizeMessageContent call, image block inserted after text extraction, before the `!content` skip guard - -## Invariants (must-keep) -- WhatsAppChannel class structure and all existing methods -- Connection lifecycle (connect, reconnect with exponential backoff, disconnect) -- LID-to-phone translation logic -- Outgoing message queue and flush logic -- Group metadata sync with 24h cache -- The `!content` guard must remain AFTER media blocks (they provide content for otherwise-empty messages) -- Local timestamp format (no Z suffix) for cursor compatibility diff --git a/.claude/skills/add-image-vision/modify/src/container-runner.ts b/.claude/skills/add-image-vision/modify/src/container-runner.ts deleted file mode 100644 index 8657e7b..0000000 --- a/.claude/skills/add-image-vision/modify/src/container-runner.ts +++ /dev/null @@ -1,703 +0,0 @@ -/** - * Container Runner for NanoClaw - * Spawns agent execution in containers and handles IPC - */ -import { ChildProcess, exec, spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { - CONTAINER_IMAGE, - CONTAINER_MAX_OUTPUT_SIZE, - CONTAINER_TIMEOUT, - DATA_DIR, - GROUPS_DIR, - IDLE_TIMEOUT, - TIMEZONE, -} from './config.js'; -import { readEnvFile } from './env.js'; -import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; -import { logger } from './logger.js'; -import { - CONTAINER_RUNTIME_BIN, - readonlyMountArgs, - stopContainer, -} from './container-runtime.js'; -import { validateAdditionalMounts } from './mount-security.js'; -import { RegisteredGroup } from './types.js'; - -// Sentinel markers for robust output parsing (must match agent-runner) -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -export interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - secrets?: Record; - imageAttachments?: Array<{ relativePath: string; mediaType: string }>; -} - -export interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface VolumeMount { - hostPath: string; - containerPath: string; - readonly: boolean; -} - -function buildVolumeMounts( - group: RegisteredGroup, - isMain: boolean, -): VolumeMount[] { - const mounts: VolumeMount[] = []; - const projectRoot = process.cwd(); - const groupDir = resolveGroupFolderPath(group.folder); - - if (isMain) { - // Main gets the project root read-only. Writable paths the agent needs - // (group folder, IPC, .claude/) are mounted separately below. - // Read-only prevents the agent from modifying host application code - // (src/, dist/, package.json, etc.) which would bypass the sandbox - // entirely on next restart. - mounts.push({ - hostPath: projectRoot, - containerPath: '/workspace/project', - readonly: true, - }); - - // Shadow .env so the agent cannot read secrets from the mounted project root. - // Secrets are passed via stdin instead (see readSecrets()). - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - mounts.push({ - hostPath: '/dev/null', - containerPath: '/workspace/project/.env', - readonly: true, - }); - } - - // Main also gets its group folder as the working directory - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - } else { - // Other groups only get their own folder - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - - // Global memory directory (read-only for non-main) - // Only directory mounts are supported, not file mounts - const globalDir = path.join(GROUPS_DIR, 'global'); - if (fs.existsSync(globalDir)) { - mounts.push({ - hostPath: globalDir, - containerPath: '/workspace/global', - readonly: true, - }); - } - } - - // Per-group Claude sessions directory (isolated from other groups) - // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - '.claude', - ); - fs.mkdirSync(groupSessionsDir, { recursive: true }); - const settingsFile = path.join(groupSessionsDir, 'settings.json'); - if (!fs.existsSync(settingsFile)) { - fs.writeFileSync( - settingsFile, - JSON.stringify( - { - env: { - // Enable agent swarms (subagent orchestration) - // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - // Load CLAUDE.md from additional mounted directories - // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - // Enable Claude's memory feature (persists user preferences between sessions) - // https://code.claude.com/docs/en/memory#manage-auto-memory - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, - null, - 2, - ) + '\n', - ); - } - - // Sync skills from container/skills/ into each group's .claude/skills/ - const skillsSrc = path.join(process.cwd(), 'container', 'skills'); - const skillsDst = path.join(groupSessionsDir, 'skills'); - if (fs.existsSync(skillsSrc)) { - for (const skillDir of fs.readdirSync(skillsSrc)) { - const srcDir = path.join(skillsSrc, skillDir); - if (!fs.statSync(srcDir).isDirectory()) continue; - const dstDir = path.join(skillsDst, skillDir); - fs.cpSync(srcDir, dstDir, { recursive: true }); - } - } - mounts.push({ - hostPath: groupSessionsDir, - containerPath: '/home/node/.claude', - readonly: false, - }); - - // Per-group IPC namespace: each group gets its own IPC directory - // This prevents cross-group privilege escalation via IPC - const groupIpcDir = resolveGroupIpcPath(group.folder); - fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); - mounts.push({ - hostPath: groupIpcDir, - containerPath: '/workspace/ipc', - readonly: false, - }); - - // Copy agent-runner source into a per-group writable location so agents - // can customize it (add tools, change behavior) without affecting other - // groups. Recompiled on container startup via entrypoint.sh. - const agentRunnerSrc = path.join( - projectRoot, - 'container', - 'agent-runner', - 'src', - ); - const groupAgentRunnerDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - 'agent-runner-src', - ); - if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); - } - mounts.push({ - hostPath: groupAgentRunnerDir, - containerPath: '/app/src', - readonly: false, - }); - - // Additional mounts validated against external allowlist (tamper-proof from containers) - if (group.containerConfig?.additionalMounts) { - const validatedMounts = validateAdditionalMounts( - group.containerConfig.additionalMounts, - group.name, - isMain, - ); - mounts.push(...validatedMounts); - } - - return mounts; -} - -/** - * Read allowed secrets from .env for passing to the container via stdin. - * Secrets are never written to disk or mounted as files. - */ -function readSecrets(): Record { - return readEnvFile([ - 'CLAUDE_CODE_OAUTH_TOKEN', - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_BASE_URL', - 'ANTHROPIC_AUTH_TOKEN', - ]); -} - -function buildContainerArgs( - mounts: VolumeMount[], - containerName: string, -): string[] { - const args: string[] = ['run', '-i', '--rm', '--name', containerName]; - - // Pass host timezone so container's local time matches the user's - args.push('-e', `TZ=${TIMEZONE}`); - - // Run as host user so bind-mounted files are accessible. - // Skip when running as root (uid 0), as the container's node user (uid 1000), - // or when getuid is unavailable (native Windows without WSL). - const hostUid = process.getuid?.(); - const hostGid = process.getgid?.(); - if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { - args.push('--user', `${hostUid}:${hostGid}`); - args.push('-e', 'HOME=/home/node'); - } - - for (const mount of mounts) { - if (mount.readonly) { - args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); - } else { - args.push('-v', `${mount.hostPath}:${mount.containerPath}`); - } - } - - args.push(CONTAINER_IMAGE); - - return args; -} - -export async function runContainerAgent( - group: RegisteredGroup, - input: ContainerInput, - onProcess: (proc: ChildProcess, containerName: string) => void, - onOutput?: (output: ContainerOutput) => Promise, -): Promise { - const startTime = Date.now(); - - const groupDir = resolveGroupFolderPath(group.folder); - fs.mkdirSync(groupDir, { recursive: true }); - - const mounts = buildVolumeMounts(group, input.isMain); - const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); - const containerName = `nanoclaw-${safeName}-${Date.now()}`; - const containerArgs = buildContainerArgs(mounts, containerName); - - logger.debug( - { - group: group.name, - containerName, - mounts: mounts.map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ), - containerArgs: containerArgs.join(' '), - }, - 'Container mount configuration', - ); - - logger.info( - { - group: group.name, - containerName, - mountCount: mounts.length, - isMain: input.isMain, - }, - 'Spawning container agent', - ); - - const logsDir = path.join(groupDir, 'logs'); - fs.mkdirSync(logsDir, { recursive: true }); - - return new Promise((resolve) => { - const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - onProcess(container, containerName); - - let stdout = ''; - let stderr = ''; - let stdoutTruncated = false; - let stderrTruncated = false; - - // Pass secrets via stdin (never written to disk or mounted as files) - input.secrets = readSecrets(); - container.stdin.write(JSON.stringify(input)); - container.stdin.end(); - // Remove secrets from input so they don't appear in logs - delete input.secrets; - - // Streaming output: parse OUTPUT_START/END marker pairs as they arrive - let parseBuffer = ''; - let newSessionId: string | undefined; - let outputChain = Promise.resolve(); - - container.stdout.on('data', (data) => { - const chunk = data.toString(); - - // Always accumulate for logging - if (!stdoutTruncated) { - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length; - if (chunk.length > remaining) { - stdout += chunk.slice(0, remaining); - stdoutTruncated = true; - logger.warn( - { group: group.name, size: stdout.length }, - 'Container stdout truncated due to size limit', - ); - } else { - stdout += chunk; - } - } - - // Stream-parse for output markers - if (onOutput) { - parseBuffer += chunk; - let startIdx: number; - while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { - const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); - if (endIdx === -1) break; // Incomplete pair, wait for more data - - const jsonStr = parseBuffer - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); - parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); - - try { - const parsed: ContainerOutput = JSON.parse(jsonStr); - if (parsed.newSessionId) { - newSessionId = parsed.newSessionId; - } - hadStreamingOutput = true; - // Activity detected — reset the hard timeout - resetTimeout(); - // Call onOutput for all markers (including null results) - // so idle timers start even for "silent" query completions. - outputChain = outputChain.then(() => onOutput(parsed)); - } catch (err) { - logger.warn( - { group: group.name, error: err }, - 'Failed to parse streamed output chunk', - ); - } - } - } - }); - - container.stderr.on('data', (data) => { - const chunk = data.toString(); - const lines = chunk.trim().split('\n'); - for (const line of lines) { - if (line) logger.debug({ container: group.folder }, line); - } - // Don't reset timeout on stderr — SDK writes debug logs continuously. - // Timeout only resets on actual output (OUTPUT_MARKER in stdout). - if (stderrTruncated) return; - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length; - if (chunk.length > remaining) { - stderr += chunk.slice(0, remaining); - stderrTruncated = true; - logger.warn( - { group: group.name, size: stderr.length }, - 'Container stderr truncated due to size limit', - ); - } else { - stderr += chunk; - } - }); - - let timedOut = false; - let hadStreamingOutput = false; - const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; - // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the - // graceful _close sentinel has time to trigger before the hard kill fires. - const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000); - - const killOnTimeout = () => { - timedOut = true; - logger.error( - { group: group.name, containerName }, - 'Container timeout, stopping gracefully', - ); - exec(stopContainer(containerName), { timeout: 15000 }, (err) => { - if (err) { - logger.warn( - { group: group.name, containerName, err }, - 'Graceful stop failed, force killing', - ); - container.kill('SIGKILL'); - } - }); - }; - - let timeout = setTimeout(killOnTimeout, timeoutMs); - - // Reset the timeout whenever there's activity (streaming output) - const resetTimeout = () => { - clearTimeout(timeout); - timeout = setTimeout(killOnTimeout, timeoutMs); - }; - - container.on('close', (code) => { - clearTimeout(timeout); - const duration = Date.now() - startTime; - - if (timedOut) { - const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const timeoutLog = path.join(logsDir, `container-${ts}.log`); - fs.writeFileSync( - timeoutLog, - [ - `=== Container Run Log (TIMEOUT) ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `Container: ${containerName}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Had Streaming Output: ${hadStreamingOutput}`, - ].join('\n'), - ); - - // Timeout after output = idle cleanup, not failure. - // The agent already sent its response; this is just the - // container being reaped after the idle period expired. - if (hadStreamingOutput) { - logger.info( - { group: group.name, containerName, duration, code }, - 'Container timed out after output (idle cleanup)', - ); - outputChain.then(() => { - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - logger.error( - { group: group.name, containerName, duration, code }, - 'Container timed out with no output', - ); - - resolve({ - status: 'error', - result: null, - error: `Container timed out after ${configTimeout}ms`, - }); - return; - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logFile = path.join(logsDir, `container-${timestamp}.log`); - const isVerbose = - process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; - - const logLines = [ - `=== Container Run Log ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `IsMain: ${input.isMain}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Stdout Truncated: ${stdoutTruncated}`, - `Stderr Truncated: ${stderrTruncated}`, - ``, - ]; - - const isError = code !== 0; - - if (isVerbose || isError) { - logLines.push( - `=== Input ===`, - JSON.stringify(input, null, 2), - ``, - `=== Container Args ===`, - containerArgs.join(' '), - ``, - `=== Mounts ===`, - mounts - .map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ) - .join('\n'), - ``, - `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, - stderr, - ``, - `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, - stdout, - ); - } else { - logLines.push( - `=== Input Summary ===`, - `Prompt length: ${input.prompt.length} chars`, - `Session ID: ${input.sessionId || 'new'}`, - ``, - `=== Mounts ===`, - mounts - .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) - .join('\n'), - ``, - ); - } - - fs.writeFileSync(logFile, logLines.join('\n')); - logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); - - if (code !== 0) { - logger.error( - { - group: group.name, - code, - duration, - stderr, - stdout, - logFile, - }, - 'Container exited with error', - ); - - resolve({ - status: 'error', - result: null, - error: `Container exited with code ${code}: ${stderr.slice(-200)}`, - }); - return; - } - - // Streaming mode: wait for output chain to settle, return completion marker - if (onOutput) { - outputChain.then(() => { - logger.info( - { group: group.name, duration, newSessionId }, - 'Container completed (streaming mode)', - ); - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - // Legacy mode: parse the last output marker pair from accumulated stdout - try { - // Extract JSON between sentinel markers for robust parsing - const startIdx = stdout.indexOf(OUTPUT_START_MARKER); - const endIdx = stdout.indexOf(OUTPUT_END_MARKER); - - let jsonLine: string; - if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - jsonLine = stdout - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); - } else { - // Fallback: last non-empty line (backwards compatibility) - const lines = stdout.trim().split('\n'); - jsonLine = lines[lines.length - 1]; - } - - const output: ContainerOutput = JSON.parse(jsonLine); - - logger.info( - { - group: group.name, - duration, - status: output.status, - hasResult: !!output.result, - }, - 'Container completed', - ); - - resolve(output); - } catch (err) { - logger.error( - { - group: group.name, - stdout, - stderr, - error: err, - }, - 'Failed to parse container output', - ); - - resolve({ - status: 'error', - result: null, - error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, - }); - } - }); - - container.on('error', (err) => { - clearTimeout(timeout); - logger.error( - { group: group.name, containerName, error: err }, - 'Container spawn error', - ); - resolve({ - status: 'error', - result: null, - error: `Container spawn error: ${err.message}`, - }); - }); - }); -} - -export function writeTasksSnapshot( - groupFolder: string, - isMain: boolean, - tasks: Array<{ - id: string; - groupFolder: string; - prompt: string; - schedule_type: string; - schedule_value: string; - status: string; - next_run: string | null; - }>, -): void { - // Write filtered tasks to the group's IPC directory - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all tasks, others only see their own - const filteredTasks = isMain - ? tasks - : tasks.filter((t) => t.groupFolder === groupFolder); - - const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); - fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); -} - -export interface AvailableGroup { - jid: string; - name: string; - lastActivity: string; - isRegistered: boolean; -} - -/** - * Write available groups snapshot for the container to read. - * Only main group can see all available groups (for activation). - * Non-main groups only see their own registration status. - */ -export function writeGroupsSnapshot( - groupFolder: string, - isMain: boolean, - groups: AvailableGroup[], - registeredJids: Set, -): void { - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all groups; others see nothing (they can't activate groups) - const visibleGroups = isMain ? groups : []; - - const groupsFile = path.join(groupIpcDir, 'available_groups.json'); - fs.writeFileSync( - groupsFile, - JSON.stringify( - { - groups: visibleGroups, - lastSync: new Date().toISOString(), - }, - null, - 2, - ), - ); -} diff --git a/.claude/skills/add-image-vision/modify/src/container-runner.ts.intent.md b/.claude/skills/add-image-vision/modify/src/container-runner.ts.intent.md deleted file mode 100644 index d30f24f..0000000 --- a/.claude/skills/add-image-vision/modify/src/container-runner.ts.intent.md +++ /dev/null @@ -1,15 +0,0 @@ -# Intent: src/container-runner.ts - -## What Changed -- Added `imageAttachments?` optional field to `ContainerInput` interface - -## Key Sections -- **ContainerInput interface**: imageAttachments optional field (`Array<{ relativePath: string; mediaType: string }>`) - -## Invariants (must-keep) -- ContainerOutput interface unchanged -- buildContainerArgs structure (run, -i, --rm, --name, mounts, image) -- runContainerAgent with streaming output parsing (OUTPUT_START/END markers) -- writeTasksSnapshot, writeGroupsSnapshot functions -- Additional mounts via validateAdditionalMounts -- Mount security validation against external allowlist diff --git a/.claude/skills/add-image-vision/modify/src/index.ts b/.claude/skills/add-image-vision/modify/src/index.ts deleted file mode 100644 index 2073a4d..0000000 --- a/.claude/skills/add-image-vision/modify/src/index.ts +++ /dev/null @@ -1,590 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { - ASSISTANT_NAME, - IDLE_TIMEOUT, - POLL_INTERVAL, - TRIGGER_PATTERN, -} from './config.js'; -import './channels/index.js'; -import { - getChannelFactory, - getRegisteredChannelNames, -} from './channels/registry.js'; -import { - ContainerOutput, - runContainerAgent, - writeGroupsSnapshot, - writeTasksSnapshot, -} from './container-runner.js'; -import { - cleanupOrphans, - ensureContainerRuntimeRunning, -} from './container-runtime.js'; -import { - getAllChats, - getAllRegisteredGroups, - getAllSessions, - getAllTasks, - getMessagesSince, - getNewMessages, - getRouterState, - initDatabase, - setRegisteredGroup, - setRouterState, - setSession, - storeChatMetadata, - storeMessage, -} from './db.js'; -import { GroupQueue } from './group-queue.js'; -import { resolveGroupFolderPath } from './group-folder.js'; -import { startIpcWatcher } from './ipc.js'; -import { findChannel, formatMessages, formatOutbound } from './router.js'; -import { - isSenderAllowed, - isTriggerAllowed, - loadSenderAllowlist, - shouldDropMessage, -} from './sender-allowlist.js'; -import { startSchedulerLoop } from './task-scheduler.js'; -import { Channel, NewMessage, RegisteredGroup } from './types.js'; -import { parseImageReferences } from './image.js'; -import { logger } from './logger.js'; - -// Re-export for backwards compatibility during refactor -export { escapeXml, formatMessages } from './router.js'; - -let lastTimestamp = ''; -let sessions: Record = {}; -let registeredGroups: Record = {}; -let lastAgentTimestamp: Record = {}; -let messageLoopRunning = false; - -const channels: Channel[] = []; -const queue = new GroupQueue(); - -function loadState(): void { - lastTimestamp = getRouterState('last_timestamp') || ''; - const agentTs = getRouterState('last_agent_timestamp'); - try { - lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {}; - } catch { - logger.warn('Corrupted last_agent_timestamp in DB, resetting'); - lastAgentTimestamp = {}; - } - sessions = getAllSessions(); - registeredGroups = getAllRegisteredGroups(); - logger.info( - { groupCount: Object.keys(registeredGroups).length }, - 'State loaded', - ); -} - -function saveState(): void { - setRouterState('last_timestamp', lastTimestamp); - setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); -} - -function registerGroup(jid: string, group: RegisteredGroup): void { - let groupDir: string; - try { - groupDir = resolveGroupFolderPath(group.folder); - } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Rejecting group registration with invalid folder', - ); - return; - } - - registeredGroups[jid] = group; - setRegisteredGroup(jid, group); - - // Create group folder - fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); - - logger.info( - { jid, name: group.name, folder: group.folder }, - 'Group registered', - ); -} - -/** - * Get available groups list for the agent. - * Returns groups ordered by most recent activity. - */ -export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] { - const chats = getAllChats(); - const registeredJids = new Set(Object.keys(registeredGroups)); - - return chats - .filter((c) => c.jid !== '__group_sync__' && c.is_group) - .map((c) => ({ - jid: c.jid, - name: c.name, - lastActivity: c.last_message_time, - isRegistered: registeredJids.has(c.jid), - })); -} - -/** @internal - exported for testing */ -export function _setRegisteredGroups( - groups: Record, -): void { - registeredGroups = groups; -} - -/** - * Process all pending messages for a group. - * Called by the GroupQueue when it's this group's turn. - */ -async function processGroupMessages(chatJid: string): Promise { - const group = registeredGroups[chatJid]; - if (!group) return true; - - const channel = findChannel(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - return true; - } - - const isMainGroup = group.isMain === true; - - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const missedMessages = getMessagesSince( - chatJid, - sinceTimestamp, - ASSISTANT_NAME, - ); - - if (missedMessages.length === 0) return true; - - // For non-main groups, check if trigger is required and present - if (!isMainGroup && group.requiresTrigger !== false) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = missedMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) return true; - } - - const prompt = formatMessages(missedMessages); - const imageAttachments = parseImageReferences(missedMessages); - - // Advance cursor so the piping path in startMessageLoop won't re-fetch - // these messages. Save the old cursor so we can roll back on error. - const previousCursor = lastAgentTimestamp[chatJid] || ''; - lastAgentTimestamp[chatJid] = - missedMessages[missedMessages.length - 1].timestamp; - saveState(); - - logger.info( - { group: group.name, messageCount: missedMessages.length }, - 'Processing messages', - ); - - // Track idle timer for closing stdin when agent is idle - let idleTimer: ReturnType | null = null; - - const resetIdleTimer = () => { - if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(() => { - logger.debug( - { group: group.name }, - 'Idle timeout, closing container stdin', - ); - queue.closeStdin(chatJid); - }, IDLE_TIMEOUT); - }; - - await channel.setTyping?.(chatJid, true); - let hadError = false; - let outputSentToUser = false; - - const output = await runAgent(group, prompt, chatJid, imageAttachments, async (result) => { - // Streaming output callback — called for each agent result - if (result.result) { - const raw = - typeof result.result === 'string' - ? result.result - : JSON.stringify(result.result); - // Strip ... blocks — agent uses these for internal reasoning - const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); - logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); - if (text) { - await channel.sendMessage(chatJid, text); - outputSentToUser = true; - } - // Only reset idle timer on actual results, not session-update markers (result: null) - resetIdleTimer(); - } - - if (result.status === 'success') { - queue.notifyIdle(chatJid); - } - - if (result.status === 'error') { - hadError = true; - } - }); - - await channel.setTyping?.(chatJid, false); - if (idleTimer) clearTimeout(idleTimer); - - if (output === 'error' || hadError) { - // If we already sent output to the user, don't roll back the cursor — - // the user got their response and re-processing would send duplicates. - if (outputSentToUser) { - logger.warn( - { group: group.name }, - 'Agent error after output was sent, skipping cursor rollback to prevent duplicates', - ); - return true; - } - // Roll back cursor so retries can re-process these messages - lastAgentTimestamp[chatJid] = previousCursor; - saveState(); - logger.warn( - { group: group.name }, - 'Agent error, rolled back message cursor for retry', - ); - return false; - } - - return true; -} - -async function runAgent( - group: RegisteredGroup, - prompt: string, - chatJid: string, - imageAttachments: Array<{ relativePath: string; mediaType: string }>, - onOutput?: (output: ContainerOutput) => Promise, -): Promise<'success' | 'error'> { - const isMain = group.isMain === true; - const sessionId = sessions[group.folder]; - - // Update tasks snapshot for container to read (filtered by group) - const tasks = getAllTasks(); - writeTasksSnapshot( - group.folder, - isMain, - tasks.map((t) => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run, - })), - ); - - // Update available groups snapshot (main group only can see all groups) - const availableGroups = getAvailableGroups(); - writeGroupsSnapshot( - group.folder, - isMain, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); - - // Wrap onOutput to track session ID from streamed results - const wrappedOnOutput = onOutput - ? async (output: ContainerOutput) => { - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - await onOutput(output); - } - : undefined; - - try { - const output = await runContainerAgent( - group, - { - prompt, - sessionId, - groupFolder: group.folder, - chatJid, - isMain, - assistantName: ASSISTANT_NAME, - ...(imageAttachments.length > 0 && { imageAttachments }), - }, - (proc, containerName) => - queue.registerProcess(chatJid, proc, containerName, group.folder), - wrappedOnOutput, - ); - - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - - if (output.status === 'error') { - logger.error( - { group: group.name, error: output.error }, - 'Container agent error', - ); - return 'error'; - } - - return 'success'; - } catch (err) { - logger.error({ group: group.name, err }, 'Agent error'); - return 'error'; - } -} - -async function startMessageLoop(): Promise { - if (messageLoopRunning) { - logger.debug('Message loop already running, skipping duplicate start'); - return; - } - messageLoopRunning = true; - - logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); - - while (true) { - try { - const jids = Object.keys(registeredGroups); - const { messages, newTimestamp } = getNewMessages( - jids, - lastTimestamp, - ASSISTANT_NAME, - ); - - if (messages.length > 0) { - logger.info({ count: messages.length }, 'New messages'); - - // Advance the "seen" cursor for all messages immediately - lastTimestamp = newTimestamp; - saveState(); - - // Deduplicate by group - const messagesByGroup = new Map(); - for (const msg of messages) { - const existing = messagesByGroup.get(msg.chat_jid); - if (existing) { - existing.push(msg); - } else { - messagesByGroup.set(msg.chat_jid, [msg]); - } - } - - for (const [chatJid, groupMessages] of messagesByGroup) { - const group = registeredGroups[chatJid]; - if (!group) continue; - - const channel = findChannel(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - continue; - } - - const isMainGroup = group.isMain === true; - const needsTrigger = !isMainGroup && group.requiresTrigger !== false; - - // For non-main groups, only act on trigger messages. - // Non-trigger messages accumulate in DB and get pulled as - // context when a trigger eventually arrives. - if (needsTrigger) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = groupMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || - isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) continue; - } - - // Pull all messages since lastAgentTimestamp so non-trigger - // context that accumulated between triggers is included. - const allPending = getMessagesSince( - chatJid, - lastAgentTimestamp[chatJid] || '', - ASSISTANT_NAME, - ); - const messagesToSend = - allPending.length > 0 ? allPending : groupMessages; - const formatted = formatMessages(messagesToSend); - - if (queue.sendMessage(chatJid, formatted)) { - logger.debug( - { chatJid, count: messagesToSend.length }, - 'Piped messages to active container', - ); - lastAgentTimestamp[chatJid] = - messagesToSend[messagesToSend.length - 1].timestamp; - saveState(); - // Show typing indicator while the container processes the piped message - channel - .setTyping?.(chatJid, true) - ?.catch((err) => - logger.warn({ chatJid, err }, 'Failed to set typing indicator'), - ); - } else { - // No active container — enqueue for a new one - queue.enqueueMessageCheck(chatJid); - } - } - } - } catch (err) { - logger.error({ err }, 'Error in message loop'); - } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - } -} - -/** - * Startup recovery: check for unprocessed messages in registered groups. - * Handles crash between advancing lastTimestamp and processing messages. - */ -function recoverPendingMessages(): void { - for (const [chatJid, group] of Object.entries(registeredGroups)) { - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); - if (pending.length > 0) { - logger.info( - { group: group.name, pendingCount: pending.length }, - 'Recovery: found unprocessed messages', - ); - queue.enqueueMessageCheck(chatJid); - } - } -} - -function ensureContainerSystemRunning(): void { - ensureContainerRuntimeRunning(); - cleanupOrphans(); -} - -async function main(): Promise { - ensureContainerSystemRunning(); - initDatabase(); - logger.info('Database initialized'); - loadState(); - - // Graceful shutdown handlers - const shutdown = async (signal: string) => { - logger.info({ signal }, 'Shutdown signal received'); - await queue.shutdown(10000); - for (const ch of channels) await ch.disconnect(); - process.exit(0); - }; - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); - - // Channel callbacks (shared by all channels) - const channelOpts = { - onMessage: (chatJid: string, msg: NewMessage) => { - // Sender allowlist drop mode: discard messages from denied senders before storing - if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { - const cfg = loadSenderAllowlist(); - if ( - shouldDropMessage(chatJid, cfg) && - !isSenderAllowed(chatJid, msg.sender, cfg) - ) { - if (cfg.logDenied) { - logger.debug( - { chatJid, sender: msg.sender }, - 'sender-allowlist: dropping message (drop mode)', - ); - } - return; - } - } - storeMessage(msg); - }, - onChatMetadata: ( - chatJid: string, - timestamp: string, - name?: string, - channel?: string, - isGroup?: boolean, - ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup), - registeredGroups: () => registeredGroups, - }; - - // Create and connect all registered channels. - // Each channel self-registers via the barrel import above. - // Factories return null when credentials are missing, so unconfigured channels are skipped. - for (const channelName of getRegisteredChannelNames()) { - const factory = getChannelFactory(channelName)!; - const channel = factory(channelOpts); - if (!channel) { - logger.warn( - { channel: channelName }, - 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.', - ); - continue; - } - channels.push(channel); - await channel.connect(); - } - if (channels.length === 0) { - logger.fatal('No channels connected'); - process.exit(1); - } - - // Start subsystems (independently of connection handler) - startSchedulerLoop({ - registeredGroups: () => registeredGroups, - getSessions: () => sessions, - queue, - onProcess: (groupJid, proc, containerName, groupFolder) => - queue.registerProcess(groupJid, proc, containerName, groupFolder), - sendMessage: async (jid, rawText) => { - const channel = findChannel(channels, jid); - if (!channel) { - logger.warn({ jid }, 'No channel owns JID, cannot send message'); - return; - } - const text = formatOutbound(rawText); - if (text) await channel.sendMessage(jid, text); - }, - }); - startIpcWatcher({ - sendMessage: (jid, text) => { - const channel = findChannel(channels, jid); - if (!channel) throw new Error(`No channel for JID: ${jid}`); - return channel.sendMessage(jid, text); - }, - registeredGroups: () => registeredGroups, - registerGroup, - syncGroups: async (force: boolean) => { - await Promise.all( - channels - .filter((ch) => ch.syncGroups) - .map((ch) => ch.syncGroups!(force)), - ); - }, - getAvailableGroups, - writeGroupsSnapshot: (gf, im, ag, rj) => - writeGroupsSnapshot(gf, im, ag, rj), - }); - queue.setProcessMessagesFn(processGroupMessages); - recoverPendingMessages(); - startMessageLoop().catch((err) => { - logger.fatal({ err }, 'Message loop crashed unexpectedly'); - process.exit(1); - }); -} - -// Guard: only run when executed directly, not when imported by tests -const isDirectRun = - process.argv[1] && - new URL(import.meta.url).pathname === - new URL(`file://${process.argv[1]}`).pathname; - -if (isDirectRun) { - main().catch((err) => { - logger.error({ err }, 'Failed to start NanoClaw'); - process.exit(1); - }); -} diff --git a/.claude/skills/add-image-vision/modify/src/index.ts.intent.md b/.claude/skills/add-image-vision/modify/src/index.ts.intent.md deleted file mode 100644 index 195b618..0000000 --- a/.claude/skills/add-image-vision/modify/src/index.ts.intent.md +++ /dev/null @@ -1,24 +0,0 @@ -# Intent: src/index.ts - -## What Changed -- Added `import { parseImageReferences } from './image.js'` -- In `processGroupMessages`: extract image references after formatting, pass `imageAttachments` to `runAgent` -- In `runAgent`: added `imageAttachments` parameter, conditionally spread into `runContainerAgent` input - -## Key Sections -- **Imports** (top of file): parseImageReferences -- **processGroupMessages**: Image extraction, threading to runAgent -- **runAgent**: Signature change + imageAttachments in input - -## Invariants (must-keep) -- State management (lastTimestamp, sessions, registeredGroups, lastAgentTimestamp) -- loadState/saveState functions -- registerGroup function with folder validation -- getAvailableGroups function -- processGroupMessages trigger logic, cursor management, idle timer, error rollback with duplicate prevention -- runAgent task/group snapshot writes, session tracking, wrappedOnOutput -- startMessageLoop with dedup-by-group and piping logic -- recoverPendingMessages startup recovery -- main() with channel setup, scheduler, IPC watcher, queue -- ensureContainerSystemRunning using container-runtime abstraction -- Graceful shutdown with queue.shutdown diff --git a/.claude/skills/add-image-vision/tests/image-vision.test.ts b/.claude/skills/add-image-vision/tests/image-vision.test.ts deleted file mode 100644 index e575ed4..0000000 --- a/.claude/skills/add-image-vision/tests/image-vision.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -const SKILL_DIR = path.resolve(__dirname, '..'); - -describe('add-image-vision skill package', () => { - describe('manifest', () => { - let content: string; - - beforeAll(() => { - content = fs.readFileSync(path.join(SKILL_DIR, 'manifest.yaml'), 'utf-8'); - }); - - it('has a valid manifest.yaml', () => { - expect(fs.existsSync(path.join(SKILL_DIR, 'manifest.yaml'))).toBe(true); - expect(content).toContain('skill: add-image-vision'); - expect(content).toContain('version: 1.1.0'); - }); - - it('declares sharp as npm dependency', () => { - expect(content).toContain('sharp:'); - expect(content).toMatch(/sharp:\s*"\^0\.34/); - }); - - it('has no env_additions', () => { - expect(content).toContain('env_additions: []'); - }); - - it('lists all add files', () => { - expect(content).toContain('src/image.ts'); - expect(content).toContain('src/image.test.ts'); - }); - - it('lists all modify files', () => { - expect(content).toContain('src/channels/whatsapp.ts'); - expect(content).toContain('src/channels/whatsapp.test.ts'); - expect(content).toContain('src/container-runner.ts'); - expect(content).toContain('src/index.ts'); - expect(content).toContain('container/agent-runner/src/index.ts'); - }); - - it('has no dependencies', () => { - expect(content).toContain('depends: []'); - }); - }); - - describe('add/ files', () => { - it('includes src/image.ts with required exports', () => { - const filePath = path.join(SKILL_DIR, 'add', 'src', 'image.ts'); - expect(fs.existsSync(filePath)).toBe(true); - - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('export function isImageMessage'); - expect(content).toContain('export async function processImage'); - expect(content).toContain('export function parseImageReferences'); - expect(content).toContain('export interface ProcessedImage'); - expect(content).toContain('export interface ImageAttachment'); - expect(content).toContain("import sharp from 'sharp'"); - }); - - it('includes src/image.test.ts with test cases', () => { - const filePath = path.join(SKILL_DIR, 'add', 'src', 'image.test.ts'); - expect(fs.existsSync(filePath)).toBe(true); - - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('isImageMessage'); - expect(content).toContain('processImage'); - expect(content).toContain('parseImageReferences'); - }); - }); - - describe('modify/ files exist', () => { - const modifyFiles = [ - 'src/channels/whatsapp.ts', - 'src/channels/whatsapp.test.ts', - 'src/container-runner.ts', - 'src/index.ts', - 'container/agent-runner/src/index.ts', - ]; - - for (const file of modifyFiles) { - it(`includes modify/${file}`, () => { - const filePath = path.join(SKILL_DIR, 'modify', file); - expect(fs.existsSync(filePath)).toBe(true); - }); - } - }); - - describe('intent files exist', () => { - const intentFiles = [ - 'src/channels/whatsapp.ts.intent.md', - 'src/channels/whatsapp.test.ts.intent.md', - 'src/container-runner.ts.intent.md', - 'src/index.ts.intent.md', - 'container/agent-runner/src/index.ts.intent.md', - ]; - - for (const file of intentFiles) { - it(`includes modify/${file}`, () => { - const filePath = path.join(SKILL_DIR, 'modify', file); - expect(fs.existsSync(filePath)).toBe(true); - }); - } - }); - - describe('modify/src/channels/whatsapp.ts', () => { - let content: string; - - beforeAll(() => { - content = fs.readFileSync( - path.join(SKILL_DIR, 'modify', 'src', 'channels', 'whatsapp.ts'), - 'utf-8', - ); - }); - - it('imports image utilities', () => { - expect(content).toContain("from '../image.js'"); - expect(content).toContain('processImage'); - }); - - it('imports downloadMediaMessage', () => { - expect(content).toContain('downloadMediaMessage'); - expect(content).toContain("from '@whiskeysockets/baileys'"); - }); - - it('imports GROUPS_DIR from config', () => { - expect(content).toContain('GROUPS_DIR'); - }); - - it('uses let content for mutable assignment', () => { - expect(content).toMatch(/let content\s*=/); - }); - - it('includes image processing block', () => { - expect(content).toContain('processImage(buffer'); - expect(content).toContain('Image - download failed'); - }); - - it('preserves core WhatsAppChannel structure', () => { - expect(content).toContain('export class WhatsAppChannel implements Channel'); - expect(content).toContain('async connect()'); - expect(content).toContain('async sendMessage('); - expect(content).toContain('async syncGroupMetadata('); - expect(content).toContain('private async translateJid('); - expect(content).toContain('private async flushOutgoingQueue('); - }); - }); - - describe('modify/src/channels/whatsapp.test.ts', () => { - let content: string; - - beforeAll(() => { - content = fs.readFileSync( - path.join(SKILL_DIR, 'modify', 'src', 'channels', 'whatsapp.test.ts'), - 'utf-8', - ); - }); - - it('mocks image.js module', () => { - expect(content).toContain("vi.mock('../image.js'"); - expect(content).toContain('isImageMessage'); - expect(content).toContain('processImage'); - }); - - it('mocks downloadMediaMessage', () => { - expect(content).toContain('downloadMediaMessage'); - }); - - it('includes image test cases', () => { - expect(content).toContain('downloads and processes image attachments'); - expect(content).toContain('handles image without caption'); - expect(content).toContain('handles image download failure gracefully'); - expect(content).toContain('falls back to caption when processImage returns null'); - }); - - it('preserves all existing test sections', () => { - expect(content).toContain('connection lifecycle'); - expect(content).toContain('authentication'); - expect(content).toContain('reconnection'); - expect(content).toContain('message handling'); - expect(content).toContain('LID to JID translation'); - expect(content).toContain('outgoing message queue'); - expect(content).toContain('group metadata sync'); - expect(content).toContain('ownsJid'); - expect(content).toContain('setTyping'); - expect(content).toContain('channel properties'); - }); - - it('includes all media handling test sections', () => { - // Image tests present (core skill feature) - expect(content).toContain('downloads and processes image attachments'); - expect(content).toContain('handles image without caption'); - }); - }); - - describe('modify/src/container-runner.ts', () => { - it('adds imageAttachments to ContainerInput', () => { - const content = fs.readFileSync( - path.join(SKILL_DIR, 'modify', 'src', 'container-runner.ts'), - 'utf-8', - ); - expect(content).toContain('imageAttachments?'); - expect(content).toContain('relativePath: string'); - expect(content).toContain('mediaType: string'); - }); - - it('preserves core container-runner structure', () => { - const content = fs.readFileSync( - path.join(SKILL_DIR, 'modify', 'src', 'container-runner.ts'), - 'utf-8', - ); - expect(content).toContain('export async function runContainerAgent'); - expect(content).toContain('ContainerInput'); - }); - }); - - describe('modify/src/index.ts', () => { - let content: string; - - beforeAll(() => { - content = fs.readFileSync( - path.join(SKILL_DIR, 'modify', 'src', 'index.ts'), - 'utf-8', - ); - }); - - it('imports parseImageReferences', () => { - expect(content).toContain("import { parseImageReferences } from './image.js'"); - }); - - it('calls parseImageReferences in processGroupMessages', () => { - expect(content).toContain('parseImageReferences(missedMessages)'); - }); - - it('passes imageAttachments to runAgent', () => { - expect(content).toContain('imageAttachments'); - expect(content).toMatch(/runAgent\(group,\s*prompt,\s*chatJid,\s*imageAttachments/); - }); - - it('spreads imageAttachments into container input', () => { - expect(content).toContain('...(imageAttachments.length > 0 && { imageAttachments })'); - }); - - it('preserves core index.ts structure', () => { - expect(content).toContain('processGroupMessages'); - expect(content).toContain('startMessageLoop'); - expect(content).toContain('async function main()'); - }); - }); - - describe('modify/container/agent-runner/src/index.ts', () => { - let content: string; - - beforeAll(() => { - content = fs.readFileSync( - path.join(SKILL_DIR, 'modify', 'container', 'agent-runner', 'src', 'index.ts'), - 'utf-8', - ); - }); - - it('defines ContentBlock types', () => { - expect(content).toContain('interface ImageContentBlock'); - expect(content).toContain('interface TextContentBlock'); - expect(content).toContain('type ContentBlock = ImageContentBlock | TextContentBlock'); - }); - - it('adds imageAttachments to ContainerInput', () => { - expect(content).toContain('imageAttachments?'); - }); - - it('adds pushMultimodal to MessageStream', () => { - expect(content).toContain('pushMultimodal(content: ContentBlock[])'); - }); - - it('includes image loading logic in runQuery', () => { - expect(content).toContain('containerInput.imageAttachments'); - expect(content).toContain("path.join('/workspace/group', img.relativePath)"); - expect(content).toContain("toString('base64')"); - expect(content).toContain('stream.pushMultimodal(blocks)'); - }); - - it('preserves core structure', () => { - expect(content).toContain('async function runQuery'); - expect(content).toContain('class MessageStream'); - expect(content).toContain('function writeOutput'); - expect(content).toContain('function createPreCompactHook'); - expect(content).toContain('function createSanitizeBashHook'); - expect(content).toContain('async function main'); - }); - - it('preserves core agent-runner exports', () => { - expect(content).toContain('async function main'); - expect(content).toContain('function writeOutput'); - }); - }); -}); diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md deleted file mode 100644 index 2205a58..0000000 --- a/.claude/skills/add-ollama-tool/SKILL.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -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. ---- - -# Add Ollama Integration - -This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models. - -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 - -Read `.nanoclaw/state.yaml`. If `ollama` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place. - -### 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. - -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 - -Run the skills engine to apply this skill's code package. - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist yet: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-ollama-tool -``` - -This deterministically: -- Adds `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server) -- Adds `scripts/ollama-watch.sh` (macOS notification watcher) -- Three-way merges Ollama MCP config into `container/agent-runner/src/index.ts` (allowedTools + mcpServers) -- Three-way merges `[OLLAMA]` log surfacing into `src/container-runner.ts` -- Records the application in `.nanoclaw/state.yaml` - -If the apply reports merge conflicts, read the intent files: -- `modify/container/agent-runner/src/index.ts.intent.md` — what changed and invariants -- `modify/src/container-runner.ts.intent.md` — what changed and invariants - -### 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 - -```bash -npm run build -./container/build.sh -``` - -Build must be clean before proceeding. - -## Phase 3: Configure - -### 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`: - -```bash -OLLAMA_HOST=http://your-ollama-host:11434 -``` - -### 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: ..." diff --git a/.claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts b/.claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts deleted file mode 100644 index 7d29bb2..0000000 --- a/.claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Ollama MCP Server for NanoClaw - * Exposes local Ollama models as tools for the container agent. - * Uses host.docker.internal to reach the host's Ollama instance from Docker. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; - -import fs from 'fs'; -import path from 'path'; - -const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://host.docker.internal:11434'; -const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json'; - -function log(msg: string): void { - console.error(`[OLLAMA] ${msg}`); -} - -function writeStatus(status: string, detail?: string): void { - try { - const data = { status, detail, timestamp: new Date().toISOString() }; - const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`; - fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true }); - fs.writeFileSync(tmpPath, JSON.stringify(data)); - fs.renameSync(tmpPath, OLLAMA_STATUS_FILE); - } catch { /* best-effort */ } -} - -async function ollamaFetch(path: string, options?: RequestInit): Promise { - const url = `${OLLAMA_HOST}${path}`; - try { - return await fetch(url, options); - } catch (err) { - // Fallback to localhost if host.docker.internal fails - if (OLLAMA_HOST.includes('host.docker.internal')) { - const fallbackUrl = url.replace('host.docker.internal', 'localhost'); - return await fetch(fallbackUrl, options); - } - throw err; - } -} - -const server = new McpServer({ - name: 'ollama', - version: '1.0.0', -}); - -server.tool( - 'ollama_list_models', - 'List all locally installed Ollama models. Use this to see which models are available before calling ollama_generate.', - {}, - async () => { - log('Listing models...'); - writeStatus('listing', 'Listing available models'); - try { - const res = await ollamaFetch('/api/tags'); - if (!res.ok) { - return { - content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }], - isError: true, - }; - } - - const data = await res.json() as { models?: Array<{ name: string; size: number; modified_at: string }> }; - const models = data.models || []; - - if (models.length === 0) { - return { content: [{ type: 'text' as const, text: 'No models installed. Run `ollama pull ` on the host to install one.' }] }; - } - - const list = models - .map(m => `- ${m.name} (${(m.size / 1e9).toFixed(1)}GB)`) - .join('\n'); - - log(`Found ${models.length} models`); - return { content: [{ type: 'text' as const, text: `Installed models:\n${list}` }] }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - }, -); - -server.tool( - 'ollama_generate', - 'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.', - { - model: z.string().describe('The model name (e.g., "llama3.2", "mistral", "gemma2")'), - prompt: z.string().describe('The prompt to send to the model'), - system: z.string().optional().describe('Optional system prompt to set model behavior'), - }, - async (args) => { - log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`); - writeStatus('generating', `Generating with ${args.model}`); - try { - const body: Record = { - model: args.model, - prompt: args.prompt, - stream: false, - }; - if (args.system) { - body.system = args.system; - } - - const res = await ollamaFetch('/api/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const errorText = await res.text(); - return { - content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], - isError: true, - }; - } - - const data = await res.json() as { response: string; total_duration?: number; eval_count?: number }; - - let meta = ''; - if (data.total_duration) { - const secs = (data.total_duration / 1e9).toFixed(1); - meta = `\n\n[${args.model} | ${secs}s${data.eval_count ? ` | ${data.eval_count} tokens` : ''}]`; - log(`<<< Done: ${args.model} | ${secs}s | ${data.eval_count || '?'} tokens | ${data.response.length} chars`); - writeStatus('done', `${args.model} | ${secs}s | ${data.eval_count || '?'} tokens`); - } else { - log(`<<< Done: ${args.model} | ${data.response.length} chars`); - writeStatus('done', `${args.model} | ${data.response.length} chars`); - } - - return { content: [{ type: 'text' as const, text: data.response + meta }] }; - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}` }], - isError: true, - }; - } - }, -); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh b/.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh deleted file mode 100755 index 1aa4a93..0000000 --- a/.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash -# Watch NanoClaw IPC for Ollama activity and show macOS notifications -# Usage: ./scripts/ollama-watch.sh - -cd "$(dirname "$0")/.." || exit 1 - -echo "Watching for Ollama activity..." -echo "Press Ctrl+C to stop" -echo "" - -LAST_TIMESTAMP="" - -while true; do - # Check all group IPC dirs for ollama_status.json - for status_file in data/ipc/*/ollama_status.json; do - [ -f "$status_file" ] || continue - - TIMESTAMP=$(python3 -c "import json; print(json.load(open('$status_file'))['timestamp'])" 2>/dev/null) - [ -z "$TIMESTAMP" ] && continue - [ "$TIMESTAMP" = "$LAST_TIMESTAMP" ] && continue - - LAST_TIMESTAMP="$TIMESTAMP" - STATUS=$(python3 -c "import json; d=json.load(open('$status_file')); print(d['status'])" 2>/dev/null) - DETAIL=$(python3 -c "import json; d=json.load(open('$status_file')); print(d.get('detail',''))" 2>/dev/null) - - case "$STATUS" in - generating) - osascript -e "display notification \"$DETAIL\" with title \"NanoClaw → Ollama\" sound name \"Submarine\"" 2>/dev/null - echo "$(date +%H:%M:%S) 🔄 $DETAIL" - ;; - done) - osascript -e "display notification \"$DETAIL\" with title \"NanoClaw ← Ollama ✓\" sound name \"Glass\"" 2>/dev/null - echo "$(date +%H:%M:%S) ✅ $DETAIL" - ;; - listing) - echo "$(date +%H:%M:%S) 📋 Listing models..." - ;; - esac - done - sleep 0.5 -done diff --git a/.claude/skills/add-ollama-tool/manifest.yaml b/.claude/skills/add-ollama-tool/manifest.yaml deleted file mode 100644 index 6ce813a..0000000 --- a/.claude/skills/add-ollama-tool/manifest.yaml +++ /dev/null @@ -1,17 +0,0 @@ -skill: ollama -version: 1.0.0 -description: "Local Ollama model inference via MCP server" -core_version: 0.1.0 -adds: - - container/agent-runner/src/ollama-mcp-stdio.ts - - scripts/ollama-watch.sh -modifies: - - container/agent-runner/src/index.ts - - src/container-runner.ts -structured: - npm_dependencies: {} - env_additions: - - OLLAMA_HOST -conflicts: [] -depends: [] -test: "npm run build" diff --git a/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts deleted file mode 100644 index 7432393..0000000 --- a/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts +++ /dev/null @@ -1,593 +0,0 @@ -/** - * NanoClaw Agent Runner - * Runs inside a container, 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/ - * Files: {type:"message", text:"..."}.json — polled and consumed - * Sentinel: /workspace/ipc/input/_close — signals session end - * - * Stdout protocol: - * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. - * Multiple results may be emitted (one per agent teams result). - * Final marker after loop ends signals completion. - */ - -import fs from 'fs'; -import path from 'path'; -import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; -import { fileURLToPath } from 'url'; - -interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - secrets?: Record; -} - -interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface SessionEntry { - sessionId: string; - fullPath: string; - summary: string; - firstPrompt: string; -} - -interface SessionsIndex { - entries: SessionEntry[]; -} - -interface SDKUserMessage { - type: 'user'; - message: { role: 'user'; content: string }; - parent_tool_use_id: null; - session_id: string; -} - -const IPC_INPUT_DIR = '/workspace/ipc/input'; -const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); -const IPC_POLL_MS = 500; - -/** - * Push-based async iterable for streaming user messages to the SDK. - * Keeps the iterable alive until end() is called, preventing isSingleUserTurn. - */ -class MessageStream { - private queue: SDKUserMessage[] = []; - private waiting: (() => void) | null = null; - private done = false; - - push(text: string): void { - this.queue.push({ - type: 'user', - message: { role: 'user', content: text }, - parent_tool_use_id: null, - session_id: '', - }); - this.waiting?.(); - } - - end(): void { - this.done = true; - this.waiting?.(); - } - - async *[Symbol.asyncIterator](): AsyncGenerator { - while (true) { - while (this.queue.length > 0) { - yield this.queue.shift()!; - } - if (this.done) return; - await new Promise(r => { this.waiting = r; }); - this.waiting = null; - } - } -} - -async function readStdin(): Promise { - return new Promise((resolve, reject) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', reject); - }); -} - -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -function writeOutput(output: ContainerOutput): void { - console.log(OUTPUT_START_MARKER); - console.log(JSON.stringify(output)); - console.log(OUTPUT_END_MARKER); -} - -function log(message: string): void { - console.error(`[agent-runner] ${message}`); -} - -function getSessionSummary(sessionId: string, transcriptPath: string): string | null { - const projectDir = path.dirname(transcriptPath); - const indexPath = path.join(projectDir, 'sessions-index.json'); - - if (!fs.existsSync(indexPath)) { - log(`Sessions index not found at ${indexPath}`); - return null; - } - - try { - const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); - const entry = index.entries.find(e => e.sessionId === sessionId); - if (entry?.summary) { - return entry.summary; - } - } catch (err) { - log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); - } - - return null; -} - -/** - * Archive the full transcript to conversations/ before compaction. - */ -function createPreCompactHook(assistantName?: string): HookCallback { - return async (input, _toolUseId, _context) => { - const preCompact = input as PreCompactHookInput; - const transcriptPath = preCompact.transcript_path; - const sessionId = preCompact.session_id; - - if (!transcriptPath || !fs.existsSync(transcriptPath)) { - log('No transcript found for archiving'); - return {}; - } - - try { - const content = fs.readFileSync(transcriptPath, 'utf-8'); - const messages = parseTranscript(content); - - if (messages.length === 0) { - log('No messages to archive'); - return {}; - } - - const summary = getSessionSummary(sessionId, transcriptPath); - const name = summary ? sanitizeFilename(summary) : generateFallbackName(); - - const conversationsDir = '/workspace/group/conversations'; - fs.mkdirSync(conversationsDir, { recursive: true }); - - const date = new Date().toISOString().split('T')[0]; - const filename = `${date}-${name}.md`; - const filePath = path.join(conversationsDir, filename); - - const markdown = formatTranscriptMarkdown(messages, summary, assistantName); - fs.writeFileSync(filePath, markdown); - - log(`Archived conversation to ${filePath}`); - } catch (err) { - log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); - } - - return {}; - }; -} - -// Secrets to strip from Bash tool subprocess environments. -// These are needed by claude-code for API auth but should never -// be visible to commands Kit runs. -const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; - -function createSanitizeBashHook(): HookCallback { - return async (input, _toolUseId, _context) => { - const preInput = input as PreToolUseHookInput; - const command = (preInput.tool_input as { command?: string })?.command; - if (!command) return {}; - - const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `; - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - updatedInput: { - ...(preInput.tool_input as Record), - command: unsetPrefix + command, - }, - }, - }; - }; -} - -function sanitizeFilename(summary: string): string { - return summary - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 50); -} - -function generateFallbackName(): string { - const time = new Date(); - return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; -} - -interface ParsedMessage { - role: 'user' | 'assistant'; - content: string; -} - -function parseTranscript(content: string): ParsedMessage[] { - const messages: ParsedMessage[] = []; - - for (const line of content.split('\n')) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - if (entry.type === 'user' && entry.message?.content) { - const text = typeof entry.message.content === 'string' - ? entry.message.content - : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); - if (text) messages.push({ role: 'user', content: text }); - } else if (entry.type === 'assistant' && entry.message?.content) { - const textParts = entry.message.content - .filter((c: { type: string }) => c.type === 'text') - .map((c: { text: string }) => c.text); - const text = textParts.join(''); - if (text) messages.push({ role: 'assistant', content: text }); - } - } catch { - } - } - - return messages; -} - -function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { - const now = new Date(); - const formatDateTime = (d: Date) => d.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }); - - const lines: string[] = []; - lines.push(`# ${title || 'Conversation'}`); - lines.push(''); - lines.push(`Archived: ${formatDateTime(now)}`); - lines.push(''); - lines.push('---'); - lines.push(''); - - for (const msg of messages) { - const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant'); - const content = msg.content.length > 2000 - ? msg.content.slice(0, 2000) + '...' - : msg.content; - lines.push(`**${sender}**: ${content}`); - lines.push(''); - } - - return lines.join('\n'); -} - -/** - * Check for _close sentinel. - */ -function shouldClose(): boolean { - if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - return true; - } - return false; -} - -/** - * Drain all pending IPC input messages. - * Returns messages found, or empty array. - */ -function drainIpcInput(): string[] { - try { - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - const files = fs.readdirSync(IPC_INPUT_DIR) - .filter(f => f.endsWith('.json')) - .sort(); - - const messages: string[] = []; - for (const file of files) { - const filePath = path.join(IPC_INPUT_DIR, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - fs.unlinkSync(filePath); - if (data.type === 'message' && data.text) { - messages.push(data.text); - } - } catch (err) { - log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`); - try { fs.unlinkSync(filePath); } catch { /* ignore */ } - } - } - return messages; - } catch (err) { - log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); - return []; - } -} - -/** - * Wait for a new IPC message or _close sentinel. - * Returns the messages as a single string, or null if _close. - */ -function waitForIpcMessage(): Promise { - return new Promise((resolve) => { - const poll = () => { - if (shouldClose()) { - resolve(null); - return; - } - const messages = drainIpcInput(); - if (messages.length > 0) { - resolve(messages.join('\n')); - return; - } - setTimeout(poll, IPC_POLL_MS); - }; - poll(); - }); -} - -/** - * Run a single query and stream results via writeOutput. - * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, - * allowing agent teams subagents to run to completion. - * Also pipes IPC messages into the stream during the query. - */ -async function runQuery( - prompt: string, - sessionId: string | undefined, - mcpServerPath: string, - containerInput: ContainerInput, - sdkEnv: Record, - resumeAt?: string, -): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> { - const stream = new MessageStream(); - stream.push(prompt); - - // Poll IPC for follow-up messages and _close sentinel during the query - let ipcPolling = true; - let closedDuringQuery = false; - const pollIpcDuringQuery = () => { - if (!ipcPolling) return; - if (shouldClose()) { - log('Close sentinel detected during query, ending stream'); - closedDuringQuery = true; - stream.end(); - ipcPolling = false; - return; - } - const messages = drainIpcInput(); - for (const text of messages) { - log(`Piping IPC message into active query (${text.length} chars)`); - stream.push(text); - } - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - }; - setTimeout(pollIpcDuringQuery, IPC_POLL_MS); - - let newSessionId: string | undefined; - let lastAssistantUuid: string | undefined; - let messageCount = 0; - let resultCount = 0; - - // Load global CLAUDE.md as additional system context (shared across all groups) - const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; - let globalClaudeMd: string | undefined; - if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { - globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); - } - - // Discover additional directories mounted at /workspace/extra/* - // These are passed to the SDK so their CLAUDE.md files are loaded automatically - const extraDirs: string[] = []; - const extraBase = '/workspace/extra'; - if (fs.existsSync(extraBase)) { - for (const entry of fs.readdirSync(extraBase)) { - const fullPath = path.join(extraBase, entry); - if (fs.statSync(fullPath).isDirectory()) { - extraDirs.push(fullPath); - } - } - } - if (extraDirs.length > 0) { - log(`Additional directories: ${extraDirs.join(', ')}`); - } - - for await (const message of query({ - prompt: stream, - options: { - cwd: '/workspace/group', - additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, - resume: sessionId, - resumeSessionAt: resumeAt, - systemPrompt: globalClaudeMd - ? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd } - : undefined, - allowedTools: [ - 'Bash', - 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'Task', 'TaskOutput', 'TaskStop', - 'TeamCreate', 'TeamDelete', 'SendMessage', - 'TodoWrite', 'ToolSearch', 'Skill', - 'NotebookEdit', - 'mcp__nanoclaw__*', - 'mcp__ollama__*' - ], - env: sdkEnv, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - settingSources: ['project', 'user'], - mcpServers: { - nanoclaw: { - command: 'node', - args: [mcpServerPath], - env: { - NANOCLAW_CHAT_JID: containerInput.chatJid, - NANOCLAW_GROUP_FOLDER: containerInput.groupFolder, - NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', - }, - }, - ollama: { - command: 'node', - args: [path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')], - }, - }, - hooks: { - PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], - PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], - }, - } - })) { - messageCount++; - const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; - log(`[msg #${messageCount}] type=${msgType}`); - - if (message.type === 'assistant' && 'uuid' in message) { - lastAssistantUuid = (message as { uuid: string }).uuid; - } - - if (message.type === 'system' && message.subtype === 'init') { - newSessionId = message.session_id; - log(`Session initialized: ${newSessionId}`); - } - - if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { - const tn = message as { task_id: string; status: string; summary: string }; - log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); - } - - if (message.type === 'result') { - resultCount++; - const textResult = 'result' in message ? (message as { result?: string }).result : null; - log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`); - writeOutput({ - status: 'success', - result: textResult || null, - newSessionId - }); - } - } - - ipcPolling = false; - log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`); - return { newSessionId, lastAssistantUuid, closedDuringQuery }; -} - -async function main(): Promise { - let containerInput: ContainerInput; - - try { - const stdinData = await readStdin(); - containerInput = JSON.parse(stdinData); - // Delete the temp file the entrypoint wrote — it contains secrets - 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); - } - - // Build SDK env: merge secrets into process.env for the SDK only. - // Secrets never touch process.env itself, so Bash subprocesses can't see them. - const sdkEnv: Record = { ...process.env }; - for (const [key, value] of Object.entries(containerInput.secrets || {})) { - sdkEnv[key] = value; - } - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); - - let sessionId = containerInput.sessionId; - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - - // Clean up stale _close sentinel from previous container runs - try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } - - // Build initial prompt (drain any pending IPC messages too) - let prompt = containerInput.prompt; - if (containerInput.isScheduledTask) { - prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`; - } - const pending = drainIpcInput(); - if (pending.length > 0) { - log(`Draining ${pending.length} pending IPC messages into initial prompt`); - prompt += '\n' + pending.join('\n'); - } - - // Query loop: run query → wait for IPC message → run new query → repeat - let resumeAt: string | undefined; - try { - while (true) { - log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`); - - const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt); - if (queryResult.newSessionId) { - sessionId = queryResult.newSessionId; - } - if (queryResult.lastAssistantUuid) { - resumeAt = queryResult.lastAssistantUuid; - } - - // If _close was consumed during the query, exit immediately. - // Don't emit a session-update marker (it would reset the host's - // idle timer and cause a 30-min delay before the next _close). - if (queryResult.closedDuringQuery) { - log('Close sentinel consumed during query, exiting'); - break; - } - - // Emit session update so host can track it - writeOutput({ status: 'success', result: null, newSessionId: sessionId }); - - log('Query ended, waiting for next IPC message...'); - - // Wait for the next message or _close sentinel - const nextMessage = await waitForIpcMessage(); - if (nextMessage === null) { - log('Close sentinel received, exiting'); - break; - } - - log(`Got new message (${nextMessage.length} chars), starting new query`); - prompt = nextMessage; - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - log(`Agent error: ${errorMessage}`); - writeOutput({ - status: 'error', - result: null, - newSessionId: sessionId, - error: errorMessage - }); - process.exit(1); - } -} - -main(); diff --git a/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md deleted file mode 100644 index a657ef5..0000000 --- a/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md +++ /dev/null @@ -1,23 +0,0 @@ -# Intent: container/agent-runner/src/index.ts modifications - -## What changed -Added Ollama MCP server configuration so the container agent can call local Ollama models as tools. - -## Key sections - -### allowedTools array (inside runQuery → options) -- Added: `'mcp__ollama__*'` to the allowedTools array (after `'mcp__nanoclaw__*'`) - -### mcpServers object (inside runQuery → options) -- Added: `ollama` entry as a stdio MCP server - - command: `'node'` - - args: resolves to `ollama-mcp-stdio.js` in the same directory as `ipc-mcp-stdio.js` - - Uses `path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')` to compute the path - -## Invariants (must-keep) -- All existing allowedTools entries unchanged -- nanoclaw MCP server config unchanged -- All other query options (permissionMode, hooks, env, etc.) unchanged -- MessageStream class unchanged -- IPC polling logic unchanged -- Session management unchanged diff --git a/.claude/skills/add-ollama-tool/modify/src/container-runner.ts b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts deleted file mode 100644 index 2324cde..0000000 --- a/.claude/skills/add-ollama-tool/modify/src/container-runner.ts +++ /dev/null @@ -1,708 +0,0 @@ -/** - * Container Runner for NanoClaw - * Spawns agent execution in containers and handles IPC - */ -import { ChildProcess, exec, spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { - CONTAINER_IMAGE, - CONTAINER_MAX_OUTPUT_SIZE, - CONTAINER_TIMEOUT, - DATA_DIR, - GROUPS_DIR, - IDLE_TIMEOUT, - TIMEZONE, -} from './config.js'; -import { readEnvFile } from './env.js'; -import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; -import { logger } from './logger.js'; -import { - CONTAINER_RUNTIME_BIN, - readonlyMountArgs, - stopContainer, -} from './container-runtime.js'; -import { validateAdditionalMounts } from './mount-security.js'; -import { RegisteredGroup } from './types.js'; - -// Sentinel markers for robust output parsing (must match agent-runner) -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -export interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; - secrets?: Record; -} - -export interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface VolumeMount { - hostPath: string; - containerPath: string; - readonly: boolean; -} - -function buildVolumeMounts( - group: RegisteredGroup, - isMain: boolean, -): VolumeMount[] { - const mounts: VolumeMount[] = []; - const projectRoot = process.cwd(); - const groupDir = resolveGroupFolderPath(group.folder); - - if (isMain) { - // Main gets the project root read-only. Writable paths the agent needs - // (group folder, IPC, .claude/) are mounted separately below. - // Read-only prevents the agent from modifying host application code - // (src/, dist/, package.json, etc.) which would bypass the sandbox - // entirely on next restart. - mounts.push({ - hostPath: projectRoot, - containerPath: '/workspace/project', - readonly: true, - }); - - // Shadow .env so the agent cannot read secrets from the mounted project root. - // Secrets are passed via stdin instead (see readSecrets()). - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - mounts.push({ - hostPath: '/dev/null', - containerPath: '/workspace/project/.env', - readonly: true, - }); - } - - // Main also gets its group folder as the working directory - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - } else { - // Other groups only get their own folder - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - - // Global memory directory (read-only for non-main) - // Only directory mounts are supported, not file mounts - const globalDir = path.join(GROUPS_DIR, 'global'); - if (fs.existsSync(globalDir)) { - mounts.push({ - hostPath: globalDir, - containerPath: '/workspace/global', - readonly: true, - }); - } - } - - // Per-group Claude sessions directory (isolated from other groups) - // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - '.claude', - ); - fs.mkdirSync(groupSessionsDir, { recursive: true }); - const settingsFile = path.join(groupSessionsDir, 'settings.json'); - if (!fs.existsSync(settingsFile)) { - fs.writeFileSync( - settingsFile, - JSON.stringify( - { - env: { - // Enable agent swarms (subagent orchestration) - // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - // Load CLAUDE.md from additional mounted directories - // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - // Enable Claude's memory feature (persists user preferences between sessions) - // https://code.claude.com/docs/en/memory#manage-auto-memory - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, - null, - 2, - ) + '\n', - ); - } - - // Sync skills from container/skills/ into each group's .claude/skills/ - const skillsSrc = path.join(process.cwd(), 'container', 'skills'); - const skillsDst = path.join(groupSessionsDir, 'skills'); - if (fs.existsSync(skillsSrc)) { - for (const skillDir of fs.readdirSync(skillsSrc)) { - const srcDir = path.join(skillsSrc, skillDir); - if (!fs.statSync(srcDir).isDirectory()) continue; - const dstDir = path.join(skillsDst, skillDir); - fs.cpSync(srcDir, dstDir, { recursive: true }); - } - } - mounts.push({ - hostPath: groupSessionsDir, - containerPath: '/home/node/.claude', - readonly: false, - }); - - // Per-group IPC namespace: each group gets its own IPC directory - // This prevents cross-group privilege escalation via IPC - const groupIpcDir = resolveGroupIpcPath(group.folder); - fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); - mounts.push({ - hostPath: groupIpcDir, - containerPath: '/workspace/ipc', - readonly: false, - }); - - // Copy agent-runner source into a per-group writable location so agents - // can customize it (add tools, change behavior) without affecting other - // groups. Recompiled on container startup via entrypoint.sh. - const agentRunnerSrc = path.join( - projectRoot, - 'container', - 'agent-runner', - 'src', - ); - const groupAgentRunnerDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - 'agent-runner-src', - ); - if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); - } - mounts.push({ - hostPath: groupAgentRunnerDir, - containerPath: '/app/src', - readonly: false, - }); - - // Additional mounts validated against external allowlist (tamper-proof from containers) - if (group.containerConfig?.additionalMounts) { - const validatedMounts = validateAdditionalMounts( - group.containerConfig.additionalMounts, - group.name, - isMain, - ); - mounts.push(...validatedMounts); - } - - return mounts; -} - -/** - * Read allowed secrets from .env for passing to the container via stdin. - * Secrets are never written to disk or mounted as files. - */ -function readSecrets(): Record { - return readEnvFile([ - 'CLAUDE_CODE_OAUTH_TOKEN', - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_BASE_URL', - 'ANTHROPIC_AUTH_TOKEN', - ]); -} - -function buildContainerArgs( - mounts: VolumeMount[], - containerName: string, -): string[] { - const args: string[] = ['run', '-i', '--rm', '--name', containerName]; - - // Pass host timezone so container's local time matches the user's - args.push('-e', `TZ=${TIMEZONE}`); - - // Run as host user so bind-mounted files are accessible. - // Skip when running as root (uid 0), as the container's node user (uid 1000), - // or when getuid is unavailable (native Windows without WSL). - const hostUid = process.getuid?.(); - const hostGid = process.getgid?.(); - if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { - args.push('--user', `${hostUid}:${hostGid}`); - args.push('-e', 'HOME=/home/node'); - } - - for (const mount of mounts) { - if (mount.readonly) { - args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); - } else { - args.push('-v', `${mount.hostPath}:${mount.containerPath}`); - } - } - - args.push(CONTAINER_IMAGE); - - return args; -} - -export async function runContainerAgent( - group: RegisteredGroup, - input: ContainerInput, - onProcess: (proc: ChildProcess, containerName: string) => void, - onOutput?: (output: ContainerOutput) => Promise, -): Promise { - const startTime = Date.now(); - - const groupDir = resolveGroupFolderPath(group.folder); - fs.mkdirSync(groupDir, { recursive: true }); - - const mounts = buildVolumeMounts(group, input.isMain); - const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); - const containerName = `nanoclaw-${safeName}-${Date.now()}`; - const containerArgs = buildContainerArgs(mounts, containerName); - - logger.debug( - { - group: group.name, - containerName, - mounts: mounts.map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ), - containerArgs: containerArgs.join(' '), - }, - 'Container mount configuration', - ); - - logger.info( - { - group: group.name, - containerName, - mountCount: mounts.length, - isMain: input.isMain, - }, - 'Spawning container agent', - ); - - const logsDir = path.join(groupDir, 'logs'); - fs.mkdirSync(logsDir, { recursive: true }); - - return new Promise((resolve) => { - const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - onProcess(container, containerName); - - let stdout = ''; - let stderr = ''; - let stdoutTruncated = false; - let stderrTruncated = false; - - // Pass secrets via stdin (never written to disk or mounted as files) - input.secrets = readSecrets(); - container.stdin.write(JSON.stringify(input)); - container.stdin.end(); - // Remove secrets from input so they don't appear in logs - delete input.secrets; - - // Streaming output: parse OUTPUT_START/END marker pairs as they arrive - let parseBuffer = ''; - let newSessionId: string | undefined; - let outputChain = Promise.resolve(); - - container.stdout.on('data', (data) => { - const chunk = data.toString(); - - // Always accumulate for logging - if (!stdoutTruncated) { - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length; - if (chunk.length > remaining) { - stdout += chunk.slice(0, remaining); - stdoutTruncated = true; - logger.warn( - { group: group.name, size: stdout.length }, - 'Container stdout truncated due to size limit', - ); - } else { - stdout += chunk; - } - } - - // Stream-parse for output markers - if (onOutput) { - parseBuffer += chunk; - let startIdx: number; - while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { - const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); - if (endIdx === -1) break; // Incomplete pair, wait for more data - - const jsonStr = parseBuffer - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); - parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); - - try { - const parsed: ContainerOutput = JSON.parse(jsonStr); - if (parsed.newSessionId) { - newSessionId = parsed.newSessionId; - } - hadStreamingOutput = true; - // Activity detected — reset the hard timeout - resetTimeout(); - // Call onOutput for all markers (including null results) - // so idle timers start even for "silent" query completions. - outputChain = outputChain.then(() => onOutput(parsed)); - } catch (err) { - logger.warn( - { group: group.name, error: err }, - 'Failed to parse streamed output chunk', - ); - } - } - } - }); - - container.stderr.on('data', (data) => { - const chunk = data.toString(); - const lines = chunk.trim().split('\n'); - for (const line of lines) { - if (!line) continue; - // Surface Ollama MCP activity at info level for visibility - if (line.includes('[OLLAMA]')) { - logger.info({ container: group.folder }, line); - } else { - logger.debug({ container: group.folder }, line); - } - } - // Don't reset timeout on stderr — SDK writes debug logs continuously. - // Timeout only resets on actual output (OUTPUT_MARKER in stdout). - if (stderrTruncated) return; - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length; - if (chunk.length > remaining) { - stderr += chunk.slice(0, remaining); - stderrTruncated = true; - logger.warn( - { group: group.name, size: stderr.length }, - 'Container stderr truncated due to size limit', - ); - } else { - stderr += chunk; - } - }); - - let timedOut = false; - let hadStreamingOutput = false; - const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; - // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the - // graceful _close sentinel has time to trigger before the hard kill fires. - const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000); - - const killOnTimeout = () => { - timedOut = true; - logger.error( - { group: group.name, containerName }, - 'Container timeout, stopping gracefully', - ); - exec(stopContainer(containerName), { timeout: 15000 }, (err) => { - if (err) { - logger.warn( - { group: group.name, containerName, err }, - 'Graceful stop failed, force killing', - ); - container.kill('SIGKILL'); - } - }); - }; - - let timeout = setTimeout(killOnTimeout, timeoutMs); - - // Reset the timeout whenever there's activity (streaming output) - const resetTimeout = () => { - clearTimeout(timeout); - timeout = setTimeout(killOnTimeout, timeoutMs); - }; - - container.on('close', (code) => { - clearTimeout(timeout); - const duration = Date.now() - startTime; - - if (timedOut) { - const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const timeoutLog = path.join(logsDir, `container-${ts}.log`); - fs.writeFileSync( - timeoutLog, - [ - `=== Container Run Log (TIMEOUT) ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `Container: ${containerName}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Had Streaming Output: ${hadStreamingOutput}`, - ].join('\n'), - ); - - // Timeout after output = idle cleanup, not failure. - // The agent already sent its response; this is just the - // container being reaped after the idle period expired. - if (hadStreamingOutput) { - logger.info( - { group: group.name, containerName, duration, code }, - 'Container timed out after output (idle cleanup)', - ); - outputChain.then(() => { - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - logger.error( - { group: group.name, containerName, duration, code }, - 'Container timed out with no output', - ); - - resolve({ - status: 'error', - result: null, - error: `Container timed out after ${configTimeout}ms`, - }); - return; - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logFile = path.join(logsDir, `container-${timestamp}.log`); - const isVerbose = - process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; - - const logLines = [ - `=== Container Run Log ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `IsMain: ${input.isMain}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Stdout Truncated: ${stdoutTruncated}`, - `Stderr Truncated: ${stderrTruncated}`, - ``, - ]; - - const isError = code !== 0; - - if (isVerbose || isError) { - logLines.push( - `=== Input ===`, - JSON.stringify(input, null, 2), - ``, - `=== Container Args ===`, - containerArgs.join(' '), - ``, - `=== Mounts ===`, - mounts - .map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ) - .join('\n'), - ``, - `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, - stderr, - ``, - `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, - stdout, - ); - } else { - logLines.push( - `=== Input Summary ===`, - `Prompt length: ${input.prompt.length} chars`, - `Session ID: ${input.sessionId || 'new'}`, - ``, - `=== Mounts ===`, - mounts - .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) - .join('\n'), - ``, - ); - } - - fs.writeFileSync(logFile, logLines.join('\n')); - logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); - - if (code !== 0) { - logger.error( - { - group: group.name, - code, - duration, - stderr, - stdout, - logFile, - }, - 'Container exited with error', - ); - - resolve({ - status: 'error', - result: null, - error: `Container exited with code ${code}: ${stderr.slice(-200)}`, - }); - return; - } - - // Streaming mode: wait for output chain to settle, return completion marker - if (onOutput) { - outputChain.then(() => { - logger.info( - { group: group.name, duration, newSessionId }, - 'Container completed (streaming mode)', - ); - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - // Legacy mode: parse the last output marker pair from accumulated stdout - try { - // Extract JSON between sentinel markers for robust parsing - const startIdx = stdout.indexOf(OUTPUT_START_MARKER); - const endIdx = stdout.indexOf(OUTPUT_END_MARKER); - - let jsonLine: string; - if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - jsonLine = stdout - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); - } else { - // Fallback: last non-empty line (backwards compatibility) - const lines = stdout.trim().split('\n'); - jsonLine = lines[lines.length - 1]; - } - - const output: ContainerOutput = JSON.parse(jsonLine); - - logger.info( - { - group: group.name, - duration, - status: output.status, - hasResult: !!output.result, - }, - 'Container completed', - ); - - resolve(output); - } catch (err) { - logger.error( - { - group: group.name, - stdout, - stderr, - error: err, - }, - 'Failed to parse container output', - ); - - resolve({ - status: 'error', - result: null, - error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, - }); - } - }); - - container.on('error', (err) => { - clearTimeout(timeout); - logger.error( - { group: group.name, containerName, error: err }, - 'Container spawn error', - ); - resolve({ - status: 'error', - result: null, - error: `Container spawn error: ${err.message}`, - }); - }); - }); -} - -export function writeTasksSnapshot( - groupFolder: string, - isMain: boolean, - tasks: Array<{ - id: string; - groupFolder: string; - prompt: string; - schedule_type: string; - schedule_value: string; - status: string; - next_run: string | null; - }>, -): void { - // Write filtered tasks to the group's IPC directory - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all tasks, others only see their own - const filteredTasks = isMain - ? tasks - : tasks.filter((t) => t.groupFolder === groupFolder); - - const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); - fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); -} - -export interface AvailableGroup { - jid: string; - name: string; - lastActivity: string; - isRegistered: boolean; -} - -/** - * Write available groups snapshot for the container to read. - * Only main group can see all available groups (for activation). - * Non-main groups only see their own registration status. - */ -export function writeGroupsSnapshot( - groupFolder: string, - isMain: boolean, - groups: AvailableGroup[], - registeredJids: Set, -): void { - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all groups; others see nothing (they can't activate groups) - const visibleGroups = isMain ? groups : []; - - const groupsFile = path.join(groupIpcDir, 'available_groups.json'); - fs.writeFileSync( - groupsFile, - JSON.stringify( - { - groups: visibleGroups, - lastSync: new Date().toISOString(), - }, - null, - 2, - ), - ); -} diff --git a/.claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md deleted file mode 100644 index 498ac6c..0000000 --- a/.claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md +++ /dev/null @@ -1,18 +0,0 @@ -# Intent: src/container-runner.ts modifications - -## What changed -Surface Ollama MCP server log lines at info level so they appear in `nanoclaw.log` for the monitoring watcher script. - -## Key sections - -### container.stderr handler (inside runContainerAgent) -- Changed: empty line check from `if (line)` to `if (!line) continue;` -- Added: `[OLLAMA]` tag detection — lines containing `[OLLAMA]` are logged at `logger.info` instead of `logger.debug` -- All other stderr lines remain at `logger.debug` level - -## Invariants (must-keep) -- Stderr truncation logic unchanged -- Timeout reset logic unchanged (stderr doesn't reset timeout) -- Stdout parsing logic unchanged -- Volume mount logic unchanged -- All other container lifecycle unchanged diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md deleted file mode 100644 index a394125..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 - -### Check if already applied - -Read `.nanoclaw/state.yaml`. If `add-pdf-reader` is in `applied_skills`, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-pdf-reader -``` - -This deterministically: -- Adds `container/skills/pdf-reader/SKILL.md` (agent-facing documentation) -- Adds `container/skills/pdf-reader/pdf-reader` (CLI script) -- Three-way merges `poppler-utils` + COPY into `container/Dockerfile` -- Three-way merges PDF attachment download into `src/channels/whatsapp.ts` -- Three-way merges PDF tests into `src/channels/whatsapp.test.ts` -- Records application in `.nanoclaw/state.yaml` - -If merge conflicts occur, read the intent files: -- `modify/container/Dockerfile.intent.md` -- `modify/src/channels/whatsapp.ts.intent.md` -- `modify/src/channels/whatsapp.test.ts.intent.md` - -### Validate - -```bash -npm test -npm run build -``` - -### Rebuild container - -```bash -./container/build.sh -``` - -### Restart service - -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 3: Verify - -### 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-pdf-reader/add/container/skills/pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/SKILL.md deleted file mode 100644 index 01fe2ca..0000000 --- a/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/SKILL.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -name: pdf-reader -description: Read and extract text from PDF files — documents, reports, contracts, spreadsheets. Use whenever you need to read PDF content, not just when explicitly asked. Handles local files, URLs, and WhatsApp attachments. -allowed-tools: Bash(pdf-reader:*) ---- - -# PDF Reader - -## Quick start - -```bash -pdf-reader extract report.pdf # Extract all text -pdf-reader extract report.pdf --layout # Preserve tables/columns -pdf-reader fetch https://example.com/doc.pdf # Download and extract -pdf-reader info report.pdf # Show metadata + size -pdf-reader list # List all PDFs in directory tree -``` - -## Commands - -### extract — Extract text from PDF - -```bash -pdf-reader extract # Full text to stdout -pdf-reader extract --layout # Preserve layout (tables, columns) -pdf-reader extract --pages 1-5 # Pages 1 through 5 -pdf-reader extract --pages 3-3 # Single page (page 3) -pdf-reader extract --layout --pages 2-10 # Layout + page range -``` - -Options: -- `--layout` — Maintains spatial positioning. Essential for tables, spreadsheets, multi-column docs. -- `--pages N-M` — Extract only pages N through M (1-based, inclusive). - -### fetch — Download and extract PDF from URL - -```bash -pdf-reader fetch # Download, verify, extract with layout -pdf-reader fetch report.pdf # Also save a local copy -``` - -Downloads the PDF, verifies it has a valid `%PDF` header, then extracts text with layout preservation. Temporary files are cleaned up automatically. - -### info — PDF metadata and file size - -```bash -pdf-reader info -``` - -Shows title, author, page count, page size, PDF version, and file size on disk. - -### list — Find all PDFs in directory tree - -```bash -pdf-reader list -``` - -Recursively lists all `.pdf` files with page count and file size. - -## WhatsApp PDF attachments - -When a user sends a PDF on WhatsApp, it is automatically saved to the `attachments/` directory. The message will include a path hint like: - -> [PDF attached: attachments/document.pdf] - -To read the attached PDF: - -```bash -pdf-reader extract attachments/document.pdf --layout -``` - -## Example workflows - -### Read a contract and summarize key terms - -```bash -pdf-reader info attachments/contract.pdf -pdf-reader extract attachments/contract.pdf --layout -``` - -### Extract specific pages from a long report - -```bash -pdf-reader info report.pdf # Check total pages -pdf-reader extract report.pdf --pages 1-3 # Executive summary -pdf-reader extract report.pdf --pages 15-20 # Financial tables -``` - -### Fetch and analyze a public document - -```bash -pdf-reader fetch https://example.com/annual-report.pdf report.pdf -pdf-reader info report.pdf -``` diff --git a/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/pdf-reader b/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/pdf-reader deleted file mode 100755 index be413c2..0000000 --- a/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/pdf-reader +++ /dev/null @@ -1,203 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# pdf-reader — CLI wrapper around poppler-utils (pdftotext, pdfinfo) -# Provides extract, fetch, info, list commands for PDF processing. - -VERSION="1.0.0" - -usage() { - cat <<'USAGE' -pdf-reader — Extract text and metadata from PDF files - -Usage: - pdf-reader extract [--layout] [--pages N-M] - pdf-reader fetch [filename] - pdf-reader info - pdf-reader list - pdf-reader help - -Commands: - extract Extract text from a PDF file to stdout - fetch Download a PDF from a URL and extract text - info Show PDF metadata and file size - list List all PDFs in current directory tree - help Show this help message - -Extract options: - --layout Preserve original layout (tables, columns) - --pages Page range to extract (e.g. 1-5, 3-3 for single page) -USAGE -} - -cmd_extract() { - local file="" - local layout=false - local first_page="" - local last_page="" - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --layout) - layout=true - shift - ;; - --pages) - if [[ -z "${2:-}" ]]; then - echo "Error: --pages requires a range argument (e.g. 1-5)" >&2 - exit 1 - fi - local range="$2" - first_page="${range%-*}" - last_page="${range#*-}" - shift 2 - ;; - -*) - echo "Error: Unknown option: $1" >&2 - exit 1 - ;; - *) - if [[ -z "$file" ]]; then - file="$1" - else - echo "Error: Unexpected argument: $1" >&2 - exit 1 - fi - shift - ;; - esac - done - - if [[ -z "$file" ]]; then - echo "Error: No file specified" >&2 - echo "Usage: pdf-reader extract [--layout] [--pages N-M]" >&2 - exit 1 - fi - - if [[ ! -f "$file" ]]; then - echo "Error: File not found: $file" >&2 - exit 1 - fi - - # Build pdftotext arguments - local args=() - if [[ "$layout" == true ]]; then - args+=(-layout) - fi - if [[ -n "$first_page" ]]; then - args+=(-f "$first_page") - fi - if [[ -n "$last_page" ]]; then - args+=(-l "$last_page") - fi - - pdftotext ${args[@]+"${args[@]}"} "$file" - -} - -cmd_fetch() { - local url="${1:-}" - local filename="${2:-}" - - if [[ -z "$url" ]]; then - echo "Error: No URL specified" >&2 - echo "Usage: pdf-reader fetch [filename]" >&2 - exit 1 - fi - - # Create temporary file - local tmpfile - tmpfile="$(mktemp /tmp/pdf-reader-XXXXXX.pdf)" - trap 'rm -f "$tmpfile"' EXIT - - # Download - echo "Downloading: $url" >&2 - if ! curl -sL -o "$tmpfile" "$url"; then - echo "Error: Failed to download: $url" >&2 - exit 1 - fi - - # Verify PDF header - local header - header="$(head -c 4 "$tmpfile")" - if [[ "$header" != "%PDF" ]]; then - echo "Error: Downloaded file is not a valid PDF (header: $header)" >&2 - exit 1 - fi - - # Save with name if requested - if [[ -n "$filename" ]]; then - cp "$tmpfile" "$filename" - echo "Saved to: $filename" >&2 - fi - - # Extract with layout - pdftotext -layout "$tmpfile" - -} - -cmd_info() { - local file="${1:-}" - - if [[ -z "$file" ]]; then - echo "Error: No file specified" >&2 - echo "Usage: pdf-reader info " >&2 - exit 1 - fi - - if [[ ! -f "$file" ]]; then - echo "Error: File not found: $file" >&2 - exit 1 - fi - - pdfinfo "$file" - echo "" - echo "File size: $(du -h "$file" | cut -f1)" -} - -cmd_list() { - local found=false - - # Use globbing to find PDFs (globstar makes **/ match recursively) - shopt -s nullglob globstar - - # Use associative array to deduplicate (*.pdf overlaps with **/*.pdf) - declare -A seen - for pdf in *.pdf **/*.pdf; do - [[ -v seen["$pdf"] ]] && continue - seen["$pdf"]=1 - found=true - - local pages="?" - local size - size="$(du -h "$pdf" | cut -f1)" - - # Try to get page count from pdfinfo - if page_line="$(pdfinfo "$pdf" 2>/dev/null | grep '^Pages:')"; then - pages="$(echo "$page_line" | awk '{print $2}')" - fi - - printf "%-60s %5s pages %8s\n" "$pdf" "$pages" "$size" - done - - if [[ "$found" == false ]]; then - echo "No PDF files found in current directory tree." >&2 - fi -} - -# Main dispatch -command="${1:-help}" -shift || true - -case "$command" in - extract) cmd_extract "$@" ;; - fetch) cmd_fetch "$@" ;; - info) cmd_info "$@" ;; - list) cmd_list ;; - help|--help|-h) usage ;; - version|--version|-v) echo "pdf-reader $VERSION" ;; - *) - echo "Error: Unknown command: $command" >&2 - echo "Run 'pdf-reader help' for usage." >&2 - exit 1 - ;; -esac diff --git a/.claude/skills/add-pdf-reader/manifest.yaml b/.claude/skills/add-pdf-reader/manifest.yaml deleted file mode 100644 index 83bf114..0000000 --- a/.claude/skills/add-pdf-reader/manifest.yaml +++ /dev/null @@ -1,17 +0,0 @@ -skill: add-pdf-reader -version: 1.1.0 -description: "Add PDF reading capability to container agents via pdftotext CLI" -core_version: 1.2.8 -adds: - - container/skills/pdf-reader/SKILL.md - - container/skills/pdf-reader/pdf-reader -modifies: - - container/Dockerfile - - src/channels/whatsapp.ts - - src/channels/whatsapp.test.ts -structured: - npm_dependencies: {} - env_additions: [] -conflicts: [] -depends: [] -test: "npx vitest run --config vitest.skills.config.ts .claude/skills/add-pdf-reader/tests/pdf-reader.test.ts" diff --git a/.claude/skills/add-pdf-reader/modify/container/Dockerfile b/.claude/skills/add-pdf-reader/modify/container/Dockerfile deleted file mode 100644 index 0654503..0000000 --- a/.claude/skills/add-pdf-reader/modify/container/Dockerfile +++ /dev/null @@ -1,74 +0,0 @@ -# NanoClaw Agent Container -# Runs Claude Agent SDK in isolated Linux VM with browser automation - -FROM node:22-slim - -# Install system dependencies for Chromium and PDF tools -RUN apt-get update && apt-get install -y \ - chromium \ - fonts-liberation \ - fonts-noto-cjk \ - fonts-noto-color-emoji \ - libgbm1 \ - libnss3 \ - libatk-bridge2.0-0 \ - libgtk-3-0 \ - libx11-xcb1 \ - libxcomposite1 \ - libxdamage1 \ - libxrandr2 \ - libasound2 \ - libpangocairo-1.0-0 \ - libcups2 \ - libdrm2 \ - libxshmfence1 \ - curl \ - git \ - poppler-utils \ - && rm -rf /var/lib/apt/lists/* - -# Set Chromium path for agent-browser -ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium - -# Install agent-browser and claude-code globally -RUN npm install -g agent-browser @anthropic-ai/claude-code - -# Create app directory -WORKDIR /app - -# Copy package files first for better caching -COPY agent-runner/package*.json ./ - -# Install dependencies -RUN npm install - -# Copy source code -COPY agent-runner/ ./ - -# Build TypeScript -RUN npm run build - -# Install pdf-reader CLI -COPY skills/pdf-reader/pdf-reader /usr/local/bin/pdf-reader -RUN chmod +x /usr/local/bin/pdf-reader - -# Create workspace directories -RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input - -# Create entrypoint script -# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it -# Follow-up messages arrive via IPC files in /workspace/ipc/input/ -RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh - -# Set ownership to node user (non-root) for writable directories -RUN chown -R node:node /workspace && chmod 777 /home/node - -# Switch to non-root user (required for --dangerously-skip-permissions) -USER node - -# Set working directory to group workspace -WORKDIR /workspace/group - -# Entry point reads JSON from stdin, outputs JSON to stdout -ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/.claude/skills/add-pdf-reader/modify/container/Dockerfile.intent.md b/.claude/skills/add-pdf-reader/modify/container/Dockerfile.intent.md deleted file mode 100644 index c20958d..0000000 --- a/.claude/skills/add-pdf-reader/modify/container/Dockerfile.intent.md +++ /dev/null @@ -1,23 +0,0 @@ -# Intent: container/Dockerfile modifications - -## What changed -Added PDF reading capability via poppler-utils and a custom pdf-reader CLI script. - -## Key sections - -### apt-get install (system dependencies block) -- Added: `poppler-utils` to the package list (provides pdftotext, pdfinfo, pdftohtml) -- Changed: Comment updated to mention PDF tools - -### After npm global installs -- Added: `COPY skills/pdf-reader/pdf-reader /usr/local/bin/pdf-reader` to copy CLI script -- Added: `RUN chmod +x /usr/local/bin/pdf-reader` to make it executable - -## Invariants (must-keep) -- All Chromium dependencies unchanged -- agent-browser and claude-code npm global installs unchanged -- WORKDIR, COPY agent-runner, npm install, npm run build sequence unchanged -- Workspace directory creation unchanged -- Entrypoint script unchanged -- User switching (node user) unchanged -- ENTRYPOINT unchanged diff --git a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts deleted file mode 100644 index 3e68b85..0000000 --- a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts +++ /dev/null @@ -1,1069 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { EventEmitter } from 'events'; - -// --- Mocks --- - -// Mock config -vi.mock('../config.js', () => ({ - STORE_DIR: '/tmp/nanoclaw-test-store', - ASSISTANT_NAME: 'Andy', - ASSISTANT_HAS_OWN_NUMBER: false, - GROUPS_DIR: '/tmp/test-groups', -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// Mock db -vi.mock('../db.js', () => ({ - getLastGroupSync: vi.fn(() => null), - setLastGroupSync: vi.fn(), - updateChatName: vi.fn(), -})); - -// Mock fs -vi.mock('fs', async () => { - const actual = await vi.importActual('fs'); - return { - ...actual, - default: { - ...actual, - existsSync: vi.fn(() => true), - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - }, - }; -}); - -// Mock child_process (used for osascript notification) -vi.mock('child_process', () => ({ - exec: vi.fn(), -})); - -// Build a fake WASocket that's an EventEmitter with the methods we need -function createFakeSocket() { - const ev = new EventEmitter(); - const sock = { - ev: { - on: (event: string, handler: (...args: unknown[]) => void) => { - ev.on(event, handler); - }, - }, - user: { - id: '1234567890:1@s.whatsapp.net', - lid: '9876543210:1@lid', - }, - sendMessage: vi.fn().mockResolvedValue(undefined), - sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), - groupFetchAllParticipating: vi.fn().mockResolvedValue({}), - updateMediaMessage: vi.fn(), - end: vi.fn(), - // Expose the event emitter for triggering events in tests - _ev: ev, - }; - return sock; -} - -let fakeSocket: ReturnType; - -// Mock Baileys -vi.mock('@whiskeysockets/baileys', () => { - return { - default: vi.fn(() => fakeSocket), - Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) }, - DisconnectReason: { - loggedOut: 401, - badSession: 500, - connectionClosed: 428, - connectionLost: 408, - connectionReplaced: 440, - timedOut: 408, - restartRequired: 515, - }, - downloadMediaMessage: vi - .fn() - .mockResolvedValue(Buffer.from('pdf-data')), - fetchLatestWaWebVersion: vi - .fn() - .mockResolvedValue({ version: [2, 3000, 0] }), - normalizeMessageContent: vi.fn((content: unknown) => content), - makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), - useMultiFileAuthState: vi.fn().mockResolvedValue({ - state: { - creds: {}, - keys: {}, - }, - saveCreds: vi.fn(), - }), - }; -}); - -import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js'; -import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js'; -import { downloadMediaMessage } from '@whiskeysockets/baileys'; - -// --- Test helpers --- - -function createTestOpts( - overrides?: Partial, -): WhatsAppChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: vi.fn(() => ({ - 'registered@g.us': { - name: 'Test Group', - folder: 'test-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - ...overrides, - }; -} - -function triggerConnection(state: string, extra?: Record) { - fakeSocket._ev.emit('connection.update', { connection: state, ...extra }); -} - -function triggerDisconnect(statusCode: number) { - fakeSocket._ev.emit('connection.update', { - connection: 'close', - lastDisconnect: { - error: { output: { statusCode } }, - }, - }); -} - -async function triggerMessages(messages: unknown[]) { - fakeSocket._ev.emit('messages.upsert', { messages }); - // Flush microtasks so the async messages.upsert handler completes - await new Promise((r) => setTimeout(r, 0)); -} - -// --- Tests --- - -describe('WhatsAppChannel', () => { - beforeEach(() => { - fakeSocket = createFakeSocket(); - vi.mocked(getLastGroupSync).mockReturnValue(null); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - /** - * Helper: start connect, flush microtasks so event handlers are registered, - * then trigger the connection open event. Returns the resolved promise. - */ - async function connectChannel(channel: WhatsAppChannel): Promise { - const p = channel.connect(); - // Flush microtasks so connectInternal completes its await and registers handlers - await new Promise((r) => setTimeout(r, 0)); - triggerConnection('open'); - return p; - } - - // --- Version fetch --- - - describe('version fetch', () => { - it('connects with fetched version', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - await connectChannel(channel); - - const { fetchLatestWaWebVersion } = - await import('@whiskeysockets/baileys'); - expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({}); - }); - - it('falls back gracefully when version fetch fails', async () => { - const { fetchLatestWaWebVersion } = - await import('@whiskeysockets/baileys'); - vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce( - new Error('network error'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - await connectChannel(channel); - - // Should still connect successfully despite fetch failure - expect(channel.isConnected()).toBe(true); - }); - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when connection opens', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - }); - - it('sets up LID to phone mapping on open', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The channel should have mapped the LID from sock.user - // We can verify by sending a message from a LID JID - // and checking the translated JID in the callback - }); - - it('flushes outgoing queue on reconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect - (channel as any).connected = false; - - // Queue a message while disconnected - await channel.sendMessage('test@g.us', 'Queued message'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - - // Reconnect - (channel as any).connected = true; - await (channel as any).flushOutgoingQueue(); - - // Group messages get prefixed when flushed - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { - text: 'Andy: Queued message', - }); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - expect(fakeSocket.end).toHaveBeenCalled(); - }); - }); - - // --- QR code and auth --- - - describe('authentication', () => { - it('exits process when QR code is emitted (no auth state)', async () => { - vi.useFakeTimers(); - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Start connect but don't await (it won't resolve - process exits) - channel.connect().catch(() => {}); - - // Flush microtasks so connectInternal registers handlers - await vi.advanceTimersByTimeAsync(0); - - // Emit QR code event - fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' }); - - // Advance timer past the 1000ms setTimeout before exit - await vi.advanceTimersByTimeAsync(1500); - - expect(mockExit).toHaveBeenCalledWith(1); - mockExit.mockRestore(); - vi.useRealTimers(); - }); - }); - - // --- Reconnection behavior --- - - describe('reconnection', () => { - it('reconnects on non-loggedOut disconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - - // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428) - triggerDisconnect(428); - - expect(channel.isConnected()).toBe(false); - // The channel should attempt to reconnect (calls connectInternal again) - }); - - it('exits on loggedOut disconnect', async () => { - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with loggedOut reason (401) - triggerDisconnect(401); - - expect(channel.isConnected()).toBe(false); - expect(mockExit).toHaveBeenCalledWith(0); - mockExit.mockRestore(); - }); - - it('retries reconnection after 5s on failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with stream error 515 - triggerDisconnect(515); - - // The channel sets a 5s retry — just verify it doesn't crash - await new Promise((r) => setTimeout(r, 100)); - }); - }); - - // --- Message handling --- - - describe('message handling', () => { - it('delivers message for registered group', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-1', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello Andy' }, - pushName: 'Alice', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - id: 'msg-1', - content: 'Hello Andy', - sender_name: 'Alice', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered groups', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-2', - remoteJid: 'unregistered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello' }, - pushName: 'Bob', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'unregistered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores status@broadcast messages', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-3', - remoteJid: 'status@broadcast', - fromMe: false, - }, - message: { conversation: 'Status update' }, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores messages with no content', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-4', - remoteJid: 'registered@g.us', - fromMe: false, - }, - message: null, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('extracts text from extendedTextMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-5', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - extendedTextMessage: { text: 'A reply message' }, - }, - pushName: 'Charlie', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'A reply message' }), - ); - }); - - it('extracts caption from imageMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-6', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - imageMessage: { - caption: 'Check this photo', - mimetype: 'image/jpeg', - }, - }, - pushName: 'Diana', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Check this photo' }), - ); - }); - - it('extracts caption from videoMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-7', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' }, - }, - pushName: 'Eve', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Watch this' }), - ); - }); - - it('handles message with no extractable text (e.g. voice note without caption)', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-8', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, - }, - pushName: 'Frank', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Skipped — no text content to process - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('uses sender JID when pushName is absent', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-9', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'No push name' }, - // pushName is undefined - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ sender_name: '5551234' }), - ); - }); - - it('downloads and injects PDF attachment path', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-pdf', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - documentMessage: { - mimetype: 'application/pdf', - fileName: 'report.pdf', - }, - }, - pushName: 'Alice', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(downloadMediaMessage).toHaveBeenCalled(); - - const fs = await import('fs'); - expect(fs.default.writeFileSync).toHaveBeenCalled(); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - content: expect.stringContaining('[PDF: attachments/report.pdf'), - }), - ); - }); - - it('preserves document caption alongside PDF info', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-pdf-caption', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - documentMessage: { - mimetype: 'application/pdf', - fileName: 'report.pdf', - caption: 'Here is the monthly report', - }, - }, - pushName: 'Alice', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - content: expect.stringContaining('Here is the monthly report'), - }), - ); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - content: expect.stringContaining('[PDF: attachments/report.pdf'), - }), - ); - }); - - it('handles PDF download failure gracefully', async () => { - vi.mocked(downloadMediaMessage).mockRejectedValueOnce( - new Error('Download failed'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-pdf-fail', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - documentMessage: { - mimetype: 'application/pdf', - fileName: 'report.pdf', - }, - }, - pushName: 'Bob', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Message skipped since content remains empty after failed download - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - }); - - // --- LID ↔ JID translation --- - - describe('LID to JID translation', () => { - it('translates known LID to phone JID', async () => { - const opts = createTestOpts({ - registeredGroups: vi.fn(() => ({ - '1234567890@s.whatsapp.net': { - name: 'Self Chat', - folder: 'self-chat', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net' - // Send a message from the LID - await triggerMessages([ - { - key: { - id: 'msg-lid', - remoteJid: '9876543210@lid', - fromMe: false, - }, - message: { conversation: 'From LID' }, - pushName: 'Self', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Should be translated to phone JID - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '1234567890@s.whatsapp.net', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - - it('passes through non-LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-normal', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Normal JID' }, - pushName: 'Grace', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - }); - - it('passes through unknown LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-unknown-lid', - remoteJid: '0000000000@lid', - fromMe: false, - }, - message: { conversation: 'Unknown LID' }, - pushName: 'Unknown', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Unknown LID passes through unchanged - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '0000000000@lid', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - }); - - // --- Outgoing message queue --- - - describe('outgoing message queue', () => { - it('sends message directly when connected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('test@g.us', 'Hello'); - // Group messages get prefixed with assistant name - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { - text: 'Andy: Hello', - }); - }); - - it('prefixes direct chat messages on shared number', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('123@s.whatsapp.net', 'Hello'); - // Shared number: DMs also get prefixed (needed for self-chat distinction) - expect(fakeSocket.sendMessage).toHaveBeenCalledWith( - '123@s.whatsapp.net', - { text: 'Andy: Hello' }, - ); - }); - - it('queues message when disconnected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Don't connect — channel starts disconnected - await channel.sendMessage('test@g.us', 'Queued'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - }); - - it('queues message on send failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Make sendMessage fail - fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error')); - - await channel.sendMessage('test@g.us', 'Will fail'); - - // Should not throw, message queued for retry - // The queue should have the message - }); - - it('flushes multiple queued messages in order', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Queue messages while disconnected - await channel.sendMessage('test@g.us', 'First'); - await channel.sendMessage('test@g.us', 'Second'); - await channel.sendMessage('test@g.us', 'Third'); - - // Connect — flush happens automatically on open - await connectChannel(channel); - - // Give the async flush time to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3); - // Group messages get prefixed - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { - text: 'Andy: First', - }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { - text: 'Andy: Second', - }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { - text: 'Andy: Third', - }); - }); - }); - - // --- Group metadata sync --- - - describe('group metadata sync', () => { - it('syncs group metadata on first connection', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Group One' }, - 'group2@g.us': { subject: 'Group Two' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Wait for async sync to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One'); - expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two'); - expect(setLastGroupSync).toHaveBeenCalled(); - }); - - it('skips sync when synced recently', async () => { - // Last sync was 1 hour ago (within 24h threshold) - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled(); - }); - - it('forces sync regardless of cache', async () => { - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group@g.us': { subject: 'Forced Group' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.syncGroupMetadata(true); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group'); - }); - - it('handles group sync failure gracefully', async () => { - fakeSocket.groupFetchAllParticipating.mockRejectedValue( - new Error('Network timeout'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Should not throw - await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined(); - }); - - it('skips groups with no subject', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Has Subject' }, - 'group2@g.us': { subject: '' }, - 'group3@g.us': {}, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Clear any calls from the automatic sync on connect - vi.mocked(updateChatName).mockClear(); - - await channel.syncGroupMetadata(true); - - expect(updateChatName).toHaveBeenCalledTimes(1); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject'); - }); - }); - - // --- JID ownership --- - - describe('ownsJid', () => { - it('owns @g.us JIDs (WhatsApp groups)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(true); - }); - - it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true); - }); - - it('does not own Telegram JIDs', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('tg:12345')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); - }); - - // --- Typing indicator --- - - describe('setTyping', () => { - it('sends composing presence when typing', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', true); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( - 'composing', - 'test@g.us', - ); - }); - - it('sends paused presence when stopping', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', false); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( - 'paused', - 'test@g.us', - ); - }); - - it('handles typing indicator failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed')); - - // Should not throw - await expect( - channel.setTyping('test@g.us', true), - ).resolves.toBeUndefined(); - }); - }); - - // --- Channel properties --- - - describe('channel properties', () => { - it('has name "whatsapp"', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.name).toBe('whatsapp'); - }); - - it('does not expose prefixAssistantName (prefix handled internally)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect('prefixAssistantName' in channel).toBe(false); - }); - }); -}); diff --git a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts.intent.md b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts.intent.md deleted file mode 100644 index c7302f6..0000000 --- a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts.intent.md +++ /dev/null @@ -1,22 +0,0 @@ -# Intent: src/channels/whatsapp.test.ts modifications - -## What changed -Added mocks for downloadMediaMessage and normalizeMessageContent, and test cases for PDF attachment handling. - -## Key sections - -### Mocks (top of file) -- Modified: config mock to export `GROUPS_DIR: '/tmp/test-groups'` -- Modified: `fs` mock to include `writeFileSync` as vi.fn() -- Modified: Baileys mock to export `downloadMediaMessage`, `normalizeMessageContent` -- Modified: fake socket factory to include `updateMediaMessage` - -### Test cases (inside "message handling" describe block) -- "downloads and injects PDF attachment path" — verifies PDF download, save, and content replacement -- "handles PDF download failure gracefully" — verifies error handling (message skipped since content remains empty) - -## Invariants (must-keep) -- All existing test cases unchanged -- All existing mocks unchanged (only additive changes) -- All existing test helpers unchanged -- All describe blocks preserved diff --git a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts deleted file mode 100644 index a5f8138..0000000 --- a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { exec } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import makeWASocket, { - Browsers, - DisconnectReason, - downloadMediaMessage, - WASocket, - fetchLatestWaWebVersion, - makeCacheableSignalKeyStore, - normalizeMessageContent, - useMultiFileAuthState, -} from '@whiskeysockets/baileys'; - -import { - ASSISTANT_HAS_OWN_NUMBER, - ASSISTANT_NAME, - GROUPS_DIR, - STORE_DIR, -} from '../config.js'; -import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js'; -import { logger } from '../logger.js'; -import { - Channel, - OnInboundMessage, - OnChatMetadata, - RegisteredGroup, -} from '../types.js'; -import { registerChannel, ChannelOpts } from './registry.js'; - -const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours - -export interface WhatsAppChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -export class WhatsAppChannel implements Channel { - name = 'whatsapp'; - - private sock!: WASocket; - private connected = false; - private lidToPhoneMap: Record = {}; - private outgoingQueue: Array<{ jid: string; text: string }> = []; - private flushing = false; - private groupSyncTimerStarted = false; - - private opts: WhatsAppChannelOpts; - - constructor(opts: WhatsAppChannelOpts) { - this.opts = opts; - } - - async connect(): Promise { - return new Promise((resolve, reject) => { - this.connectInternal(resolve).catch(reject); - }); - } - - private async connectInternal(onFirstOpen?: () => void): Promise { - const authDir = path.join(STORE_DIR, 'auth'); - fs.mkdirSync(authDir, { recursive: true }); - - const { state, saveCreds } = await useMultiFileAuthState(authDir); - - const { version } = await fetchLatestWaWebVersion({}).catch((err) => { - logger.warn( - { err }, - 'Failed to fetch latest WA Web version, using default', - ); - return { version: undefined }; - }); - this.sock = makeWASocket({ - version, - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, logger), - }, - printQRInTerminal: false, - logger, - browser: Browsers.macOS('Chrome'), - }); - - this.sock.ev.on('connection.update', (update) => { - const { connection, lastDisconnect, qr } = update; - - if (qr) { - const msg = - 'WhatsApp authentication required. Run /setup in Claude Code.'; - logger.error(msg); - exec( - `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`, - ); - setTimeout(() => process.exit(1), 1000); - } - - if (connection === 'close') { - this.connected = false; - const reason = ( - lastDisconnect?.error as { output?: { statusCode?: number } } - )?.output?.statusCode; - const shouldReconnect = reason !== DisconnectReason.loggedOut; - logger.info( - { - reason, - shouldReconnect, - queuedMessages: this.outgoingQueue.length, - }, - 'Connection closed', - ); - - if (shouldReconnect) { - logger.info('Reconnecting...'); - this.connectInternal().catch((err) => { - logger.error({ err }, 'Failed to reconnect, retrying in 5s'); - setTimeout(() => { - this.connectInternal().catch((err2) => { - logger.error({ err: err2 }, 'Reconnection retry failed'); - }); - }, 5000); - }); - } else { - logger.info('Logged out. Run /setup to re-authenticate.'); - process.exit(0); - } - } else if (connection === 'open') { - this.connected = true; - logger.info('Connected to WhatsApp'); - - // Announce availability so WhatsApp relays subsequent presence updates (typing indicators) - this.sock.sendPresenceUpdate('available').catch((err) => { - logger.warn({ err }, 'Failed to send presence update'); - }); - - // Build LID to phone mapping from auth state for self-chat translation - if (this.sock.user) { - const phoneUser = this.sock.user.id.split(':')[0]; - const lidUser = this.sock.user.lid?.split(':')[0]; - if (lidUser && phoneUser) { - this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`; - logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set'); - } - } - - // Flush any messages queued while disconnected - this.flushOutgoingQueue().catch((err) => - logger.error({ err }, 'Failed to flush outgoing queue'), - ); - - // Sync group metadata on startup (respects 24h cache) - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Initial group sync failed'), - ); - // Set up daily sync timer (only once) - if (!this.groupSyncTimerStarted) { - this.groupSyncTimerStarted = true; - setInterval(() => { - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Periodic group sync failed'), - ); - }, GROUP_SYNC_INTERVAL_MS); - } - - // Signal first connection to caller - if (onFirstOpen) { - onFirstOpen(); - onFirstOpen = undefined; - } - } - }); - - this.sock.ev.on('creds.update', saveCreds); - - this.sock.ev.on('messages.upsert', async ({ messages }) => { - for (const msg of messages) { - try { - if (!msg.message) continue; - // Unwrap container types (viewOnceMessageV2, ephemeralMessage, - // editedMessage, etc.) so that conversation, extendedTextMessage, - // imageMessage, etc. are accessible at the top level. - const normalized = normalizeMessageContent(msg.message); - if (!normalized) continue; - const rawJid = msg.key.remoteJid; - if (!rawJid || rawJid === 'status@broadcast') continue; - - // Translate LID JID to phone JID if applicable - const chatJid = await this.translateJid(rawJid); - - const timestamp = new Date( - Number(msg.messageTimestamp) * 1000, - ).toISOString(); - - // Always notify about chat metadata for group discovery - const isGroup = chatJid.endsWith('@g.us'); - this.opts.onChatMetadata( - chatJid, - timestamp, - undefined, - 'whatsapp', - isGroup, - ); - - // Only deliver full message for registered groups - const groups = this.opts.registeredGroups(); - if (groups[chatJid]) { - let content = - normalized.conversation || - normalized.extendedTextMessage?.text || - normalized.imageMessage?.caption || - normalized.videoMessage?.caption || - ''; - - // PDF attachment handling - if (normalized?.documentMessage?.mimetype === 'application/pdf') { - try { - const buffer = await downloadMediaMessage(msg, 'buffer', {}); - const groupDir = path.join(GROUPS_DIR, groups[chatJid].folder); - const attachDir = path.join(groupDir, 'attachments'); - fs.mkdirSync(attachDir, { recursive: true }); - const filename = path.basename( - normalized.documentMessage.fileName || - `doc-${Date.now()}.pdf`, - ); - const filePath = path.join(attachDir, filename); - fs.writeFileSync(filePath, buffer as Buffer); - const sizeKB = Math.round((buffer as Buffer).length / 1024); - const pdfRef = `[PDF: attachments/${filename} (${sizeKB}KB)]\nUse: pdf-reader extract attachments/${filename}`; - const caption = normalized.documentMessage.caption || ''; - content = caption ? `${caption}\n\n${pdfRef}` : pdfRef; - logger.info( - { jid: chatJid, filename }, - 'Downloaded PDF attachment', - ); - } catch (err) { - logger.warn( - { err, jid: chatJid }, - 'Failed to download PDF attachment', - ); - } - } - - // Skip protocol messages with no text content (encryption keys, read receipts, etc.) - if (!content) continue; - - const sender = msg.key.participant || msg.key.remoteJid || ''; - const senderName = msg.pushName || sender.split('@')[0]; - - const fromMe = msg.key.fromMe || false; - // Detect bot messages: with own number, fromMe is reliable - // since only the bot sends from that number. - // With shared number, bot messages carry the assistant name prefix - // (even in DMs/self-chat) so we check for that. - const isBotMessage = ASSISTANT_HAS_OWN_NUMBER - ? fromMe - : content.startsWith(`${ASSISTANT_NAME}:`); - - this.opts.onMessage(chatJid, { - id: msg.key.id || '', - chat_jid: chatJid, - sender, - sender_name: senderName, - content, - timestamp, - is_from_me: fromMe, - is_bot_message: isBotMessage, - }); - } - } catch (err) { - logger.error( - { err, remoteJid: msg.key?.remoteJid }, - 'Error processing incoming message', - ); - } - } - }); - } - - async sendMessage(jid: string, text: string): Promise { - // Prefix bot messages with assistant name so users know who's speaking. - // On a shared number, prefix is also needed in DMs (including self-chat) - // to distinguish bot output from user messages. - // Skip only when the assistant has its own dedicated phone number. - const prefixed = ASSISTANT_HAS_OWN_NUMBER - ? text - : `${ASSISTANT_NAME}: ${text}`; - - if (!this.connected) { - this.outgoingQueue.push({ jid, text: prefixed }); - logger.info( - { jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, - 'WA disconnected, message queued', - ); - return; - } - try { - await this.sock.sendMessage(jid, { text: prefixed }); - logger.info({ jid, length: prefixed.length }, 'Message sent'); - } catch (err) { - // If send fails, queue it for retry on reconnect - this.outgoingQueue.push({ jid, text: prefixed }); - logger.warn( - { jid, err, queueSize: this.outgoingQueue.length }, - 'Failed to send, message queued', - ); - } - } - - isConnected(): boolean { - return this.connected; - } - - ownsJid(jid: string): boolean { - return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net'); - } - - async disconnect(): Promise { - this.connected = false; - this.sock?.end(undefined); - } - - async setTyping(jid: string, isTyping: boolean): Promise { - try { - const status = isTyping ? 'composing' : 'paused'; - logger.debug({ jid, status }, 'Sending presence update'); - await this.sock.sendPresenceUpdate(status, jid); - } catch (err) { - logger.debug({ jid, err }, 'Failed to update typing status'); - } - } - - async syncGroups(force: boolean): Promise { - return this.syncGroupMetadata(force); - } - - /** - * Sync group metadata from WhatsApp. - * Fetches all participating groups and stores their names in the database. - * Called on startup, daily, and on-demand via IPC. - */ - async syncGroupMetadata(force = false): Promise { - if (!force) { - const lastSync = getLastGroupSync(); - if (lastSync) { - const lastSyncTime = new Date(lastSync).getTime(); - if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) { - logger.debug({ lastSync }, 'Skipping group sync - synced recently'); - return; - } - } - } - - try { - logger.info('Syncing group metadata from WhatsApp...'); - const groups = await this.sock.groupFetchAllParticipating(); - - let count = 0; - for (const [jid, metadata] of Object.entries(groups)) { - if (metadata.subject) { - updateChatName(jid, metadata.subject); - count++; - } - } - - setLastGroupSync(); - logger.info({ count }, 'Group metadata synced'); - } catch (err) { - logger.error({ err }, 'Failed to sync group metadata'); - } - } - - private async translateJid(jid: string): Promise { - if (!jid.endsWith('@lid')) return jid; - const lidUser = jid.split('@')[0].split(':')[0]; - - // Check local cache first - const cached = this.lidToPhoneMap[lidUser]; - if (cached) { - logger.debug( - { lidJid: jid, phoneJid: cached }, - 'Translated LID to phone JID (cached)', - ); - return cached; - } - - // Query Baileys' signal repository for the mapping - try { - const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid); - if (pn) { - const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; - this.lidToPhoneMap[lidUser] = phoneJid; - logger.info( - { lidJid: jid, phoneJid }, - 'Translated LID to phone JID (signalRepository)', - ); - return phoneJid; - } - } catch (err) { - logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository'); - } - - return jid; - } - - private async flushOutgoingQueue(): Promise { - if (this.flushing || this.outgoingQueue.length === 0) return; - this.flushing = true; - try { - logger.info( - { count: this.outgoingQueue.length }, - 'Flushing outgoing message queue', - ); - while (this.outgoingQueue.length > 0) { - const item = this.outgoingQueue.shift()!; - // Send directly — queued items are already prefixed by sendMessage - await this.sock.sendMessage(item.jid, { text: item.text }); - logger.info( - { jid: item.jid, length: item.text.length }, - 'Queued message sent', - ); - } - } finally { - this.flushing = false; - } - } -} - -registerChannel('whatsapp', (opts: ChannelOpts) => new WhatsAppChannel(opts)); diff --git a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts.intent.md b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts.intent.md deleted file mode 100644 index 112efa2..0000000 --- a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts.intent.md +++ /dev/null @@ -1,29 +0,0 @@ -# Intent: src/channels/whatsapp.ts modifications - -## What changed -Added PDF attachment download and path injection. When a WhatsApp message contains a PDF document, it is downloaded to the group's attachments/ directory and the message content is replaced with the file path and a usage hint. Also uses `normalizeMessageContent()` from Baileys to unwrap container types before reading fields. - -## Key sections - -### Imports (top of file) -- Added: `downloadMediaMessage` from `@whiskeysockets/baileys` -- Added: `normalizeMessageContent` from `@whiskeysockets/baileys` -- Added: `GROUPS_DIR` from `../config.js` - -### messages.upsert handler (inside connectInternal) -- Added: `normalizeMessageContent(msg.message)` call to unwrap container types -- Changed: `let content` to allow reassignment for PDF messages -- Added: Check for `normalized.documentMessage?.mimetype === 'application/pdf'` -- Added: Download PDF via `downloadMediaMessage`, save to `groups/{folder}/attachments/` -- Added: Replace content with `[PDF: attachments/{filename} ({size}KB)]` and usage hint -- Note: PDF check is placed BEFORE the `if (!content) continue;` guard so PDF-only messages are not skipped - -## Invariants (must-keep) -- All existing message handling (conversation, extendedTextMessage, imageMessage, videoMessage) -- Connection lifecycle (connect, reconnect with exponential backoff, disconnect) -- LID translation logic unchanged -- Outgoing message queue unchanged -- Group metadata sync unchanged -- sendMessage prefix logic unchanged -- setTyping, ownsJid, isConnected — all unchanged -- Local timestamp format (no Z suffix) diff --git a/.claude/skills/add-pdf-reader/tests/pdf-reader.test.ts b/.claude/skills/add-pdf-reader/tests/pdf-reader.test.ts deleted file mode 100644 index 2d9e961..0000000 --- a/.claude/skills/add-pdf-reader/tests/pdf-reader.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('pdf-reader skill package', () => { - const skillDir = path.resolve(__dirname, '..'); - - it('has a valid manifest', () => { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - expect(fs.existsSync(manifestPath)).toBe(true); - - const content = fs.readFileSync(manifestPath, 'utf-8'); - expect(content).toContain('skill: add-pdf-reader'); - expect(content).toContain('version: 1.1.0'); - expect(content).toContain('container/Dockerfile'); - }); - - it('has all files declared in adds', () => { - const skillMd = path.join(skillDir, 'add', 'container', 'skills', 'pdf-reader', 'SKILL.md'); - const pdfReaderScript = path.join(skillDir, 'add', 'container', 'skills', 'pdf-reader', 'pdf-reader'); - - expect(fs.existsSync(skillMd)).toBe(true); - expect(fs.existsSync(pdfReaderScript)).toBe(true); - }); - - it('pdf-reader script is a valid Bash script', () => { - const scriptPath = path.join(skillDir, 'add', 'container', 'skills', 'pdf-reader', 'pdf-reader'); - const content = fs.readFileSync(scriptPath, 'utf-8'); - - // Valid shell script - expect(content).toMatch(/^#!/); - - // Core CLI commands - expect(content).toContain('pdftotext'); - expect(content).toContain('pdfinfo'); - expect(content).toContain('extract'); - expect(content).toContain('fetch'); - expect(content).toContain('info'); - expect(content).toContain('list'); - - // Key options - expect(content).toContain('--layout'); - expect(content).toContain('--pages'); - }); - - it('container skill SKILL.md has correct frontmatter', () => { - const skillMdPath = path.join(skillDir, 'add', 'container', 'skills', 'pdf-reader', 'SKILL.md'); - const content = fs.readFileSync(skillMdPath, 'utf-8'); - - expect(content).toContain('name: pdf-reader'); - expect(content).toContain('allowed-tools: Bash(pdf-reader:*)'); - expect(content).toContain('pdf-reader extract'); - expect(content).toContain('pdf-reader fetch'); - expect(content).toContain('pdf-reader info'); - }); - - it('has all files declared in modifies', () => { - const dockerfile = path.join(skillDir, 'modify', 'container', 'Dockerfile'); - const whatsappTs = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'); - const whatsappTestTs = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'); - - expect(fs.existsSync(dockerfile)).toBe(true); - expect(fs.existsSync(whatsappTs)).toBe(true); - expect(fs.existsSync(whatsappTestTs)).toBe(true); - }); - - it('has intent files for all modified files', () => { - expect( - fs.existsSync(path.join(skillDir, 'modify', 'container', 'Dockerfile.intent.md')), - ).toBe(true); - expect( - fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts.intent.md')), - ).toBe(true); - expect( - fs.existsSync( - path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts.intent.md'), - ), - ).toBe(true); - }); - - it('modified Dockerfile includes poppler-utils and pdf-reader', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'container', 'Dockerfile'), - 'utf-8', - ); - - expect(content).toContain('poppler-utils'); - expect(content).toContain('pdf-reader'); - expect(content).toContain('/usr/local/bin/pdf-reader'); - }); - - it('modified Dockerfile preserves core structure', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'container', 'Dockerfile'), - 'utf-8', - ); - - expect(content).toContain('FROM node:22-slim'); - expect(content).toContain('chromium'); - expect(content).toContain('agent-browser'); - expect(content).toContain('WORKDIR /app'); - expect(content).toContain('COPY agent-runner/'); - expect(content).toContain('ENTRYPOINT'); - expect(content).toContain('/workspace/group'); - expect(content).toContain('USER node'); - }); - - it('modified whatsapp.ts includes PDF attachment handling', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'), - 'utf-8', - ); - - expect(content).toContain('documentMessage'); - expect(content).toContain('application/pdf'); - expect(content).toContain('downloadMediaMessage'); - expect(content).toContain('attachments'); - expect(content).toContain('pdf-reader extract'); - }); - - it('modified whatsapp.ts preserves core structure', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'), - 'utf-8', - ); - - // Core class and methods preserved - expect(content).toContain('class WhatsAppChannel'); - expect(content).toContain('implements Channel'); - expect(content).toContain('async connect()'); - expect(content).toContain('async sendMessage('); - expect(content).toContain('isConnected()'); - expect(content).toContain('ownsJid('); - expect(content).toContain('async disconnect()'); - expect(content).toContain('async setTyping('); - - // Core imports preserved - expect(content).toContain('ASSISTANT_NAME'); - expect(content).toContain('STORE_DIR'); - }); - - it('modified whatsapp.test.ts includes PDF attachment tests', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'), - 'utf-8', - ); - - expect(content).toContain('PDF'); - expect(content).toContain('documentMessage'); - expect(content).toContain('application/pdf'); - }); - - it('modified whatsapp.test.ts preserves all existing test sections', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'), - 'utf-8', - ); - - // All existing test describe blocks preserved - expect(content).toContain("describe('connection lifecycle'"); - expect(content).toContain("describe('authentication'"); - expect(content).toContain("describe('reconnection'"); - expect(content).toContain("describe('message handling'"); - expect(content).toContain("describe('LID to JID translation'"); - expect(content).toContain("describe('outgoing message queue'"); - expect(content).toContain("describe('group metadata sync'"); - expect(content).toContain("describe('ownsJid'"); - expect(content).toContain("describe('setTyping'"); - expect(content).toContain("describe('channel properties'"); - }); -}); diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md deleted file mode 100644 index 76f59ec..0000000 --- a/.claude/skills/add-reactions/SKILL.md +++ /dev/null @@ -1,103 +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 - -Read `.nanoclaw/state.yaml`. If `reactions` is in `applied_skills`, skip to Phase 3 (Verify). The code changes are already in place. - -## Phase 2: Apply Code Changes - -Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md. - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-reactions -``` - -This deterministically: -- Adds `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes) -- Adds `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry) -- Adds `src/status-tracker.test.ts` (unit tests for StatusTracker) -- Adds `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool) -- Modifies `src/db.ts` — adds `Reaction` interface, `reactions` table schema, `storeReaction`, `getReactionsForMessage`, `getMessagesByReaction`, `getReactionsByUser`, `getReactionStats`, `getLatestMessage`, `getMessageFromMe` -- Modifies `src/channels/whatsapp.ts` — adds `messages.reaction` event handler, `sendReaction()`, `reactToLatestMessage()` methods -- Modifies `src/types.ts` — adds optional `sendReaction` and `reactToLatestMessage` to `Channel` interface -- Modifies `src/ipc.ts` — adds `type: 'reaction'` IPC handler with group-scoped authorization -- Modifies `src/index.ts` — wires `sendReaction` dependency into IPC watcher -- Modifies `src/group-queue.ts` — `GroupQueue` class for per-group container concurrency with retry -- Modifies `container/agent-runner/src/ipc-mcp-stdio.ts` — adds `react_to_message` MCP tool exposed to container agents -- Records the application in `.nanoclaw/state.yaml` - -### 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-reactions/add/container/skills/reactions/SKILL.md b/.claude/skills/add-reactions/add/container/skills/reactions/SKILL.md deleted file mode 100644 index 4d8eeec..0000000 --- a/.claude/skills/add-reactions/add/container/skills/reactions/SKILL.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -name: reactions -description: React to WhatsApp messages with emoji. Use when the user asks you to react, when acknowledging a message with a reaction makes sense, or when you want to express a quick response without sending a full message. ---- - -# Reactions - -React to messages with emoji using the `mcp__nanoclaw__react_to_message` tool. - -## When to use - -- User explicitly asks you to react ("react with a thumbs up", "heart that message") -- Quick acknowledgment is more appropriate than a full text reply -- Expressing agreement, approval, or emotion about a specific message - -## How to use - -### React to the latest message - -``` -mcp__nanoclaw__react_to_message(emoji: "👍") -``` - -Omitting `message_id` reacts to the most recent message in the chat. - -### React to a specific message - -``` -mcp__nanoclaw__react_to_message(emoji: "❤️", message_id: "3EB0F4C9E7...") -``` - -Pass a `message_id` to react to a specific message. You can find message IDs by querying the messages database: - -```bash -sqlite3 /workspace/project/store/messages.db " - SELECT id, sender_name, substr(content, 1, 80), timestamp - FROM messages - WHERE chat_jid = '' - ORDER BY timestamp DESC - LIMIT 5; -" -``` - -### Remove a reaction - -Send an empty string to remove your reaction: - -``` -mcp__nanoclaw__react_to_message(emoji: "") -``` - -## Common emoji - -| Emoji | When to use | -|-------|-------------| -| 👍 | Acknowledgment, approval | -| ❤️ | Appreciation, love | -| 😂 | Something funny | -| 🔥 | Impressive, exciting | -| 🎉 | Celebration, congrats | -| 🙏 | Thanks, prayer | -| ✅ | Task done, confirmed | -| ❓ | Needs clarification | diff --git a/.claude/skills/add-reactions/add/scripts/migrate-reactions.ts b/.claude/skills/add-reactions/add/scripts/migrate-reactions.ts deleted file mode 100644 index 8dec46e..0000000 --- a/.claude/skills/add-reactions/add/scripts/migrate-reactions.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Database migration script for reactions table -// Run: npx tsx scripts/migrate-reactions.ts - -import Database from 'better-sqlite3'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const STORE_DIR = process.env.STORE_DIR || path.join(process.cwd(), 'store'); -const dbPath = path.join(STORE_DIR, 'messages.db'); - -console.log(`Migrating database at: ${dbPath}`); - -const db = new Database(dbPath); - -try { - db.transaction(() => { - db.exec(` - CREATE TABLE IF NOT EXISTS reactions ( - message_id TEXT NOT NULL, - message_chat_jid TEXT NOT NULL, - reactor_jid TEXT NOT NULL, - reactor_name TEXT, - emoji TEXT NOT NULL, - timestamp TEXT NOT NULL, - PRIMARY KEY (message_id, message_chat_jid, reactor_jid) - ); - `); - - console.log('Created reactions table'); - - db.exec(` - CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id, message_chat_jid); - CREATE INDEX IF NOT EXISTS idx_reactions_reactor ON reactions(reactor_jid); - CREATE INDEX IF NOT EXISTS idx_reactions_emoji ON reactions(emoji); - CREATE INDEX IF NOT EXISTS idx_reactions_timestamp ON reactions(timestamp); - `); - - console.log('Created indexes'); - })(); - - const tableInfo = db.prepare(`PRAGMA table_info(reactions)`).all(); - console.log('\nReactions table schema:'); - console.table(tableInfo); - - const count = db.prepare(`SELECT COUNT(*) as count FROM reactions`).get() as { - count: number; - }; - console.log(`\nCurrent reaction count: ${count.count}`); - - console.log('\nMigration complete!'); -} catch (err) { - console.error('Migration failed:', err); - process.exit(1); -} finally { - db.close(); -} diff --git a/.claude/skills/add-reactions/add/src/status-tracker.test.ts b/.claude/skills/add-reactions/add/src/status-tracker.test.ts deleted file mode 100644 index 53a439d..0000000 --- a/.claude/skills/add-reactions/add/src/status-tracker.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -vi.mock('fs', async () => { - const actual = await vi.importActual('fs'); - return { - ...actual, - default: { - ...actual, - existsSync: vi.fn(() => false), - writeFileSync: vi.fn(), - readFileSync: vi.fn(() => '[]'), - mkdirSync: vi.fn(), - }, - }; -}); - -vi.mock('./logger.js', () => ({ - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, -})); - -import { StatusTracker, StatusState, StatusTrackerDeps } from './status-tracker.js'; - -function makeDeps() { - return { - sendReaction: vi.fn(async () => {}), - sendMessage: vi.fn(async () => {}), - isMainGroup: vi.fn((jid) => jid === 'main@s.whatsapp.net'), - isContainerAlive: vi.fn(() => true), - }; -} - -describe('StatusTracker', () => { - let tracker: StatusTracker; - let deps: ReturnType; - - beforeEach(() => { - deps = makeDeps(); - tracker = new StatusTracker(deps); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('forward-only transitions', () => { - it('transitions RECEIVED -> THINKING -> WORKING -> DONE', async () => { - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markThinking('msg1'); - tracker.markWorking('msg1'); - tracker.markDone('msg1'); - - // Wait for all reaction sends to complete - await tracker.flush(); - - expect(deps.sendReaction).toHaveBeenCalledTimes(4); - const emojis = deps.sendReaction.mock.calls.map((c) => c[2]); - expect(emojis).toEqual(['\u{1F440}', '\u{1F4AD}', '\u{1F504}', '\u{2705}']); - }); - - it('rejects backward transitions (WORKING -> THINKING is no-op)', async () => { - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markThinking('msg1'); - tracker.markWorking('msg1'); - - const result = tracker.markThinking('msg1'); - expect(result).toBe(false); - - await tracker.flush(); - expect(deps.sendReaction).toHaveBeenCalledTimes(3); - }); - - it('rejects duplicate transitions (DONE -> DONE is no-op)', async () => { - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markDone('msg1'); - - const result = tracker.markDone('msg1'); - expect(result).toBe(false); - - await tracker.flush(); - expect(deps.sendReaction).toHaveBeenCalledTimes(2); - }); - - it('allows FAILED from any non-terminal state', async () => { - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markFailed('msg1'); - await tracker.flush(); - - const emojis = deps.sendReaction.mock.calls.map((c) => c[2]); - expect(emojis).toEqual(['\u{1F440}', '\u{274C}']); - }); - - it('rejects FAILED after DONE', async () => { - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markDone('msg1'); - - const result = tracker.markFailed('msg1'); - expect(result).toBe(false); - - await tracker.flush(); - expect(deps.sendReaction).toHaveBeenCalledTimes(2); - }); - }); - - describe('main group gating', () => { - it('ignores messages from non-main groups', async () => { - tracker.markReceived('msg1', 'group@g.us', false); - await tracker.flush(); - expect(deps.sendReaction).not.toHaveBeenCalled(); - }); - }); - - describe('duplicate tracking', () => { - it('rejects duplicate markReceived for same messageId', async () => { - const first = tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - const second = tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - - expect(first).toBe(true); - expect(second).toBe(false); - - await tracker.flush(); - expect(deps.sendReaction).toHaveBeenCalledTimes(1); - }); - }); - - describe('unknown message handling', () => { - it('returns false for transitions on untracked messages', () => { - expect(tracker.markThinking('unknown')).toBe(false); - expect(tracker.markWorking('unknown')).toBe(false); - expect(tracker.markDone('unknown')).toBe(false); - expect(tracker.markFailed('unknown')).toBe(false); - }); - }); - - describe('batch operations', () => { - it('markAllDone transitions all tracked messages for a chatJid', async () => { - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markReceived('msg2', 'main@s.whatsapp.net', false); - tracker.markAllDone('main@s.whatsapp.net'); - await tracker.flush(); - - const doneCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '\u{2705}'); - expect(doneCalls).toHaveLength(2); - }); - - it('markAllFailed transitions all tracked messages and sends error message', async () => { - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markReceived('msg2', 'main@s.whatsapp.net', false); - tracker.markAllFailed('main@s.whatsapp.net', 'Task crashed'); - await tracker.flush(); - - const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '\u{274C}'); - expect(failCalls).toHaveLength(2); - expect(deps.sendMessage).toHaveBeenCalledWith('main@s.whatsapp.net', '[system] Task crashed'); - }); - }); - - describe('serialized sends', () => { - it('sends reactions in order even when transitions are rapid', async () => { - const order: string[] = []; - deps.sendReaction.mockImplementation(async (_jid, _key, emoji) => { - await new Promise((r) => setTimeout(r, Math.random() * 10)); - order.push(emoji); - }); - - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markThinking('msg1'); - tracker.markWorking('msg1'); - tracker.markDone('msg1'); - - await tracker.flush(); - expect(order).toEqual(['\u{1F440}', '\u{1F4AD}', '\u{1F504}', '\u{2705}']); - }); - }); - - describe('recover', () => { - it('marks orphaned non-terminal entries as failed and sends error message', async () => { - const fs = await import('fs'); - const persisted = JSON.stringify([ - { messageId: 'orphan1', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 0, terminal: null, trackedAt: 1000 }, - { messageId: 'orphan2', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 2, terminal: null, trackedAt: 2000 }, - { messageId: 'done1', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 3, terminal: 'done', trackedAt: 3000 }, - ]); - (fs.default.existsSync as ReturnType).mockReturnValue(true); - (fs.default.readFileSync as ReturnType).mockReturnValue(persisted); - - await tracker.recover(); - - // Should send ❌ reaction for the 2 non-terminal entries only - const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); - expect(failCalls).toHaveLength(2); - - // Should send one error message per chatJid - expect(deps.sendMessage).toHaveBeenCalledWith( - 'main@s.whatsapp.net', - '[system] Restarted — reprocessing your message.', - ); - expect(deps.sendMessage).toHaveBeenCalledTimes(1); - }); - - it('handles missing persistence file gracefully', async () => { - const fs = await import('fs'); - (fs.default.existsSync as ReturnType).mockReturnValue(false); - - await tracker.recover(); // should not throw - expect(deps.sendReaction).not.toHaveBeenCalled(); - }); - - it('skips error message when sendErrorMessage is false', async () => { - const fs = await import('fs'); - const persisted = JSON.stringify([ - { messageId: 'orphan1', chatJid: 'main@s.whatsapp.net', fromMe: false, state: 1, terminal: null, trackedAt: 1000 }, - ]); - (fs.default.existsSync as ReturnType).mockReturnValue(true); - (fs.default.readFileSync as ReturnType).mockReturnValue(persisted); - - await tracker.recover(false); - - // Still sends ❌ reaction - expect(deps.sendReaction).toHaveBeenCalledTimes(1); - expect(deps.sendReaction.mock.calls[0][2]).toBe('❌'); - // But no text message - expect(deps.sendMessage).not.toHaveBeenCalled(); - }); - }); - - describe('heartbeatCheck', () => { - it('marks messages as failed when container is dead', async () => { - deps.isContainerAlive.mockReturnValue(false); - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markThinking('msg1'); - - tracker.heartbeatCheck(); - await tracker.flush(); - - const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); - expect(failCalls).toHaveLength(1); - expect(deps.sendMessage).toHaveBeenCalledWith( - 'main@s.whatsapp.net', - '[system] Task crashed — retrying.', - ); - }); - - it('does nothing when container is alive', async () => { - deps.isContainerAlive.mockReturnValue(true); - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markThinking('msg1'); - - tracker.heartbeatCheck(); - await tracker.flush(); - - // Only the 👀 and 💭 reactions, no ❌ - expect(deps.sendReaction).toHaveBeenCalledTimes(2); - const emojis = deps.sendReaction.mock.calls.map((c) => c[2]); - expect(emojis).toEqual(['👀', '💭']); - }); - - it('skips RECEIVED messages within grace period even if container is dead', async () => { - vi.useFakeTimers(); - deps.isContainerAlive.mockReturnValue(false); - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - - // Only 10s elapsed — within 30s grace period - vi.advanceTimersByTime(10_000); - tracker.heartbeatCheck(); - await tracker.flush(); - - // Only the 👀 reaction, no ❌ - expect(deps.sendReaction).toHaveBeenCalledTimes(1); - expect(deps.sendReaction.mock.calls[0][2]).toBe('👀'); - }); - - it('fails RECEIVED messages after grace period when container is dead', async () => { - vi.useFakeTimers(); - deps.isContainerAlive.mockReturnValue(false); - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - - // 31s elapsed — past 30s grace period - vi.advanceTimersByTime(31_000); - tracker.heartbeatCheck(); - await tracker.flush(); - - const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); - expect(failCalls).toHaveLength(1); - expect(deps.sendMessage).toHaveBeenCalledWith( - 'main@s.whatsapp.net', - '[system] Task crashed — retrying.', - ); - }); - - it('does NOT fail RECEIVED messages after grace period when container is alive', async () => { - vi.useFakeTimers(); - deps.isContainerAlive.mockReturnValue(true); - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - - // 31s elapsed but container is alive — don't fail - vi.advanceTimersByTime(31_000); - tracker.heartbeatCheck(); - await tracker.flush(); - - expect(deps.sendReaction).toHaveBeenCalledTimes(1); - expect(deps.sendReaction.mock.calls[0][2]).toBe('👀'); - }); - - it('detects stuck messages beyond timeout', async () => { - vi.useFakeTimers(); - deps.isContainerAlive.mockReturnValue(true); // container "alive" but hung - - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markThinking('msg1'); - - // Advance time beyond container timeout (default 1800000ms = 30min) - vi.advanceTimersByTime(1_800_001); - - tracker.heartbeatCheck(); - await tracker.flush(); - - const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); - expect(failCalls).toHaveLength(1); - expect(deps.sendMessage).toHaveBeenCalledWith( - 'main@s.whatsapp.net', - '[system] Task timed out — retrying.', - ); - }); - - it('does not timeout messages queued long in RECEIVED before reaching THINKING', async () => { - vi.useFakeTimers(); - deps.isContainerAlive.mockReturnValue(true); - - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - - // Message sits in RECEIVED for longer than CONTAINER_TIMEOUT (queued, waiting for slot) - vi.advanceTimersByTime(2_000_000); - - // Now container starts — trackedAt resets on THINKING transition - tracker.markThinking('msg1'); - - // Check immediately — should NOT timeout (trackedAt was just reset) - tracker.heartbeatCheck(); - await tracker.flush(); - - const failCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); - expect(failCalls).toHaveLength(0); - - // Advance past CONTAINER_TIMEOUT from THINKING — NOW it should timeout - vi.advanceTimersByTime(1_800_001); - - tracker.heartbeatCheck(); - await tracker.flush(); - - const failCallsAfter = deps.sendReaction.mock.calls.filter((c) => c[2] === '❌'); - expect(failCallsAfter).toHaveLength(1); - }); - }); - - describe('cleanup', () => { - it('removes terminal messages after delay', async () => { - vi.useFakeTimers(); - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markDone('msg1'); - - // Message should still be tracked - expect(tracker.isTracked('msg1')).toBe(true); - - // Advance past cleanup delay - vi.advanceTimersByTime(6000); - - expect(tracker.isTracked('msg1')).toBe(false); - }); - }); - - describe('reaction retry', () => { - it('retries failed sends with exponential backoff (2s, 4s)', async () => { - vi.useFakeTimers(); - let callCount = 0; - deps.sendReaction.mockImplementation(async () => { - callCount++; - if (callCount <= 2) throw new Error('network error'); - }); - - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - - // First attempt fires immediately - await vi.advanceTimersByTimeAsync(0); - expect(callCount).toBe(1); - - // After 2s: second attempt (first retry delay = 2s) - await vi.advanceTimersByTimeAsync(2000); - expect(callCount).toBe(2); - - // After 1s more (3s total): still waiting for 4s delay - await vi.advanceTimersByTimeAsync(1000); - expect(callCount).toBe(2); - - // After 3s more (6s total): third attempt fires (second retry delay = 4s) - await vi.advanceTimersByTimeAsync(3000); - expect(callCount).toBe(3); - - await tracker.flush(); - }); - - it('gives up after max retries', async () => { - vi.useFakeTimers(); - let callCount = 0; - deps.sendReaction.mockImplementation(async () => { - callCount++; - throw new Error('permanent failure'); - }); - - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - - await vi.advanceTimersByTimeAsync(10_000); - await tracker.flush(); - - expect(callCount).toBe(3); // MAX_RETRIES = 3 - }); - }); - - describe('batch transitions', () => { - it('markThinking can be called on multiple messages independently', async () => { - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markReceived('msg2', 'main@s.whatsapp.net', false); - tracker.markReceived('msg3', 'main@s.whatsapp.net', false); - - // Mark all as thinking (simulates batch behavior) - tracker.markThinking('msg1'); - tracker.markThinking('msg2'); - tracker.markThinking('msg3'); - - await tracker.flush(); - - const thinkingCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '💭'); - expect(thinkingCalls).toHaveLength(3); - }); - - it('markWorking can be called on multiple messages independently', async () => { - tracker.markReceived('msg1', 'main@s.whatsapp.net', false); - tracker.markReceived('msg2', 'main@s.whatsapp.net', false); - tracker.markThinking('msg1'); - tracker.markThinking('msg2'); - - tracker.markWorking('msg1'); - tracker.markWorking('msg2'); - - await tracker.flush(); - - const workingCalls = deps.sendReaction.mock.calls.filter((c) => c[2] === '🔄'); - expect(workingCalls).toHaveLength(2); - }); - }); -}); diff --git a/.claude/skills/add-reactions/add/src/status-tracker.ts b/.claude/skills/add-reactions/add/src/status-tracker.ts deleted file mode 100644 index 3753264..0000000 --- a/.claude/skills/add-reactions/add/src/status-tracker.ts +++ /dev/null @@ -1,324 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { DATA_DIR, CONTAINER_TIMEOUT } from './config.js'; -import { logger } from './logger.js'; - -// DONE and FAILED share value 3: both are terminal states with monotonic -// forward-only transitions (state >= current). The emoji differs but the -// ordering logic treats them identically. -export enum StatusState { - RECEIVED = 0, - THINKING = 1, - WORKING = 2, - DONE = 3, - FAILED = 3, -} - -const DONE_EMOJI = '\u{2705}'; -const FAILED_EMOJI = '\u{274C}'; - -const CLEANUP_DELAY_MS = 5000; -const RECEIVED_GRACE_MS = 30_000; -const REACTION_MAX_RETRIES = 3; -const REACTION_BASE_DELAY_MS = 2000; - -interface MessageKey { - id: string; - remoteJid: string; - fromMe?: boolean; -} - -interface TrackedMessage { - messageId: string; - chatJid: string; - fromMe: boolean; - state: number; - terminal: 'done' | 'failed' | null; - sendChain: Promise; - trackedAt: number; -} - -interface PersistedEntry { - messageId: string; - chatJid: string; - fromMe: boolean; - state: number; - terminal: 'done' | 'failed' | null; - trackedAt: number; -} - -export interface StatusTrackerDeps { - sendReaction: ( - chatJid: string, - messageKey: MessageKey, - emoji: string, - ) => Promise; - sendMessage: (chatJid: string, text: string) => Promise; - isMainGroup: (chatJid: string) => boolean; - isContainerAlive: (chatJid: string) => boolean; -} - -export class StatusTracker { - private tracked = new Map(); - private deps: StatusTrackerDeps; - private persistPath: string; - private _shuttingDown = false; - - constructor(deps: StatusTrackerDeps) { - this.deps = deps; - this.persistPath = path.join(DATA_DIR, 'status-tracker.json'); - } - - markReceived(messageId: string, chatJid: string, fromMe: boolean): boolean { - if (!this.deps.isMainGroup(chatJid)) return false; - if (this.tracked.has(messageId)) return false; - - const msg: TrackedMessage = { - messageId, - chatJid, - fromMe, - state: StatusState.RECEIVED, - terminal: null, - sendChain: Promise.resolve(), - trackedAt: Date.now(), - }; - - this.tracked.set(messageId, msg); - this.enqueueSend(msg, '\u{1F440}'); - this.persist(); - return true; - } - - markThinking(messageId: string): boolean { - return this.transition(messageId, StatusState.THINKING, '\u{1F4AD}'); - } - - markWorking(messageId: string): boolean { - return this.transition(messageId, StatusState.WORKING, '\u{1F504}'); - } - - markDone(messageId: string): boolean { - return this.transitionTerminal(messageId, 'done', DONE_EMOJI); - } - - markFailed(messageId: string): boolean { - return this.transitionTerminal(messageId, 'failed', FAILED_EMOJI); - } - - markAllDone(chatJid: string): void { - for (const [id, msg] of this.tracked) { - if (msg.chatJid === chatJid && msg.terminal === null) { - this.transitionTerminal(id, 'done', DONE_EMOJI); - } - } - } - - markAllFailed(chatJid: string, errorMessage: string): void { - let anyFailed = false; - for (const [id, msg] of this.tracked) { - if (msg.chatJid === chatJid && msg.terminal === null) { - this.transitionTerminal(id, 'failed', FAILED_EMOJI); - anyFailed = true; - } - } - if (anyFailed) { - this.deps.sendMessage(chatJid, `[system] ${errorMessage}`).catch((err) => - logger.error({ chatJid, err }, 'Failed to send status error message'), - ); - } - } - - isTracked(messageId: string): boolean { - return this.tracked.has(messageId); - } - - /** Wait for all pending reaction sends to complete. */ - async flush(): Promise { - const chains = Array.from(this.tracked.values()).map((m) => m.sendChain); - await Promise.allSettled(chains); - } - - /** Signal shutdown and flush. Prevents new retry sleeps so flush resolves quickly. */ - async shutdown(): Promise { - this._shuttingDown = true; - await this.flush(); - } - - /** - * Startup recovery: read persisted state and mark all non-terminal entries as failed. - * Call this before the message loop starts. - */ - async recover(sendErrorMessage: boolean = true): Promise { - let entries: PersistedEntry[] = []; - try { - if (fs.existsSync(this.persistPath)) { - const raw = fs.readFileSync(this.persistPath, 'utf-8'); - entries = JSON.parse(raw); - } - } catch (err) { - logger.warn({ err }, 'Failed to read status tracker persistence file'); - return; - } - - const orphanedByChat = new Map(); - for (const entry of entries) { - if (entry.terminal !== null) continue; - - // Reconstruct tracked message for the reaction send - const msg: TrackedMessage = { - messageId: entry.messageId, - chatJid: entry.chatJid, - fromMe: entry.fromMe, - state: entry.state, - terminal: null, - sendChain: Promise.resolve(), - trackedAt: entry.trackedAt, - }; - this.tracked.set(entry.messageId, msg); - this.transitionTerminal(entry.messageId, 'failed', FAILED_EMOJI); - orphanedByChat.set(entry.chatJid, (orphanedByChat.get(entry.chatJid) || 0) + 1); - } - - if (sendErrorMessage) { - for (const [chatJid] of orphanedByChat) { - this.deps.sendMessage( - chatJid, - `[system] Restarted \u{2014} reprocessing your message.`, - ).catch((err) => - logger.error({ chatJid, err }, 'Failed to send recovery message'), - ); - } - } - - await this.flush(); - this.clearPersistence(); - logger.info({ recoveredCount: entries.filter((e) => e.terminal === null).length }, 'Status tracker recovery complete'); - } - - /** - * Heartbeat: check for stale tracked messages where container has died. - * Call this from the IPC poll cycle. - */ - heartbeatCheck(): void { - const now = Date.now(); - for (const [id, msg] of this.tracked) { - if (msg.terminal !== null) continue; - - // For RECEIVED messages, only fail if container is dead AND grace period elapsed. - // This closes the gap where a container dies before advancing to THINKING. - if (msg.state < StatusState.THINKING) { - if (!this.deps.isContainerAlive(msg.chatJid) && now - msg.trackedAt > RECEIVED_GRACE_MS) { - logger.warn({ messageId: id, chatJid: msg.chatJid, age: now - msg.trackedAt }, 'Heartbeat: RECEIVED message stuck with dead container'); - this.markAllFailed(msg.chatJid, 'Task crashed \u{2014} retrying.'); - return; // Safe for main-chat-only scope. If expanded to multiple chats, loop instead of return. - } - continue; - } - - if (!this.deps.isContainerAlive(msg.chatJid)) { - logger.warn({ messageId: id, chatJid: msg.chatJid }, 'Heartbeat: container dead, marking failed'); - this.markAllFailed(msg.chatJid, 'Task crashed \u{2014} retrying.'); - return; // Safe for main-chat-only scope. If expanded to multiple chats, loop instead of return. - } - - if (now - msg.trackedAt > CONTAINER_TIMEOUT) { - logger.warn({ messageId: id, chatJid: msg.chatJid, age: now - msg.trackedAt }, 'Heartbeat: message stuck beyond timeout'); - this.markAllFailed(msg.chatJid, 'Task timed out \u{2014} retrying.'); - return; // See above re: single-chat scope. - } - } - } - - private transition(messageId: string, newState: number, emoji: string): boolean { - const msg = this.tracked.get(messageId); - if (!msg) return false; - if (msg.terminal !== null) return false; - if (newState <= msg.state) return false; - - msg.state = newState; - // Reset trackedAt on THINKING so heartbeat timeout measures from container start, not message receipt - if (newState === StatusState.THINKING) { - msg.trackedAt = Date.now(); - } - this.enqueueSend(msg, emoji); - this.persist(); - return true; - } - - private transitionTerminal(messageId: string, terminal: 'done' | 'failed', emoji: string): boolean { - const msg = this.tracked.get(messageId); - if (!msg) return false; - if (msg.terminal !== null) return false; - - msg.state = StatusState.DONE; // DONE and FAILED both = 3 - msg.terminal = terminal; - this.enqueueSend(msg, emoji); - this.persist(); - this.scheduleCleanup(messageId); - return true; - } - - private enqueueSend(msg: TrackedMessage, emoji: string): void { - const key: MessageKey = { - id: msg.messageId, - remoteJid: msg.chatJid, - fromMe: msg.fromMe, - }; - msg.sendChain = msg.sendChain.then(async () => { - for (let attempt = 1; attempt <= REACTION_MAX_RETRIES; attempt++) { - try { - await this.deps.sendReaction(msg.chatJid, key, emoji); - return; - } catch (err) { - if (attempt === REACTION_MAX_RETRIES) { - logger.error({ messageId: msg.messageId, emoji, err, attempts: attempt }, 'Failed to send status reaction after retries'); - } else if (this._shuttingDown) { - logger.warn({ messageId: msg.messageId, emoji, attempt, err }, 'Reaction send failed, skipping retry (shutting down)'); - return; - } else { - const delay = REACTION_BASE_DELAY_MS * Math.pow(2, attempt - 1); - logger.warn({ messageId: msg.messageId, emoji, attempt, delay, err }, 'Reaction send failed, retrying'); - await new Promise((r) => setTimeout(r, delay)); - } - } - } - }); - } - - /** Must remain async (setTimeout) — synchronous deletion would break iteration in markAllDone/markAllFailed. */ - private scheduleCleanup(messageId: string): void { - setTimeout(() => { - this.tracked.delete(messageId); - this.persist(); - }, CLEANUP_DELAY_MS); - } - - private persist(): void { - try { - const entries: PersistedEntry[] = []; - for (const msg of this.tracked.values()) { - entries.push({ - messageId: msg.messageId, - chatJid: msg.chatJid, - fromMe: msg.fromMe, - state: msg.state, - terminal: msg.terminal, - trackedAt: msg.trackedAt, - }); - } - fs.mkdirSync(path.dirname(this.persistPath), { recursive: true }); - fs.writeFileSync(this.persistPath, JSON.stringify(entries)); - } catch (err) { - logger.warn({ err }, 'Failed to persist status tracker state'); - } - } - - private clearPersistence(): void { - try { - fs.writeFileSync(this.persistPath, '[]'); - } catch { - // ignore - } - } -} diff --git a/.claude/skills/add-reactions/manifest.yaml b/.claude/skills/add-reactions/manifest.yaml deleted file mode 100644 index e26a419..0000000 --- a/.claude/skills/add-reactions/manifest.yaml +++ /dev/null @@ -1,23 +0,0 @@ -skill: reactions -version: 1.0.0 -description: "WhatsApp emoji reaction support with status tracking" -core_version: 0.1.0 -adds: - - scripts/migrate-reactions.ts - - container/skills/reactions/SKILL.md - - src/status-tracker.ts - - src/status-tracker.test.ts -modifies: - - src/db.ts - - src/db.test.ts - - src/channels/whatsapp.ts - - src/types.ts - - src/ipc.ts - - src/index.ts - - container/agent-runner/src/ipc-mcp-stdio.ts - - src/channels/whatsapp.test.ts - - src/group-queue.test.ts - - src/ipc-auth.test.ts -conflicts: [] -depends: [] -test: "npx tsc --noEmit" diff --git a/.claude/skills/add-reactions/modify/container/agent-runner/src/ipc-mcp-stdio.ts b/.claude/skills/add-reactions/modify/container/agent-runner/src/ipc-mcp-stdio.ts deleted file mode 100644 index 042d809..0000000 --- a/.claude/skills/add-reactions/modify/container/agent-runner/src/ipc-mcp-stdio.ts +++ /dev/null @@ -1,440 +0,0 @@ -/** - * Stdio MCP Server for NanoClaw - * Standalone process that agent teams subagents can inherit. - * Reads context from environment variables, writes IPC files for the host. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; -import fs from 'fs'; -import path from 'path'; -import { CronExpressionParser } from 'cron-parser'; - -const IPC_DIR = '/workspace/ipc'; -const MESSAGES_DIR = path.join(IPC_DIR, 'messages'); -const TASKS_DIR = path.join(IPC_DIR, 'tasks'); - -// Context from environment variables (set by the agent runner) -const chatJid = process.env.NANOCLAW_CHAT_JID!; -const groupFolder = process.env.NANOCLAW_GROUP_FOLDER!; -const isMain = process.env.NANOCLAW_IS_MAIN === '1'; - -function writeIpcFile(dir: string, data: object): string { - fs.mkdirSync(dir, { recursive: true }); - - const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`; - const filepath = path.join(dir, filename); - - // Atomic write: temp file then rename - const tempPath = `${filepath}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); - fs.renameSync(tempPath, filepath); - - return filename; -} - -const server = new McpServer({ - name: 'nanoclaw', - version: '1.0.0', -}); - -server.tool( - 'send_message', - "Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times. Note: when running as a scheduled task, your final output is NOT sent to the user — use this tool if you need to communicate with the user or group.", - { - text: z.string().describe('The message text to send'), - sender: z - .string() - .optional() - .describe( - 'Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.', - ), - }, - async (args) => { - const data: Record = { - type: 'message', - chatJid, - text: args.text, - sender: args.sender || undefined, - groupFolder, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(MESSAGES_DIR, data); - - return { content: [{ type: 'text' as const, text: 'Message sent.' }] }; - }, -); - -server.tool( - 'react_to_message', - 'React to a message with an emoji. Omit message_id to react to the most recent message in the chat.', - { - emoji: z - .string() - .describe('The emoji to react with (e.g. "👍", "❤️", "🔥")'), - message_id: z - .string() - .optional() - .describe( - 'The message ID to react to. If omitted, reacts to the latest message in the chat.', - ), - }, - async (args) => { - const data: Record = { - type: 'reaction', - chatJid, - emoji: args.emoji, - messageId: args.message_id || undefined, - groupFolder, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(MESSAGES_DIR, data); - - return { - content: [ - { type: 'text' as const, text: `Reaction ${args.emoji} sent.` }, - ], - }; - }, -); - -server.tool( - 'schedule_task', - `Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. - -CONTEXT MODE - Choose based on task type: -\u2022 "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions. -\u2022 "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself. - -If unsure which mode to use, you can ask the user. Examples: -- "Remind me about our discussion" \u2192 group (needs conversation context) -- "Check the weather every morning" \u2192 isolated (self-contained task) -- "Follow up on my request" \u2192 group (needs to know what was requested) -- "Generate a daily report" \u2192 isolated (just needs instructions in prompt) - -MESSAGING BEHAVIOR - The task agent's output is sent to the user or group. It can also use send_message for immediate delivery, or wrap output in tags to suppress it. Include guidance in the prompt about whether the agent should: -\u2022 Always send a message (e.g., reminders, daily briefings) -\u2022 Only send a message when there's something to report (e.g., "notify me if...") -\u2022 Never send a message (background maintenance tasks) - -SCHEDULE VALUE FORMAT (all times are LOCAL timezone): -\u2022 cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time) -\u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour) -\u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`, - { - prompt: z - .string() - .describe( - 'What the agent should do when the task runs. For isolated mode, include all necessary context here.', - ), - schedule_type: z - .enum(['cron', 'interval', 'once']) - .describe( - 'cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time', - ), - schedule_value: z - .string() - .describe( - 'cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)', - ), - context_mode: z - .enum(['group', 'isolated']) - .default('group') - .describe( - 'group=runs with chat history and memory, isolated=fresh session (include context in prompt)', - ), - target_group_jid: z - .string() - .optional() - .describe( - '(Main group only) JID of the group to schedule the task for. Defaults to the current group.', - ), - }, - async (args) => { - // Validate schedule_value before writing IPC - if (args.schedule_type === 'cron') { - try { - CronExpressionParser.parse(args.schedule_value); - } catch { - return { - content: [ - { - type: 'text' as const, - text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).`, - }, - ], - isError: true, - }; - } - } else if (args.schedule_type === 'interval') { - const ms = parseInt(args.schedule_value, 10); - if (isNaN(ms) || ms <= 0) { - return { - content: [ - { - type: 'text' as const, - text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).`, - }, - ], - isError: true, - }; - } - } else if (args.schedule_type === 'once') { - if ( - /[Zz]$/.test(args.schedule_value) || - /[+-]\d{2}:\d{2}$/.test(args.schedule_value) - ) { - return { - content: [ - { - type: 'text' as const, - text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".`, - }, - ], - isError: true, - }; - } - const date = new Date(args.schedule_value); - if (isNaN(date.getTime())) { - return { - content: [ - { - type: 'text' as const, - text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".`, - }, - ], - isError: true, - }; - } - } - - // Non-main groups can only schedule for themselves - const targetJid = - isMain && args.target_group_jid ? args.target_group_jid : chatJid; - - const data = { - type: 'schedule_task', - prompt: args.prompt, - schedule_type: args.schedule_type, - schedule_value: args.schedule_value, - context_mode: args.context_mode || 'group', - targetJid, - createdBy: groupFolder, - timestamp: new Date().toISOString(), - }; - - const filename = writeIpcFile(TASKS_DIR, data); - - return { - content: [ - { - type: 'text' as const, - text: `Task scheduled (${filename}): ${args.schedule_type} - ${args.schedule_value}`, - }, - ], - }; - }, -); - -server.tool( - 'list_tasks', - "List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.", - {}, - async () => { - const tasksFile = path.join(IPC_DIR, 'current_tasks.json'); - - try { - if (!fs.existsSync(tasksFile)) { - return { - content: [ - { type: 'text' as const, text: 'No scheduled tasks found.' }, - ], - }; - } - - const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8')); - - const tasks = isMain - ? allTasks - : allTasks.filter( - (t: { groupFolder: string }) => t.groupFolder === groupFolder, - ); - - if (tasks.length === 0) { - return { - content: [ - { type: 'text' as const, text: 'No scheduled tasks found.' }, - ], - }; - } - - const formatted = tasks - .map( - (t: { - id: string; - prompt: string; - schedule_type: string; - schedule_value: string; - status: string; - next_run: string; - }) => - `- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`, - ) - .join('\n'); - - return { - content: [ - { type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }, - ], - }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - }; - } - }, -); - -server.tool( - 'pause_task', - 'Pause a scheduled task. It will not run until resumed.', - { task_id: z.string().describe('The task ID to pause') }, - async (args) => { - const data = { - type: 'pause_task', - taskId: args.task_id, - groupFolder, - isMain, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(TASKS_DIR, data); - - return { - content: [ - { - type: 'text' as const, - text: `Task ${args.task_id} pause requested.`, - }, - ], - }; - }, -); - -server.tool( - 'resume_task', - 'Resume a paused task.', - { task_id: z.string().describe('The task ID to resume') }, - async (args) => { - const data = { - type: 'resume_task', - taskId: args.task_id, - groupFolder, - isMain, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(TASKS_DIR, data); - - return { - content: [ - { - type: 'text' as const, - text: `Task ${args.task_id} resume requested.`, - }, - ], - }; - }, -); - -server.tool( - 'cancel_task', - 'Cancel and delete a scheduled task.', - { task_id: z.string().describe('The task ID to cancel') }, - async (args) => { - const data = { - type: 'cancel_task', - taskId: args.task_id, - groupFolder, - isMain, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(TASKS_DIR, data); - - return { - content: [ - { - type: 'text' as const, - text: `Task ${args.task_id} cancellation requested.`, - }, - ], - }; - }, -); - -server.tool( - 'register_group', - `Register a new chat/group so the agent can respond to messages there. Main group only. - -Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`, - { - jid: z - .string() - .describe( - 'The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")', - ), - name: z.string().describe('Display name for the group'), - folder: z - .string() - .describe( - 'Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")', - ), - trigger: z.string().describe('Trigger word (e.g., "@Andy")'), - }, - async (args) => { - if (!isMain) { - return { - content: [ - { - type: 'text' as const, - text: 'Only the main group can register new groups.', - }, - ], - isError: true, - }; - } - - const data = { - type: 'register_group', - jid: args.jid, - name: args.name, - folder: args.folder, - trigger: args.trigger, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(TASKS_DIR, data); - - return { - content: [ - { - type: 'text' as const, - text: `Group "${args.name}" registered. It will start receiving messages immediately.`, - }, - ], - }; - }, -); - -// Start the stdio transport -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/.claude/skills/add-reactions/modify/src/channels/whatsapp.test.ts b/.claude/skills/add-reactions/modify/src/channels/whatsapp.test.ts deleted file mode 100644 index f332811..0000000 --- a/.claude/skills/add-reactions/modify/src/channels/whatsapp.test.ts +++ /dev/null @@ -1,952 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { EventEmitter } from 'events'; - -// --- Mocks --- - -// Mock config -vi.mock('../config.js', () => ({ - STORE_DIR: '/tmp/nanoclaw-test-store', - ASSISTANT_NAME: 'Andy', - ASSISTANT_HAS_OWN_NUMBER: false, -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// Mock db -vi.mock('../db.js', () => ({ - getLastGroupSync: vi.fn(() => null), - getLatestMessage: vi.fn(() => undefined), - getMessageFromMe: vi.fn(() => false), - setLastGroupSync: vi.fn(), - storeReaction: vi.fn(), - updateChatName: vi.fn(), -})); - -// Mock fs -vi.mock('fs', async () => { - const actual = await vi.importActual('fs'); - return { - ...actual, - default: { - ...actual, - existsSync: vi.fn(() => true), - mkdirSync: vi.fn(), - }, - }; -}); - -// Mock child_process (used for osascript notification) -vi.mock('child_process', () => ({ - exec: vi.fn(), -})); - -// Build a fake WASocket that's an EventEmitter with the methods we need -function createFakeSocket() { - const ev = new EventEmitter(); - const sock = { - ev: { - on: (event: string, handler: (...args: unknown[]) => void) => { - ev.on(event, handler); - }, - }, - user: { - id: '1234567890:1@s.whatsapp.net', - lid: '9876543210:1@lid', - }, - sendMessage: vi.fn().mockResolvedValue(undefined), - sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), - groupFetchAllParticipating: vi.fn().mockResolvedValue({}), - end: vi.fn(), - // Expose the event emitter for triggering events in tests - _ev: ev, - }; - return sock; -} - -let fakeSocket: ReturnType; - -// Mock Baileys -vi.mock('@whiskeysockets/baileys', () => { - return { - default: vi.fn(() => fakeSocket), - Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) }, - DisconnectReason: { - loggedOut: 401, - badSession: 500, - connectionClosed: 428, - connectionLost: 408, - connectionReplaced: 440, - timedOut: 408, - restartRequired: 515, - }, - fetchLatestWaWebVersion: vi - .fn() - .mockResolvedValue({ version: [2, 3000, 0] }), - makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), - useMultiFileAuthState: vi.fn().mockResolvedValue({ - state: { - creds: {}, - keys: {}, - }, - saveCreds: vi.fn(), - }), - }; -}); - -import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js'; -import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js'; - -// --- Test helpers --- - -function createTestOpts( - overrides?: Partial, -): WhatsAppChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: vi.fn(() => ({ - 'registered@g.us': { - name: 'Test Group', - folder: 'test-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - ...overrides, - }; -} - -function triggerConnection(state: string, extra?: Record) { - fakeSocket._ev.emit('connection.update', { connection: state, ...extra }); -} - -function triggerDisconnect(statusCode: number) { - fakeSocket._ev.emit('connection.update', { - connection: 'close', - lastDisconnect: { - error: { output: { statusCode } }, - }, - }); -} - -async function triggerMessages(messages: unknown[]) { - fakeSocket._ev.emit('messages.upsert', { messages }); - // Flush microtasks so the async messages.upsert handler completes - await new Promise((r) => setTimeout(r, 0)); -} - -// --- Tests --- - -describe('WhatsAppChannel', () => { - beforeEach(() => { - fakeSocket = createFakeSocket(); - vi.mocked(getLastGroupSync).mockReturnValue(null); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - /** - * Helper: start connect, flush microtasks so event handlers are registered, - * then trigger the connection open event. Returns the resolved promise. - */ - async function connectChannel(channel: WhatsAppChannel): Promise { - const p = channel.connect(); - // Flush microtasks so connectInternal completes its await and registers handlers - await new Promise((r) => setTimeout(r, 0)); - triggerConnection('open'); - return p; - } - - // --- Version fetch --- - - describe('version fetch', () => { - it('connects with fetched version', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - await connectChannel(channel); - - const { fetchLatestWaWebVersion } = - await import('@whiskeysockets/baileys'); - expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({}); - }); - - it('falls back gracefully when version fetch fails', async () => { - const { fetchLatestWaWebVersion } = - await import('@whiskeysockets/baileys'); - vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce( - new Error('network error'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - await connectChannel(channel); - - // Should still connect successfully despite fetch failure - expect(channel.isConnected()).toBe(true); - }); - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when connection opens', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - }); - - it('sets up LID to phone mapping on open', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The channel should have mapped the LID from sock.user - // We can verify by sending a message from a LID JID - // and checking the translated JID in the callback - }); - - it('flushes outgoing queue on reconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect - (channel as any).connected = false; - - // Queue a message while disconnected - await channel.sendMessage('test@g.us', 'Queued message'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - - // Reconnect - (channel as any).connected = true; - await (channel as any).flushOutgoingQueue(); - - // Group messages get prefixed when flushed - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { - text: 'Andy: Queued message', - }); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - expect(fakeSocket.end).toHaveBeenCalled(); - }); - }); - - // --- QR code and auth --- - - describe('authentication', () => { - it('exits process when QR code is emitted (no auth state)', async () => { - vi.useFakeTimers(); - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Start connect but don't await (it won't resolve - process exits) - channel.connect().catch(() => {}); - - // Flush microtasks so connectInternal registers handlers - await vi.advanceTimersByTimeAsync(0); - - // Emit QR code event - fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' }); - - // Advance timer past the 1000ms setTimeout before exit - await vi.advanceTimersByTimeAsync(1500); - - expect(mockExit).toHaveBeenCalledWith(1); - mockExit.mockRestore(); - vi.useRealTimers(); - }); - }); - - // --- Reconnection behavior --- - - describe('reconnection', () => { - it('reconnects on non-loggedOut disconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - - // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428) - triggerDisconnect(428); - - expect(channel.isConnected()).toBe(false); - // The channel should attempt to reconnect (calls connectInternal again) - }); - - it('exits on loggedOut disconnect', async () => { - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with loggedOut reason (401) - triggerDisconnect(401); - - expect(channel.isConnected()).toBe(false); - expect(mockExit).toHaveBeenCalledWith(0); - mockExit.mockRestore(); - }); - - it('retries reconnection after 5s on failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with stream error 515 - triggerDisconnect(515); - - // The channel sets a 5s retry — just verify it doesn't crash - await new Promise((r) => setTimeout(r, 100)); - }); - }); - - // --- Message handling --- - - describe('message handling', () => { - it('delivers message for registered group', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-1', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello Andy' }, - pushName: 'Alice', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - id: 'msg-1', - content: 'Hello Andy', - sender_name: 'Alice', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered groups', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-2', - remoteJid: 'unregistered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello' }, - pushName: 'Bob', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'unregistered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores status@broadcast messages', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-3', - remoteJid: 'status@broadcast', - fromMe: false, - }, - message: { conversation: 'Status update' }, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores messages with no content', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-4', - remoteJid: 'registered@g.us', - fromMe: false, - }, - message: null, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('extracts text from extendedTextMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-5', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - extendedTextMessage: { text: 'A reply message' }, - }, - pushName: 'Charlie', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'A reply message' }), - ); - }); - - it('extracts caption from imageMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-6', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - imageMessage: { - caption: 'Check this photo', - mimetype: 'image/jpeg', - }, - }, - pushName: 'Diana', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Check this photo' }), - ); - }); - - it('extracts caption from videoMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-7', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' }, - }, - pushName: 'Eve', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Watch this' }), - ); - }); - - it('handles message with no extractable text (e.g. voice note without caption)', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-8', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, - }, - pushName: 'Frank', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Skipped — no text content to process - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('uses sender JID when pushName is absent', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-9', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'No push name' }, - // pushName is undefined - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ sender_name: '5551234' }), - ); - }); - }); - - // --- LID ↔ JID translation --- - - describe('LID to JID translation', () => { - it('translates known LID to phone JID', async () => { - const opts = createTestOpts({ - registeredGroups: vi.fn(() => ({ - '1234567890@s.whatsapp.net': { - name: 'Self Chat', - folder: 'self-chat', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net' - // Send a message from the LID - await triggerMessages([ - { - key: { - id: 'msg-lid', - remoteJid: '9876543210@lid', - fromMe: false, - }, - message: { conversation: 'From LID' }, - pushName: 'Self', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Should be translated to phone JID - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '1234567890@s.whatsapp.net', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - - it('passes through non-LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-normal', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Normal JID' }, - pushName: 'Grace', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - }); - - it('passes through unknown LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-unknown-lid', - remoteJid: '0000000000@lid', - fromMe: false, - }, - message: { conversation: 'Unknown LID' }, - pushName: 'Unknown', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Unknown LID passes through unchanged - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '0000000000@lid', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - }); - - // --- Outgoing message queue --- - - describe('outgoing message queue', () => { - it('sends message directly when connected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('test@g.us', 'Hello'); - // Group messages get prefixed with assistant name - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { - text: 'Andy: Hello', - }); - }); - - it('prefixes direct chat messages on shared number', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('123@s.whatsapp.net', 'Hello'); - // Shared number: DMs also get prefixed (needed for self-chat distinction) - expect(fakeSocket.sendMessage).toHaveBeenCalledWith( - '123@s.whatsapp.net', - { text: 'Andy: Hello' }, - ); - }); - - it('queues message when disconnected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Don't connect — channel starts disconnected - await channel.sendMessage('test@g.us', 'Queued'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - }); - - it('queues message on send failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Make sendMessage fail - fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error')); - - await channel.sendMessage('test@g.us', 'Will fail'); - - // Should not throw, message queued for retry - // The queue should have the message - }); - - it('flushes multiple queued messages in order', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Queue messages while disconnected - await channel.sendMessage('test@g.us', 'First'); - await channel.sendMessage('test@g.us', 'Second'); - await channel.sendMessage('test@g.us', 'Third'); - - // Connect — flush happens automatically on open - await connectChannel(channel); - - // Give the async flush time to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3); - // Group messages get prefixed - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { - text: 'Andy: First', - }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { - text: 'Andy: Second', - }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { - text: 'Andy: Third', - }); - }); - }); - - // --- Group metadata sync --- - - describe('group metadata sync', () => { - it('syncs group metadata on first connection', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Group One' }, - 'group2@g.us': { subject: 'Group Two' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Wait for async sync to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One'); - expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two'); - expect(setLastGroupSync).toHaveBeenCalled(); - }); - - it('skips sync when synced recently', async () => { - // Last sync was 1 hour ago (within 24h threshold) - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled(); - }); - - it('forces sync regardless of cache', async () => { - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group@g.us': { subject: 'Forced Group' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.syncGroupMetadata(true); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group'); - }); - - it('handles group sync failure gracefully', async () => { - fakeSocket.groupFetchAllParticipating.mockRejectedValue( - new Error('Network timeout'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Should not throw - await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined(); - }); - - it('skips groups with no subject', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Has Subject' }, - 'group2@g.us': { subject: '' }, - 'group3@g.us': {}, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Clear any calls from the automatic sync on connect - vi.mocked(updateChatName).mockClear(); - - await channel.syncGroupMetadata(true); - - expect(updateChatName).toHaveBeenCalledTimes(1); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject'); - }); - }); - - // --- JID ownership --- - - describe('ownsJid', () => { - it('owns @g.us JIDs (WhatsApp groups)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(true); - }); - - it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true); - }); - - it('does not own Telegram JIDs', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('tg:12345')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); - }); - - // --- Typing indicator --- - - describe('setTyping', () => { - it('sends composing presence when typing', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', true); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( - 'composing', - 'test@g.us', - ); - }); - - it('sends paused presence when stopping', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', false); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( - 'paused', - 'test@g.us', - ); - }); - - it('handles typing indicator failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed')); - - // Should not throw - await expect( - channel.setTyping('test@g.us', true), - ).resolves.toBeUndefined(); - }); - }); - - // --- Channel properties --- - - describe('channel properties', () => { - it('has name "whatsapp"', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.name).toBe('whatsapp'); - }); - - it('does not expose prefixAssistantName (prefix handled internally)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect('prefixAssistantName' in channel).toBe(false); - }); - }); -}); diff --git a/.claude/skills/add-reactions/modify/src/channels/whatsapp.ts b/.claude/skills/add-reactions/modify/src/channels/whatsapp.ts deleted file mode 100644 index f718ee4..0000000 --- a/.claude/skills/add-reactions/modify/src/channels/whatsapp.ts +++ /dev/null @@ -1,457 +0,0 @@ -import { exec } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import makeWASocket, { - Browsers, - DisconnectReason, - WASocket, - fetchLatestWaWebVersion, - makeCacheableSignalKeyStore, - useMultiFileAuthState, -} from '@whiskeysockets/baileys'; - -import { - ASSISTANT_HAS_OWN_NUMBER, - ASSISTANT_NAME, - STORE_DIR, -} from '../config.js'; -import { getLastGroupSync, getLatestMessage, setLastGroupSync, storeReaction, updateChatName } from '../db.js'; -import { logger } from '../logger.js'; -import { - Channel, - OnInboundMessage, - OnChatMetadata, - RegisteredGroup, -} from '../types.js'; - -const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours - -export interface WhatsAppChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -export class WhatsAppChannel implements Channel { - name = 'whatsapp'; - - private sock!: WASocket; - private connected = false; - private lidToPhoneMap: Record = {}; - private outgoingQueue: Array<{ jid: string; text: string }> = []; - private flushing = false; - private groupSyncTimerStarted = false; - - private opts: WhatsAppChannelOpts; - - constructor(opts: WhatsAppChannelOpts) { - this.opts = opts; - } - - async connect(): Promise { - return new Promise((resolve, reject) => { - this.connectInternal(resolve).catch(reject); - }); - } - - private async connectInternal(onFirstOpen?: () => void): Promise { - const authDir = path.join(STORE_DIR, 'auth'); - fs.mkdirSync(authDir, { recursive: true }); - - const { state, saveCreds } = await useMultiFileAuthState(authDir); - - const { version } = await fetchLatestWaWebVersion({}).catch((err) => { - logger.warn( - { err }, - 'Failed to fetch latest WA Web version, using default', - ); - return { version: undefined }; - }); - this.sock = makeWASocket({ - version, - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, logger), - }, - printQRInTerminal: false, - logger, - browser: Browsers.macOS('Chrome'), - }); - - this.sock.ev.on('connection.update', (update) => { - const { connection, lastDisconnect, qr } = update; - - if (qr) { - const msg = - 'WhatsApp authentication required. Run /setup in Claude Code.'; - logger.error(msg); - exec( - `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`, - ); - setTimeout(() => process.exit(1), 1000); - } - - if (connection === 'close') { - this.connected = false; - const reason = ( - lastDisconnect?.error as { output?: { statusCode?: number } } - )?.output?.statusCode; - const shouldReconnect = reason !== DisconnectReason.loggedOut; - logger.info( - { - reason, - shouldReconnect, - queuedMessages: this.outgoingQueue.length, - }, - 'Connection closed', - ); - - if (shouldReconnect) { - logger.info('Reconnecting...'); - this.connectInternal().catch((err) => { - logger.error({ err }, 'Failed to reconnect, retrying in 5s'); - setTimeout(() => { - this.connectInternal().catch((err2) => { - logger.error({ err: err2 }, 'Reconnection retry failed'); - }); - }, 5000); - }); - } else { - logger.info('Logged out. Run /setup to re-authenticate.'); - process.exit(0); - } - } else if (connection === 'open') { - this.connected = true; - logger.info('Connected to WhatsApp'); - - // Announce availability so WhatsApp relays subsequent presence updates (typing indicators) - this.sock.sendPresenceUpdate('available').catch((err) => { - logger.warn({ err }, 'Failed to send presence update'); - }); - - // Build LID to phone mapping from auth state for self-chat translation - if (this.sock.user) { - const phoneUser = this.sock.user.id.split(':')[0]; - const lidUser = this.sock.user.lid?.split(':')[0]; - if (lidUser && phoneUser) { - this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`; - logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set'); - } - } - - // Flush any messages queued while disconnected - this.flushOutgoingQueue().catch((err) => - logger.error({ err }, 'Failed to flush outgoing queue'), - ); - - // Sync group metadata on startup (respects 24h cache) - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Initial group sync failed'), - ); - // Set up daily sync timer (only once) - if (!this.groupSyncTimerStarted) { - this.groupSyncTimerStarted = true; - setInterval(() => { - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Periodic group sync failed'), - ); - }, GROUP_SYNC_INTERVAL_MS); - } - - // Signal first connection to caller - if (onFirstOpen) { - onFirstOpen(); - onFirstOpen = undefined; - } - } - }); - - this.sock.ev.on('creds.update', saveCreds); - - this.sock.ev.on('messages.upsert', async ({ messages }) => { - for (const msg of messages) { - if (!msg.message) continue; - const rawJid = msg.key.remoteJid; - if (!rawJid || rawJid === 'status@broadcast') continue; - - // Translate LID JID to phone JID if applicable - const chatJid = await this.translateJid(rawJid); - - const timestamp = new Date( - Number(msg.messageTimestamp) * 1000, - ).toISOString(); - - // Always notify about chat metadata for group discovery - const isGroup = chatJid.endsWith('@g.us'); - this.opts.onChatMetadata( - chatJid, - timestamp, - undefined, - 'whatsapp', - isGroup, - ); - - // Only deliver full message for registered groups - const groups = this.opts.registeredGroups(); - if (groups[chatJid]) { - const content = - msg.message?.conversation || - msg.message?.extendedTextMessage?.text || - msg.message?.imageMessage?.caption || - msg.message?.videoMessage?.caption || - ''; - - // Skip protocol messages with no text content (encryption keys, read receipts, etc.) - if (!content) continue; - - const sender = msg.key.participant || msg.key.remoteJid || ''; - const senderName = msg.pushName || sender.split('@')[0]; - - const fromMe = msg.key.fromMe || false; - // Detect bot messages: with own number, fromMe is reliable - // since only the bot sends from that number. - // With shared number, bot messages carry the assistant name prefix - // (even in DMs/self-chat) so we check for that. - const isBotMessage = ASSISTANT_HAS_OWN_NUMBER - ? fromMe - : content.startsWith(`${ASSISTANT_NAME}:`); - - this.opts.onMessage(chatJid, { - id: msg.key.id || '', - chat_jid: chatJid, - sender, - sender_name: senderName, - content, - timestamp, - is_from_me: fromMe, - is_bot_message: isBotMessage, - }); - } - } - }); - - // Listen for message reactions - this.sock.ev.on('messages.reaction', async (reactions) => { - for (const { key, reaction } of reactions) { - try { - const messageId = key.id; - if (!messageId) continue; - const rawChatJid = key.remoteJid; - if (!rawChatJid || rawChatJid === 'status@broadcast') continue; - const chatJid = await this.translateJid(rawChatJid); - const groups = this.opts.registeredGroups(); - if (!groups[chatJid]) continue; - const reactorJid = reaction.key?.participant || reaction.key?.remoteJid || ''; - const emoji = reaction.text || ''; - const timestamp = reaction.senderTimestampMs - ? new Date(Number(reaction.senderTimestampMs)).toISOString() - : new Date().toISOString(); - storeReaction({ - message_id: messageId, - message_chat_jid: chatJid, - reactor_jid: reactorJid, - reactor_name: reactorJid.split('@')[0], - emoji, - timestamp, - }); - logger.info( - { - chatJid, - messageId: messageId.slice(0, 10) + '...', - reactor: reactorJid.split('@')[0], - emoji: emoji || '(removed)', - }, - emoji ? 'Reaction added' : 'Reaction removed' - ); - } catch (err) { - logger.error({ err }, 'Failed to process reaction'); - } - } - }); - } - - async sendMessage(jid: string, text: string): Promise { - // Prefix bot messages with assistant name so users know who's speaking. - // On a shared number, prefix is also needed in DMs (including self-chat) - // to distinguish bot output from user messages. - // Skip only when the assistant has its own dedicated phone number. - const prefixed = ASSISTANT_HAS_OWN_NUMBER - ? text - : `${ASSISTANT_NAME}: ${text}`; - - if (!this.connected) { - this.outgoingQueue.push({ jid, text: prefixed }); - logger.info( - { jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, - 'WA disconnected, message queued', - ); - return; - } - try { - await this.sock.sendMessage(jid, { text: prefixed }); - logger.info({ jid, length: prefixed.length }, 'Message sent'); - } catch (err) { - // If send fails, queue it for retry on reconnect - this.outgoingQueue.push({ jid, text: prefixed }); - logger.warn( - { jid, err, queueSize: this.outgoingQueue.length }, - 'Failed to send, message queued', - ); - } - } - - async sendReaction( - chatJid: string, - messageKey: { id: string; remoteJid: string; fromMe?: boolean; participant?: string }, - emoji: string - ): Promise { - if (!this.connected) { - logger.warn({ chatJid, emoji }, 'Cannot send reaction - not connected'); - throw new Error('Not connected to WhatsApp'); - } - try { - await this.sock.sendMessage(chatJid, { - react: { text: emoji, key: messageKey }, - }); - logger.info( - { - chatJid, - messageId: messageKey.id?.slice(0, 10) + '...', - emoji: emoji || '(removed)', - }, - emoji ? 'Reaction sent' : 'Reaction removed' - ); - } catch (err) { - logger.error({ chatJid, emoji, err }, 'Failed to send reaction'); - throw err; - } - } - - async reactToLatestMessage(chatJid: string, emoji: string): Promise { - const latest = getLatestMessage(chatJid); - if (!latest) { - throw new Error(`No messages found for chat ${chatJid}`); - } - const messageKey = { - id: latest.id, - remoteJid: chatJid, - fromMe: latest.fromMe, - }; - await this.sendReaction(chatJid, messageKey, emoji); - } - - isConnected(): boolean { - return this.connected; - } - - ownsJid(jid: string): boolean { - return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net'); - } - - async disconnect(): Promise { - this.connected = false; - this.sock?.end(undefined); - } - - async setTyping(jid: string, isTyping: boolean): Promise { - try { - const status = isTyping ? 'composing' : 'paused'; - logger.debug({ jid, status }, 'Sending presence update'); - await this.sock.sendPresenceUpdate(status, jid); - } catch (err) { - logger.debug({ jid, err }, 'Failed to update typing status'); - } - } - - /** - * Sync group metadata from WhatsApp. - * Fetches all participating groups and stores their names in the database. - * Called on startup, daily, and on-demand via IPC. - */ - async syncGroupMetadata(force = false): Promise { - if (!force) { - const lastSync = getLastGroupSync(); - if (lastSync) { - const lastSyncTime = new Date(lastSync).getTime(); - if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) { - logger.debug({ lastSync }, 'Skipping group sync - synced recently'); - return; - } - } - } - - try { - logger.info('Syncing group metadata from WhatsApp...'); - const groups = await this.sock.groupFetchAllParticipating(); - - let count = 0; - for (const [jid, metadata] of Object.entries(groups)) { - if (metadata.subject) { - updateChatName(jid, metadata.subject); - count++; - } - } - - setLastGroupSync(); - logger.info({ count }, 'Group metadata synced'); - } catch (err) { - logger.error({ err }, 'Failed to sync group metadata'); - } - } - - private async translateJid(jid: string): Promise { - if (!jid.endsWith('@lid')) return jid; - const lidUser = jid.split('@')[0].split(':')[0]; - - // Check local cache first - const cached = this.lidToPhoneMap[lidUser]; - if (cached) { - logger.debug( - { lidJid: jid, phoneJid: cached }, - 'Translated LID to phone JID (cached)', - ); - return cached; - } - - // Query Baileys' signal repository for the mapping - try { - const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid); - if (pn) { - const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; - this.lidToPhoneMap[lidUser] = phoneJid; - logger.info( - { lidJid: jid, phoneJid }, - 'Translated LID to phone JID (signalRepository)', - ); - return phoneJid; - } - } catch (err) { - logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository'); - } - - return jid; - } - - private async flushOutgoingQueue(): Promise { - if (this.flushing || this.outgoingQueue.length === 0) return; - this.flushing = true; - try { - logger.info( - { count: this.outgoingQueue.length }, - 'Flushing outgoing message queue', - ); - while (this.outgoingQueue.length > 0) { - const item = this.outgoingQueue.shift()!; - // Send directly — queued items are already prefixed by sendMessage - await this.sock.sendMessage(item.jid, { text: item.text }); - logger.info( - { jid: item.jid, length: item.text.length }, - 'Queued message sent', - ); - } - } finally { - this.flushing = false; - } - } -} diff --git a/.claude/skills/add-reactions/modify/src/db.test.ts b/.claude/skills/add-reactions/modify/src/db.test.ts deleted file mode 100644 index 0732542..0000000 --- a/.claude/skills/add-reactions/modify/src/db.test.ts +++ /dev/null @@ -1,715 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; - -import { - _initTestDatabase, - createTask, - deleteTask, - getAllChats, - getLatestMessage, - getMessageFromMe, - getMessagesByReaction, - getMessagesSince, - getNewMessages, - getReactionsForMessage, - getReactionsByUser, - getReactionStats, - getTaskById, - storeChatMetadata, - storeMessage, - storeReaction, - updateTask, -} from './db.js'; - -beforeEach(() => { - _initTestDatabase(); -}); - -// Helper to store a message using the normalized NewMessage interface -function store(overrides: { - id: string; - chat_jid: string; - sender: string; - sender_name: string; - content: string; - timestamp: string; - is_from_me?: boolean; -}) { - storeMessage({ - id: overrides.id, - chat_jid: overrides.chat_jid, - sender: overrides.sender, - sender_name: overrides.sender_name, - content: overrides.content, - timestamp: overrides.timestamp, - is_from_me: overrides.is_from_me ?? false, - }); -} - -// --- storeMessage (NewMessage format) --- - -describe('storeMessage', () => { - it('stores a message and retrieves it', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - - store({ - id: 'msg-1', - chat_jid: 'group@g.us', - sender: '123@s.whatsapp.net', - sender_name: 'Alice', - content: 'hello world', - timestamp: '2024-01-01T00:00:01.000Z', - }); - - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); - expect(messages).toHaveLength(1); - expect(messages[0].id).toBe('msg-1'); - expect(messages[0].sender).toBe('123@s.whatsapp.net'); - expect(messages[0].sender_name).toBe('Alice'); - expect(messages[0].content).toBe('hello world'); - }); - - it('filters out empty content', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - - store({ - id: 'msg-2', - chat_jid: 'group@g.us', - sender: '111@s.whatsapp.net', - sender_name: 'Dave', - content: '', - timestamp: '2024-01-01T00:00:04.000Z', - }); - - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); - expect(messages).toHaveLength(0); - }); - - it('stores is_from_me flag', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - - store({ - id: 'msg-3', - chat_jid: 'group@g.us', - sender: 'me@s.whatsapp.net', - sender_name: 'Me', - content: 'my message', - timestamp: '2024-01-01T00:00:05.000Z', - is_from_me: true, - }); - - // Message is stored (we can retrieve it — is_from_me doesn't affect retrieval) - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); - expect(messages).toHaveLength(1); - }); - - it('upserts on duplicate id+chat_jid', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - - store({ - id: 'msg-dup', - chat_jid: 'group@g.us', - sender: '123@s.whatsapp.net', - sender_name: 'Alice', - content: 'original', - timestamp: '2024-01-01T00:00:01.000Z', - }); - - store({ - id: 'msg-dup', - chat_jid: 'group@g.us', - sender: '123@s.whatsapp.net', - sender_name: 'Alice', - content: 'updated', - timestamp: '2024-01-01T00:00:01.000Z', - }); - - const messages = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); - expect(messages).toHaveLength(1); - expect(messages[0].content).toBe('updated'); - }); -}); - -// --- getMessagesSince --- - -describe('getMessagesSince', () => { - beforeEach(() => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - - store({ - id: 'm1', - chat_jid: 'group@g.us', - sender: 'Alice@s.whatsapp.net', - sender_name: 'Alice', - content: 'first', - timestamp: '2024-01-01T00:00:01.000Z', - }); - store({ - id: 'm2', - chat_jid: 'group@g.us', - sender: 'Bob@s.whatsapp.net', - sender_name: 'Bob', - content: 'second', - timestamp: '2024-01-01T00:00:02.000Z', - }); - storeMessage({ - id: 'm3', - chat_jid: 'group@g.us', - sender: 'Bot@s.whatsapp.net', - sender_name: 'Bot', - content: 'bot reply', - timestamp: '2024-01-01T00:00:03.000Z', - is_bot_message: true, - }); - store({ - id: 'm4', - chat_jid: 'group@g.us', - sender: 'Carol@s.whatsapp.net', - sender_name: 'Carol', - content: 'third', - timestamp: '2024-01-01T00:00:04.000Z', - }); - }); - - it('returns messages after the given timestamp', () => { - const msgs = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:02.000Z', - 'Andy', - ); - // Should exclude m1, m2 (before/at timestamp), m3 (bot message) - expect(msgs).toHaveLength(1); - expect(msgs[0].content).toBe('third'); - }); - - it('excludes bot messages via is_bot_message flag', () => { - const msgs = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:00.000Z', - 'Andy', - ); - const botMsgs = msgs.filter((m) => m.content === 'bot reply'); - expect(botMsgs).toHaveLength(0); - }); - - it('returns all non-bot messages when sinceTimestamp is empty', () => { - const msgs = getMessagesSince('group@g.us', '', 'Andy'); - // 3 user messages (bot message excluded) - expect(msgs).toHaveLength(3); - }); - - it('filters pre-migration bot messages via content prefix backstop', () => { - // Simulate a message written before migration: has prefix but is_bot_message = 0 - store({ - id: 'm5', - chat_jid: 'group@g.us', - sender: 'Bot@s.whatsapp.net', - sender_name: 'Bot', - content: 'Andy: old bot reply', - timestamp: '2024-01-01T00:00:05.000Z', - }); - const msgs = getMessagesSince( - 'group@g.us', - '2024-01-01T00:00:04.000Z', - 'Andy', - ); - expect(msgs).toHaveLength(0); - }); -}); - -// --- getNewMessages --- - -describe('getNewMessages', () => { - beforeEach(() => { - storeChatMetadata('group1@g.us', '2024-01-01T00:00:00.000Z'); - storeChatMetadata('group2@g.us', '2024-01-01T00:00:00.000Z'); - - store({ - id: 'a1', - chat_jid: 'group1@g.us', - sender: 'user@s.whatsapp.net', - sender_name: 'User', - content: 'g1 msg1', - timestamp: '2024-01-01T00:00:01.000Z', - }); - store({ - id: 'a2', - chat_jid: 'group2@g.us', - sender: 'user@s.whatsapp.net', - sender_name: 'User', - content: 'g2 msg1', - timestamp: '2024-01-01T00:00:02.000Z', - }); - storeMessage({ - id: 'a3', - chat_jid: 'group1@g.us', - sender: 'user@s.whatsapp.net', - sender_name: 'User', - content: 'bot reply', - timestamp: '2024-01-01T00:00:03.000Z', - is_bot_message: true, - }); - store({ - id: 'a4', - chat_jid: 'group1@g.us', - sender: 'user@s.whatsapp.net', - sender_name: 'User', - content: 'g1 msg2', - timestamp: '2024-01-01T00:00:04.000Z', - }); - }); - - it('returns new messages across multiple groups', () => { - const { messages, newTimestamp } = getNewMessages( - ['group1@g.us', 'group2@g.us'], - '2024-01-01T00:00:00.000Z', - 'Andy', - ); - // Excludes bot message, returns 3 user messages - expect(messages).toHaveLength(3); - expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z'); - }); - - it('filters by timestamp', () => { - const { messages } = getNewMessages( - ['group1@g.us', 'group2@g.us'], - '2024-01-01T00:00:02.000Z', - 'Andy', - ); - // Only g1 msg2 (after ts, not bot) - expect(messages).toHaveLength(1); - expect(messages[0].content).toBe('g1 msg2'); - }); - - it('returns empty for no registered groups', () => { - const { messages, newTimestamp } = getNewMessages([], '', 'Andy'); - expect(messages).toHaveLength(0); - expect(newTimestamp).toBe(''); - }); -}); - -// --- storeChatMetadata --- - -describe('storeChatMetadata', () => { - it('stores chat with JID as default name', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - const chats = getAllChats(); - expect(chats).toHaveLength(1); - expect(chats[0].jid).toBe('group@g.us'); - expect(chats[0].name).toBe('group@g.us'); - }); - - it('stores chat with explicit name', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z', 'My Group'); - const chats = getAllChats(); - expect(chats[0].name).toBe('My Group'); - }); - - it('updates name on subsequent call with name', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Updated Name'); - const chats = getAllChats(); - expect(chats).toHaveLength(1); - expect(chats[0].name).toBe('Updated Name'); - }); - - it('preserves newer timestamp on conflict', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:05.000Z'); - storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z'); - const chats = getAllChats(); - expect(chats[0].last_message_time).toBe('2024-01-01T00:00:05.000Z'); - }); -}); - -// --- Task CRUD --- - -describe('task CRUD', () => { - it('creates and retrieves a task', () => { - createTask({ - id: 'task-1', - group_folder: 'main', - chat_jid: 'group@g.us', - prompt: 'do something', - schedule_type: 'once', - schedule_value: '2024-06-01T00:00:00.000Z', - context_mode: 'isolated', - next_run: '2024-06-01T00:00:00.000Z', - status: 'active', - created_at: '2024-01-01T00:00:00.000Z', - }); - - const task = getTaskById('task-1'); - expect(task).toBeDefined(); - expect(task!.prompt).toBe('do something'); - expect(task!.status).toBe('active'); - }); - - it('updates task status', () => { - createTask({ - id: 'task-2', - group_folder: 'main', - chat_jid: 'group@g.us', - prompt: 'test', - schedule_type: 'once', - schedule_value: '2024-06-01T00:00:00.000Z', - context_mode: 'isolated', - next_run: null, - status: 'active', - created_at: '2024-01-01T00:00:00.000Z', - }); - - updateTask('task-2', { status: 'paused' }); - expect(getTaskById('task-2')!.status).toBe('paused'); - }); - - it('deletes a task and its run logs', () => { - createTask({ - id: 'task-3', - group_folder: 'main', - chat_jid: 'group@g.us', - prompt: 'delete me', - schedule_type: 'once', - schedule_value: '2024-06-01T00:00:00.000Z', - context_mode: 'isolated', - next_run: null, - status: 'active', - created_at: '2024-01-01T00:00:00.000Z', - }); - - deleteTask('task-3'); - expect(getTaskById('task-3')).toBeUndefined(); - }); -}); - -// --- getLatestMessage --- - -describe('getLatestMessage', () => { - it('returns the most recent message for a chat', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - store({ - id: 'old', - chat_jid: 'group@g.us', - sender: 'a@s.whatsapp.net', - sender_name: 'A', - content: 'old', - timestamp: '2024-01-01T00:00:01.000Z', - }); - store({ - id: 'new', - chat_jid: 'group@g.us', - sender: 'b@s.whatsapp.net', - sender_name: 'B', - content: 'new', - timestamp: '2024-01-01T00:00:02.000Z', - }); - - const latest = getLatestMessage('group@g.us'); - expect(latest).toEqual({ id: 'new', fromMe: false }); - }); - - it('returns fromMe: true for own messages', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - store({ - id: 'mine', - chat_jid: 'group@g.us', - sender: 'me@s.whatsapp.net', - sender_name: 'Me', - content: 'my msg', - timestamp: '2024-01-01T00:00:01.000Z', - is_from_me: true, - }); - - const latest = getLatestMessage('group@g.us'); - expect(latest).toEqual({ id: 'mine', fromMe: true }); - }); - - it('returns undefined for empty chat', () => { - expect(getLatestMessage('nonexistent@g.us')).toBeUndefined(); - }); -}); - -// --- getMessageFromMe --- - -describe('getMessageFromMe', () => { - it('returns true for own messages', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - store({ - id: 'mine', - chat_jid: 'group@g.us', - sender: 'me@s.whatsapp.net', - sender_name: 'Me', - content: 'my msg', - timestamp: '2024-01-01T00:00:01.000Z', - is_from_me: true, - }); - - expect(getMessageFromMe('mine', 'group@g.us')).toBe(true); - }); - - it('returns false for other messages', () => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - store({ - id: 'theirs', - chat_jid: 'group@g.us', - sender: 'a@s.whatsapp.net', - sender_name: 'A', - content: 'their msg', - timestamp: '2024-01-01T00:00:01.000Z', - }); - - expect(getMessageFromMe('theirs', 'group@g.us')).toBe(false); - }); - - it('returns false for nonexistent message', () => { - expect(getMessageFromMe('nonexistent', 'group@g.us')).toBe(false); - }); -}); - -// --- storeReaction --- - -describe('storeReaction', () => { - it('stores and retrieves a reaction', () => { - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'user@s.whatsapp.net', - reactor_name: 'Alice', - emoji: '👍', - timestamp: '2024-01-01T00:00:01.000Z', - }); - - const reactions = getReactionsForMessage('msg-1', 'group@g.us'); - expect(reactions).toHaveLength(1); - expect(reactions[0].emoji).toBe('👍'); - expect(reactions[0].reactor_name).toBe('Alice'); - }); - - it('upserts on same reactor + message', () => { - const base = { - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'user@s.whatsapp.net', - reactor_name: 'Alice', - timestamp: '2024-01-01T00:00:01.000Z', - }; - storeReaction({ ...base, emoji: '👍' }); - storeReaction({ - ...base, - emoji: '❤️', - timestamp: '2024-01-01T00:00:02.000Z', - }); - - const reactions = getReactionsForMessage('msg-1', 'group@g.us'); - expect(reactions).toHaveLength(1); - expect(reactions[0].emoji).toBe('❤️'); - }); - - it('removes reaction when emoji is empty', () => { - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'user@s.whatsapp.net', - emoji: '👍', - timestamp: '2024-01-01T00:00:01.000Z', - }); - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'user@s.whatsapp.net', - emoji: '', - timestamp: '2024-01-01T00:00:02.000Z', - }); - - expect(getReactionsForMessage('msg-1', 'group@g.us')).toHaveLength(0); - }); -}); - -// --- getReactionsForMessage --- - -describe('getReactionsForMessage', () => { - it('returns multiple reactions ordered by timestamp', () => { - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'b@s.whatsapp.net', - emoji: '❤️', - timestamp: '2024-01-01T00:00:02.000Z', - }); - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'a@s.whatsapp.net', - emoji: '👍', - timestamp: '2024-01-01T00:00:01.000Z', - }); - - const reactions = getReactionsForMessage('msg-1', 'group@g.us'); - expect(reactions).toHaveLength(2); - expect(reactions[0].reactor_jid).toBe('a@s.whatsapp.net'); - expect(reactions[1].reactor_jid).toBe('b@s.whatsapp.net'); - }); - - it('returns empty array for message with no reactions', () => { - expect(getReactionsForMessage('nonexistent', 'group@g.us')).toEqual([]); - }); -}); - -// --- getMessagesByReaction --- - -describe('getMessagesByReaction', () => { - beforeEach(() => { - storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); - store({ - id: 'msg-1', - chat_jid: 'group@g.us', - sender: 'author@s.whatsapp.net', - sender_name: 'Author', - content: 'bookmarked msg', - timestamp: '2024-01-01T00:00:01.000Z', - }); - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'user@s.whatsapp.net', - emoji: '📌', - timestamp: '2024-01-01T00:00:02.000Z', - }); - }); - - it('joins reactions with messages', () => { - const results = getMessagesByReaction('user@s.whatsapp.net', '📌'); - expect(results).toHaveLength(1); - expect(results[0].content).toBe('bookmarked msg'); - expect(results[0].sender_name).toBe('Author'); - }); - - it('filters by chatJid when provided', () => { - const results = getMessagesByReaction( - 'user@s.whatsapp.net', - '📌', - 'group@g.us', - ); - expect(results).toHaveLength(1); - - const empty = getMessagesByReaction( - 'user@s.whatsapp.net', - '📌', - 'other@g.us', - ); - expect(empty).toHaveLength(0); - }); - - it('returns empty when no matching reactions', () => { - expect(getMessagesByReaction('user@s.whatsapp.net', '🔥')).toHaveLength(0); - }); -}); - -// --- getReactionsByUser --- - -describe('getReactionsByUser', () => { - it('returns reactions for a user ordered by timestamp desc', () => { - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'user@s.whatsapp.net', - emoji: '👍', - timestamp: '2024-01-01T00:00:01.000Z', - }); - storeReaction({ - message_id: 'msg-2', - message_chat_jid: 'group@g.us', - reactor_jid: 'user@s.whatsapp.net', - emoji: '❤️', - timestamp: '2024-01-01T00:00:02.000Z', - }); - - const reactions = getReactionsByUser('user@s.whatsapp.net'); - expect(reactions).toHaveLength(2); - expect(reactions[0].emoji).toBe('❤️'); // newer first - expect(reactions[1].emoji).toBe('👍'); - }); - - it('respects the limit parameter', () => { - for (let i = 0; i < 5; i++) { - storeReaction({ - message_id: `msg-${i}`, - message_chat_jid: 'group@g.us', - reactor_jid: 'user@s.whatsapp.net', - emoji: '👍', - timestamp: `2024-01-01T00:00:0${i}.000Z`, - }); - } - - expect(getReactionsByUser('user@s.whatsapp.net', 3)).toHaveLength(3); - }); - - it('returns empty for user with no reactions', () => { - expect(getReactionsByUser('nobody@s.whatsapp.net')).toEqual([]); - }); -}); - -// --- getReactionStats --- - -describe('getReactionStats', () => { - beforeEach(() => { - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'a@s.whatsapp.net', - emoji: '👍', - timestamp: '2024-01-01T00:00:01.000Z', - }); - storeReaction({ - message_id: 'msg-2', - message_chat_jid: 'group@g.us', - reactor_jid: 'b@s.whatsapp.net', - emoji: '👍', - timestamp: '2024-01-01T00:00:02.000Z', - }); - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'group@g.us', - reactor_jid: 'c@s.whatsapp.net', - emoji: '❤️', - timestamp: '2024-01-01T00:00:03.000Z', - }); - storeReaction({ - message_id: 'msg-1', - message_chat_jid: 'other@g.us', - reactor_jid: 'a@s.whatsapp.net', - emoji: '🔥', - timestamp: '2024-01-01T00:00:04.000Z', - }); - }); - - it('returns global stats ordered by count desc', () => { - const stats = getReactionStats(); - expect(stats[0]).toEqual({ emoji: '👍', count: 2 }); - expect(stats).toHaveLength(3); - }); - - it('filters by chatJid', () => { - const stats = getReactionStats('group@g.us'); - expect(stats).toHaveLength(2); - expect(stats.find((s) => s.emoji === '🔥')).toBeUndefined(); - }); - - it('returns empty for chat with no reactions', () => { - expect(getReactionStats('empty@g.us')).toEqual([]); - }); -}); diff --git a/.claude/skills/add-reactions/modify/src/db.ts b/.claude/skills/add-reactions/modify/src/db.ts deleted file mode 100644 index 5200c9f..0000000 --- a/.claude/skills/add-reactions/modify/src/db.ts +++ /dev/null @@ -1,801 +0,0 @@ -import Database from 'better-sqlite3'; -import fs from 'fs'; -import path from 'path'; - -import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; -import { isValidGroupFolder } from './group-folder.js'; -import { logger } from './logger.js'; -import { - NewMessage, - RegisteredGroup, - ScheduledTask, - TaskRunLog, -} from './types.js'; - -let db: Database.Database; - -export interface Reaction { - message_id: string; - message_chat_jid: string; - reactor_jid: string; - reactor_name?: string; - emoji: string; - timestamp: string; -} - -function createSchema(database: Database.Database): void { - database.exec(` - CREATE TABLE IF NOT EXISTS chats ( - jid TEXT PRIMARY KEY, - name TEXT, - last_message_time TEXT, - channel TEXT, - is_group INTEGER DEFAULT 0 - ); - CREATE TABLE IF NOT EXISTS messages ( - id TEXT, - chat_jid TEXT, - sender TEXT, - sender_name TEXT, - content TEXT, - timestamp TEXT, - is_from_me INTEGER, - is_bot_message INTEGER DEFAULT 0, - PRIMARY KEY (id, chat_jid), - FOREIGN KEY (chat_jid) REFERENCES chats(jid) - ); - CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp); - - CREATE TABLE IF NOT EXISTS scheduled_tasks ( - id TEXT PRIMARY KEY, - group_folder TEXT NOT NULL, - chat_jid TEXT NOT NULL, - prompt TEXT NOT NULL, - schedule_type TEXT NOT NULL, - schedule_value TEXT NOT NULL, - next_run TEXT, - last_run TEXT, - last_result TEXT, - status TEXT DEFAULT 'active', - created_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_next_run ON scheduled_tasks(next_run); - CREATE INDEX IF NOT EXISTS idx_status ON scheduled_tasks(status); - - CREATE TABLE IF NOT EXISTS task_run_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - run_at TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - status TEXT NOT NULL, - result TEXT, - error TEXT, - FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) - ); - CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at); - - CREATE TABLE IF NOT EXISTS router_state ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS sessions ( - group_folder TEXT PRIMARY KEY, - session_id TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS registered_groups ( - jid TEXT PRIMARY KEY, - name TEXT NOT NULL, - folder TEXT NOT NULL UNIQUE, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - container_config TEXT, - requires_trigger INTEGER DEFAULT 1 - ); - - CREATE TABLE IF NOT EXISTS reactions ( - message_id TEXT NOT NULL, - message_chat_jid TEXT NOT NULL, - reactor_jid TEXT NOT NULL, - reactor_name TEXT, - emoji TEXT NOT NULL, - timestamp TEXT NOT NULL, - PRIMARY KEY (message_id, message_chat_jid, reactor_jid) - ); - CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id, message_chat_jid); - CREATE INDEX IF NOT EXISTS idx_reactions_reactor ON reactions(reactor_jid); - CREATE INDEX IF NOT EXISTS idx_reactions_emoji ON reactions(emoji); - CREATE INDEX IF NOT EXISTS idx_reactions_timestamp ON reactions(timestamp); - `); - - // Add context_mode column if it doesn't exist (migration for existing DBs) - try { - database.exec( - `ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`, - ); - } catch { - /* column already exists */ - } - - // Add is_bot_message column if it doesn't exist (migration for existing DBs) - try { - database.exec( - `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, - ); - // Backfill: mark existing bot messages that used the content prefix pattern - database - .prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`) - .run(`${ASSISTANT_NAME}:%`); - } catch { - /* column already exists */ - } - - // Add channel and is_group columns if they don't exist (migration for existing DBs) - try { - database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); - database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`); - // Backfill from JID patterns - database.exec( - `UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`, - ); - database.exec( - `UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`, - ); - database.exec( - `UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`, - ); - database.exec( - `UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`, - ); - } catch { - /* columns already exist */ - } -} - -export function initDatabase(): void { - const dbPath = path.join(STORE_DIR, 'messages.db'); - fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - - db = new Database(dbPath); - createSchema(db); - - // Migrate from JSON files if they exist - migrateJsonState(); -} - -/** @internal - for tests only. Creates a fresh in-memory database. */ -export function _initTestDatabase(): void { - db = new Database(':memory:'); - createSchema(db); -} - -/** - * Store chat metadata only (no message content). - * Used for all chats to enable group discovery without storing sensitive content. - */ -export function storeChatMetadata( - chatJid: string, - timestamp: string, - name?: string, - channel?: string, - isGroup?: boolean, -): void { - const ch = channel ?? null; - const group = isGroup === undefined ? null : isGroup ? 1 : 0; - - if (name) { - // Update with name, preserving existing timestamp if newer - db.prepare( - ` - INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(jid) DO UPDATE SET - name = excluded.name, - last_message_time = MAX(last_message_time, excluded.last_message_time), - channel = COALESCE(excluded.channel, channel), - is_group = COALESCE(excluded.is_group, is_group) - `, - ).run(chatJid, name, timestamp, ch, group); - } else { - // Update timestamp only, preserve existing name if any - db.prepare( - ` - INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(jid) DO UPDATE SET - last_message_time = MAX(last_message_time, excluded.last_message_time), - channel = COALESCE(excluded.channel, channel), - is_group = COALESCE(excluded.is_group, is_group) - `, - ).run(chatJid, chatJid, timestamp, ch, group); - } -} - -/** - * 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; - last_message_time: string; - channel: string; - is_group: number; -} - -/** - * Get all known chats, ordered by most recent activity. - */ -export function getAllChats(): ChatInfo[] { - return db - .prepare( - ` - SELECT jid, name, last_message_time, channel, is_group - FROM chats - ORDER BY last_message_time DESC - `, - ) - .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. - */ -export function storeMessage(msg: NewMessage): 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, - ); -} - -/** - * Store a message directly (for non-WhatsApp channels that don't use Baileys proto). - */ -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, - botPrefix: string, -): { messages: NewMessage[]; newTimestamp: string } { - 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. - const sql = ` - SELECT id, chat_jid, sender, sender_name, content, timestamp - FROM messages - WHERE timestamp > ? AND chat_jid IN (${placeholders}) - AND is_bot_message = 0 AND content NOT LIKE ? - AND content != '' AND content IS NOT NULL - ORDER BY timestamp - `; - - const rows = db - .prepare(sql) - .all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[]; - - let newTimestamp = lastTimestamp; - for (const row of rows) { - if (row.timestamp > newTimestamp) newTimestamp = row.timestamp; - } - - return { messages: rows, newTimestamp }; -} - -export function getMessagesSince( - chatJid: string, - sinceTimestamp: string, - botPrefix: string, -): 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. - const sql = ` - SELECT id, chat_jid, sender, sender_name, content, timestamp - FROM messages - WHERE chat_jid = ? AND timestamp > ? - AND is_bot_message = 0 AND content NOT LIKE ? - AND content != '' AND content IS NOT NULL - ORDER BY timestamp - `; - return db - .prepare(sql) - .all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[]; -} - -export function getMessageFromMe(messageId: string, chatJid: string): boolean { - const row = db - .prepare(`SELECT is_from_me FROM messages WHERE id = ? AND chat_jid = ? LIMIT 1`) - .get(messageId, chatJid) as { is_from_me: number | null } | undefined; - return row?.is_from_me === 1; -} - -export function getLatestMessage(chatJid: string): { id: string; fromMe: boolean } | undefined { - const row = db - .prepare(`SELECT id, is_from_me FROM messages WHERE chat_jid = ? ORDER BY timestamp DESC LIMIT 1`) - .get(chatJid) as { id: string; is_from_me: number | null } | undefined; - if (!row) return undefined; - return { id: row.id, fromMe: row.is_from_me === 1 }; -} - -export function storeReaction(reaction: Reaction): void { - if (!reaction.emoji) { - db.prepare( - `DELETE FROM reactions WHERE message_id = ? AND message_chat_jid = ? AND reactor_jid = ?` - ).run(reaction.message_id, reaction.message_chat_jid, reaction.reactor_jid); - return; - } - db.prepare( - `INSERT OR REPLACE INTO reactions (message_id, message_chat_jid, reactor_jid, reactor_name, emoji, timestamp) - VALUES (?, ?, ?, ?, ?, ?)` - ).run( - reaction.message_id, - reaction.message_chat_jid, - reaction.reactor_jid, - reaction.reactor_name || null, - reaction.emoji, - reaction.timestamp - ); -} - -export function getReactionsForMessage( - messageId: string, - chatJid: string -): Reaction[] { - return db - .prepare( - `SELECT * FROM reactions WHERE message_id = ? AND message_chat_jid = ? ORDER BY timestamp` - ) - .all(messageId, chatJid) as Reaction[]; -} - -export function getMessagesByReaction( - reactorJid: string, - emoji: string, - chatJid?: string -): Array { - const sql = chatJid - ? ` - SELECT r.*, m.content, m.sender_name, m.timestamp as message_timestamp - FROM reactions r - JOIN messages m ON r.message_id = m.id AND r.message_chat_jid = m.chat_jid - WHERE r.reactor_jid = ? AND r.emoji = ? AND r.message_chat_jid = ? - ORDER BY r.timestamp DESC - ` - : ` - SELECT r.*, m.content, m.sender_name, m.timestamp as message_timestamp - FROM reactions r - JOIN messages m ON r.message_id = m.id AND r.message_chat_jid = m.chat_jid - WHERE r.reactor_jid = ? AND r.emoji = ? - ORDER BY r.timestamp DESC - `; - - type Result = Reaction & { content: string; sender_name: string; message_timestamp: string }; - return chatJid - ? (db.prepare(sql).all(reactorJid, emoji, chatJid) as Result[]) - : (db.prepare(sql).all(reactorJid, emoji) as Result[]); -} - -export function getReactionsByUser( - reactorJid: string, - limit: number = 50 -): Reaction[] { - return db - .prepare( - `SELECT * FROM reactions WHERE reactor_jid = ? ORDER BY timestamp DESC LIMIT ?` - ) - .all(reactorJid, limit) as Reaction[]; -} - -export function getReactionStats(chatJid?: string): Array<{ - emoji: string; - count: number; -}> { - const sql = chatJid - ? ` - SELECT emoji, COUNT(*) as count - FROM reactions - WHERE message_chat_jid = ? - GROUP BY emoji - ORDER BY count DESC - ` - : ` - SELECT emoji, COUNT(*) as count - FROM reactions - GROUP BY emoji - ORDER BY count DESC - `; - - type Result = { emoji: string; count: number }; - return chatJid - ? (db.prepare(sql).all(chatJid) as Result[]) - : (db.prepare(sql).all() as Result[]); -} - -export function createTask( - task: Omit, -): void { - db.prepare( - ` - INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - task.id, - task.group_folder, - task.chat_jid, - task.prompt, - task.schedule_type, - task.schedule_value, - task.context_mode || 'isolated', - task.next_run, - task.status, - task.created_at, - ); -} - -export function getTaskById(id: string): ScheduledTask | undefined { - return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as - | ScheduledTask - | undefined; -} - -export function getTasksForGroup(groupFolder: string): ScheduledTask[] { - return db - .prepare( - 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', - ) - .all(groupFolder) as ScheduledTask[]; -} - -export function getAllTasks(): ScheduledTask[] { - return db - .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') - .all() as ScheduledTask[]; -} - -export function updateTask( - id: string, - updates: Partial< - Pick< - ScheduledTask, - 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' - > - >, -): void { - const fields: string[] = []; - const values: unknown[] = []; - - if (updates.prompt !== undefined) { - fields.push('prompt = ?'); - values.push(updates.prompt); - } - if (updates.schedule_type !== undefined) { - fields.push('schedule_type = ?'); - values.push(updates.schedule_type); - } - if (updates.schedule_value !== undefined) { - fields.push('schedule_value = ?'); - values.push(updates.schedule_value); - } - if (updates.next_run !== undefined) { - fields.push('next_run = ?'); - values.push(updates.next_run); - } - if (updates.status !== undefined) { - fields.push('status = ?'); - values.push(updates.status); - } - - if (fields.length === 0) return; - - values.push(id); - db.prepare( - `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, - ).run(...values); -} - -export function deleteTask(id: string): void { - // Delete child records first (FK constraint) - db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id); - db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); -} - -export function getDueTasks(): ScheduledTask[] { - const now = new Date().toISOString(); - return db - .prepare( - ` - SELECT * FROM scheduled_tasks - WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ? - ORDER BY next_run - `, - ) - .all(now) as ScheduledTask[]; -} - -export function updateTaskAfterRun( - id: string, - nextRun: string | null, - lastResult: string, -): void { - const now = new Date().toISOString(); - db.prepare( - ` - UPDATE scheduled_tasks - SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END - WHERE id = ? - `, - ).run(nextRun, now, lastResult, nextRun, id); -} - -export function logTaskRun(log: TaskRunLog): void { - db.prepare( - ` - INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error) - VALUES (?, ?, ?, ?, ?, ?) - `, - ).run( - log.task_id, - log.run_at, - log.duration_ms, - log.status, - log.result, - log.error, - ); -} - -// --- Router state accessors --- - -export function getRouterState(key: string): string | undefined { - const row = db - .prepare('SELECT value FROM router_state WHERE key = ?') - .get(key) as { value: string } | undefined; - return row?.value; -} - -export function setRouterState(key: string, value: string): void { - db.prepare( - 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', - ).run(key, 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; - 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); -} - -export function getAllSessions(): Record { - const rows = db - .prepare('SELECT group_folder, session_id FROM sessions') - .all() as Array<{ group_folder: string; session_id: string }>; - const result: Record = {}; - for (const row of rows) { - result[row.group_folder] = row.session_id; - } - return result; -} - -// --- Registered group accessors --- - -export function getRegisteredGroup( - jid: string, -): (RegisteredGroup & { jid: string }) | undefined { - const row = db - .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; - } - | undefined; - if (!row) return undefined; - if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); - return undefined; - } - return { - jid: row.jid, - name: row.name, - folder: row.folder, - trigger: row.trigger_pattern, - added_at: row.added_at, - containerConfig: row.container_config - ? JSON.parse(row.container_config) - : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, - }; -} - -export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { - if (!isValidGroupFolder(group.folder)) { - 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) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run( - jid, - group.name, - group.folder, - group.trigger, - group.added_at, - group.containerConfig ? JSON.stringify(group.containerConfig) : null, - group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, - ); -} - -export function getAllRegisteredGroups(): Record { - const rows = 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; - }>; - const result: Record = {}; - for (const row of rows) { - if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); - continue; - } - result[row.jid] = { - name: row.name, - folder: row.folder, - trigger: row.trigger_pattern, - added_at: row.added_at, - containerConfig: row.container_config - ? JSON.parse(row.container_config) - : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, - }; - } - return result; -} - -// --- JSON migration --- - -function migrateJsonState(): void { - const migrateFile = (filename: string) => { - const filePath = path.join(DATA_DIR, filename); - if (!fs.existsSync(filePath)) return null; - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - fs.renameSync(filePath, `${filePath}.migrated`); - return data; - } catch { - return null; - } - }; - - // Migrate router_state.json - const routerState = migrateFile('router_state.json') as { - last_timestamp?: string; - last_agent_timestamp?: Record; - } | null; - if (routerState) { - if (routerState.last_timestamp) { - setRouterState('last_timestamp', routerState.last_timestamp); - } - if (routerState.last_agent_timestamp) { - setRouterState( - 'last_agent_timestamp', - JSON.stringify(routerState.last_agent_timestamp), - ); - } - } - - // Migrate sessions.json - const sessions = migrateFile('sessions.json') as Record< - string, - string - > | null; - if (sessions) { - for (const [folder, sessionId] of Object.entries(sessions)) { - setSession(folder, sessionId); - } - } - - // Migrate registered_groups.json - const groups = migrateFile('registered_groups.json') as Record< - string, - RegisteredGroup - > | null; - if (groups) { - for (const [jid, group] of Object.entries(groups)) { - try { - setRegisteredGroup(jid, group); - } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Skipping migrated registered group with invalid folder', - ); - } - } - } -} diff --git a/.claude/skills/add-reactions/modify/src/group-queue.test.ts b/.claude/skills/add-reactions/modify/src/group-queue.test.ts deleted file mode 100644 index 6c0447a..0000000 --- a/.claude/skills/add-reactions/modify/src/group-queue.test.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -import { GroupQueue } from './group-queue.js'; - -// Mock config to control concurrency limit -vi.mock('./config.js', () => ({ - DATA_DIR: '/tmp/nanoclaw-test-data', - MAX_CONCURRENT_CONTAINERS: 2, -})); - -// Mock fs operations used by sendMessage/closeStdin -vi.mock('fs', async () => { - const actual = await vi.importActual('fs'); - return { - ...actual, - default: { - ...actual, - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - renameSync: vi.fn(), - }, - }; -}); - -describe('GroupQueue', () => { - let queue: GroupQueue; - - beforeEach(() => { - vi.useFakeTimers(); - queue = new GroupQueue(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - // --- Single group at a time --- - - it('only runs one container per group at a time', async () => { - let concurrentCount = 0; - let maxConcurrent = 0; - - const processMessages = vi.fn(async (groupJid: string) => { - concurrentCount++; - maxConcurrent = Math.max(maxConcurrent, concurrentCount); - // Simulate async work - await new Promise((resolve) => setTimeout(resolve, 100)); - concurrentCount--; - return true; - }); - - queue.setProcessMessagesFn(processMessages); - - // Enqueue two messages for the same group - queue.enqueueMessageCheck('group1@g.us'); - queue.enqueueMessageCheck('group1@g.us'); - - // Advance timers to let the first process complete - await vi.advanceTimersByTimeAsync(200); - - // Second enqueue should have been queued, not concurrent - expect(maxConcurrent).toBe(1); - }); - - // --- Global concurrency limit --- - - it('respects global concurrency limit', async () => { - let activeCount = 0; - let maxActive = 0; - const completionCallbacks: Array<() => void> = []; - - const processMessages = vi.fn(async (groupJid: string) => { - activeCount++; - maxActive = Math.max(maxActive, activeCount); - await new Promise((resolve) => completionCallbacks.push(resolve)); - activeCount--; - return true; - }); - - queue.setProcessMessagesFn(processMessages); - - // Enqueue 3 groups (limit is 2) - queue.enqueueMessageCheck('group1@g.us'); - queue.enqueueMessageCheck('group2@g.us'); - queue.enqueueMessageCheck('group3@g.us'); - - // Let promises settle - await vi.advanceTimersByTimeAsync(10); - - // Only 2 should be active (MAX_CONCURRENT_CONTAINERS = 2) - expect(maxActive).toBe(2); - expect(activeCount).toBe(2); - - // Complete one — third should start - completionCallbacks[0](); - await vi.advanceTimersByTimeAsync(10); - - expect(processMessages).toHaveBeenCalledTimes(3); - }); - - // --- Tasks prioritized over messages --- - - it('drains tasks before messages for same group', async () => { - const executionOrder: string[] = []; - let resolveFirst: () => void; - - const processMessages = vi.fn(async (groupJid: string) => { - if (executionOrder.length === 0) { - // First call: block until we release it - await new Promise((resolve) => { - resolveFirst = resolve; - }); - } - executionOrder.push('messages'); - return true; - }); - - queue.setProcessMessagesFn(processMessages); - - // Start processing messages (takes the active slot) - queue.enqueueMessageCheck('group1@g.us'); - await vi.advanceTimersByTimeAsync(10); - - // While active, enqueue both a task and pending messages - const taskFn = vi.fn(async () => { - executionOrder.push('task'); - }); - queue.enqueueTask('group1@g.us', 'task-1', taskFn); - queue.enqueueMessageCheck('group1@g.us'); - - // Release the first processing - resolveFirst!(); - await vi.advanceTimersByTimeAsync(10); - - // Task should have run before the second message check - expect(executionOrder[0]).toBe('messages'); // first call - expect(executionOrder[1]).toBe('task'); // task runs first in drain - // Messages would run after task completes - }); - - // --- Retry with backoff on failure --- - - it('retries with exponential backoff on failure', async () => { - let callCount = 0; - - const processMessages = vi.fn(async () => { - callCount++; - return false; // failure - }); - - queue.setProcessMessagesFn(processMessages); - queue.enqueueMessageCheck('group1@g.us'); - - // First call happens immediately - await vi.advanceTimersByTimeAsync(10); - expect(callCount).toBe(1); - - // First retry after 5000ms (BASE_RETRY_MS * 2^0) - await vi.advanceTimersByTimeAsync(5000); - await vi.advanceTimersByTimeAsync(10); - expect(callCount).toBe(2); - - // Second retry after 10000ms (BASE_RETRY_MS * 2^1) - await vi.advanceTimersByTimeAsync(10000); - await vi.advanceTimersByTimeAsync(10); - expect(callCount).toBe(3); - }); - - // --- Shutdown prevents new enqueues --- - - it('prevents new enqueues after shutdown', async () => { - const processMessages = vi.fn(async () => true); - queue.setProcessMessagesFn(processMessages); - - await queue.shutdown(1000); - - queue.enqueueMessageCheck('group1@g.us'); - await vi.advanceTimersByTimeAsync(100); - - expect(processMessages).not.toHaveBeenCalled(); - }); - - // --- Max retries exceeded --- - - it('stops retrying after MAX_RETRIES and resets', async () => { - let callCount = 0; - - const processMessages = vi.fn(async () => { - callCount++; - return false; // always fail - }); - - queue.setProcessMessagesFn(processMessages); - queue.enqueueMessageCheck('group1@g.us'); - - // Run through all 5 retries (MAX_RETRIES = 5) - // Initial call - await vi.advanceTimersByTimeAsync(10); - expect(callCount).toBe(1); - - // Retry 1: 5000ms, Retry 2: 10000ms, Retry 3: 20000ms, Retry 4: 40000ms, Retry 5: 80000ms - const retryDelays = [5000, 10000, 20000, 40000, 80000]; - for (let i = 0; i < retryDelays.length; i++) { - await vi.advanceTimersByTimeAsync(retryDelays[i] + 10); - expect(callCount).toBe(i + 2); - } - - // After 5 retries (6 total calls), should stop — no more retries - const countAfterMaxRetries = callCount; - await vi.advanceTimersByTimeAsync(200000); // Wait a long time - expect(callCount).toBe(countAfterMaxRetries); - }); - - // --- Waiting groups get drained when slots free up --- - - it('drains waiting groups when active slots free up', async () => { - const processed: string[] = []; - const completionCallbacks: Array<() => void> = []; - - const processMessages = vi.fn(async (groupJid: string) => { - processed.push(groupJid); - await new Promise((resolve) => completionCallbacks.push(resolve)); - return true; - }); - - queue.setProcessMessagesFn(processMessages); - - // Fill both slots - queue.enqueueMessageCheck('group1@g.us'); - queue.enqueueMessageCheck('group2@g.us'); - await vi.advanceTimersByTimeAsync(10); - - // Queue a third - queue.enqueueMessageCheck('group3@g.us'); - await vi.advanceTimersByTimeAsync(10); - - expect(processed).toEqual(['group1@g.us', 'group2@g.us']); - - // Free up a slot - completionCallbacks[0](); - await vi.advanceTimersByTimeAsync(10); - - expect(processed).toContain('group3@g.us'); - }); - - // --- Running task dedup (Issue #138) --- - - it('rejects duplicate enqueue of a currently-running task', async () => { - let resolveTask: () => void; - let taskCallCount = 0; - - const taskFn = vi.fn(async () => { - taskCallCount++; - await new Promise((resolve) => { - resolveTask = resolve; - }); - }); - - // Start the task (runs immediately — slot available) - queue.enqueueTask('group1@g.us', 'task-1', taskFn); - await vi.advanceTimersByTimeAsync(10); - expect(taskCallCount).toBe(1); - - // Scheduler poll re-discovers the same task while it's running — - // this must be silently dropped - const dupFn = vi.fn(async () => {}); - queue.enqueueTask('group1@g.us', 'task-1', dupFn); - await vi.advanceTimersByTimeAsync(10); - - // Duplicate was NOT queued - expect(dupFn).not.toHaveBeenCalled(); - - // Complete the original task - resolveTask!(); - await vi.advanceTimersByTimeAsync(10); - - // Only one execution total - expect(taskCallCount).toBe(1); - }); - - // --- Idle preemption --- - - it('does NOT preempt active container when not idle', async () => { - const fs = await import('fs'); - let resolveProcess: () => void; - - const processMessages = vi.fn(async () => { - await new Promise((resolve) => { - resolveProcess = resolve; - }); - return true; - }); - - queue.setProcessMessagesFn(processMessages); - - // Start processing (takes the active slot) - queue.enqueueMessageCheck('group1@g.us'); - await vi.advanceTimersByTimeAsync(10); - - // Register a process so closeStdin has a groupFolder - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); - - // Enqueue a task while container is active but NOT idle - const taskFn = vi.fn(async () => {}); - queue.enqueueTask('group1@g.us', 'task-1', taskFn); - - // _close should NOT have been written (container is working, not idle) - const writeFileSync = vi.mocked(fs.default.writeFileSync); - const closeWrites = writeFileSync.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), - ); - expect(closeWrites).toHaveLength(0); - - resolveProcess!(); - await vi.advanceTimersByTimeAsync(10); - }); - - it('preempts idle container when task is enqueued', async () => { - const fs = await import('fs'); - let resolveProcess: () => void; - - const processMessages = vi.fn(async () => { - await new Promise((resolve) => { - resolveProcess = resolve; - }); - return true; - }); - - queue.setProcessMessagesFn(processMessages); - - // Start processing - queue.enqueueMessageCheck('group1@g.us'); - await vi.advanceTimersByTimeAsync(10); - - // Register process and mark idle - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); - queue.notifyIdle('group1@g.us'); - - // Clear previous writes, then enqueue a task - const writeFileSync = vi.mocked(fs.default.writeFileSync); - writeFileSync.mockClear(); - - const taskFn = vi.fn(async () => {}); - queue.enqueueTask('group1@g.us', 'task-1', taskFn); - - // _close SHOULD have been written (container is idle) - const closeWrites = writeFileSync.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), - ); - expect(closeWrites).toHaveLength(1); - - resolveProcess!(); - await vi.advanceTimersByTimeAsync(10); - }); - - it('sendMessage resets idleWaiting so a subsequent task enqueue does not preempt', async () => { - const fs = await import('fs'); - let resolveProcess: () => void; - - const processMessages = vi.fn(async () => { - await new Promise((resolve) => { - resolveProcess = resolve; - }); - return true; - }); - - queue.setProcessMessagesFn(processMessages); - queue.enqueueMessageCheck('group1@g.us'); - await vi.advanceTimersByTimeAsync(10); - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); - - // Container becomes idle - queue.notifyIdle('group1@g.us'); - - // A new user message arrives — resets idleWaiting - queue.sendMessage('group1@g.us', 'hello'); - - // Task enqueued after message reset — should NOT preempt (agent is working) - const writeFileSync = vi.mocked(fs.default.writeFileSync); - writeFileSync.mockClear(); - - const taskFn = vi.fn(async () => {}); - queue.enqueueTask('group1@g.us', 'task-1', taskFn); - - const closeWrites = writeFileSync.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), - ); - expect(closeWrites).toHaveLength(0); - - resolveProcess!(); - await vi.advanceTimersByTimeAsync(10); - }); - - it('sendMessage returns false for task containers so user messages queue up', async () => { - let resolveTask: () => void; - - const taskFn = vi.fn(async () => { - await new Promise((resolve) => { - resolveTask = resolve; - }); - }); - - // Start a task (sets isTaskContainer = true) - queue.enqueueTask('group1@g.us', 'task-1', taskFn); - await vi.advanceTimersByTimeAsync(10); - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); - - // sendMessage should return false — user messages must not go to task containers - const result = queue.sendMessage('group1@g.us', 'hello'); - expect(result).toBe(false); - - resolveTask!(); - await vi.advanceTimersByTimeAsync(10); - }); - - it('preempts when idle arrives with pending tasks', async () => { - const fs = await import('fs'); - let resolveProcess: () => void; - - const processMessages = vi.fn(async () => { - await new Promise((resolve) => { - resolveProcess = resolve; - }); - return true; - }); - - queue.setProcessMessagesFn(processMessages); - - // Start processing - queue.enqueueMessageCheck('group1@g.us'); - await vi.advanceTimersByTimeAsync(10); - - // Register process and enqueue a task (no idle yet — no preemption) - queue.registerProcess( - 'group1@g.us', - {} as any, - 'container-1', - 'test-group', - ); - - const writeFileSync = vi.mocked(fs.default.writeFileSync); - writeFileSync.mockClear(); - - const taskFn = vi.fn(async () => {}); - queue.enqueueTask('group1@g.us', 'task-1', taskFn); - - let closeWrites = writeFileSync.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), - ); - expect(closeWrites).toHaveLength(0); - - // Now container becomes idle — should preempt because task is pending - writeFileSync.mockClear(); - queue.notifyIdle('group1@g.us'); - - closeWrites = writeFileSync.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), - ); - expect(closeWrites).toHaveLength(1); - - resolveProcess!(); - await vi.advanceTimersByTimeAsync(10); - }); - - describe('isActive', () => { - it('returns false for unknown groups', () => { - expect(queue.isActive('unknown@g.us')).toBe(false); - }); - - it('returns true when group has active container', async () => { - let resolve: () => void; - const block = new Promise((r) => { - resolve = r; - }); - - queue.setProcessMessagesFn(async () => { - await block; - return true; - }); - queue.enqueueMessageCheck('group@g.us'); - - // Let the microtask start running - await vi.advanceTimersByTimeAsync(0); - expect(queue.isActive('group@g.us')).toBe(true); - - resolve!(); - await vi.advanceTimersByTimeAsync(0); - }); - }); -}); diff --git a/.claude/skills/add-reactions/modify/src/index.ts b/.claude/skills/add-reactions/modify/src/index.ts deleted file mode 100644 index 15e63db..0000000 --- a/.claude/skills/add-reactions/modify/src/index.ts +++ /dev/null @@ -1,726 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { - ASSISTANT_NAME, - IDLE_TIMEOUT, - POLL_INTERVAL, - TRIGGER_PATTERN, -} from './config.js'; -import './channels/index.js'; -import { - getChannelFactory, - getRegisteredChannelNames, -} from './channels/registry.js'; -import { - ContainerOutput, - runContainerAgent, - writeGroupsSnapshot, - writeTasksSnapshot, -} from './container-runner.js'; -import { - cleanupOrphans, - ensureContainerRuntimeRunning, -} from './container-runtime.js'; -import { - getAllChats, - getAllRegisteredGroups, - getAllSessions, - getAllTasks, - getMessageFromMe, - getMessagesSince, - getNewMessages, - getRouterState, - initDatabase, - setRegisteredGroup, - setRouterState, - setSession, - storeChatMetadata, - storeMessage, -} from './db.js'; -import { GroupQueue } from './group-queue.js'; -import { resolveGroupFolderPath } from './group-folder.js'; -import { startIpcWatcher } from './ipc.js'; -import { findChannel, formatMessages, formatOutbound } from './router.js'; -import { - isSenderAllowed, - isTriggerAllowed, - loadSenderAllowlist, - shouldDropMessage, -} from './sender-allowlist.js'; -import { startSchedulerLoop } from './task-scheduler.js'; -import { Channel, NewMessage, RegisteredGroup } from './types.js'; -import { StatusTracker } from './status-tracker.js'; -import { logger } from './logger.js'; - -// Re-export for backwards compatibility during refactor -export { escapeXml, formatMessages } from './router.js'; - -let lastTimestamp = ''; -let sessions: Record = {}; -let registeredGroups: Record = {}; -let lastAgentTimestamp: Record = {}; -// Tracks cursor value before messages were piped to an active container. -// Used to roll back if the container dies after piping. -let cursorBeforePipe: Record = {}; -let messageLoopRunning = false; - -const channels: Channel[] = []; -const queue = new GroupQueue(); -let statusTracker: StatusTracker; - -function loadState(): void { - lastTimestamp = getRouterState('last_timestamp') || ''; - const agentTs = getRouterState('last_agent_timestamp'); - try { - lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {}; - } catch { - logger.warn('Corrupted last_agent_timestamp in DB, resetting'); - lastAgentTimestamp = {}; - } - const pipeCursor = getRouterState('cursor_before_pipe'); - try { - cursorBeforePipe = pipeCursor ? JSON.parse(pipeCursor) : {}; - } catch { - logger.warn('Corrupted cursor_before_pipe in DB, resetting'); - cursorBeforePipe = {}; - } - sessions = getAllSessions(); - registeredGroups = getAllRegisteredGroups(); - logger.info( - { groupCount: Object.keys(registeredGroups).length }, - 'State loaded', - ); -} - -function saveState(): void { - setRouterState('last_timestamp', lastTimestamp); - setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); - setRouterState('cursor_before_pipe', JSON.stringify(cursorBeforePipe)); -} - -function registerGroup(jid: string, group: RegisteredGroup): void { - let groupDir: string; - try { - groupDir = resolveGroupFolderPath(group.folder); - } catch (err) { - logger.warn( - { jid, folder: group.folder, err }, - 'Rejecting group registration with invalid folder', - ); - return; - } - - registeredGroups[jid] = group; - setRegisteredGroup(jid, group); - - // Create group folder - fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); - - logger.info( - { jid, name: group.name, folder: group.folder }, - 'Group registered', - ); -} - -/** - * Get available groups list for the agent. - * Returns groups ordered by most recent activity. - */ -export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] { - const chats = getAllChats(); - const registeredJids = new Set(Object.keys(registeredGroups)); - - return chats - .filter((c) => c.jid !== '__group_sync__' && c.is_group) - .map((c) => ({ - jid: c.jid, - name: c.name, - lastActivity: c.last_message_time, - isRegistered: registeredJids.has(c.jid), - })); -} - -/** @internal - exported for testing */ -export function _setRegisteredGroups( - groups: Record, -): void { - registeredGroups = groups; -} - -/** - * Process all pending messages for a group. - * Called by the GroupQueue when it's this group's turn. - */ -async function processGroupMessages(chatJid: string): Promise { - const group = registeredGroups[chatJid]; - if (!group) return true; - - const channel = findChannel(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - return true; - } - - const isMainGroup = group.isMain === true; - - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const missedMessages = getMessagesSince( - chatJid, - sinceTimestamp, - ASSISTANT_NAME, - ); - - if (missedMessages.length === 0) return true; - - // For non-main groups, check if trigger is required and present - if (!isMainGroup && group.requiresTrigger !== false) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = missedMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) return true; - } - - // Ensure all user messages are tracked — recovery messages enter processGroupMessages - // directly via the queue, bypassing startMessageLoop where markReceived normally fires. - // markReceived is idempotent (rejects duplicates), so this is safe for normal-path messages too. - for (const msg of missedMessages) { - statusTracker.markReceived(msg.id, chatJid, false); - } - - // Mark all user messages as thinking (container is spawning) - const userMessages = missedMessages.filter( - (m) => !m.is_from_me && !m.is_bot_message, - ); - for (const msg of userMessages) { - statusTracker.markThinking(msg.id); - } - - const prompt = formatMessages(missedMessages); - - // Advance cursor so the piping path in startMessageLoop won't re-fetch - // these messages. Save the old cursor so we can roll back on error. - const previousCursor = lastAgentTimestamp[chatJid] || ''; - lastAgentTimestamp[chatJid] = - missedMessages[missedMessages.length - 1].timestamp; - saveState(); - - logger.info( - { group: group.name, messageCount: missedMessages.length }, - 'Processing messages', - ); - - // Track idle timer for closing stdin when agent is idle - let idleTimer: ReturnType | null = null; - - const resetIdleTimer = () => { - if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(() => { - logger.debug( - { group: group.name }, - 'Idle timeout, closing container stdin', - ); - queue.closeStdin(chatJid); - }, IDLE_TIMEOUT); - }; - - await channel.setTyping?.(chatJid, true); - let hadError = false; - let outputSentToUser = false; - let firstOutputSeen = false; - - const output = await runAgent(group, prompt, chatJid, async (result) => { - // Streaming output callback — called for each agent result - if (result.result) { - if (!firstOutputSeen) { - firstOutputSeen = true; - for (const um of userMessages) { - statusTracker.markWorking(um.id); - } - } - const raw = - typeof result.result === 'string' - ? result.result - : JSON.stringify(result.result); - // Strip ... blocks — agent uses these for internal reasoning - const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); - logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); - if (text) { - await channel.sendMessage(chatJid, text); - outputSentToUser = true; - } - // Only reset idle timer on actual results, not session-update markers (result: null) - resetIdleTimer(); - } - - if (result.status === 'success') { - statusTracker.markAllDone(chatJid); - queue.notifyIdle(chatJid); - } - - if (result.status === 'error') { - hadError = true; - } - }); - - await channel.setTyping?.(chatJid, false); - if (idleTimer) clearTimeout(idleTimer); - - if (output === 'error' || hadError) { - if (outputSentToUser) { - // Output was sent for the initial batch, so don't roll those back. - // But if messages were piped AFTER that output, roll back to recover them. - if (cursorBeforePipe[chatJid]) { - lastAgentTimestamp[chatJid] = cursorBeforePipe[chatJid]; - delete cursorBeforePipe[chatJid]; - saveState(); - logger.warn( - { group: group.name }, - 'Agent error after output, rolled back piped messages for retry', - ); - statusTracker.markAllFailed(chatJid, 'Task crashed — retrying.'); - return false; - } - logger.warn( - { group: group.name }, - 'Agent error after output was sent, no piped messages to recover', - ); - statusTracker.markAllDone(chatJid); - return true; - } - // No output sent — roll back everything so the full batch is retried - lastAgentTimestamp[chatJid] = previousCursor; - delete cursorBeforePipe[chatJid]; - saveState(); - logger.warn( - { group: group.name }, - 'Agent error, rolled back message cursor for retry', - ); - statusTracker.markAllFailed(chatJid, 'Task crashed — retrying.'); - return false; - } - - // Success — clear pipe tracking (markAllDone already fired in streaming callback) - delete cursorBeforePipe[chatJid]; - saveState(); - return true; -} - -async function runAgent( - group: RegisteredGroup, - prompt: string, - chatJid: string, - onOutput?: (output: ContainerOutput) => Promise, -): Promise<'success' | 'error'> { - const isMain = group.isMain === true; - const sessionId = sessions[group.folder]; - - // Update tasks snapshot for container to read (filtered by group) - const tasks = getAllTasks(); - writeTasksSnapshot( - group.folder, - isMain, - tasks.map((t) => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run, - })), - ); - - // Update available groups snapshot (main group only can see all groups) - const availableGroups = getAvailableGroups(); - writeGroupsSnapshot( - group.folder, - isMain, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); - - // Wrap onOutput to track session ID from streamed results - const wrappedOnOutput = onOutput - ? async (output: ContainerOutput) => { - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - await onOutput(output); - } - : undefined; - - try { - const output = await runContainerAgent( - group, - { - prompt, - sessionId, - groupFolder: group.folder, - chatJid, - isMain, - assistantName: ASSISTANT_NAME, - }, - (proc, containerName) => - queue.registerProcess(chatJid, proc, containerName, group.folder), - wrappedOnOutput, - ); - - if (output.newSessionId) { - sessions[group.folder] = output.newSessionId; - setSession(group.folder, output.newSessionId); - } - - if (output.status === 'error') { - logger.error( - { group: group.name, error: output.error }, - 'Container agent error', - ); - return 'error'; - } - - return 'success'; - } catch (err) { - logger.error({ group: group.name, err }, 'Agent error'); - return 'error'; - } -} - -async function startMessageLoop(): Promise { - if (messageLoopRunning) { - logger.debug('Message loop already running, skipping duplicate start'); - return; - } - messageLoopRunning = true; - - logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); - - while (true) { - try { - const jids = Object.keys(registeredGroups); - const { messages, newTimestamp } = getNewMessages( - jids, - lastTimestamp, - ASSISTANT_NAME, - ); - - if (messages.length > 0) { - logger.info({ count: messages.length }, 'New messages'); - - // Advance the "seen" cursor for all messages immediately - lastTimestamp = newTimestamp; - saveState(); - - // Deduplicate by group - const messagesByGroup = new Map(); - for (const msg of messages) { - const existing = messagesByGroup.get(msg.chat_jid); - if (existing) { - existing.push(msg); - } else { - messagesByGroup.set(msg.chat_jid, [msg]); - } - } - - for (const [chatJid, groupMessages] of messagesByGroup) { - const group = registeredGroups[chatJid]; - if (!group) continue; - - const channel = findChannel(channels, chatJid); - if (!channel) { - logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); - continue; - } - - const isMainGroup = group.isMain === true; - const needsTrigger = !isMainGroup && group.requiresTrigger !== false; - - // For non-main groups, only act on trigger messages. - // Non-trigger messages accumulate in DB and get pulled as - // context when a trigger eventually arrives. - if (needsTrigger) { - const allowlistCfg = loadSenderAllowlist(); - const hasTrigger = groupMessages.some( - (m) => - TRIGGER_PATTERN.test(m.content.trim()) && - (m.is_from_me || - isTriggerAllowed(chatJid, m.sender, allowlistCfg)), - ); - if (!hasTrigger) continue; - } - - // Mark each user message as received (status emoji) - for (const msg of groupMessages) { - if (!msg.is_from_me && !msg.is_bot_message) { - statusTracker.markReceived(msg.id, chatJid, false); - } - } - - // Pull all messages since lastAgentTimestamp so non-trigger - // context that accumulated between triggers is included. - const allPending = getMessagesSince( - chatJid, - lastAgentTimestamp[chatJid] || '', - ASSISTANT_NAME, - ); - const messagesToSend = - allPending.length > 0 ? allPending : groupMessages; - const formatted = formatMessages(messagesToSend); - - if (queue.sendMessage(chatJid, formatted)) { - logger.debug( - { chatJid, count: messagesToSend.length }, - 'Piped messages to active container', - ); - // Mark new user messages as thinking (only groupMessages were markReceived'd; - // accumulated allPending context messages are untracked and would no-op) - for (const msg of groupMessages) { - if (!msg.is_from_me && !msg.is_bot_message) { - statusTracker.markThinking(msg.id); - } - } - // Save cursor before first pipe so we can roll back if container dies - if (!cursorBeforePipe[chatJid]) { - cursorBeforePipe[chatJid] = lastAgentTimestamp[chatJid] || ''; - } - lastAgentTimestamp[chatJid] = - messagesToSend[messagesToSend.length - 1].timestamp; - saveState(); - // Show typing indicator while the container processes the piped message - channel - .setTyping?.(chatJid, true) - ?.catch((err) => - logger.warn({ chatJid, err }, 'Failed to set typing indicator'), - ); - } else { - // No active container — enqueue for a new one - queue.enqueueMessageCheck(chatJid); - } - } - } - } catch (err) { - logger.error({ err }, 'Error in message loop'); - } - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - } -} - -/** - * Startup recovery: check for unprocessed messages in registered groups. - * Handles crash between advancing lastTimestamp and processing messages. - */ -function recoverPendingMessages(): void { - // Roll back any piped-message cursors that were persisted before a crash. - // This ensures messages piped to a now-dead container are re-fetched. - // IMPORTANT: Only roll back if the container is no longer running — rolling - // back while the container is alive causes duplicate processing. - let rolledBack = false; - for (const [chatJid, savedCursor] of Object.entries(cursorBeforePipe)) { - if (queue.isActive(chatJid)) { - logger.debug( - { chatJid }, - 'Recovery: skipping piped-cursor rollback, container still active', - ); - continue; - } - logger.info( - { chatJid, rolledBackTo: savedCursor }, - 'Recovery: rolling back piped-message cursor', - ); - lastAgentTimestamp[chatJid] = savedCursor; - delete cursorBeforePipe[chatJid]; - rolledBack = true; - } - if (rolledBack) { - saveState(); - } - - for (const [chatJid, group] of Object.entries(registeredGroups)) { - const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; - const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); - if (pending.length > 0) { - logger.info( - { group: group.name, pendingCount: pending.length }, - 'Recovery: found unprocessed messages', - ); - queue.enqueueMessageCheck(chatJid); - } - } -} - -function ensureContainerSystemRunning(): void { - ensureContainerRuntimeRunning(); - cleanupOrphans(); -} - -async function main(): Promise { - ensureContainerSystemRunning(); - initDatabase(); - logger.info('Database initialized'); - loadState(); - - // Graceful shutdown handlers - const shutdown = async (signal: string) => { - logger.info({ signal }, 'Shutdown signal received'); - await queue.shutdown(10000); - for (const ch of channels) await ch.disconnect(); - await statusTracker.shutdown(); - process.exit(0); - }; - process.on('SIGTERM', () => shutdown('SIGTERM')); - process.on('SIGINT', () => shutdown('SIGINT')); - - // Channel callbacks (shared by all channels) - const channelOpts = { - onMessage: (chatJid: string, msg: NewMessage) => { - // Sender allowlist drop mode: discard messages from denied senders before storing - if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { - const cfg = loadSenderAllowlist(); - if ( - shouldDropMessage(chatJid, cfg) && - !isSenderAllowed(chatJid, msg.sender, cfg) - ) { - if (cfg.logDenied) { - logger.debug( - { chatJid, sender: msg.sender }, - 'sender-allowlist: dropping message (drop mode)', - ); - } - return; - } - } - storeMessage(msg); - }, - onChatMetadata: ( - chatJid: string, - timestamp: string, - name?: string, - channel?: string, - isGroup?: boolean, - ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup), - registeredGroups: () => registeredGroups, - }; - - // Initialize status tracker (uses channels via callbacks, channels don't need to be connected yet) - statusTracker = new StatusTracker({ - sendReaction: async (chatJid, messageKey, emoji) => { - const channel = findChannel(channels, chatJid); - if (!channel?.sendReaction) return; - await channel.sendReaction(chatJid, messageKey, emoji); - }, - sendMessage: async (chatJid, text) => { - const channel = findChannel(channels, chatJid); - if (!channel) return; - await channel.sendMessage(chatJid, text); - }, - isMainGroup: (chatJid) => { - const group = registeredGroups[chatJid]; - return group?.isMain === true; - }, - isContainerAlive: (chatJid) => queue.isActive(chatJid), - }); - - // Create and connect all registered channels. - // Each channel self-registers via the barrel import above. - // Factories return null when credentials are missing, so unconfigured channels are skipped. - for (const channelName of getRegisteredChannelNames()) { - const factory = getChannelFactory(channelName)!; - const channel = factory(channelOpts); - if (!channel) { - logger.warn( - { channel: channelName }, - 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.', - ); - continue; - } - channels.push(channel); - await channel.connect(); - } - if (channels.length === 0) { - logger.fatal('No channels connected'); - process.exit(1); - } - - // Start subsystems (independently of connection handler) - startSchedulerLoop({ - registeredGroups: () => registeredGroups, - getSessions: () => sessions, - queue, - onProcess: (groupJid, proc, containerName, groupFolder) => - queue.registerProcess(groupJid, proc, containerName, groupFolder), - sendMessage: async (jid, rawText) => { - const channel = findChannel(channels, jid); - if (!channel) { - logger.warn({ jid }, 'No channel owns JID, cannot send message'); - return; - } - const text = formatOutbound(rawText); - if (text) await channel.sendMessage(jid, text); - }, - }); - startIpcWatcher({ - sendMessage: (jid, text) => { - const channel = findChannel(channels, jid); - if (!channel) throw new Error(`No channel for JID: ${jid}`); - return channel.sendMessage(jid, text); - }, - sendReaction: async (jid, emoji, messageId) => { - const channel = findChannel(channels, jid); - if (!channel) throw new Error(`No channel for JID: ${jid}`); - if (messageId) { - if (!channel.sendReaction) - throw new Error('Channel does not support sendReaction'); - const messageKey = { - id: messageId, - remoteJid: jid, - fromMe: getMessageFromMe(messageId, jid), - }; - await channel.sendReaction(jid, messageKey, emoji); - } else { - if (!channel.reactToLatestMessage) - throw new Error('Channel does not support reactions'); - await channel.reactToLatestMessage(jid, emoji); - } - }, - registeredGroups: () => registeredGroups, - registerGroup, - syncGroups: async (force: boolean) => { - await Promise.all( - channels - .filter((ch) => ch.syncGroups) - .map((ch) => ch.syncGroups!(force)), - ); - }, - getAvailableGroups, - writeGroupsSnapshot: (gf, im, ag, rj) => - writeGroupsSnapshot(gf, im, ag, rj), - statusHeartbeat: () => statusTracker.heartbeatCheck(), - recoverPendingMessages, - }); - // Recover status tracker AFTER channels connect, so recovery reactions - // can actually be sent via the WhatsApp channel. - await statusTracker.recover(); - queue.setProcessMessagesFn(processGroupMessages); - recoverPendingMessages(); - startMessageLoop().catch((err) => { - logger.fatal({ err }, 'Message loop crashed unexpectedly'); - process.exit(1); - }); -} - -// Guard: only run when executed directly, not when imported by tests -const isDirectRun = - process.argv[1] && - new URL(import.meta.url).pathname === - new URL(`file://${process.argv[1]}`).pathname; - -if (isDirectRun) { - main().catch((err) => { - logger.error({ err }, 'Failed to start NanoClaw'); - process.exit(1); - }); -} diff --git a/.claude/skills/add-reactions/modify/src/ipc-auth.test.ts b/.claude/skills/add-reactions/modify/src/ipc-auth.test.ts deleted file mode 100644 index 9637850..0000000 --- a/.claude/skills/add-reactions/modify/src/ipc-auth.test.ts +++ /dev/null @@ -1,807 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; - -import { - _initTestDatabase, - createTask, - getAllTasks, - getRegisteredGroup, - getTaskById, - setRegisteredGroup, -} from './db.js'; -import { processTaskIpc, IpcDeps } from './ipc.js'; -import { RegisteredGroup } from './types.js'; - -// Set up registered groups used across tests -const MAIN_GROUP: RegisteredGroup = { - name: 'Main', - folder: 'main', - trigger: 'always', - added_at: '2024-01-01T00:00:00.000Z', -}; - -const OTHER_GROUP: RegisteredGroup = { - name: 'Other', - folder: 'other-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', -}; - -const THIRD_GROUP: RegisteredGroup = { - name: 'Third', - folder: 'third-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', -}; - -let groups: Record; -let deps: IpcDeps; - -beforeEach(() => { - _initTestDatabase(); - - groups = { - 'main@g.us': MAIN_GROUP, - 'other@g.us': OTHER_GROUP, - 'third@g.us': THIRD_GROUP, - }; - - // Populate DB as well - setRegisteredGroup('main@g.us', MAIN_GROUP); - setRegisteredGroup('other@g.us', OTHER_GROUP); - setRegisteredGroup('third@g.us', THIRD_GROUP); - - deps = { - sendMessage: async () => {}, - sendReaction: async () => {}, - registeredGroups: () => groups, - registerGroup: (jid, group) => { - groups[jid] = group; - setRegisteredGroup(jid, group); - }, - unregisterGroup: (jid) => { - const existed = jid in groups; - delete groups[jid]; - return existed; - }, - syncGroupMetadata: async () => {}, - getAvailableGroups: () => [], - writeGroupsSnapshot: () => {}, - }; -}); - -// --- schedule_task authorization --- - -describe('schedule_task authorization', () => { - it('main group can schedule for another group', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'do something', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - // Verify task was created in DB for the other group - const allTasks = getAllTasks(); - expect(allTasks.length).toBe(1); - expect(allTasks[0].group_folder).toBe('other-group'); - }); - - it('non-main group can schedule for itself', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'self task', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - targetJid: 'other@g.us', - }, - 'other-group', - false, - deps, - ); - - const allTasks = getAllTasks(); - expect(allTasks.length).toBe(1); - expect(allTasks[0].group_folder).toBe('other-group'); - }); - - it('non-main group cannot schedule for another group', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'unauthorized', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - targetJid: 'main@g.us', - }, - 'other-group', - false, - deps, - ); - - const allTasks = getAllTasks(); - expect(allTasks.length).toBe(0); - }); - - it('rejects schedule_task for unregistered target JID', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'no target', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - targetJid: 'unknown@g.us', - }, - 'main', - true, - deps, - ); - - const allTasks = getAllTasks(); - expect(allTasks.length).toBe(0); - }); -}); - -// --- pause_task authorization --- - -describe('pause_task authorization', () => { - beforeEach(() => { - createTask({ - id: 'task-main', - group_folder: 'main', - chat_jid: 'main@g.us', - prompt: 'main task', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - context_mode: 'isolated', - next_run: '2025-06-01T00:00:00.000Z', - status: 'active', - created_at: '2024-01-01T00:00:00.000Z', - }); - createTask({ - id: 'task-other', - group_folder: 'other-group', - chat_jid: 'other@g.us', - prompt: 'other task', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - context_mode: 'isolated', - next_run: '2025-06-01T00:00:00.000Z', - status: 'active', - created_at: '2024-01-01T00:00:00.000Z', - }); - }); - - it('main group can pause any task', async () => { - await processTaskIpc( - { type: 'pause_task', taskId: 'task-other' }, - 'main', - true, - deps, - ); - expect(getTaskById('task-other')!.status).toBe('paused'); - }); - - it('non-main group can pause its own task', async () => { - await processTaskIpc( - { type: 'pause_task', taskId: 'task-other' }, - 'other-group', - false, - deps, - ); - expect(getTaskById('task-other')!.status).toBe('paused'); - }); - - it('non-main group cannot pause another groups task', async () => { - await processTaskIpc( - { type: 'pause_task', taskId: 'task-main' }, - 'other-group', - false, - deps, - ); - expect(getTaskById('task-main')!.status).toBe('active'); - }); -}); - -// --- resume_task authorization --- - -describe('resume_task authorization', () => { - beforeEach(() => { - createTask({ - id: 'task-paused', - group_folder: 'other-group', - chat_jid: 'other@g.us', - prompt: 'paused task', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - context_mode: 'isolated', - next_run: '2025-06-01T00:00:00.000Z', - status: 'paused', - created_at: '2024-01-01T00:00:00.000Z', - }); - }); - - it('main group can resume any task', async () => { - await processTaskIpc( - { type: 'resume_task', taskId: 'task-paused' }, - 'main', - true, - deps, - ); - expect(getTaskById('task-paused')!.status).toBe('active'); - }); - - it('non-main group can resume its own task', async () => { - await processTaskIpc( - { type: 'resume_task', taskId: 'task-paused' }, - 'other-group', - false, - deps, - ); - expect(getTaskById('task-paused')!.status).toBe('active'); - }); - - it('non-main group cannot resume another groups task', async () => { - await processTaskIpc( - { type: 'resume_task', taskId: 'task-paused' }, - 'third-group', - false, - deps, - ); - expect(getTaskById('task-paused')!.status).toBe('paused'); - }); -}); - -// --- cancel_task authorization --- - -describe('cancel_task authorization', () => { - it('main group can cancel any task', async () => { - createTask({ - id: 'task-to-cancel', - group_folder: 'other-group', - chat_jid: 'other@g.us', - prompt: 'cancel me', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - context_mode: 'isolated', - next_run: null, - status: 'active', - created_at: '2024-01-01T00:00:00.000Z', - }); - - await processTaskIpc( - { type: 'cancel_task', taskId: 'task-to-cancel' }, - 'main', - true, - deps, - ); - expect(getTaskById('task-to-cancel')).toBeUndefined(); - }); - - it('non-main group can cancel its own task', async () => { - createTask({ - id: 'task-own', - group_folder: 'other-group', - chat_jid: 'other@g.us', - prompt: 'my task', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - context_mode: 'isolated', - next_run: null, - status: 'active', - created_at: '2024-01-01T00:00:00.000Z', - }); - - await processTaskIpc( - { type: 'cancel_task', taskId: 'task-own' }, - 'other-group', - false, - deps, - ); - expect(getTaskById('task-own')).toBeUndefined(); - }); - - it('non-main group cannot cancel another groups task', async () => { - createTask({ - id: 'task-foreign', - group_folder: 'main', - chat_jid: 'main@g.us', - prompt: 'not yours', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - context_mode: 'isolated', - next_run: null, - status: 'active', - created_at: '2024-01-01T00:00:00.000Z', - }); - - await processTaskIpc( - { type: 'cancel_task', taskId: 'task-foreign' }, - 'other-group', - false, - deps, - ); - expect(getTaskById('task-foreign')).toBeDefined(); - }); -}); - -// --- register_group authorization --- - -describe('register_group authorization', () => { - it('non-main group cannot register a group', async () => { - await processTaskIpc( - { - type: 'register_group', - jid: 'new@g.us', - name: 'New Group', - folder: 'new-group', - trigger: '@Andy', - }, - 'other-group', - false, - deps, - ); - - // registeredGroups should not have changed - expect(groups['new@g.us']).toBeUndefined(); - }); - - it('main group cannot register with unsafe folder path', async () => { - await processTaskIpc( - { - type: 'register_group', - jid: 'new@g.us', - name: 'New Group', - folder: '../../outside', - trigger: '@Andy', - }, - 'main', - true, - deps, - ); - - expect(groups['new@g.us']).toBeUndefined(); - }); -}); - -// --- refresh_groups authorization --- - -describe('refresh_groups authorization', () => { - it('non-main group cannot trigger refresh', async () => { - // This should be silently blocked (no crash, no effect) - await processTaskIpc( - { type: 'refresh_groups' }, - 'other-group', - false, - deps, - ); - // If we got here without error, the auth gate worked - }); -}); - -// --- IPC message authorization --- -// Tests the authorization pattern from startIpcWatcher (ipc.ts). -// The logic: isMain || (targetGroup && targetGroup.folder === sourceGroup) - -describe('IPC message authorization', () => { - // Replicate the exact check from the IPC watcher - function isMessageAuthorized( - sourceGroup: string, - isMain: boolean, - targetChatJid: string, - registeredGroups: Record, - ): boolean { - const targetGroup = registeredGroups[targetChatJid]; - return isMain || (!!targetGroup && targetGroup.folder === sourceGroup); - } - - it('main group can send to any group', () => { - expect(isMessageAuthorized('main', true, 'other@g.us', groups)).toBe(true); - expect(isMessageAuthorized('main', true, 'third@g.us', groups)).toBe(true); - }); - - it('non-main group can send to its own chat', () => { - expect( - isMessageAuthorized('other-group', false, 'other@g.us', groups), - ).toBe(true); - }); - - it('non-main group cannot send to another groups chat', () => { - expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe( - false, - ); - expect( - isMessageAuthorized('other-group', false, 'third@g.us', groups), - ).toBe(false); - }); - - it('non-main group cannot send to unregistered JID', () => { - expect( - isMessageAuthorized('other-group', false, 'unknown@g.us', groups), - ).toBe(false); - }); - - it('main group can send to unregistered JID', () => { - // Main is always authorized regardless of target - expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe( - true, - ); - }); -}); - -// --- IPC reaction authorization --- -// Same authorization pattern as message sending (ipc.ts lines 104-127). - -describe('IPC reaction authorization', () => { - // Replicate the exact check from the IPC watcher for reactions - function isReactionAuthorized( - sourceGroup: string, - isMain: boolean, - targetChatJid: string, - registeredGroups: Record, - ): boolean { - const targetGroup = registeredGroups[targetChatJid]; - return isMain || (!!targetGroup && targetGroup.folder === sourceGroup); - } - - it('main group can react in any chat', () => { - expect(isReactionAuthorized('main', true, 'other@g.us', groups)).toBe(true); - expect(isReactionAuthorized('main', true, 'third@g.us', groups)).toBe(true); - }); - - it('non-main group can react in its own chat', () => { - expect( - isReactionAuthorized('other-group', false, 'other@g.us', groups), - ).toBe(true); - }); - - it('non-main group cannot react in another groups chat', () => { - expect( - isReactionAuthorized('other-group', false, 'main@g.us', groups), - ).toBe(false); - expect( - isReactionAuthorized('other-group', false, 'third@g.us', groups), - ).toBe(false); - }); - - it('non-main group cannot react in unregistered JID', () => { - expect( - isReactionAuthorized('other-group', false, 'unknown@g.us', groups), - ).toBe(false); - }); -}); - -// --- sendReaction mock is exercised --- -// The sendReaction dep is wired in but was never called in tests. -// These tests verify startIpcWatcher would call it by testing the pattern inline. - -describe('IPC reaction sendReaction integration', () => { - it('sendReaction mock is callable', async () => { - const calls: Array<{ jid: string; emoji: string; messageId?: string }> = []; - deps.sendReaction = async (jid, emoji, messageId) => { - calls.push({ jid, emoji, messageId }); - }; - - // Simulate what processIpcFiles does for a reaction - const data = { - type: 'reaction' as const, - chatJid: 'other@g.us', - emoji: '👍', - messageId: 'msg-123', - }; - const sourceGroup = 'main'; - const isMain = true; - const registeredGroups = deps.registeredGroups(); - const targetGroup = registeredGroups[data.chatJid]; - - if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { - await deps.sendReaction(data.chatJid, data.emoji, data.messageId); - } - - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ - jid: 'other@g.us', - emoji: '👍', - messageId: 'msg-123', - }); - }); - - it('sendReaction is blocked for unauthorized group', async () => { - const calls: Array<{ jid: string; emoji: string; messageId?: string }> = []; - deps.sendReaction = async (jid, emoji, messageId) => { - calls.push({ jid, emoji, messageId }); - }; - - const data = { - type: 'reaction' as const, - chatJid: 'main@g.us', - emoji: '❤️', - }; - const sourceGroup = 'other-group'; - const isMain = false; - const registeredGroups = deps.registeredGroups(); - const targetGroup = registeredGroups[data.chatJid]; - - if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { - await deps.sendReaction(data.chatJid, data.emoji); - } - - expect(calls).toHaveLength(0); - }); - - it('sendReaction works without messageId (react to latest)', async () => { - const calls: Array<{ jid: string; emoji: string; messageId?: string }> = []; - deps.sendReaction = async (jid, emoji, messageId) => { - calls.push({ jid, emoji, messageId }); - }; - - const data = { - type: 'reaction' as const, - chatJid: 'other@g.us', - emoji: '🔥', - }; - const sourceGroup = 'other-group'; - const isMain = false; - const registeredGroups = deps.registeredGroups(); - const targetGroup = registeredGroups[data.chatJid]; - - if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { - await deps.sendReaction(data.chatJid, data.emoji, undefined); - } - - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ - jid: 'other@g.us', - emoji: '🔥', - messageId: undefined, - }); - }); -}); - -// --- schedule_task with cron and interval types --- - -describe('schedule_task schedule types', () => { - it('creates task with cron schedule and computes next_run', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'cron task', - schedule_type: 'cron', - schedule_value: '0 9 * * *', // every day at 9am - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - const tasks = getAllTasks(); - expect(tasks).toHaveLength(1); - expect(tasks[0].schedule_type).toBe('cron'); - expect(tasks[0].next_run).toBeTruthy(); - // next_run should be a valid ISO date in the future - expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan( - Date.now() - 60000, - ); - }); - - it('rejects invalid cron expression', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'bad cron', - schedule_type: 'cron', - schedule_value: 'not a cron', - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - expect(getAllTasks()).toHaveLength(0); - }); - - it('creates task with interval schedule', async () => { - const before = Date.now(); - - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'interval task', - schedule_type: 'interval', - schedule_value: '3600000', // 1 hour - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - const tasks = getAllTasks(); - expect(tasks).toHaveLength(1); - expect(tasks[0].schedule_type).toBe('interval'); - // next_run should be ~1 hour from now - const nextRun = new Date(tasks[0].next_run!).getTime(); - expect(nextRun).toBeGreaterThanOrEqual(before + 3600000 - 1000); - expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000); - }); - - it('rejects invalid interval (non-numeric)', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'bad interval', - schedule_type: 'interval', - schedule_value: 'abc', - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - expect(getAllTasks()).toHaveLength(0); - }); - - it('rejects invalid interval (zero)', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'zero interval', - schedule_type: 'interval', - schedule_value: '0', - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - expect(getAllTasks()).toHaveLength(0); - }); - - it('rejects invalid once timestamp', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'bad once', - schedule_type: 'once', - schedule_value: 'not-a-date', - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - expect(getAllTasks()).toHaveLength(0); - }); -}); - -// --- context_mode defaulting --- - -describe('schedule_task context_mode', () => { - it('accepts context_mode=group', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'group context', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - context_mode: 'group', - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - const tasks = getAllTasks(); - expect(tasks[0].context_mode).toBe('group'); - }); - - it('accepts context_mode=isolated', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'isolated context', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - context_mode: 'isolated', - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - const tasks = getAllTasks(); - expect(tasks[0].context_mode).toBe('isolated'); - }); - - it('defaults invalid context_mode to isolated', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'bad context', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - context_mode: 'bogus' as any, - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - const tasks = getAllTasks(); - expect(tasks[0].context_mode).toBe('isolated'); - }); - - it('defaults missing context_mode to isolated', async () => { - await processTaskIpc( - { - type: 'schedule_task', - prompt: 'no context mode', - schedule_type: 'once', - schedule_value: '2025-06-01T00:00:00.000Z', - targetJid: 'other@g.us', - }, - 'main', - true, - deps, - ); - - const tasks = getAllTasks(); - expect(tasks[0].context_mode).toBe('isolated'); - }); -}); - -// --- register_group success path --- - -describe('register_group success', () => { - it('main group can register a new group', async () => { - await processTaskIpc( - { - type: 'register_group', - jid: 'new@g.us', - name: 'New Group', - folder: 'new-group', - trigger: '@Andy', - }, - 'main', - true, - deps, - ); - - // Verify group was registered in DB - const group = getRegisteredGroup('new@g.us'); - expect(group).toBeDefined(); - expect(group!.name).toBe('New Group'); - expect(group!.folder).toBe('new-group'); - expect(group!.trigger).toBe('@Andy'); - }); - - it('register_group rejects request with missing fields', async () => { - await processTaskIpc( - { - type: 'register_group', - jid: 'partial@g.us', - name: 'Partial', - // missing folder and trigger - }, - 'main', - true, - deps, - ); - - expect(getRegisteredGroup('partial@g.us')).toBeUndefined(); - }); -}); diff --git a/.claude/skills/add-reactions/modify/src/ipc.ts b/.claude/skills/add-reactions/modify/src/ipc.ts deleted file mode 100644 index 4681092..0000000 --- a/.claude/skills/add-reactions/modify/src/ipc.ts +++ /dev/null @@ -1,446 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { CronExpressionParser } from 'cron-parser'; - -import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; -import { AvailableGroup } from './container-runner.js'; -import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; -import { isValidGroupFolder } from './group-folder.js'; -import { logger } from './logger.js'; -import { RegisteredGroup } from './types.js'; - -export interface IpcDeps { - sendMessage: (jid: string, text: string) => Promise; - sendReaction?: ( - jid: string, - emoji: string, - messageId?: string, - ) => Promise; - registeredGroups: () => Record; - registerGroup: (jid: string, group: RegisteredGroup) => void; - syncGroups: (force: boolean) => Promise; - getAvailableGroups: () => AvailableGroup[]; - writeGroupsSnapshot: ( - groupFolder: string, - isMain: boolean, - availableGroups: AvailableGroup[], - registeredJids: Set, - ) => void; - statusHeartbeat?: () => void; - recoverPendingMessages?: () => void; -} - -let ipcWatcherRunning = false; -const RECOVERY_INTERVAL_MS = 60_000; - -export function startIpcWatcher(deps: IpcDeps): void { - if (ipcWatcherRunning) { - logger.debug('IPC watcher already running, skipping duplicate start'); - return; - } - ipcWatcherRunning = true; - - const ipcBaseDir = path.join(DATA_DIR, 'ipc'); - fs.mkdirSync(ipcBaseDir, { recursive: true }); - let lastRecoveryTime = Date.now(); - - const processIpcFiles = async () => { - // Scan all group IPC directories (identity determined by directory) - let groupFolders: string[]; - try { - groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => { - const stat = fs.statSync(path.join(ipcBaseDir, f)); - return stat.isDirectory() && f !== 'errors'; - }); - } catch (err) { - logger.error({ err }, 'Error reading IPC base directory'); - setTimeout(processIpcFiles, IPC_POLL_INTERVAL); - return; - } - - const registeredGroups = deps.registeredGroups(); - - // Build folder→isMain lookup from registered groups - const folderIsMain = new Map(); - for (const group of Object.values(registeredGroups)) { - if (group.isMain) folderIsMain.set(group.folder, true); - } - - for (const sourceGroup of groupFolders) { - const isMain = folderIsMain.get(sourceGroup) === true; - const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages'); - const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks'); - - // Process messages from this group's IPC directory - try { - if (fs.existsSync(messagesDir)) { - const messageFiles = fs - .readdirSync(messagesDir) - .filter((f) => f.endsWith('.json')); - for (const file of messageFiles) { - const filePath = path.join(messagesDir, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - if (data.type === 'message' && data.chatJid && data.text) { - // Authorization: verify this group can send to this chatJid - const targetGroup = registeredGroups[data.chatJid]; - if ( - isMain || - (targetGroup && targetGroup.folder === sourceGroup) - ) { - await deps.sendMessage(data.chatJid, data.text); - logger.info( - { chatJid: data.chatJid, sourceGroup }, - 'IPC message sent', - ); - } else { - logger.warn( - { chatJid: data.chatJid, sourceGroup }, - 'Unauthorized IPC message attempt blocked', - ); - } - } else if ( - data.type === 'reaction' && - data.chatJid && - data.emoji && - deps.sendReaction - ) { - const targetGroup = registeredGroups[data.chatJid]; - if ( - isMain || - (targetGroup && targetGroup.folder === sourceGroup) - ) { - try { - await deps.sendReaction( - data.chatJid, - data.emoji, - data.messageId, - ); - logger.info( - { chatJid: data.chatJid, emoji: data.emoji, sourceGroup }, - 'IPC reaction sent', - ); - } catch (err) { - logger.error( - { - chatJid: data.chatJid, - emoji: data.emoji, - sourceGroup, - err, - }, - 'IPC reaction failed', - ); - } - } else { - logger.warn( - { chatJid: data.chatJid, sourceGroup }, - 'Unauthorized IPC reaction attempt blocked', - ); - } - } - fs.unlinkSync(filePath); - } catch (err) { - logger.error( - { file, sourceGroup, err }, - 'Error processing IPC message', - ); - const errorDir = path.join(ipcBaseDir, 'errors'); - fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync( - filePath, - path.join(errorDir, `${sourceGroup}-${file}`), - ); - } - } - } - } catch (err) { - logger.error( - { err, sourceGroup }, - 'Error reading IPC messages directory', - ); - } - - // Process tasks from this group's IPC directory - try { - if (fs.existsSync(tasksDir)) { - const taskFiles = fs - .readdirSync(tasksDir) - .filter((f) => f.endsWith('.json')); - for (const file of taskFiles) { - const filePath = path.join(tasksDir, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - // Pass source group identity to processTaskIpc for authorization - await processTaskIpc(data, sourceGroup, isMain, deps); - fs.unlinkSync(filePath); - } catch (err) { - logger.error( - { file, sourceGroup, err }, - 'Error processing IPC task', - ); - const errorDir = path.join(ipcBaseDir, 'errors'); - fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync( - filePath, - path.join(errorDir, `${sourceGroup}-${file}`), - ); - } - } - } - } catch (err) { - logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory'); - } - } - - // Status emoji heartbeat — detect dead containers with stale emoji state - deps.statusHeartbeat?.(); - - // Periodic message recovery — catch stuck messages after retry exhaustion or pipeline stalls - const now = Date.now(); - if (now - lastRecoveryTime >= RECOVERY_INTERVAL_MS) { - lastRecoveryTime = now; - deps.recoverPendingMessages?.(); - } - - setTimeout(processIpcFiles, IPC_POLL_INTERVAL); - }; - - processIpcFiles(); - logger.info('IPC watcher started (per-group namespaces)'); -} - -export async function processTaskIpc( - data: { - type: string; - taskId?: string; - prompt?: string; - schedule_type?: string; - schedule_value?: string; - context_mode?: string; - groupFolder?: string; - chatJid?: string; - targetJid?: string; - // For register_group - jid?: string; - name?: string; - folder?: string; - trigger?: string; - requiresTrigger?: boolean; - containerConfig?: RegisteredGroup['containerConfig']; - }, - sourceGroup: string, // Verified identity from IPC directory - isMain: boolean, // Verified from directory path - deps: IpcDeps, -): Promise { - const registeredGroups = deps.registeredGroups(); - - switch (data.type) { - case 'schedule_task': - if ( - data.prompt && - data.schedule_type && - data.schedule_value && - data.targetJid - ) { - // Resolve the target group from JID - const targetJid = data.targetJid as string; - const targetGroupEntry = registeredGroups[targetJid]; - - if (!targetGroupEntry) { - logger.warn( - { targetJid }, - 'Cannot schedule task: target group not registered', - ); - break; - } - - const targetFolder = targetGroupEntry.folder; - - // Authorization: non-main groups can only schedule for themselves - if (!isMain && targetFolder !== sourceGroup) { - logger.warn( - { sourceGroup, targetFolder }, - 'Unauthorized schedule_task attempt blocked', - ); - break; - } - - const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once'; - - let nextRun: string | null = null; - if (scheduleType === 'cron') { - try { - const interval = CronExpressionParser.parse(data.schedule_value, { - tz: TIMEZONE, - }); - nextRun = interval.next().toISOString(); - } catch { - logger.warn( - { scheduleValue: data.schedule_value }, - 'Invalid cron expression', - ); - break; - } - } else if (scheduleType === 'interval') { - const ms = parseInt(data.schedule_value, 10); - if (isNaN(ms) || ms <= 0) { - logger.warn( - { scheduleValue: data.schedule_value }, - 'Invalid interval', - ); - break; - } - nextRun = new Date(Date.now() + ms).toISOString(); - } else if (scheduleType === 'once') { - const scheduled = new Date(data.schedule_value); - if (isNaN(scheduled.getTime())) { - logger.warn( - { scheduleValue: data.schedule_value }, - 'Invalid timestamp', - ); - break; - } - nextRun = scheduled.toISOString(); - } - - const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const contextMode = - data.context_mode === 'group' || data.context_mode === 'isolated' - ? data.context_mode - : 'isolated'; - createTask({ - id: taskId, - group_folder: targetFolder, - chat_jid: targetJid, - prompt: data.prompt, - schedule_type: scheduleType, - schedule_value: data.schedule_value, - context_mode: contextMode, - next_run: nextRun, - status: 'active', - created_at: new Date().toISOString(), - }); - logger.info( - { taskId, sourceGroup, targetFolder, contextMode }, - 'Task created via IPC', - ); - } - break; - - case 'pause_task': - if (data.taskId) { - const task = getTaskById(data.taskId); - if (task && (isMain || task.group_folder === sourceGroup)) { - updateTask(data.taskId, { status: 'paused' }); - logger.info( - { taskId: data.taskId, sourceGroup }, - 'Task paused via IPC', - ); - } else { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task pause attempt', - ); - } - } - break; - - case 'resume_task': - if (data.taskId) { - const task = getTaskById(data.taskId); - if (task && (isMain || task.group_folder === sourceGroup)) { - updateTask(data.taskId, { status: 'active' }); - logger.info( - { taskId: data.taskId, sourceGroup }, - 'Task resumed via IPC', - ); - } else { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task resume attempt', - ); - } - } - break; - - case 'cancel_task': - if (data.taskId) { - const task = getTaskById(data.taskId); - if (task && (isMain || task.group_folder === sourceGroup)) { - deleteTask(data.taskId); - logger.info( - { taskId: data.taskId, sourceGroup }, - 'Task cancelled via IPC', - ); - } else { - logger.warn( - { taskId: data.taskId, sourceGroup }, - 'Unauthorized task cancel attempt', - ); - } - } - break; - - case 'refresh_groups': - // Only main group can request a refresh - if (isMain) { - logger.info( - { sourceGroup }, - 'Group metadata refresh requested via IPC', - ); - await deps.syncGroups(true); - // Write updated snapshot immediately - const availableGroups = deps.getAvailableGroups(); - deps.writeGroupsSnapshot( - sourceGroup, - true, - availableGroups, - new Set(Object.keys(registeredGroups)), - ); - } else { - logger.warn( - { sourceGroup }, - 'Unauthorized refresh_groups attempt blocked', - ); - } - break; - - case 'register_group': - // Only main group can register new groups - if (!isMain) { - logger.warn( - { sourceGroup }, - 'Unauthorized register_group attempt blocked', - ); - break; - } - if (data.jid && data.name && data.folder && data.trigger) { - if (!isValidGroupFolder(data.folder)) { - logger.warn( - { sourceGroup, folder: data.folder }, - 'Invalid register_group request - unsafe folder name', - ); - break; - } - // Defense in depth: agent cannot set isMain via IPC - deps.registerGroup(data.jid, { - name: data.name, - folder: data.folder, - trigger: data.trigger, - added_at: new Date().toISOString(), - containerConfig: data.containerConfig, - requiresTrigger: data.requiresTrigger, - }); - } else { - logger.warn( - { data }, - 'Invalid register_group request - missing required fields', - ); - } - break; - - default: - logger.warn({ type: data.type }, 'Unknown IPC task type'); - } -} diff --git a/.claude/skills/add-reactions/modify/src/types.ts b/.claude/skills/add-reactions/modify/src/types.ts deleted file mode 100644 index 1542408..0000000 --- a/.claude/skills/add-reactions/modify/src/types.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 -} - -/** - * Mount Allowlist - Security configuration for additional mounts - * This file should be stored at ~/.config/nanoclaw/mount-allowlist.json - * and is NOT mounted into any container, making it tamper-proof from agents. - */ -export interface MountAllowlist { - // Directories that can be mounted into containers - allowedRoots: AllowedRoot[]; - // Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg") - blockedPatterns: string[]; - // If true, non-main groups can only mount read-only regardless of config - nonMainReadOnly: boolean; -} - -export interface AllowedRoot { - // Absolute path or ~ for home (e.g., "~/projects", "/var/repos") - path: string; - // Whether read-write mounts are allowed under this root - allowReadWrite: boolean; - // Optional description for documentation - description?: string; -} - -export interface ContainerConfig { - additionalMounts?: AdditionalMount[]; - timeout?: number; // Default: 300000 (5 minutes) -} - -export interface RegisteredGroup { - name: string; - folder: string; - trigger: string; - added_at: string; - containerConfig?: ContainerConfig; - requiresTrigger?: boolean; // Default: true for groups, false for solo chats -} - -export interface NewMessage { - id: string; - chat_jid: string; - sender: string; - sender_name: string; - content: string; - timestamp: string; - is_from_me?: boolean; - is_bot_message?: boolean; -} - -export interface ScheduledTask { - id: string; - group_folder: string; - chat_jid: string; - prompt: string; - schedule_type: 'cron' | 'interval' | 'once'; - schedule_value: string; - context_mode: 'group' | 'isolated'; - next_run: string | null; - last_run: string | null; - last_result: string | null; - status: 'active' | 'paused' | 'completed'; - created_at: string; -} - -export interface TaskRunLog { - task_id: string; - run_at: string; - duration_ms: number; - status: 'success' | 'error'; - result: string | null; - error: string | null; -} - -// --- Channel abstraction --- - -export interface Channel { - name: string; - connect(): Promise; - sendMessage(jid: string, text: string): Promise; - isConnected(): boolean; - ownsJid(jid: string): boolean; - disconnect(): Promise; - // Optional: typing indicator. Channels that support it implement it. - setTyping?(jid: string, isTyping: boolean): Promise; - // Optional: reaction support - sendReaction?( - chatJid: string, - messageKey: { id: string; remoteJid: string; fromMe?: boolean; participant?: string }, - emoji: string - ): Promise; - reactToLatestMessage?(chatJid: string, emoji: string): Promise; -} - -// Callback type that channels use to deliver inbound messages -export type OnInboundMessage = (chatJid: string, message: NewMessage) => void; - -// Callback for chat metadata discovery. -// name is optional — channels that deliver names inline (Telegram) pass it here; -// channels that sync names separately (WhatsApp syncGroupMetadata) omit it. -export type OnChatMetadata = ( - chatJid: string, - timestamp: string, - name?: string, - channel?: string, - isGroup?: boolean, -) => void; diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md deleted file mode 100644 index 416c778..0000000 --- a/.claude/skills/add-slack/SKILL.md +++ /dev/null @@ -1,215 +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 using the skills engine for deterministic code changes, then walks through interactive setup. - -## Phase 1: Pre-flight - -### Check if already applied - -Read `.nanoclaw/state.yaml`. If `slack` is in `applied_skills`, 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 - -Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md. - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist yet: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -Or call `initSkillsSystem()` from `skills-engine/migrate.ts`. - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-slack -``` - -This deterministically: -- Adds `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`) -- Adds `src/channels/slack.test.ts` (46 unit tests) -- Appends `import './slack.js'` to the channel barrel file `src/channels/index.ts` -- Installs the `@slack/bolt` npm dependency -- Records the application in `.nanoclaw/state.yaml` - -If the apply reports merge conflicts, read the intent file: -- `modify/src/channels/index.ts.intent.md` — what changed and invariants - -### Validate code changes - -```bash -npm test -npm run build -``` - -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-slack/SLACK_SETUP.md b/.claude/skills/add-slack/SLACK_SETUP.md deleted file mode 100644 index 90e2041..0000000 --- a/.claude/skills/add-slack/SLACK_SETUP.md +++ /dev/null @@ -1,149 +0,0 @@ -# Slack App Setup for NanoClaw - -Step-by-step guide to creating and configuring a Slack app for use with NanoClaw. - -## Prerequisites - -- A Slack workspace where you have admin permissions (or permission to install apps) -- Your NanoClaw instance with the `/add-slack` skill applied - -## Step 1: Create the Slack App - -1. Go to [api.slack.com/apps](https://api.slack.com/apps) -2. Click **Create New App** -3. Choose **From scratch** -4. Enter an app name (e.g., your `ASSISTANT_NAME` value, or any name you like) -5. Select the workspace you want to install it in -6. Click **Create App** - -## Step 2: Enable Socket Mode - -Socket Mode lets the bot connect to Slack without needing a public URL. This is what makes it work from your local machine. - -1. In the sidebar, click **Socket Mode** -2. Toggle **Enable Socket Mode** to **On** -3. When prompted for a token name, enter something like `nanoclaw` -4. Click **Generate** -5. **Copy the App-Level Token** — it starts with `xapp-`. Save this somewhere safe; you'll need it later. - -## Step 3: Subscribe to Events - -This tells Slack which messages to forward to your bot. - -1. In the sidebar, click **Event Subscriptions** -2. Toggle **Enable Events** to **On** -3. Under **Subscribe to bot events**, click **Add Bot User Event** and add these three events: - -| Event | What it does | -|-------|-------------| -| `message.channels` | Receive messages in public channels the bot is in | -| `message.groups` | Receive messages in private channels the bot is in | -| `message.im` | Receive direct messages to the bot | - -4. Click **Save Changes** at the bottom of the page - -## Step 4: Set Bot Permissions (OAuth Scopes) - -These scopes control what the bot is allowed to do. - -1. In the sidebar, click **OAuth & Permissions** -2. Scroll down to **Scopes** > **Bot Token Scopes** -3. Click **Add an OAuth Scope** and add each of these: - -| Scope | Why it's needed | -|-------|----------------| -| `chat:write` | Send messages to channels and DMs | -| `channels:history` | Read messages in public channels | -| `groups:history` | Read messages in private channels | -| `im:history` | Read direct messages | -| `channels:read` | List channels (for metadata sync) | -| `groups:read` | List private channels (for metadata sync) | -| `users:read` | Look up user display names | - -## Step 5: Install to Workspace - -1. In the sidebar, click **Install App** -2. Click **Install to Workspace** -3. Review the permissions and click **Allow** -4. **Copy the Bot User OAuth Token** — it starts with `xoxb-`. Save this somewhere safe. - -## Step 6: Configure NanoClaw - -Add both tokens to your `.env` file: - -``` -SLACK_BOT_TOKEN=xoxb-your-bot-token-here -SLACK_APP_TOKEN=xapp-your-app-token-here -``` - -If you want Slack to replace WhatsApp entirely (no WhatsApp channel), also add: - -``` -SLACK_ONLY=true -``` - -Then sync the environment to the container: - -```bash -mkdir -p data/env && cp .env data/env/env -``` - -## Step 7: Add the Bot to Channels - -The bot only receives messages from channels it has been explicitly added to. - -1. Open the Slack channel you want the bot to monitor -2. Click the channel name at the top to open channel details -3. Go to **Integrations** > **Add apps** -4. Search for your bot name and add it - -Repeat for each channel you want the bot in. - -## Step 8: Get Channel IDs for Registration - -You need the Slack channel ID to register it with NanoClaw. - -**Option A — From the URL:** -Open the channel in Slack on the web. The URL looks like: -``` -https://app.slack.com/client/TXXXXXXX/C0123456789 -``` -The `C0123456789` part is the channel ID. - -**Option B — Right-click:** -Right-click the channel name in Slack > **Copy link** > the channel ID is the last path segment. - -**Option C — Via API:** -```bash -curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ - "https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}' -``` - -The NanoClaw JID format is `slack:` followed by the channel ID, e.g., `slack:C0123456789`. - -## Token Reference - -| Token | Prefix | Where to find it | -|-------|--------|-----------------| -| Bot User OAuth Token | `xoxb-` | **OAuth & Permissions** > **Bot User OAuth Token** | -| App-Level Token | `xapp-` | **Basic Information** > **App-Level Tokens** (or during Socket Mode setup) | - -## Troubleshooting - -**Bot not receiving messages:** -- Verify Socket Mode is enabled (Step 2) -- Verify all three events are subscribed (Step 3) -- Verify the bot has been added to the channel (Step 7) - -**"missing_scope" errors:** -- Go back to **OAuth & Permissions** and add the missing scope -- After adding scopes, you must **reinstall the app** to your workspace (Slack will show a banner prompting you to do this) - -**Bot can't send messages:** -- Verify the `chat:write` scope is added -- Verify the bot has been added to the target channel - -**Token not working:** -- Bot tokens start with `xoxb-` — if yours doesn't, you may have copied the wrong token -- App tokens start with `xapp-` — these are generated in the Socket Mode or Basic Information pages -- If you regenerated a token, update `.env` and re-sync: `cp .env data/env/env` diff --git a/.claude/skills/add-slack/add/src/channels/slack.test.ts b/.claude/skills/add-slack/add/src/channels/slack.test.ts deleted file mode 100644 index 241d09a..0000000 --- a/.claude/skills/add-slack/add/src/channels/slack.test.ts +++ /dev/null @@ -1,851 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// --- Mocks --- - -// Mock registry (registerChannel runs at import time) -vi.mock('./registry.js', () => ({ registerChannel: vi.fn() })); - -// Mock config -vi.mock('../config.js', () => ({ - ASSISTANT_NAME: 'Jonesy', - TRIGGER_PATTERN: /^@Jonesy\b/i, -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// Mock db -vi.mock('../db.js', () => ({ - updateChatName: vi.fn(), -})); - -// --- @slack/bolt mock --- - -type Handler = (...args: any[]) => any; - -const appRef = vi.hoisted(() => ({ current: null as any })); - -vi.mock('@slack/bolt', () => ({ - App: class MockApp { - eventHandlers = new Map(); - token: string; - appToken: string; - - client = { - auth: { - test: vi.fn().mockResolvedValue({ user_id: 'U_BOT_123' }), - }, - chat: { - postMessage: vi.fn().mockResolvedValue(undefined), - }, - conversations: { - list: vi.fn().mockResolvedValue({ - channels: [], - response_metadata: {}, - }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { real_name: 'Alice Smith', name: 'alice' }, - }), - }, - }; - - constructor(opts: any) { - this.token = opts.token; - this.appToken = opts.appToken; - appRef.current = this; - } - - event(name: string, handler: Handler) { - this.eventHandlers.set(name, handler); - } - - async start() {} - async stop() {} - }, - LogLevel: { ERROR: 'error' }, -})); - -// Mock env -vi.mock('../env.js', () => ({ - readEnvFile: vi.fn().mockReturnValue({ - SLACK_BOT_TOKEN: 'xoxb-test-token', - SLACK_APP_TOKEN: 'xapp-test-token', - }), -})); - -import { SlackChannel, SlackChannelOpts } from './slack.js'; -import { updateChatName } from '../db.js'; -import { readEnvFile } from '../env.js'; - -// --- Test helpers --- - -function createTestOpts( - overrides?: Partial, -): SlackChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: vi.fn(() => ({ - 'slack:C0123456789': { - name: 'Test Channel', - folder: 'test-channel', - trigger: '@Jonesy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - ...overrides, - }; -} - -function createMessageEvent(overrides: { - channel?: string; - channelType?: string; - user?: string; - text?: string; - ts?: string; - threadTs?: string; - subtype?: string; - botId?: string; -}) { - return { - channel: overrides.channel ?? 'C0123456789', - channel_type: overrides.channelType ?? 'channel', - user: overrides.user ?? 'U_USER_456', - text: 'text' in overrides ? overrides.text : 'Hello everyone', - ts: overrides.ts ?? '1704067200.000000', - thread_ts: overrides.threadTs, - subtype: overrides.subtype, - bot_id: overrides.botId, - }; -} - -function currentApp() { - return appRef.current; -} - -async function triggerMessageEvent(event: ReturnType) { - const handler = currentApp().eventHandlers.get('message'); - if (handler) await handler({ event }); -} - -// --- Tests --- - -describe('SlackChannel', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when app starts', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - await channel.connect(); - - expect(channel.isConnected()).toBe(true); - }); - - it('registers message event handler on construction', () => { - const opts = createTestOpts(); - new SlackChannel(opts); - - expect(currentApp().eventHandlers.has('message')).toBe(true); - }); - - it('gets bot user ID on connect', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - await channel.connect(); - - expect(currentApp().client.auth.test).toHaveBeenCalled(); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - await channel.connect(); - expect(channel.isConnected()).toBe(true); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - }); - - it('isConnected() returns false before connect', () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - expect(channel.isConnected()).toBe(false); - }); - }); - - // --- Message handling --- - - describe('message handling', () => { - it('delivers message for registered channel', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ text: 'Hello everyone' }); - await triggerMessageEvent(event); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.any(String), - undefined, - 'slack', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - id: '1704067200.000000', - chat_jid: 'slack:C0123456789', - sender: 'U_USER_456', - content: 'Hello everyone', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered channels', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ channel: 'C9999999999' }); - await triggerMessageEvent(event); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'slack:C9999999999', - expect.any(String), - undefined, - 'slack', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('skips non-text subtypes (channel_join, etc.)', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ subtype: 'channel_join' }); - await triggerMessageEvent(event); - - expect(opts.onMessage).not.toHaveBeenCalled(); - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - }); - - it('allows bot_message subtype through', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ - subtype: 'bot_message', - botId: 'B_OTHER_BOT', - text: 'Bot message', - }); - await triggerMessageEvent(event); - - expect(opts.onChatMetadata).toHaveBeenCalled(); - }); - - it('skips messages with no text', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ text: undefined as any }); - await triggerMessageEvent(event); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('detects bot messages by bot_id', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ - subtype: 'bot_message', - botId: 'B_MY_BOT', - text: 'Bot response', - }); - await triggerMessageEvent(event); - - // Has bot_id so should be marked as bot message - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - is_from_me: true, - is_bot_message: true, - sender_name: 'Jonesy', - }), - ); - }); - - it('detects bot messages by matching bot user ID', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ user: 'U_BOT_123', text: 'Self message' }); - await triggerMessageEvent(event); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - is_from_me: true, - is_bot_message: true, - }), - ); - }); - - it('identifies IM channel type as non-group', async () => { - const opts = createTestOpts({ - registeredGroups: vi.fn(() => ({ - 'slack:D0123456789': { - name: 'DM', - folder: 'dm', - trigger: '@Jonesy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ - channel: 'D0123456789', - channelType: 'im', - }); - await triggerMessageEvent(event); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'slack:D0123456789', - expect.any(String), - undefined, - 'slack', - false, // IM is not a group - ); - }); - - it('converts ts to ISO timestamp', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ ts: '1704067200.000000' }); - await triggerMessageEvent(event); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - timestamp: '2024-01-01T00:00:00.000Z', - }), - ); - }); - - it('resolves user name from Slack API', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ user: 'U_USER_456', text: 'Hello' }); - await triggerMessageEvent(event); - - expect(currentApp().client.users.info).toHaveBeenCalledWith({ - user: 'U_USER_456', - }); - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - sender_name: 'Alice Smith', - }), - ); - }); - - it('caches user names to avoid repeated API calls', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - // First message — API call - await triggerMessageEvent(createMessageEvent({ user: 'U_USER_456', text: 'First' })); - // Second message — should use cache - await triggerMessageEvent(createMessageEvent({ - user: 'U_USER_456', - text: 'Second', - ts: '1704067201.000000', - })); - - expect(currentApp().client.users.info).toHaveBeenCalledTimes(1); - }); - - it('falls back to user ID when API fails', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - currentApp().client.users.info.mockRejectedValueOnce(new Error('API error')); - - const event = createMessageEvent({ user: 'U_UNKNOWN', text: 'Hi' }); - await triggerMessageEvent(event); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - sender_name: 'U_UNKNOWN', - }), - ); - }); - - it('flattens threaded replies into channel messages', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ - ts: '1704067201.000000', - threadTs: '1704067200.000000', // parent message ts — this is a reply - text: 'Thread reply', - }); - await triggerMessageEvent(event); - - // Threaded replies are delivered as regular channel messages - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - content: 'Thread reply', - }), - ); - }); - - it('delivers thread parent messages normally', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ - ts: '1704067200.000000', - threadTs: '1704067200.000000', // same as ts — this IS the parent - text: 'Thread parent', - }); - await triggerMessageEvent(event); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - content: 'Thread parent', - }), - ); - }); - - it('delivers messages without thread_ts normally', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ text: 'Normal message' }); - await triggerMessageEvent(event); - - expect(opts.onMessage).toHaveBeenCalled(); - }); - }); - - // --- @mention translation --- - - describe('@mention translation', () => { - it('prepends trigger when bot is @mentioned via Slack format', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); // sets botUserId to 'U_BOT_123' - - const event = createMessageEvent({ - text: 'Hey <@U_BOT_123> what do you think?', - user: 'U_USER_456', - }); - await triggerMessageEvent(event); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - content: '@Jonesy Hey <@U_BOT_123> what do you think?', - }), - ); - }); - - it('does not prepend trigger when trigger pattern already matches', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ - text: '@Jonesy <@U_BOT_123> hello', - user: 'U_USER_456', - }); - await triggerMessageEvent(event); - - // Content should be unchanged since it already matches TRIGGER_PATTERN - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - content: '@Jonesy <@U_BOT_123> hello', - }), - ); - }); - - it('does not translate mentions in bot messages', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ - text: 'Echo: <@U_BOT_123>', - subtype: 'bot_message', - botId: 'B_MY_BOT', - }); - await triggerMessageEvent(event); - - // Bot messages skip mention translation - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - content: 'Echo: <@U_BOT_123>', - }), - ); - }); - - it('does not translate mentions for other users', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const event = createMessageEvent({ - text: 'Hey <@U_OTHER_USER> look at this', - user: 'U_USER_456', - }); - await triggerMessageEvent(event); - - // Mention is for a different user, not the bot - expect(opts.onMessage).toHaveBeenCalledWith( - 'slack:C0123456789', - expect.objectContaining({ - content: 'Hey <@U_OTHER_USER> look at this', - }), - ); - }); - }); - - // --- sendMessage --- - - describe('sendMessage', () => { - it('sends message via Slack client', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - await channel.sendMessage('slack:C0123456789', 'Hello'); - - expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({ - channel: 'C0123456789', - text: 'Hello', - }); - }); - - it('strips slack: prefix from JID', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - await channel.sendMessage('slack:D9876543210', 'DM message'); - - expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({ - channel: 'D9876543210', - text: 'DM message', - }); - }); - - it('queues message when disconnected', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - // Don't connect — should queue - await channel.sendMessage('slack:C0123456789', 'Queued message'); - - expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled(); - }); - - it('queues message on send failure', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - currentApp().client.chat.postMessage.mockRejectedValueOnce( - new Error('Network error'), - ); - - // Should not throw - await expect( - channel.sendMessage('slack:C0123456789', 'Will fail'), - ).resolves.toBeUndefined(); - }); - - it('splits long messages at 4000 character boundary', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - // Create a message longer than 4000 chars - const longText = 'A'.repeat(4500); - await channel.sendMessage('slack:C0123456789', longText); - - // Should be split into 2 messages: 4000 + 500 - expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(2); - expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(1, { - channel: 'C0123456789', - text: 'A'.repeat(4000), - }); - expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(2, { - channel: 'C0123456789', - text: 'A'.repeat(500), - }); - }); - - it('sends exactly-4000-char messages as a single message', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const text = 'B'.repeat(4000); - await channel.sendMessage('slack:C0123456789', text); - - expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(1); - expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({ - channel: 'C0123456789', - text, - }); - }); - - it('splits messages into 3 parts when over 8000 chars', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - await channel.connect(); - - const longText = 'C'.repeat(8500); - await channel.sendMessage('slack:C0123456789', longText); - - // 4000 + 4000 + 500 = 3 messages - expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(3); - }); - - it('flushes queued messages on connect', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - // Queue messages while disconnected - await channel.sendMessage('slack:C0123456789', 'First queued'); - await channel.sendMessage('slack:C0123456789', 'Second queued'); - - expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled(); - - // Connect triggers flush - await channel.connect(); - - expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({ - channel: 'C0123456789', - text: 'First queued', - }); - expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({ - channel: 'C0123456789', - text: 'Second queued', - }); - }); - }); - - // --- ownsJid --- - - describe('ownsJid', () => { - it('owns slack: JIDs', () => { - const channel = new SlackChannel(createTestOpts()); - expect(channel.ownsJid('slack:C0123456789')).toBe(true); - }); - - it('owns slack: DM JIDs', () => { - const channel = new SlackChannel(createTestOpts()); - expect(channel.ownsJid('slack:D0123456789')).toBe(true); - }); - - it('does not own WhatsApp group JIDs', () => { - const channel = new SlackChannel(createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(false); - }); - - it('does not own WhatsApp DM JIDs', () => { - const channel = new SlackChannel(createTestOpts()); - expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false); - }); - - it('does not own Telegram JIDs', () => { - const channel = new SlackChannel(createTestOpts()); - expect(channel.ownsJid('tg:123456')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new SlackChannel(createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); - }); - - // --- syncChannelMetadata --- - - describe('syncChannelMetadata', () => { - it('calls conversations.list and updates chat names', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - currentApp().client.conversations.list.mockResolvedValue({ - channels: [ - { id: 'C001', name: 'general', is_member: true }, - { id: 'C002', name: 'random', is_member: true }, - { id: 'C003', name: 'external', is_member: false }, - ], - response_metadata: {}, - }); - - await channel.connect(); - - // connect() calls syncChannelMetadata internally - expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general'); - expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random'); - // Non-member channels are skipped - expect(updateChatName).not.toHaveBeenCalledWith('slack:C003', 'external'); - }); - - it('handles API errors gracefully', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - currentApp().client.conversations.list.mockRejectedValue( - new Error('API error'), - ); - - // Should not throw - await expect(channel.connect()).resolves.toBeUndefined(); - }); - }); - - // --- setTyping --- - - describe('setTyping', () => { - it('resolves without error (no-op)', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - // Should not throw — Slack has no bot typing indicator API - await expect( - channel.setTyping('slack:C0123456789', true), - ).resolves.toBeUndefined(); - }); - - it('accepts false without error', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - await expect( - channel.setTyping('slack:C0123456789', false), - ).resolves.toBeUndefined(); - }); - }); - - // --- Constructor error handling --- - - describe('constructor', () => { - it('throws when SLACK_BOT_TOKEN is missing', () => { - vi.mocked(readEnvFile).mockReturnValueOnce({ - SLACK_BOT_TOKEN: '', - SLACK_APP_TOKEN: 'xapp-test-token', - }); - - expect(() => new SlackChannel(createTestOpts())).toThrow( - 'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env', - ); - }); - - it('throws when SLACK_APP_TOKEN is missing', () => { - vi.mocked(readEnvFile).mockReturnValueOnce({ - SLACK_BOT_TOKEN: 'xoxb-test-token', - SLACK_APP_TOKEN: '', - }); - - expect(() => new SlackChannel(createTestOpts())).toThrow( - 'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env', - ); - }); - }); - - // --- syncChannelMetadata pagination --- - - describe('syncChannelMetadata pagination', () => { - it('paginates through multiple pages of channels', async () => { - const opts = createTestOpts(); - const channel = new SlackChannel(opts); - - // First page returns a cursor; second page returns no cursor - currentApp().client.conversations.list - .mockResolvedValueOnce({ - channels: [ - { id: 'C001', name: 'general', is_member: true }, - ], - response_metadata: { next_cursor: 'cursor_page2' }, - }) - .mockResolvedValueOnce({ - channels: [ - { id: 'C002', name: 'random', is_member: true }, - ], - response_metadata: {}, - }); - - await channel.connect(); - - // Should have called conversations.list twice (once per page) - expect(currentApp().client.conversations.list).toHaveBeenCalledTimes(2); - expect(currentApp().client.conversations.list).toHaveBeenNthCalledWith(2, - expect.objectContaining({ cursor: 'cursor_page2' }), - ); - - // Both channels from both pages stored - expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general'); - expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random'); - }); - }); - - // --- Channel properties --- - - describe('channel properties', () => { - it('has name "slack"', () => { - const channel = new SlackChannel(createTestOpts()); - expect(channel.name).toBe('slack'); - }); - }); -}); diff --git a/.claude/skills/add-slack/add/src/channels/slack.ts b/.claude/skills/add-slack/add/src/channels/slack.ts deleted file mode 100644 index c783240..0000000 --- a/.claude/skills/add-slack/add/src/channels/slack.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { App, LogLevel } from '@slack/bolt'; -import type { GenericMessageEvent, BotMessageEvent } from '@slack/types'; - -import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; -import { updateChatName } from '../db.js'; -import { readEnvFile } from '../env.js'; -import { logger } from '../logger.js'; -import { registerChannel, ChannelOpts } from './registry.js'; -import { - Channel, - OnInboundMessage, - OnChatMetadata, - RegisteredGroup, -} from '../types.js'; - -// Slack's chat.postMessage API limits text to ~4000 characters per call. -// Messages exceeding this are split into sequential chunks. -const MAX_MESSAGE_LENGTH = 4000; - -// The message subtypes we process. Bolt delivers all subtypes via app.event('message'); -// we filter to regular messages (GenericMessageEvent, subtype undefined) and bot messages -// (BotMessageEvent, subtype 'bot_message') so we can track our own output. -type HandledMessageEvent = GenericMessageEvent | BotMessageEvent; - -export interface SlackChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -export class SlackChannel implements Channel { - name = 'slack'; - - private app: App; - private botUserId: string | undefined; - private connected = false; - private outgoingQueue: Array<{ jid: string; text: string }> = []; - private flushing = false; - private userNameCache = new Map(); - - private opts: SlackChannelOpts; - - constructor(opts: SlackChannelOpts) { - this.opts = opts; - - // Read tokens from .env (not process.env — keeps secrets off the environment - // so they don't leak to child processes, matching NanoClaw's security pattern) - const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']); - const botToken = env.SLACK_BOT_TOKEN; - const appToken = env.SLACK_APP_TOKEN; - - if (!botToken || !appToken) { - throw new Error( - 'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env', - ); - } - - this.app = new App({ - token: botToken, - appToken, - socketMode: true, - logLevel: LogLevel.ERROR, - }); - - this.setupEventHandlers(); - } - - private setupEventHandlers(): void { - // Use app.event('message') instead of app.message() to capture all - // message subtypes including bot_message (needed to track our own output) - this.app.event('message', async ({ event }) => { - // Bolt's event type is the full MessageEvent union (17+ subtypes). - // We filter on subtype first, then narrow to the two types we handle. - const subtype = (event as { subtype?: string }).subtype; - if (subtype && subtype !== 'bot_message') return; - - // After filtering, event is either GenericMessageEvent or BotMessageEvent - const msg = event as HandledMessageEvent; - - if (!msg.text) return; - - // Threaded replies are flattened into the channel conversation. - // The agent sees them alongside channel-level messages; responses - // always go to the channel, not back into the thread. - - const jid = `slack:${msg.channel}`; - const timestamp = new Date(parseFloat(msg.ts) * 1000).toISOString(); - const isGroup = msg.channel_type !== 'im'; - - // Always report metadata for group discovery - this.opts.onChatMetadata(jid, timestamp, undefined, 'slack', isGroup); - - // Only deliver full messages for registered groups - const groups = this.opts.registeredGroups(); - if (!groups[jid]) return; - - const isBotMessage = - !!msg.bot_id || msg.user === this.botUserId; - - let senderName: string; - if (isBotMessage) { - senderName = ASSISTANT_NAME; - } else { - senderName = - (await this.resolveUserName(msg.user)) || - msg.user || - 'unknown'; - } - - // Translate Slack <@UBOTID> mentions into TRIGGER_PATTERN format. - // Slack encodes @mentions as <@U12345>, which won't match TRIGGER_PATTERN - // (e.g., ^@\b), so we prepend the trigger when the bot is @mentioned. - let content = msg.text; - if (this.botUserId && !isBotMessage) { - const mentionPattern = `<@${this.botUserId}>`; - if (content.includes(mentionPattern) && !TRIGGER_PATTERN.test(content)) { - content = `@${ASSISTANT_NAME} ${content}`; - } - } - - this.opts.onMessage(jid, { - id: msg.ts, - chat_jid: jid, - sender: msg.user || msg.bot_id || '', - sender_name: senderName, - content, - timestamp, - is_from_me: isBotMessage, - is_bot_message: isBotMessage, - }); - }); - } - - async connect(): Promise { - await this.app.start(); - - // Get bot's own user ID for self-message detection. - // Resolve this BEFORE setting connected=true so that messages arriving - // during startup can correctly detect bot-sent messages. - try { - const auth = await this.app.client.auth.test(); - this.botUserId = auth.user_id as string; - logger.info({ botUserId: this.botUserId }, 'Connected to Slack'); - } catch (err) { - logger.warn( - { err }, - 'Connected to Slack but failed to get bot user ID', - ); - } - - this.connected = true; - - // Flush any messages queued before connection - await this.flushOutgoingQueue(); - - // Sync channel names on startup - await this.syncChannelMetadata(); - } - - async sendMessage(jid: string, text: string): Promise { - const channelId = jid.replace(/^slack:/, ''); - - if (!this.connected) { - this.outgoingQueue.push({ jid, text }); - logger.info( - { jid, queueSize: this.outgoingQueue.length }, - 'Slack disconnected, message queued', - ); - return; - } - - try { - // Slack limits messages to ~4000 characters; split if needed - if (text.length <= MAX_MESSAGE_LENGTH) { - await this.app.client.chat.postMessage({ channel: channelId, text }); - } else { - for (let i = 0; i < text.length; i += MAX_MESSAGE_LENGTH) { - await this.app.client.chat.postMessage({ - channel: channelId, - text: text.slice(i, i + MAX_MESSAGE_LENGTH), - }); - } - } - logger.info({ jid, length: text.length }, 'Slack message sent'); - } catch (err) { - this.outgoingQueue.push({ jid, text }); - logger.warn( - { jid, err, queueSize: this.outgoingQueue.length }, - 'Failed to send Slack message, queued', - ); - } - } - - isConnected(): boolean { - return this.connected; - } - - ownsJid(jid: string): boolean { - return jid.startsWith('slack:'); - } - - async disconnect(): Promise { - this.connected = false; - await this.app.stop(); - } - - // Slack does not expose a typing indicator API for bots. - // This no-op satisfies the Channel interface so the orchestrator - // doesn't need channel-specific branching. - async setTyping(_jid: string, _isTyping: boolean): Promise { - // no-op: Slack Bot API has no typing indicator endpoint - } - - /** - * Sync channel metadata from Slack. - * Fetches channels the bot is a member of and stores their names in the DB. - */ - async syncChannelMetadata(): Promise { - try { - logger.info('Syncing channel metadata from Slack...'); - let cursor: string | undefined; - let count = 0; - - do { - const result = await this.app.client.conversations.list({ - types: 'public_channel,private_channel', - exclude_archived: true, - limit: 200, - cursor, - }); - - for (const ch of result.channels || []) { - if (ch.id && ch.name && ch.is_member) { - updateChatName(`slack:${ch.id}`, ch.name); - count++; - } - } - - cursor = result.response_metadata?.next_cursor || undefined; - } while (cursor); - - logger.info({ count }, 'Slack channel metadata synced'); - } catch (err) { - logger.error({ err }, 'Failed to sync Slack channel metadata'); - } - } - - private async resolveUserName( - userId: string, - ): Promise { - if (!userId) return undefined; - - const cached = this.userNameCache.get(userId); - if (cached) return cached; - - try { - const result = await this.app.client.users.info({ user: userId }); - const name = result.user?.real_name || result.user?.name; - if (name) this.userNameCache.set(userId, name); - return name; - } catch (err) { - logger.debug({ userId, err }, 'Failed to resolve Slack user name'); - return undefined; - } - } - - private async flushOutgoingQueue(): Promise { - if (this.flushing || this.outgoingQueue.length === 0) return; - this.flushing = true; - try { - logger.info( - { count: this.outgoingQueue.length }, - 'Flushing Slack outgoing queue', - ); - while (this.outgoingQueue.length > 0) { - const item = this.outgoingQueue.shift()!; - const channelId = item.jid.replace(/^slack:/, ''); - await this.app.client.chat.postMessage({ - channel: channelId, - text: item.text, - }); - logger.info( - { jid: item.jid, length: item.text.length }, - 'Queued Slack message sent', - ); - } - } finally { - this.flushing = false; - } - } -} - -registerChannel('slack', (opts: ChannelOpts) => { - const envVars = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN']); - if (!envVars.SLACK_BOT_TOKEN || !envVars.SLACK_APP_TOKEN) { - logger.warn('Slack: SLACK_BOT_TOKEN or SLACK_APP_TOKEN not set'); - return null; - } - return new SlackChannel(opts); -}); diff --git a/.claude/skills/add-slack/manifest.yaml b/.claude/skills/add-slack/manifest.yaml deleted file mode 100644 index 80cec1e..0000000 --- a/.claude/skills/add-slack/manifest.yaml +++ /dev/null @@ -1,18 +0,0 @@ -skill: slack -version: 1.0.0 -description: "Slack Bot integration via @slack/bolt with Socket Mode" -core_version: 0.1.0 -adds: - - src/channels/slack.ts - - src/channels/slack.test.ts -modifies: - - src/channels/index.ts -structured: - npm_dependencies: - "@slack/bolt": "^4.6.0" - env_additions: - - SLACK_BOT_TOKEN - - SLACK_APP_TOKEN -conflicts: [] -depends: [] -test: "npx vitest run src/channels/slack.test.ts" diff --git a/.claude/skills/add-slack/modify/src/channels/index.ts b/.claude/skills/add-slack/modify/src/channels/index.ts deleted file mode 100644 index e8118a7..0000000 --- a/.claude/skills/add-slack/modify/src/channels/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Channel self-registration barrel file. -// Each import triggers the channel module's registerChannel() call. - -// discord - -// gmail - -// slack -import './slack.js'; - -// telegram - -// whatsapp diff --git a/.claude/skills/add-slack/modify/src/channels/index.ts.intent.md b/.claude/skills/add-slack/modify/src/channels/index.ts.intent.md deleted file mode 100644 index 51ccb1c..0000000 --- a/.claude/skills/add-slack/modify/src/channels/index.ts.intent.md +++ /dev/null @@ -1,7 +0,0 @@ -# Intent: Add Slack channel import - -Add `import './slack.js';` to the channel barrel file so the Slack -module self-registers with the channel registry on startup. - -This is an append-only change — existing import lines for other channels -must be preserved. diff --git a/.claude/skills/add-slack/tests/slack.test.ts b/.claude/skills/add-slack/tests/slack.test.ts deleted file mode 100644 index 320a8cc..0000000 --- a/.claude/skills/add-slack/tests/slack.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('slack skill package', () => { - const skillDir = path.resolve(__dirname, '..'); - - it('has a valid manifest', () => { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - expect(fs.existsSync(manifestPath)).toBe(true); - - const content = fs.readFileSync(manifestPath, 'utf-8'); - expect(content).toContain('skill: slack'); - expect(content).toContain('version: 1.0.0'); - expect(content).toContain('@slack/bolt'); - }); - - it('has all files declared in adds', () => { - const channelFile = path.join( - skillDir, - 'add', - 'src', - 'channels', - 'slack.ts', - ); - expect(fs.existsSync(channelFile)).toBe(true); - - const content = fs.readFileSync(channelFile, 'utf-8'); - expect(content).toContain('class SlackChannel'); - expect(content).toContain('implements Channel'); - expect(content).toContain("registerChannel('slack'"); - - // Test file for the channel - const testFile = path.join( - skillDir, - 'add', - 'src', - 'channels', - 'slack.test.ts', - ); - expect(fs.existsSync(testFile)).toBe(true); - - const testContent = fs.readFileSync(testFile, 'utf-8'); - expect(testContent).toContain("describe('SlackChannel'"); - }); - - it('has all files declared in modifies', () => { - // Channel barrel file - const indexFile = path.join( - skillDir, - 'modify', - 'src', - 'channels', - 'index.ts', - ); - expect(fs.existsSync(indexFile)).toBe(true); - - const indexContent = fs.readFileSync(indexFile, 'utf-8'); - expect(indexContent).toContain("import './slack.js'"); - }); - - it('has intent files for modified files', () => { - expect( - fs.existsSync( - path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'), - ), - ).toBe(true); - }); - - it('has setup documentation', () => { - expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(skillDir, 'SLACK_SETUP.md'))).toBe(true); - }); - - it('slack.ts implements required Channel interface methods', () => { - const content = fs.readFileSync( - path.join(skillDir, 'add', 'src', 'channels', 'slack.ts'), - 'utf-8', - ); - - // Channel interface methods - expect(content).toContain('async connect()'); - expect(content).toContain('async sendMessage('); - expect(content).toContain('isConnected()'); - expect(content).toContain('ownsJid('); - expect(content).toContain('async disconnect()'); - expect(content).toContain('async setTyping('); - - // Security pattern: reads tokens from .env, not process.env - expect(content).toContain('readEnvFile'); - expect(content).not.toContain('process.env.SLACK_BOT_TOKEN'); - expect(content).not.toContain('process.env.SLACK_APP_TOKEN'); - - // Key behaviors - expect(content).toContain('socketMode: true'); - expect(content).toContain('MAX_MESSAGE_LENGTH'); - expect(content).toContain('TRIGGER_PATTERN'); - expect(content).toContain('userNameCache'); - }); -}); diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md deleted file mode 100644 index 484d851..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 using the skills engine for deterministic code changes, then walks through interactive setup. - -## Phase 1: Pre-flight - -### Check if already applied - -Read `.nanoclaw/state.yaml`. If `telegram` is in `applied_skills`, 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 - -Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md. - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist yet: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -Or call `initSkillsSystem()` from `skills-engine/migrate.ts`. - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-telegram -``` - -This deterministically: -- Adds `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`) -- Adds `src/channels/telegram.test.ts` (46 unit tests) -- Appends `import './telegram.js'` to the channel barrel file `src/channels/index.ts` -- Installs the `grammy` npm dependency -- Updates `.env.example` with `TELEGRAM_BOT_TOKEN` -- Records the application in `.nanoclaw/state.yaml` - -If the apply reports merge conflicts, read the intent file: -- `modify/src/channels/index.ts.intent.md` — what changed and invariants - -### Validate code changes - -```bash -npm test -npm run build -``` - -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-telegram/add/src/channels/telegram.test.ts b/.claude/skills/add-telegram/add/src/channels/telegram.test.ts deleted file mode 100644 index 9a97223..0000000 --- a/.claude/skills/add-telegram/add/src/channels/telegram.test.ts +++ /dev/null @@ -1,932 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// --- Mocks --- - -// Mock registry (registerChannel runs at import time) -vi.mock('./registry.js', () => ({ registerChannel: vi.fn() })); - -// Mock env reader (used by the factory, not needed in unit tests) -vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); - -// Mock config -vi.mock('../config.js', () => ({ - ASSISTANT_NAME: 'Andy', - TRIGGER_PATTERN: /^@Andy\b/i, -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// --- Grammy mock --- - -type Handler = (...args: any[]) => any; - -const botRef = vi.hoisted(() => ({ current: null as any })); - -vi.mock('grammy', () => ({ - Bot: class MockBot { - token: string; - commandHandlers = new Map(); - filterHandlers = new Map(); - errorHandler: Handler | null = null; - - api = { - sendMessage: vi.fn().mockResolvedValue(undefined), - sendChatAction: vi.fn().mockResolvedValue(undefined), - }; - - constructor(token: string) { - this.token = token; - botRef.current = this; - } - - command(name: string, handler: Handler) { - this.commandHandlers.set(name, handler); - } - - on(filter: string, handler: Handler) { - const existing = this.filterHandlers.get(filter) || []; - existing.push(handler); - this.filterHandlers.set(filter, existing); - } - - catch(handler: Handler) { - this.errorHandler = handler; - } - - start(opts: { onStart: (botInfo: any) => void }) { - opts.onStart({ username: 'andy_ai_bot', id: 12345 }); - } - - stop() {} - }, -})); - -import { TelegramChannel, TelegramChannelOpts } from './telegram.js'; - -// --- Test helpers --- - -function createTestOpts( - overrides?: Partial, -): TelegramChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: vi.fn(() => ({ - 'tg:100200300': { - name: 'Test Group', - folder: 'test-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - ...overrides, - }; -} - -function createTextCtx(overrides: { - chatId?: number; - chatType?: string; - chatTitle?: string; - text: string; - fromId?: number; - firstName?: string; - username?: string; - messageId?: number; - date?: number; - entities?: any[]; -}) { - const chatId = overrides.chatId ?? 100200300; - const chatType = overrides.chatType ?? 'group'; - return { - chat: { - id: chatId, - type: chatType, - title: overrides.chatTitle ?? 'Test Group', - }, - from: { - id: overrides.fromId ?? 99001, - first_name: overrides.firstName ?? 'Alice', - username: overrides.username ?? 'alice_user', - }, - message: { - text: overrides.text, - date: overrides.date ?? Math.floor(Date.now() / 1000), - message_id: overrides.messageId ?? 1, - entities: overrides.entities ?? [], - }, - me: { username: 'andy_ai_bot' }, - reply: vi.fn(), - }; -} - -function createMediaCtx(overrides: { - chatId?: number; - chatType?: string; - fromId?: number; - firstName?: string; - date?: number; - messageId?: number; - caption?: string; - extra?: Record; -}) { - const chatId = overrides.chatId ?? 100200300; - return { - chat: { - id: chatId, - type: overrides.chatType ?? 'group', - title: 'Test Group', - }, - from: { - id: overrides.fromId ?? 99001, - first_name: overrides.firstName ?? 'Alice', - username: 'alice_user', - }, - message: { - date: overrides.date ?? Math.floor(Date.now() / 1000), - message_id: overrides.messageId ?? 1, - caption: overrides.caption, - ...(overrides.extra || {}), - }, - me: { username: 'andy_ai_bot' }, - }; -} - -function currentBot() { - return botRef.current; -} - -async function triggerTextMessage(ctx: ReturnType) { - const handlers = currentBot().filterHandlers.get('message:text') || []; - for (const h of handlers) await h(ctx); -} - -async function triggerMediaMessage( - filter: string, - ctx: ReturnType, -) { - const handlers = currentBot().filterHandlers.get(filter) || []; - for (const h of handlers) await h(ctx); -} - -// --- Tests --- - -describe('TelegramChannel', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when bot starts', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - await channel.connect(); - - expect(channel.isConnected()).toBe(true); - }); - - it('registers command and message handlers on connect', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - await channel.connect(); - - expect(currentBot().commandHandlers.has('chatid')).toBe(true); - expect(currentBot().commandHandlers.has('ping')).toBe(true); - expect(currentBot().filterHandlers.has('message:text')).toBe(true); - expect(currentBot().filterHandlers.has('message:photo')).toBe(true); - expect(currentBot().filterHandlers.has('message:video')).toBe(true); - expect(currentBot().filterHandlers.has('message:voice')).toBe(true); - expect(currentBot().filterHandlers.has('message:audio')).toBe(true); - expect(currentBot().filterHandlers.has('message:document')).toBe(true); - expect(currentBot().filterHandlers.has('message:sticker')).toBe(true); - expect(currentBot().filterHandlers.has('message:location')).toBe(true); - expect(currentBot().filterHandlers.has('message:contact')).toBe(true); - }); - - it('registers error handler on connect', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - await channel.connect(); - - expect(currentBot().errorHandler).not.toBeNull(); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - await channel.connect(); - expect(channel.isConnected()).toBe(true); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - }); - - it('isConnected() returns false before connect', () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - expect(channel.isConnected()).toBe(false); - }); - }); - - // --- Text message handling --- - - describe('text message handling', () => { - it('delivers message for registered group', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'Hello everyone' }); - await triggerTextMessage(ctx); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'tg:100200300', - expect.any(String), - 'Test Group', - 'telegram', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - id: '1', - chat_jid: 'tg:100200300', - sender: '99001', - sender_name: 'Alice', - content: 'Hello everyone', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered chats', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ chatId: 999999, text: 'Unknown chat' }); - await triggerTextMessage(ctx); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'tg:999999', - expect.any(String), - 'Test Group', - 'telegram', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('skips command messages (starting with /)', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: '/start' }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).not.toHaveBeenCalled(); - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - }); - - it('extracts sender name from first_name', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'Hi', firstName: 'Bob' }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ sender_name: 'Bob' }), - ); - }); - - it('falls back to username when first_name missing', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'Hi' }); - ctx.from.first_name = undefined as any; - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ sender_name: 'alice_user' }), - ); - }); - - it('falls back to user ID when name and username missing', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'Hi', fromId: 42 }); - ctx.from.first_name = undefined as any; - ctx.from.username = undefined as any; - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ sender_name: '42' }), - ); - }); - - it('uses sender name as chat name for private chats', async () => { - const opts = createTestOpts({ - registeredGroups: vi.fn(() => ({ - 'tg:100200300': { - name: 'Private', - folder: 'private', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: 'Hello', - chatType: 'private', - firstName: 'Alice', - }); - await triggerTextMessage(ctx); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'tg:100200300', - expect.any(String), - 'Alice', // Private chats use sender name - 'telegram', - false, - ); - }); - - it('uses chat title as name for group chats', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: 'Hello', - chatType: 'supergroup', - chatTitle: 'Project Team', - }); - await triggerTextMessage(ctx); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'tg:100200300', - expect.any(String), - 'Project Team', - 'telegram', - true, - ); - }); - - it('converts message.date to ISO timestamp', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const unixTime = 1704067200; // 2024-01-01T00:00:00.000Z - const ctx = createTextCtx({ text: 'Hello', date: unixTime }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - timestamp: '2024-01-01T00:00:00.000Z', - }), - ); - }); - }); - - // --- @mention translation --- - - describe('@mention translation', () => { - it('translates @bot_username mention to trigger format', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: '@andy_ai_bot what time is it?', - entities: [{ type: 'mention', offset: 0, length: 12 }], - }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: '@Andy @andy_ai_bot what time is it?', - }), - ); - }); - - it('does not translate if message already matches trigger', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: '@Andy @andy_ai_bot hello', - entities: [{ type: 'mention', offset: 6, length: 12 }], - }); - await triggerTextMessage(ctx); - - // Should NOT double-prepend — already starts with @Andy - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: '@Andy @andy_ai_bot hello', - }), - ); - }); - - it('does not translate mentions of other bots', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: '@some_other_bot hi', - entities: [{ type: 'mention', offset: 0, length: 15 }], - }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: '@some_other_bot hi', // No translation - }), - ); - }); - - it('handles mention in middle of message', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: 'hey @andy_ai_bot check this', - entities: [{ type: 'mention', offset: 4, length: 12 }], - }); - await triggerTextMessage(ctx); - - // Bot is mentioned, message doesn't match trigger → prepend trigger - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: '@Andy hey @andy_ai_bot check this', - }), - ); - }); - - it('handles message with no entities', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ text: 'plain message' }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: 'plain message', - }), - ); - }); - - it('ignores non-mention entities', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createTextCtx({ - text: 'check https://example.com', - entities: [{ type: 'url', offset: 6, length: 19 }], - }); - await triggerTextMessage(ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ - content: 'check https://example.com', - }), - ); - }); - }); - - // --- Non-text messages --- - - describe('non-text messages', () => { - it('stores photo with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:photo', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Photo]' }), - ); - }); - - it('stores photo with caption', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ caption: 'Look at this' }); - await triggerMediaMessage('message:photo', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Photo] Look at this' }), - ); - }); - - it('stores video with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:video', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Video]' }), - ); - }); - - it('stores voice message with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:voice', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Voice message]' }), - ); - }); - - it('stores audio with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:audio', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Audio]' }), - ); - }); - - it('stores document with filename', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ - extra: { document: { file_name: 'report.pdf' } }, - }); - await triggerMediaMessage('message:document', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Document: report.pdf]' }), - ); - }); - - it('stores document with fallback name when filename missing', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ extra: { document: {} } }); - await triggerMediaMessage('message:document', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Document: file]' }), - ); - }); - - it('stores sticker with emoji', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ - extra: { sticker: { emoji: '😂' } }, - }); - await triggerMediaMessage('message:sticker', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Sticker 😂]' }), - ); - }); - - it('stores location with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:location', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Location]' }), - ); - }); - - it('stores contact with placeholder', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({}); - await triggerMediaMessage('message:contact', ctx); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'tg:100200300', - expect.objectContaining({ content: '[Contact]' }), - ); - }); - - it('ignores non-text messages from unregistered chats', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const ctx = createMediaCtx({ chatId: 999999 }); - await triggerMediaMessage('message:photo', ctx); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - }); - - // --- sendMessage --- - - describe('sendMessage', () => { - it('sends message via bot API', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - await channel.sendMessage('tg:100200300', 'Hello'); - - expect(currentBot().api.sendMessage).toHaveBeenCalledWith( - '100200300', - 'Hello', - ); - }); - - it('strips tg: prefix from JID', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - await channel.sendMessage('tg:-1001234567890', 'Group message'); - - expect(currentBot().api.sendMessage).toHaveBeenCalledWith( - '-1001234567890', - 'Group message', - ); - }); - - it('splits messages exceeding 4096 characters', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const longText = 'x'.repeat(5000); - await channel.sendMessage('tg:100200300', longText); - - expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(2); - expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( - 1, - '100200300', - 'x'.repeat(4096), - ); - expect(currentBot().api.sendMessage).toHaveBeenNthCalledWith( - 2, - '100200300', - 'x'.repeat(904), - ); - }); - - it('sends exactly one message at 4096 characters', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const exactText = 'y'.repeat(4096); - await channel.sendMessage('tg:100200300', exactText); - - expect(currentBot().api.sendMessage).toHaveBeenCalledTimes(1); - }); - - it('handles send failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - currentBot().api.sendMessage.mockRejectedValueOnce( - new Error('Network error'), - ); - - // Should not throw - await expect( - channel.sendMessage('tg:100200300', 'Will fail'), - ).resolves.toBeUndefined(); - }); - - it('does nothing when bot is not initialized', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - // Don't connect — bot is null - await channel.sendMessage('tg:100200300', 'No bot'); - - // No error, no API call - }); - }); - - // --- ownsJid --- - - describe('ownsJid', () => { - it('owns tg: JIDs', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('tg:123456')).toBe(true); - }); - - it('owns tg: JIDs with negative IDs (groups)', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('tg:-1001234567890')).toBe(true); - }); - - it('does not own WhatsApp group JIDs', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(false); - }); - - it('does not own WhatsApp DM JIDs', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); - }); - - // --- setTyping --- - - describe('setTyping', () => { - it('sends typing action when isTyping is true', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - await channel.setTyping('tg:100200300', true); - - expect(currentBot().api.sendChatAction).toHaveBeenCalledWith( - '100200300', - 'typing', - ); - }); - - it('does nothing when isTyping is false', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - await channel.setTyping('tg:100200300', false); - - expect(currentBot().api.sendChatAction).not.toHaveBeenCalled(); - }); - - it('does nothing when bot is not initialized', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - - // Don't connect - await channel.setTyping('tg:100200300', true); - - // No error, no API call - }); - - it('handles typing indicator failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - currentBot().api.sendChatAction.mockRejectedValueOnce( - new Error('Rate limited'), - ); - - await expect( - channel.setTyping('tg:100200300', true), - ).resolves.toBeUndefined(); - }); - }); - - // --- Bot commands --- - - describe('bot commands', () => { - it('/chatid replies with chat ID and metadata', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const handler = currentBot().commandHandlers.get('chatid')!; - const ctx = { - chat: { id: 100200300, type: 'group' as const }, - from: { first_name: 'Alice' }, - reply: vi.fn(), - }; - - await handler(ctx); - - expect(ctx.reply).toHaveBeenCalledWith( - expect.stringContaining('tg:100200300'), - expect.objectContaining({ parse_mode: 'Markdown' }), - ); - }); - - it('/chatid shows chat type', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const handler = currentBot().commandHandlers.get('chatid')!; - const ctx = { - chat: { id: 555, type: 'private' as const }, - from: { first_name: 'Bob' }, - reply: vi.fn(), - }; - - await handler(ctx); - - expect(ctx.reply).toHaveBeenCalledWith( - expect.stringContaining('private'), - expect.any(Object), - ); - }); - - it('/ping replies with bot status', async () => { - const opts = createTestOpts(); - const channel = new TelegramChannel('test-token', opts); - await channel.connect(); - - const handler = currentBot().commandHandlers.get('ping')!; - const ctx = { reply: vi.fn() }; - - await handler(ctx); - - expect(ctx.reply).toHaveBeenCalledWith('Andy is online.'); - }); - }); - - // --- Channel properties --- - - describe('channel properties', () => { - it('has name "telegram"', () => { - const channel = new TelegramChannel('test-token', createTestOpts()); - expect(channel.name).toBe('telegram'); - }); - }); -}); diff --git a/.claude/skills/add-telegram/add/src/channels/telegram.ts b/.claude/skills/add-telegram/add/src/channels/telegram.ts deleted file mode 100644 index 4176f03..0000000 --- a/.claude/skills/add-telegram/add/src/channels/telegram.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { Bot } from 'grammy'; - -import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js'; -import { readEnvFile } from '../env.js'; -import { logger } from '../logger.js'; -import { registerChannel, ChannelOpts } from './registry.js'; -import { - Channel, - OnChatMetadata, - OnInboundMessage, - RegisteredGroup, -} from '../types.js'; - -export interface TelegramChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -export class TelegramChannel implements Channel { - name = 'telegram'; - - private bot: Bot | null = null; - private opts: TelegramChannelOpts; - private botToken: string; - - constructor(botToken: string, opts: TelegramChannelOpts) { - this.botToken = botToken; - this.opts = opts; - } - - async connect(): Promise { - this.bot = new Bot(this.botToken); - - // Command to get chat ID (useful for registration) - this.bot.command('chatid', (ctx) => { - const chatId = ctx.chat.id; - const chatType = ctx.chat.type; - const chatName = - chatType === 'private' - ? ctx.from?.first_name || 'Private' - : (ctx.chat as any).title || 'Unknown'; - - ctx.reply( - `Chat ID: \`tg:${chatId}\`\nName: ${chatName}\nType: ${chatType}`, - { parse_mode: 'Markdown' }, - ); - }); - - // Command to check bot status - this.bot.command('ping', (ctx) => { - ctx.reply(`${ASSISTANT_NAME} is online.`); - }); - - this.bot.on('message:text', async (ctx) => { - // Skip commands - if (ctx.message.text.startsWith('/')) return; - - const chatJid = `tg:${ctx.chat.id}`; - let content = ctx.message.text; - const timestamp = new Date(ctx.message.date * 1000).toISOString(); - const senderName = - ctx.from?.first_name || - ctx.from?.username || - ctx.from?.id.toString() || - 'Unknown'; - const sender = ctx.from?.id.toString() || ''; - const msgId = ctx.message.message_id.toString(); - - // Determine chat name - const chatName = - ctx.chat.type === 'private' - ? senderName - : (ctx.chat as any).title || chatJid; - - // Translate Telegram @bot_username mentions into TRIGGER_PATTERN format. - // Telegram @mentions (e.g., @andy_ai_bot) won't match TRIGGER_PATTERN - // (e.g., ^@Andy\b), so we prepend the trigger when the bot is @mentioned. - const botUsername = ctx.me?.username?.toLowerCase(); - if (botUsername) { - const entities = ctx.message.entities || []; - const isBotMentioned = entities.some((entity) => { - if (entity.type === 'mention') { - const mentionText = content - .substring(entity.offset, entity.offset + entity.length) - .toLowerCase(); - return mentionText === `@${botUsername}`; - } - return false; - }); - if (isBotMentioned && !TRIGGER_PATTERN.test(content)) { - content = `@${ASSISTANT_NAME} ${content}`; - } - } - - // Store chat metadata for discovery - const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata(chatJid, timestamp, chatName, 'telegram', isGroup); - - // Only deliver full message for registered groups - const group = this.opts.registeredGroups()[chatJid]; - if (!group) { - logger.debug( - { chatJid, chatName }, - 'Message from unregistered Telegram chat', - ); - return; - } - - // Deliver message — startMessageLoop() will pick it up - this.opts.onMessage(chatJid, { - id: msgId, - chat_jid: chatJid, - sender, - sender_name: senderName, - content, - timestamp, - is_from_me: false, - }); - - logger.info( - { chatJid, chatName, sender: senderName }, - 'Telegram message stored', - ); - }); - - // Handle non-text messages with placeholders so the agent knows something was sent - const storeNonText = (ctx: any, placeholder: string) => { - const chatJid = `tg:${ctx.chat.id}`; - const group = this.opts.registeredGroups()[chatJid]; - if (!group) return; - - const timestamp = new Date(ctx.message.date * 1000).toISOString(); - const senderName = - ctx.from?.first_name || - ctx.from?.username || - ctx.from?.id?.toString() || - 'Unknown'; - const caption = ctx.message.caption ? ` ${ctx.message.caption}` : ''; - - const isGroup = ctx.chat.type === 'group' || ctx.chat.type === 'supergroup'; - this.opts.onChatMetadata(chatJid, timestamp, undefined, 'telegram', isGroup); - this.opts.onMessage(chatJid, { - id: ctx.message.message_id.toString(), - chat_jid: chatJid, - sender: ctx.from?.id?.toString() || '', - sender_name: senderName, - content: `${placeholder}${caption}`, - timestamp, - is_from_me: false, - }); - }; - - this.bot.on('message:photo', (ctx) => storeNonText(ctx, '[Photo]')); - this.bot.on('message:video', (ctx) => storeNonText(ctx, '[Video]')); - this.bot.on('message:voice', (ctx) => - storeNonText(ctx, '[Voice message]'), - ); - this.bot.on('message:audio', (ctx) => storeNonText(ctx, '[Audio]')); - this.bot.on('message:document', (ctx) => { - const name = ctx.message.document?.file_name || 'file'; - storeNonText(ctx, `[Document: ${name}]`); - }); - this.bot.on('message:sticker', (ctx) => { - const emoji = ctx.message.sticker?.emoji || ''; - storeNonText(ctx, `[Sticker ${emoji}]`); - }); - this.bot.on('message:location', (ctx) => storeNonText(ctx, '[Location]')); - this.bot.on('message:contact', (ctx) => storeNonText(ctx, '[Contact]')); - - // Handle errors gracefully - this.bot.catch((err) => { - logger.error({ err: err.message }, 'Telegram bot error'); - }); - - // Start polling — returns a Promise that resolves when started - return new Promise((resolve) => { - this.bot!.start({ - onStart: (botInfo) => { - logger.info( - { username: botInfo.username, id: botInfo.id }, - 'Telegram bot connected', - ); - console.log(`\n Telegram bot: @${botInfo.username}`); - console.log( - ` Send /chatid to the bot to get a chat's registration ID\n`, - ); - resolve(); - }, - }); - }); - } - - async sendMessage(jid: string, text: string): Promise { - if (!this.bot) { - logger.warn('Telegram bot not initialized'); - return; - } - - try { - const numericId = jid.replace(/^tg:/, ''); - - // Telegram has a 4096 character limit per message — split if needed - const MAX_LENGTH = 4096; - if (text.length <= MAX_LENGTH) { - await this.bot.api.sendMessage(numericId, text); - } else { - for (let i = 0; i < text.length; i += MAX_LENGTH) { - await this.bot.api.sendMessage( - numericId, - text.slice(i, i + MAX_LENGTH), - ); - } - } - logger.info({ jid, length: text.length }, 'Telegram message sent'); - } catch (err) { - logger.error({ jid, err }, 'Failed to send Telegram message'); - } - } - - isConnected(): boolean { - return this.bot !== null; - } - - ownsJid(jid: string): boolean { - return jid.startsWith('tg:'); - } - - async disconnect(): Promise { - if (this.bot) { - this.bot.stop(); - this.bot = null; - logger.info('Telegram bot stopped'); - } - } - - async setTyping(jid: string, isTyping: boolean): Promise { - if (!this.bot || !isTyping) return; - try { - const numericId = jid.replace(/^tg:/, ''); - await this.bot.api.sendChatAction(numericId, 'typing'); - } catch (err) { - logger.debug({ jid, err }, 'Failed to send Telegram typing indicator'); - } - } -} - -registerChannel('telegram', (opts: ChannelOpts) => { - const envVars = readEnvFile(['TELEGRAM_BOT_TOKEN']); - const token = - process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN || ''; - if (!token) { - logger.warn('Telegram: TELEGRAM_BOT_TOKEN not set'); - return null; - } - return new TelegramChannel(token, opts); -}); diff --git a/.claude/skills/add-telegram/manifest.yaml b/.claude/skills/add-telegram/manifest.yaml deleted file mode 100644 index ab279e0..0000000 --- a/.claude/skills/add-telegram/manifest.yaml +++ /dev/null @@ -1,17 +0,0 @@ -skill: telegram -version: 1.0.0 -description: "Telegram Bot API integration via Grammy" -core_version: 0.1.0 -adds: - - src/channels/telegram.ts - - src/channels/telegram.test.ts -modifies: - - src/channels/index.ts -structured: - npm_dependencies: - grammy: "^1.39.3" - env_additions: - - TELEGRAM_BOT_TOKEN -conflicts: [] -depends: [] -test: "npx vitest run src/channels/telegram.test.ts" diff --git a/.claude/skills/add-telegram/modify/src/channels/index.ts b/.claude/skills/add-telegram/modify/src/channels/index.ts deleted file mode 100644 index 48356db..0000000 --- a/.claude/skills/add-telegram/modify/src/channels/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Channel self-registration barrel file. -// Each import triggers the channel module's registerChannel() call. - -// discord - -// gmail - -// slack - -// telegram -import './telegram.js'; - -// whatsapp diff --git a/.claude/skills/add-telegram/modify/src/channels/index.ts.intent.md b/.claude/skills/add-telegram/modify/src/channels/index.ts.intent.md deleted file mode 100644 index 1791175..0000000 --- a/.claude/skills/add-telegram/modify/src/channels/index.ts.intent.md +++ /dev/null @@ -1,7 +0,0 @@ -# Intent: Add Telegram channel import - -Add `import './telegram.js';` to the channel barrel file so the Telegram -module self-registers with the channel registry on startup. - -This is an append-only change — existing import lines for other channels -must be preserved. diff --git a/.claude/skills/add-telegram/tests/telegram.test.ts b/.claude/skills/add-telegram/tests/telegram.test.ts deleted file mode 100644 index 882986a..0000000 --- a/.claude/skills/add-telegram/tests/telegram.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('telegram skill package', () => { - const skillDir = path.resolve(__dirname, '..'); - - it('has a valid manifest', () => { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - expect(fs.existsSync(manifestPath)).toBe(true); - - const content = fs.readFileSync(manifestPath, 'utf-8'); - expect(content).toContain('skill: telegram'); - expect(content).toContain('version: 1.0.0'); - expect(content).toContain('grammy'); - }); - - it('has all files declared in adds', () => { - const channelFile = path.join( - skillDir, - 'add', - 'src', - 'channels', - 'telegram.ts', - ); - expect(fs.existsSync(channelFile)).toBe(true); - - const content = fs.readFileSync(channelFile, 'utf-8'); - expect(content).toContain('class TelegramChannel'); - expect(content).toContain('implements Channel'); - expect(content).toContain("registerChannel('telegram'"); - - // Test file for the channel - const testFile = path.join( - skillDir, - 'add', - 'src', - 'channels', - 'telegram.test.ts', - ); - expect(fs.existsSync(testFile)).toBe(true); - - const testContent = fs.readFileSync(testFile, 'utf-8'); - expect(testContent).toContain("describe('TelegramChannel'"); - }); - - it('has all files declared in modifies', () => { - // Channel barrel file - const indexFile = path.join( - skillDir, - 'modify', - 'src', - 'channels', - 'index.ts', - ); - expect(fs.existsSync(indexFile)).toBe(true); - - const indexContent = fs.readFileSync(indexFile, 'utf-8'); - expect(indexContent).toContain("import './telegram.js'"); - }); - - it('has intent files for modified files', () => { - expect( - fs.existsSync( - path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md'), - ), - ).toBe(true); - }); -}); diff --git a/.claude/skills/add-voice-transcription/SKILL.md b/.claude/skills/add-voice-transcription/SKILL.md deleted file mode 100644 index 771c2d8..0000000 --- a/.claude/skills/add-voice-transcription/SKILL.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -name: add-voice-transcription -description: Add voice message transcription to NanoClaw using OpenAI's Whisper API. Automatically transcribes WhatsApp voice notes so the agent can read and respond to them. ---- - -# Add Voice Transcription - -This skill adds automatic voice message transcription to NanoClaw's WhatsApp channel using OpenAI's Whisper API. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice: ]`. - -## Phase 1: Pre-flight - -### Check if already applied - -Read `.nanoclaw/state.yaml`. If `voice-transcription` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place. - -### Ask the user - -Use `AskUserQuestion` to collect information: - -AskUserQuestion: Do you have an OpenAI API key for Whisper transcription? - -If yes, collect it now. If no, direct them to create one at https://platform.openai.com/api-keys. - -## Phase 2: Apply Code Changes - -Run the skills engine to apply this skill's code package. - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist yet: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-voice-transcription -``` - -This deterministically: -- Adds `src/transcription.ts` (voice transcription module using OpenAI Whisper) -- Three-way merges voice handling into `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call) -- Three-way merges transcription tests into `src/channels/whatsapp.test.ts` (mock + 3 test cases) -- Installs the `openai` npm dependency -- Updates `.env.example` with `OPENAI_API_KEY` -- Records the application in `.nanoclaw/state.yaml` - -If the apply reports merge conflicts, read the intent files: -- `modify/src/channels/whatsapp.ts.intent.md` — what changed and invariants for whatsapp.ts -- `modify/src/channels/whatsapp.test.ts.intent.md` — what changed for whatsapp.test.ts - -### Validate code changes - -```bash -npm test -npm run build -``` - -All tests must pass (including the 3 new voice transcription tests) and build must be clean before proceeding. - -## Phase 3: Configure - -### Get OpenAI API key (if needed) - -If the user doesn't have an API key: - -> I need you to create an OpenAI API key: -> -> 1. Go to https://platform.openai.com/api-keys -> 2. Click "Create new secret key" -> 3. Give it a name (e.g., "NanoClaw Transcription") -> 4. Copy the key (starts with `sk-`) -> -> Cost: ~$0.006 per minute of audio (~$0.003 per typical 30-second voice note) - -Wait for the user to provide the key. - -### Add to environment - -Add to `.env`: - -```bash -OPENAI_API_KEY= -``` - -Sync to container environment: - -```bash -mkdir -p data/env && cp .env data/env/env -``` - -The container reads environment from `data/env/env`, not `.env` directly. - -### Build and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Verify - -### Test with a voice note - -Tell the user: - -> Send a voice note in any registered WhatsApp chat. The agent should receive it as `[Voice: ]` and respond to its content. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log | grep -i voice -``` - -Look for: -- `Transcribed voice message` — successful transcription with character count -- `OPENAI_API_KEY not set` — key missing from `.env` -- `OpenAI transcription failed` — API error (check key validity, billing) -- `Failed to download audio message` — media download issue - -## Troubleshooting - -### Voice notes show "[Voice Message - transcription unavailable]" - -1. Check `OPENAI_API_KEY` is set in `.env` AND synced to `data/env/env` -2. Verify key works: `curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 200` -3. Check OpenAI billing — Whisper requires a funded account - -### Voice notes show "[Voice Message - transcription failed]" - -Check logs for the specific error. Common causes: -- Network timeout — transient, will work on next message -- Invalid API key — regenerate at https://platform.openai.com/api-keys -- Rate limiting — wait and retry - -### Agent doesn't respond to voice notes - -Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups. diff --git a/.claude/skills/add-voice-transcription/add/src/transcription.ts b/.claude/skills/add-voice-transcription/add/src/transcription.ts deleted file mode 100644 index 91c5e7f..0000000 --- a/.claude/skills/add-voice-transcription/add/src/transcription.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { downloadMediaMessage } from '@whiskeysockets/baileys'; -import { WAMessage, WASocket } from '@whiskeysockets/baileys'; - -import { readEnvFile } from './env.js'; - -interface TranscriptionConfig { - model: string; - enabled: boolean; - fallbackMessage: string; -} - -const DEFAULT_CONFIG: TranscriptionConfig = { - model: 'whisper-1', - enabled: true, - fallbackMessage: '[Voice Message - transcription unavailable]', -}; - -async function transcribeWithOpenAI( - audioBuffer: Buffer, - config: TranscriptionConfig, -): Promise { - const env = readEnvFile(['OPENAI_API_KEY']); - const apiKey = env.OPENAI_API_KEY; - - if (!apiKey) { - console.warn('OPENAI_API_KEY not set in .env'); - return null; - } - - try { - const openaiModule = await import('openai'); - const OpenAI = openaiModule.default; - const toFile = openaiModule.toFile; - - const openai = new OpenAI({ apiKey }); - - const file = await toFile(audioBuffer, 'voice.ogg', { - type: 'audio/ogg', - }); - - const transcription = await openai.audio.transcriptions.create({ - file: file, - model: config.model, - response_format: 'text', - }); - - // When response_format is 'text', the API returns a plain string - return transcription as unknown as string; - } catch (err) { - console.error('OpenAI transcription failed:', err); - return null; - } -} - -export async function transcribeAudioMessage( - msg: WAMessage, - sock: WASocket, -): Promise { - const config = DEFAULT_CONFIG; - - if (!config.enabled) { - return config.fallbackMessage; - } - - try { - const buffer = (await downloadMediaMessage( - msg, - 'buffer', - {}, - { - logger: console as any, - reuploadRequest: sock.updateMediaMessage, - }, - )) as Buffer; - - if (!buffer || buffer.length === 0) { - console.error('Failed to download audio message'); - return config.fallbackMessage; - } - - console.log(`Downloaded audio message: ${buffer.length} bytes`); - - const transcript = await transcribeWithOpenAI(buffer, config); - - if (!transcript) { - return config.fallbackMessage; - } - - return transcript.trim(); - } catch (err) { - console.error('Transcription error:', err); - return config.fallbackMessage; - } -} - -export function isVoiceMessage(msg: WAMessage): boolean { - return msg.message?.audioMessage?.ptt === true; -} diff --git a/.claude/skills/add-voice-transcription/manifest.yaml b/.claude/skills/add-voice-transcription/manifest.yaml deleted file mode 100644 index cb4d587..0000000 --- a/.claude/skills/add-voice-transcription/manifest.yaml +++ /dev/null @@ -1,17 +0,0 @@ -skill: voice-transcription -version: 1.0.0 -description: "Voice message transcription via OpenAI Whisper" -core_version: 0.1.0 -adds: - - src/transcription.ts -modifies: - - src/channels/whatsapp.ts - - src/channels/whatsapp.test.ts -structured: - npm_dependencies: - openai: "^4.77.0" - env_additions: - - OPENAI_API_KEY -conflicts: [] -depends: [] -test: "npx vitest run src/channels/whatsapp.test.ts" diff --git a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts deleted file mode 100644 index b6ef502..0000000 --- a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts +++ /dev/null @@ -1,967 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { EventEmitter } from 'events'; - -// --- Mocks --- - -// Mock config -vi.mock('../config.js', () => ({ - STORE_DIR: '/tmp/nanoclaw-test-store', - ASSISTANT_NAME: 'Andy', - ASSISTANT_HAS_OWN_NUMBER: false, -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// Mock db -vi.mock('../db.js', () => ({ - getLastGroupSync: vi.fn(() => null), - setLastGroupSync: vi.fn(), - updateChatName: vi.fn(), -})); - -// Mock transcription -vi.mock('../transcription.js', () => ({ - isVoiceMessage: vi.fn((msg: any) => msg.message?.audioMessage?.ptt === true), - transcribeAudioMessage: vi.fn().mockResolvedValue('Hello this is a voice message'), -})); - -// Mock fs -vi.mock('fs', async () => { - const actual = await vi.importActual('fs'); - return { - ...actual, - default: { - ...actual, - existsSync: vi.fn(() => true), - mkdirSync: vi.fn(), - }, - }; -}); - -// Mock child_process (used for osascript notification) -vi.mock('child_process', () => ({ - exec: vi.fn(), -})); - -// Build a fake WASocket that's an EventEmitter with the methods we need -function createFakeSocket() { - const ev = new EventEmitter(); - const sock = { - ev: { - on: (event: string, handler: (...args: unknown[]) => void) => { - ev.on(event, handler); - }, - }, - user: { - id: '1234567890:1@s.whatsapp.net', - lid: '9876543210:1@lid', - }, - sendMessage: vi.fn().mockResolvedValue(undefined), - sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), - groupFetchAllParticipating: vi.fn().mockResolvedValue({}), - end: vi.fn(), - // Expose the event emitter for triggering events in tests - _ev: ev, - }; - return sock; -} - -let fakeSocket: ReturnType; - -// Mock Baileys -vi.mock('@whiskeysockets/baileys', () => { - return { - default: vi.fn(() => fakeSocket), - Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) }, - DisconnectReason: { - loggedOut: 401, - badSession: 500, - connectionClosed: 428, - connectionLost: 408, - connectionReplaced: 440, - timedOut: 408, - restartRequired: 515, - }, - fetchLatestWaWebVersion: vi - .fn() - .mockResolvedValue({ version: [2, 3000, 0] }), - normalizeMessageContent: vi.fn((content: unknown) => content), - makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), - useMultiFileAuthState: vi.fn().mockResolvedValue({ - state: { - creds: {}, - keys: {}, - }, - saveCreds: vi.fn(), - }), - }; -}); - -import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js'; -import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js'; -import { transcribeAudioMessage } from '../transcription.js'; - -// --- Test helpers --- - -function createTestOpts(overrides?: Partial): WhatsAppChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: vi.fn(() => ({ - 'registered@g.us': { - name: 'Test Group', - folder: 'test-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - ...overrides, - }; -} - -function triggerConnection(state: string, extra?: Record) { - fakeSocket._ev.emit('connection.update', { connection: state, ...extra }); -} - -function triggerDisconnect(statusCode: number) { - fakeSocket._ev.emit('connection.update', { - connection: 'close', - lastDisconnect: { - error: { output: { statusCode } }, - }, - }); -} - -async function triggerMessages(messages: unknown[]) { - fakeSocket._ev.emit('messages.upsert', { messages }); - // Flush microtasks so the async messages.upsert handler completes - await new Promise((r) => setTimeout(r, 0)); -} - -// --- Tests --- - -describe('WhatsAppChannel', () => { - beforeEach(() => { - fakeSocket = createFakeSocket(); - vi.mocked(getLastGroupSync).mockReturnValue(null); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - /** - * Helper: start connect, flush microtasks so event handlers are registered, - * then trigger the connection open event. Returns the resolved promise. - */ - async function connectChannel(channel: WhatsAppChannel): Promise { - const p = channel.connect(); - // Flush microtasks so connectInternal completes its await and registers handlers - await new Promise((r) => setTimeout(r, 0)); - triggerConnection('open'); - return p; - } - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when connection opens', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - }); - - it('sets up LID to phone mapping on open', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The channel should have mapped the LID from sock.user - // We can verify by sending a message from a LID JID - // and checking the translated JID in the callback - }); - - it('flushes outgoing queue on reconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect - (channel as any).connected = false; - - // Queue a message while disconnected - await channel.sendMessage('test@g.us', 'Queued message'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - - // Reconnect - (channel as any).connected = true; - await (channel as any).flushOutgoingQueue(); - - // Group messages get prefixed when flushed - expect(fakeSocket.sendMessage).toHaveBeenCalledWith( - 'test@g.us', - { text: 'Andy: Queued message' }, - ); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - expect(fakeSocket.end).toHaveBeenCalled(); - }); - }); - - // --- QR code and auth --- - - describe('authentication', () => { - it('exits process when QR code is emitted (no auth state)', async () => { - vi.useFakeTimers(); - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Start connect but don't await (it won't resolve - process exits) - channel.connect().catch(() => {}); - - // Flush microtasks so connectInternal registers handlers - await vi.advanceTimersByTimeAsync(0); - - // Emit QR code event - fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' }); - - // Advance timer past the 1000ms setTimeout before exit - await vi.advanceTimersByTimeAsync(1500); - - expect(mockExit).toHaveBeenCalledWith(1); - mockExit.mockRestore(); - vi.useRealTimers(); - }); - }); - - // --- Reconnection behavior --- - - describe('reconnection', () => { - it('reconnects on non-loggedOut disconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - - // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428) - triggerDisconnect(428); - - expect(channel.isConnected()).toBe(false); - // The channel should attempt to reconnect (calls connectInternal again) - }); - - it('exits on loggedOut disconnect', async () => { - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with loggedOut reason (401) - triggerDisconnect(401); - - expect(channel.isConnected()).toBe(false); - expect(mockExit).toHaveBeenCalledWith(0); - mockExit.mockRestore(); - }); - - it('retries reconnection after 5s on failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with stream error 515 - triggerDisconnect(515); - - // The channel sets a 5s retry — just verify it doesn't crash - await new Promise((r) => setTimeout(r, 100)); - }); - }); - - // --- Message handling --- - - describe('message handling', () => { - it('delivers message for registered group', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-1', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello Andy' }, - pushName: 'Alice', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - id: 'msg-1', - content: 'Hello Andy', - sender_name: 'Alice', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered groups', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-2', - remoteJid: 'unregistered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello' }, - pushName: 'Bob', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'unregistered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores status@broadcast messages', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-3', - remoteJid: 'status@broadcast', - fromMe: false, - }, - message: { conversation: 'Status update' }, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores messages with no content', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-4', - remoteJid: 'registered@g.us', - fromMe: false, - }, - message: null, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('extracts text from extendedTextMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-5', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - extendedTextMessage: { text: 'A reply message' }, - }, - pushName: 'Charlie', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'A reply message' }), - ); - }); - - it('extracts caption from imageMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-6', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - imageMessage: { caption: 'Check this photo', mimetype: 'image/jpeg' }, - }, - pushName: 'Diana', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Check this photo' }), - ); - }); - - it('extracts caption from videoMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-7', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' }, - }, - pushName: 'Eve', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Watch this' }), - ); - }); - - it('transcribes voice messages', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-8', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, - }, - pushName: 'Frank', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(transcribeAudioMessage).toHaveBeenCalled(); - expect(opts.onMessage).toHaveBeenCalledTimes(1); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: '[Voice: Hello this is a voice message]' }), - ); - }); - - it('falls back when transcription returns null', async () => { - vi.mocked(transcribeAudioMessage).mockResolvedValueOnce(null); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-8b', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, - }, - pushName: 'Frank', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledTimes(1); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: '[Voice Message - transcription unavailable]' }), - ); - }); - - it('falls back when transcription throws', async () => { - vi.mocked(transcribeAudioMessage).mockRejectedValueOnce(new Error('API error')); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-8c', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, - }, - pushName: 'Frank', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledTimes(1); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: '[Voice Message - transcription failed]' }), - ); - }); - - it('uses sender JID when pushName is absent', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-9', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'No push name' }, - // pushName is undefined - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ sender_name: '5551234' }), - ); - }); - }); - - // --- LID ↔ JID translation --- - - describe('LID to JID translation', () => { - it('translates known LID to phone JID', async () => { - const opts = createTestOpts({ - registeredGroups: vi.fn(() => ({ - '1234567890@s.whatsapp.net': { - name: 'Self Chat', - folder: 'self-chat', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net' - // Send a message from the LID - await triggerMessages([ - { - key: { - id: 'msg-lid', - remoteJid: '9876543210@lid', - fromMe: false, - }, - message: { conversation: 'From LID' }, - pushName: 'Self', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Should be translated to phone JID - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '1234567890@s.whatsapp.net', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - - it('passes through non-LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-normal', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Normal JID' }, - pushName: 'Grace', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - }); - - it('passes through unknown LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-unknown-lid', - remoteJid: '0000000000@lid', - fromMe: false, - }, - message: { conversation: 'Unknown LID' }, - pushName: 'Unknown', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Unknown LID passes through unchanged - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '0000000000@lid', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - }); - - // --- Outgoing message queue --- - - describe('outgoing message queue', () => { - it('sends message directly when connected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('test@g.us', 'Hello'); - // Group messages get prefixed with assistant name - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { text: 'Andy: Hello' }); - }); - - it('prefixes direct chat messages on shared number', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('123@s.whatsapp.net', 'Hello'); - // Shared number: DMs also get prefixed (needed for self-chat distinction) - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('123@s.whatsapp.net', { text: 'Andy: Hello' }); - }); - - it('queues message when disconnected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Don't connect — channel starts disconnected - await channel.sendMessage('test@g.us', 'Queued'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - }); - - it('queues message on send failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Make sendMessage fail - fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error')); - - await channel.sendMessage('test@g.us', 'Will fail'); - - // Should not throw, message queued for retry - // The queue should have the message - }); - - it('flushes multiple queued messages in order', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Queue messages while disconnected - await channel.sendMessage('test@g.us', 'First'); - await channel.sendMessage('test@g.us', 'Second'); - await channel.sendMessage('test@g.us', 'Third'); - - // Connect — flush happens automatically on open - await connectChannel(channel); - - // Give the async flush time to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3); - // Group messages get prefixed - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { text: 'Andy: First' }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { text: 'Andy: Second' }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { text: 'Andy: Third' }); - }); - }); - - // --- Group metadata sync --- - - describe('group metadata sync', () => { - it('syncs group metadata on first connection', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Group One' }, - 'group2@g.us': { subject: 'Group Two' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Wait for async sync to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One'); - expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two'); - expect(setLastGroupSync).toHaveBeenCalled(); - }); - - it('skips sync when synced recently', async () => { - // Last sync was 1 hour ago (within 24h threshold) - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled(); - }); - - it('forces sync regardless of cache', async () => { - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group@g.us': { subject: 'Forced Group' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.syncGroupMetadata(true); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group'); - }); - - it('handles group sync failure gracefully', async () => { - fakeSocket.groupFetchAllParticipating.mockRejectedValue( - new Error('Network timeout'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Should not throw - await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined(); - }); - - it('skips groups with no subject', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Has Subject' }, - 'group2@g.us': { subject: '' }, - 'group3@g.us': {}, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Clear any calls from the automatic sync on connect - vi.mocked(updateChatName).mockClear(); - - await channel.syncGroupMetadata(true); - - expect(updateChatName).toHaveBeenCalledTimes(1); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject'); - }); - }); - - // --- JID ownership --- - - describe('ownsJid', () => { - it('owns @g.us JIDs (WhatsApp groups)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(true); - }); - - it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true); - }); - - it('does not own Telegram JIDs', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('tg:12345')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); - }); - - // --- Typing indicator --- - - describe('setTyping', () => { - it('sends composing presence when typing', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', true); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('composing', 'test@g.us'); - }); - - it('sends paused presence when stopping', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', false); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('paused', 'test@g.us'); - }); - - it('handles typing indicator failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed')); - - // Should not throw - await expect(channel.setTyping('test@g.us', true)).resolves.toBeUndefined(); - }); - }); - - // --- Channel properties --- - - describe('channel properties', () => { - it('has name "whatsapp"', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.name).toBe('whatsapp'); - }); - - it('does not expose prefixAssistantName (prefix handled internally)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect('prefixAssistantName' in channel).toBe(false); - }); - }); -}); diff --git a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md deleted file mode 100644 index a07e7f0..0000000 --- a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md +++ /dev/null @@ -1,27 +0,0 @@ -# Intent: src/channels/whatsapp.test.ts modifications - -## What changed -Added mock for the transcription module and 3 new test cases for voice message handling. - -## Key sections - -### Mocks (top of file) -- Added: `vi.mock('../transcription.js', ...)` with `isVoiceMessage` and `transcribeAudioMessage` mocks -- Added: `import { transcribeAudioMessage } from '../transcription.js'` for test assertions -- Updated: Baileys mock to include `fetchLatestWaWebVersion` and `normalizeMessageContent` exports (required by current upstream whatsapp.ts) - -### Test cases (inside "message handling" describe block) -- Changed: "handles message with no extractable text (e.g. voice note without caption)" → "transcribes voice messages" - - Now expects `[Voice: Hello this is a voice message]` instead of empty content -- Added: "falls back when transcription returns null" — expects `[Voice Message - transcription unavailable]` -- Added: "falls back when transcription throws" — expects `[Voice Message - transcription failed]` - -## Invariants (must-keep) -- All existing test cases for text, extendedTextMessage, imageMessage, videoMessage unchanged -- All connection lifecycle tests unchanged -- All LID translation tests unchanged -- All outgoing queue tests unchanged -- All group metadata sync tests unchanged -- All ownsJid and setTyping tests unchanged -- All existing mocks (config, logger, db, fs, child_process, baileys) unchanged -- Test helpers (createTestOpts, triggerConnection, triggerDisconnect, triggerMessages, connectChannel) unchanged diff --git a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts deleted file mode 100644 index 025e905..0000000 --- a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { exec } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import makeWASocket, { - Browsers, - DisconnectReason, - WASocket, - fetchLatestWaWebVersion, - makeCacheableSignalKeyStore, - useMultiFileAuthState, -} from '@whiskeysockets/baileys'; - -import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, STORE_DIR } from '../config.js'; -import { - getLastGroupSync, - setLastGroupSync, - updateChatName, -} from '../db.js'; -import { logger } from '../logger.js'; -import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js'; -import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js'; -import { registerChannel, ChannelOpts } from './registry.js'; - -const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours - -export interface WhatsAppChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -export class WhatsAppChannel implements Channel { - name = 'whatsapp'; - - private sock!: WASocket; - private connected = false; - private lidToPhoneMap: Record = {}; - private outgoingQueue: Array<{ jid: string; text: string }> = []; - private flushing = false; - private groupSyncTimerStarted = false; - - private opts: WhatsAppChannelOpts; - - constructor(opts: WhatsAppChannelOpts) { - this.opts = opts; - } - - async connect(): Promise { - return new Promise((resolve, reject) => { - this.connectInternal(resolve).catch(reject); - }); - } - - private async connectInternal(onFirstOpen?: () => void): Promise { - const authDir = path.join(STORE_DIR, 'auth'); - fs.mkdirSync(authDir, { recursive: true }); - - const { state, saveCreds } = await useMultiFileAuthState(authDir); - - const { version } = await fetchLatestWaWebVersion({}).catch((err) => { - logger.warn({ err }, 'Failed to fetch latest WA Web version, using default'); - return { version: undefined }; - }); - this.sock = makeWASocket({ - version, - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, logger), - }, - printQRInTerminal: false, - logger, - browser: Browsers.macOS('Chrome'), - }); - - this.sock.ev.on('connection.update', (update) => { - const { connection, lastDisconnect, qr } = update; - - if (qr) { - const msg = - 'WhatsApp authentication required. Run /setup in Claude Code.'; - logger.error(msg); - exec( - `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`, - ); - setTimeout(() => process.exit(1), 1000); - } - - if (connection === 'close') { - this.connected = false; - const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode; - const shouldReconnect = reason !== DisconnectReason.loggedOut; - logger.info({ reason, shouldReconnect, queuedMessages: this.outgoingQueue.length }, 'Connection closed'); - - if (shouldReconnect) { - logger.info('Reconnecting...'); - this.connectInternal().catch((err) => { - logger.error({ err }, 'Failed to reconnect, retrying in 5s'); - setTimeout(() => { - this.connectInternal().catch((err2) => { - logger.error({ err: err2 }, 'Reconnection retry failed'); - }); - }, 5000); - }); - } else { - logger.info('Logged out. Run /setup to re-authenticate.'); - process.exit(0); - } - } else if (connection === 'open') { - this.connected = true; - logger.info('Connected to WhatsApp'); - - // Announce availability so WhatsApp relays subsequent presence updates (typing indicators) - this.sock.sendPresenceUpdate('available').catch((err) => { - logger.warn({ err }, 'Failed to send presence update'); - }); - - // Build LID to phone mapping from auth state for self-chat translation - if (this.sock.user) { - const phoneUser = this.sock.user.id.split(':')[0]; - const lidUser = this.sock.user.lid?.split(':')[0]; - if (lidUser && phoneUser) { - this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`; - logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set'); - } - } - - // Flush any messages queued while disconnected - this.flushOutgoingQueue().catch((err) => - logger.error({ err }, 'Failed to flush outgoing queue'), - ); - - // Sync group metadata on startup (respects 24h cache) - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Initial group sync failed'), - ); - // Set up daily sync timer (only once) - if (!this.groupSyncTimerStarted) { - this.groupSyncTimerStarted = true; - setInterval(() => { - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Periodic group sync failed'), - ); - }, GROUP_SYNC_INTERVAL_MS); - } - - // Signal first connection to caller - if (onFirstOpen) { - onFirstOpen(); - onFirstOpen = undefined; - } - } - }); - - this.sock.ev.on('creds.update', saveCreds); - - this.sock.ev.on('messages.upsert', async ({ messages }) => { - for (const msg of messages) { - if (!msg.message) continue; - const rawJid = msg.key.remoteJid; - if (!rawJid || rawJid === 'status@broadcast') continue; - - // Translate LID JID to phone JID if applicable - const chatJid = await this.translateJid(rawJid); - - const timestamp = new Date( - Number(msg.messageTimestamp) * 1000, - ).toISOString(); - - // Always notify about chat metadata for group discovery - const isGroup = chatJid.endsWith('@g.us'); - this.opts.onChatMetadata(chatJid, timestamp, undefined, 'whatsapp', isGroup); - - // Only deliver full message for registered groups - const groups = this.opts.registeredGroups(); - if (groups[chatJid]) { - const content = - msg.message?.conversation || - msg.message?.extendedTextMessage?.text || - msg.message?.imageMessage?.caption || - msg.message?.videoMessage?.caption || - ''; - - // Skip protocol messages with no text content (encryption keys, read receipts, etc.) - // but allow voice messages through for transcription - if (!content && !isVoiceMessage(msg)) continue; - - const sender = msg.key.participant || msg.key.remoteJid || ''; - const senderName = msg.pushName || sender.split('@')[0]; - - const fromMe = msg.key.fromMe || false; - // Detect bot messages: with own number, fromMe is reliable - // since only the bot sends from that number. - // With shared number, bot messages carry the assistant name prefix - // (even in DMs/self-chat) so we check for that. - const isBotMessage = ASSISTANT_HAS_OWN_NUMBER - ? fromMe - : content.startsWith(`${ASSISTANT_NAME}:`); - - // Transcribe voice messages before storing - let finalContent = content; - if (isVoiceMessage(msg)) { - try { - const transcript = await transcribeAudioMessage(msg, this.sock); - if (transcript) { - finalContent = `[Voice: ${transcript}]`; - logger.info({ chatJid, length: transcript.length }, 'Transcribed voice message'); - } else { - finalContent = '[Voice Message - transcription unavailable]'; - } - } catch (err) { - logger.error({ err }, 'Voice transcription error'); - finalContent = '[Voice Message - transcription failed]'; - } - } - - this.opts.onMessage(chatJid, { - id: msg.key.id || '', - chat_jid: chatJid, - sender, - sender_name: senderName, - content: finalContent, - timestamp, - is_from_me: fromMe, - is_bot_message: isBotMessage, - }); - } - } - }); - } - - async sendMessage(jid: string, text: string): Promise { - // Prefix bot messages with assistant name so users know who's speaking. - // On a shared number, prefix is also needed in DMs (including self-chat) - // to distinguish bot output from user messages. - // Skip only when the assistant has its own dedicated phone number. - const prefixed = ASSISTANT_HAS_OWN_NUMBER - ? text - : `${ASSISTANT_NAME}: ${text}`; - - if (!this.connected) { - this.outgoingQueue.push({ jid, text: prefixed }); - logger.info({ jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, 'WA disconnected, message queued'); - return; - } - try { - await this.sock.sendMessage(jid, { text: prefixed }); - logger.info({ jid, length: prefixed.length }, 'Message sent'); - } catch (err) { - // If send fails, queue it for retry on reconnect - this.outgoingQueue.push({ jid, text: prefixed }); - logger.warn({ jid, err, queueSize: this.outgoingQueue.length }, 'Failed to send, message queued'); - } - } - - isConnected(): boolean { - return this.connected; - } - - ownsJid(jid: string): boolean { - return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net'); - } - - async disconnect(): Promise { - this.connected = false; - this.sock?.end(undefined); - } - - async setTyping(jid: string, isTyping: boolean): Promise { - try { - const status = isTyping ? 'composing' : 'paused'; - logger.debug({ jid, status }, 'Sending presence update'); - await this.sock.sendPresenceUpdate(status, jid); - } catch (err) { - logger.debug({ jid, err }, 'Failed to update typing status'); - } - } - - /** - * Sync group metadata from WhatsApp. - * Fetches all participating groups and stores their names in the database. - * Called on startup, daily, and on-demand via IPC. - */ - async syncGroupMetadata(force = false): Promise { - if (!force) { - const lastSync = getLastGroupSync(); - if (lastSync) { - const lastSyncTime = new Date(lastSync).getTime(); - if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) { - logger.debug({ lastSync }, 'Skipping group sync - synced recently'); - return; - } - } - } - - try { - logger.info('Syncing group metadata from WhatsApp...'); - const groups = await this.sock.groupFetchAllParticipating(); - - let count = 0; - for (const [jid, metadata] of Object.entries(groups)) { - if (metadata.subject) { - updateChatName(jid, metadata.subject); - count++; - } - } - - setLastGroupSync(); - logger.info({ count }, 'Group metadata synced'); - } catch (err) { - logger.error({ err }, 'Failed to sync group metadata'); - } - } - - private async translateJid(jid: string): Promise { - if (!jid.endsWith('@lid')) return jid; - const lidUser = jid.split('@')[0].split(':')[0]; - - // Check local cache first - const cached = this.lidToPhoneMap[lidUser]; - if (cached) { - logger.debug({ lidJid: jid, phoneJid: cached }, 'Translated LID to phone JID (cached)'); - return cached; - } - - // Query Baileys' signal repository for the mapping - try { - const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid); - if (pn) { - const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; - this.lidToPhoneMap[lidUser] = phoneJid; - logger.info({ lidJid: jid, phoneJid }, 'Translated LID to phone JID (signalRepository)'); - return phoneJid; - } - } catch (err) { - logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository'); - } - - return jid; - } - - private async flushOutgoingQueue(): Promise { - if (this.flushing || this.outgoingQueue.length === 0) return; - this.flushing = true; - try { - logger.info({ count: this.outgoingQueue.length }, 'Flushing outgoing message queue'); - while (this.outgoingQueue.length > 0) { - const item = this.outgoingQueue.shift()!; - // Send directly — queued items are already prefixed by sendMessage - await this.sock.sendMessage(item.jid, { text: item.text }); - logger.info({ jid: item.jid, length: item.text.length }, 'Queued message sent'); - } - } finally { - this.flushing = false; - } - } -} - -registerChannel('whatsapp', (opts: ChannelOpts) => { - const authDir = path.join(STORE_DIR, 'auth'); - if (!fs.existsSync(path.join(authDir, 'creds.json'))) { - logger.warn('WhatsApp: credentials not found. Run /add-whatsapp to authenticate.'); - return null; - } - return new WhatsAppChannel(opts); -}); diff --git a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts.intent.md b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts.intent.md deleted file mode 100644 index 0049fed..0000000 --- a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts.intent.md +++ /dev/null @@ -1,27 +0,0 @@ -# Intent: src/channels/whatsapp.ts modifications - -## What changed -Added voice message transcription support. When a WhatsApp voice note (PTT audio) arrives, it is downloaded and transcribed via OpenAI Whisper before being stored as message content. - -## Key sections - -### Imports (top of file) -- Added: `isVoiceMessage`, `transcribeAudioMessage` from `../transcription.js` - -### messages.upsert handler (inside connectInternal) -- Added: `let finalContent = content` variable to allow voice transcription to override text content -- Added: `isVoiceMessage(msg)` check after content extraction -- Added: try/catch block calling `transcribeAudioMessage(msg, this.sock)` - - Success: `finalContent = '[Voice: ]'` - - Null result: `finalContent = '[Voice Message - transcription unavailable]'` - - Error: `finalContent = '[Voice Message - transcription failed]'` -- Changed: `this.opts.onMessage()` call uses `finalContent` instead of `content` - -## Invariants (must-keep) -- All existing message handling (conversation, extendedTextMessage, imageMessage, videoMessage) unchanged -- Connection lifecycle (connect, reconnect, disconnect) unchanged -- LID translation logic unchanged -- Outgoing message queue unchanged -- Group metadata sync unchanged -- sendMessage prefix logic unchanged -- setTyping, ownsJid, isConnected — all unchanged diff --git a/.claude/skills/add-voice-transcription/tests/voice-transcription.test.ts b/.claude/skills/add-voice-transcription/tests/voice-transcription.test.ts deleted file mode 100644 index 76ebd0d..0000000 --- a/.claude/skills/add-voice-transcription/tests/voice-transcription.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('voice-transcription skill package', () => { - const skillDir = path.resolve(__dirname, '..'); - - it('has a valid manifest', () => { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - expect(fs.existsSync(manifestPath)).toBe(true); - - const content = fs.readFileSync(manifestPath, 'utf-8'); - expect(content).toContain('skill: voice-transcription'); - expect(content).toContain('version: 1.0.0'); - expect(content).toContain('openai'); - expect(content).toContain('OPENAI_API_KEY'); - }); - - it('has all files declared in adds', () => { - const transcriptionFile = path.join(skillDir, 'add', 'src', 'transcription.ts'); - expect(fs.existsSync(transcriptionFile)).toBe(true); - - const content = fs.readFileSync(transcriptionFile, 'utf-8'); - expect(content).toContain('transcribeAudioMessage'); - expect(content).toContain('isVoiceMessage'); - expect(content).toContain('transcribeWithOpenAI'); - expect(content).toContain('downloadMediaMessage'); - expect(content).toContain('readEnvFile'); - }); - - it('has all files declared in modifies', () => { - const whatsappFile = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'); - const whatsappTestFile = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'); - - expect(fs.existsSync(whatsappFile)).toBe(true); - expect(fs.existsSync(whatsappTestFile)).toBe(true); - }); - - it('has intent files for modified files', () => { - expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts.intent.md'))).toBe(true); - expect(fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts.intent.md'))).toBe(true); - }); - - it('modified whatsapp.ts preserves core structure', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'), - 'utf-8', - ); - - // Core class and methods preserved - expect(content).toContain('class WhatsAppChannel'); - expect(content).toContain('implements Channel'); - expect(content).toContain('async connect()'); - expect(content).toContain('async sendMessage('); - expect(content).toContain('isConnected()'); - expect(content).toContain('ownsJid('); - expect(content).toContain('async disconnect()'); - expect(content).toContain('async setTyping('); - expect(content).toContain('async syncGroupMetadata('); - expect(content).toContain('private async translateJid('); - expect(content).toContain('private async flushOutgoingQueue('); - - // Core imports preserved - expect(content).toContain('ASSISTANT_HAS_OWN_NUMBER'); - expect(content).toContain('ASSISTANT_NAME'); - expect(content).toContain('STORE_DIR'); - }); - - it('modified whatsapp.ts includes transcription integration', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'), - 'utf-8', - ); - - // Transcription imports - expect(content).toContain("import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js'"); - - // Voice message handling - expect(content).toContain('isVoiceMessage(msg)'); - expect(content).toContain('transcribeAudioMessage(msg, this.sock)'); - expect(content).toContain('finalContent'); - expect(content).toContain('[Voice:'); - expect(content).toContain('[Voice Message - transcription unavailable]'); - expect(content).toContain('[Voice Message - transcription failed]'); - }); - - it('modified whatsapp.test.ts includes transcription mock and tests', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'), - 'utf-8', - ); - - // Transcription mock - expect(content).toContain("vi.mock('../transcription.js'"); - expect(content).toContain('isVoiceMessage'); - expect(content).toContain('transcribeAudioMessage'); - - // Voice transcription test cases - expect(content).toContain('transcribes voice messages'); - expect(content).toContain('falls back when transcription returns null'); - expect(content).toContain('falls back when transcription throws'); - expect(content).toContain('[Voice: Hello this is a voice message]'); - }); - - it('modified whatsapp.test.ts preserves all existing test sections', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'), - 'utf-8', - ); - - // All existing test describe blocks preserved - expect(content).toContain("describe('connection lifecycle'"); - expect(content).toContain("describe('authentication'"); - expect(content).toContain("describe('reconnection'"); - expect(content).toContain("describe('message handling'"); - expect(content).toContain("describe('LID to JID translation'"); - expect(content).toContain("describe('outgoing message queue'"); - expect(content).toContain("describe('group metadata sync'"); - expect(content).toContain("describe('ownsJid'"); - expect(content).toContain("describe('setTyping'"); - expect(content).toContain("describe('channel properties'"); - }); -}); diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md deleted file mode 100644 index 023e748..0000000 --- a/.claude/skills/add-whatsapp/SKILL.md +++ /dev/null @@ -1,361 +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: Verify Code - -Apply the skill to install the WhatsApp channel code and dependencies: - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp -``` - -Verify the code was placed correctly: - -```bash -test -f src/channels/whatsapp.ts && echo "WhatsApp channel code present" || echo "ERROR: WhatsApp channel code missing — re-run skill apply" -``` - -### Verify dependencies - -```bash -node -e "require('@whiskeysockets/baileys')" 2>/dev/null && echo "Baileys installed" || echo "Installing Baileys..." -``` - -If not installed: - -```bash -npm install @whiskeysockets/baileys qrcode qrcode-terminal -``` - -### Validate build - -```bash -npm run build -``` - -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:** The JID is the **user's** phone number — the number they will message *from* (not the bot's own number). Ask: - -AskUserQuestion: What is your personal phone number? (The number you'll use to message the bot — include country code without +, e.g. 1234567890) - -JID = `@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 # For self-chat and DM with bot (1:1 conversations don't need a trigger prefix) -``` - -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/add-whatsapp/add/setup/whatsapp-auth.ts b/.claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts deleted file mode 100644 index 2cbec76..0000000 --- a/.claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** - * Step: whatsapp-auth — WhatsApp interactive auth (QR code / pairing code). - */ -import { execSync, spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { logger } from '../src/logger.js'; -import { openBrowser, isHeadless } from './platform.js'; -import { emitStatus } from './status.js'; - -const QR_AUTH_TEMPLATE = ` -NanoClaw - WhatsApp Auth - - -
-

Scan with WhatsApp

-
Expires in 60s
-
{{QR_SVG}}
-
Settings \\u2192 Linked Devices \\u2192 Link a Device
-
-`; - -const SUCCESS_HTML = ` -NanoClaw - Connected! - -
-
-

Connected to WhatsApp

-

You can close this tab.

-
- -`; - -function parseArgs(args: string[]): { method: string; phone: string } { - let method = ''; - let phone = ''; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--method' && args[i + 1]) { - method = args[i + 1]; - i++; - } - if (args[i] === '--phone' && args[i + 1]) { - phone = args[i + 1]; - i++; - } - } - return { method, phone }; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function readFileSafe(filePath: string): string { - try { - return fs.readFileSync(filePath, 'utf-8'); - } catch { - return ''; - } -} - -function getPhoneNumber(projectRoot: string): string { - try { - const creds = JSON.parse( - fs.readFileSync( - path.join(projectRoot, 'store', 'auth', 'creds.json'), - 'utf-8', - ), - ); - if (creds.me?.id) { - return creds.me.id.split(':')[0].split('@')[0]; - } - } catch { - // Not available yet - } - return ''; -} - -function emitAuthStatus( - method: string, - authStatus: string, - status: string, - extra: Record = {}, -): void { - const fields: Record = { - AUTH_METHOD: method, - AUTH_STATUS: authStatus, - ...extra, - STATUS: status, - LOG: 'logs/setup.log', - }; - emitStatus('AUTH_WHATSAPP', fields); -} - -export async function run(args: string[]): Promise { - const projectRoot = process.cwd(); - - const { method, phone } = parseArgs(args); - const statusFile = path.join(projectRoot, 'store', 'auth-status.txt'); - const qrFile = path.join(projectRoot, 'store', 'qr-data.txt'); - - if (!method) { - emitAuthStatus('unknown', 'failed', 'failed', { - ERROR: 'missing_method_flag', - }); - process.exit(4); - } - - // qr-terminal is a manual flow - if (method === 'qr-terminal') { - emitAuthStatus('qr-terminal', 'manual', 'manual', { - PROJECT_PATH: projectRoot, - }); - return; - } - - if (method === 'pairing-code' && !phone) { - emitAuthStatus('pairing-code', 'failed', 'failed', { - ERROR: 'missing_phone_number', - }); - process.exit(4); - } - - if (!['qr-browser', 'pairing-code'].includes(method)) { - emitAuthStatus(method, 'failed', 'failed', { ERROR: 'unknown_method' }); - process.exit(4); - } - - // Clean stale state - logger.info({ method }, 'Starting channel authentication'); - try { - fs.rmSync(path.join(projectRoot, 'store', 'auth'), { - recursive: true, - force: true, - }); - } catch { - /* ok */ - } - try { - fs.unlinkSync(qrFile); - } catch { - /* ok */ - } - try { - fs.unlinkSync(statusFile); - } catch { - /* ok */ - } - - // Start auth process in background - const authArgs = - method === 'pairing-code' - ? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone] - : ['src/whatsapp-auth.ts']; - - const authProc = spawn('npx', ['tsx', ...authArgs], { - cwd: projectRoot, - stdio: ['ignore', 'pipe', 'pipe'], - detached: false, - }); - - const logFile = path.join(projectRoot, 'logs', 'setup.log'); - const logStream = fs.createWriteStream(logFile, { flags: 'a' }); - authProc.stdout?.pipe(logStream); - authProc.stderr?.pipe(logStream); - - // Cleanup on exit - const cleanup = () => { - try { - authProc.kill(); - } catch { - /* ok */ - } - }; - process.on('exit', cleanup); - - try { - if (method === 'qr-browser') { - await handleQrBrowser(projectRoot, statusFile, qrFile); - } else { - await handlePairingCode(projectRoot, statusFile, phone); - } - } finally { - cleanup(); - process.removeListener('exit', cleanup); - } -} - -async function handleQrBrowser( - projectRoot: string, - statusFile: string, - qrFile: string, -): Promise { - // Poll for QR data (15s) - let qrReady = false; - for (let i = 0; i < 15; i++) { - const statusContent = readFileSafe(statusFile); - if (statusContent === 'already_authenticated') { - emitAuthStatus('qr-browser', 'already_authenticated', 'success'); - return; - } - if (fs.existsSync(qrFile)) { - qrReady = true; - break; - } - await sleep(1000); - } - - if (!qrReady) { - emitAuthStatus('qr-browser', 'failed', 'failed', { ERROR: 'qr_timeout' }); - process.exit(3); - } - - // Generate QR SVG and HTML - const qrData = fs.readFileSync(qrFile, 'utf-8'); - try { - const svg = execSync( - `node -e "const QR=require('qrcode');const data='${qrData}';QR.toString(data,{type:'svg'},(e,s)=>{if(e)process.exit(1);process.stdout.write(s)})"`, - { cwd: projectRoot, encoding: 'utf-8' }, - ); - const html = QR_AUTH_TEMPLATE.replace('{{QR_SVG}}', svg); - const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html'); - fs.writeFileSync(htmlPath, html); - - // Open in browser (cross-platform) - if (!isHeadless()) { - const opened = openBrowser(htmlPath); - if (!opened) { - logger.warn( - 'Could not open browser — display QR in terminal as fallback', - ); - } - } else { - logger.info( - 'Headless environment — QR HTML saved but browser not opened', - ); - } - } catch (err) { - logger.error({ err }, 'Failed to generate QR HTML'); - } - - // Poll for completion (120s) - await pollAuthCompletion('qr-browser', statusFile, projectRoot); -} - -async function handlePairingCode( - projectRoot: string, - statusFile: string, - phone: string, -): Promise { - // Poll for pairing code (15s) - let pairingCode = ''; - for (let i = 0; i < 15; i++) { - const statusContent = readFileSafe(statusFile); - if (statusContent === 'already_authenticated') { - emitAuthStatus('pairing-code', 'already_authenticated', 'success'); - return; - } - if (statusContent.startsWith('pairing_code:')) { - pairingCode = statusContent.replace('pairing_code:', ''); - break; - } - if (statusContent.startsWith('failed:')) { - emitAuthStatus('pairing-code', 'failed', 'failed', { - ERROR: statusContent.replace('failed:', ''), - }); - process.exit(1); - } - await sleep(1000); - } - - if (!pairingCode) { - emitAuthStatus('pairing-code', 'failed', 'failed', { - ERROR: 'pairing_code_timeout', - }); - process.exit(3); - } - - // Write to file immediately so callers can read it without waiting for stdout - try { - fs.writeFileSync( - path.join(projectRoot, 'store', 'pairing-code.txt'), - pairingCode, - ); - } catch { - /* non-fatal */ - } - - // Emit pairing code immediately so the caller can display it to the user - emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', { - PAIRING_CODE: pairingCode, - }); - - // Poll for completion (120s) - await pollAuthCompletion( - 'pairing-code', - statusFile, - projectRoot, - pairingCode, - ); -} - -async function pollAuthCompletion( - method: string, - statusFile: string, - projectRoot: string, - pairingCode?: string, -): Promise { - const extra: Record = {}; - if (pairingCode) extra.PAIRING_CODE = pairingCode; - - for (let i = 0; i < 60; i++) { - const content = readFileSafe(statusFile); - - if (content === 'authenticated' || content === 'already_authenticated') { - // Write success page if qr-auth.html exists - const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html'); - if (fs.existsSync(htmlPath)) { - fs.writeFileSync(htmlPath, SUCCESS_HTML); - } - const phoneNumber = getPhoneNumber(projectRoot); - if (phoneNumber) extra.PHONE_NUMBER = phoneNumber; - emitAuthStatus(method, content, 'success', extra); - return; - } - - if (content.startsWith('failed:')) { - const error = content.replace('failed:', ''); - emitAuthStatus(method, 'failed', 'failed', { ERROR: error, ...extra }); - process.exit(1); - } - - await sleep(2000); - } - - emitAuthStatus(method, 'failed', 'failed', { ERROR: 'timeout', ...extra }); - process.exit(3); -} diff --git a/.claude/skills/add-whatsapp/add/src/channels/whatsapp.test.ts b/.claude/skills/add-whatsapp/add/src/channels/whatsapp.test.ts deleted file mode 100644 index 5bf1893..0000000 --- a/.claude/skills/add-whatsapp/add/src/channels/whatsapp.test.ts +++ /dev/null @@ -1,950 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { EventEmitter } from 'events'; - -// --- Mocks --- - -// Mock config -vi.mock('../config.js', () => ({ - STORE_DIR: '/tmp/nanoclaw-test-store', - ASSISTANT_NAME: 'Andy', - ASSISTANT_HAS_OWN_NUMBER: false, -})); - -// Mock logger -vi.mock('../logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// Mock db -vi.mock('../db.js', () => ({ - getLastGroupSync: vi.fn(() => null), - setLastGroupSync: vi.fn(), - updateChatName: vi.fn(), -})); - -// Mock fs -vi.mock('fs', async () => { - const actual = await vi.importActual('fs'); - return { - ...actual, - default: { - ...actual, - existsSync: vi.fn(() => true), - mkdirSync: vi.fn(), - }, - }; -}); - -// Mock child_process (used for osascript notification) -vi.mock('child_process', () => ({ - exec: vi.fn(), -})); - -// Build a fake WASocket that's an EventEmitter with the methods we need -function createFakeSocket() { - const ev = new EventEmitter(); - const sock = { - ev: { - on: (event: string, handler: (...args: unknown[]) => void) => { - ev.on(event, handler); - }, - }, - user: { - id: '1234567890:1@s.whatsapp.net', - lid: '9876543210:1@lid', - }, - sendMessage: vi.fn().mockResolvedValue(undefined), - sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), - groupFetchAllParticipating: vi.fn().mockResolvedValue({}), - end: vi.fn(), - // Expose the event emitter for triggering events in tests - _ev: ev, - }; - return sock; -} - -let fakeSocket: ReturnType; - -// Mock Baileys -vi.mock('@whiskeysockets/baileys', () => { - return { - default: vi.fn(() => fakeSocket), - Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) }, - DisconnectReason: { - loggedOut: 401, - badSession: 500, - connectionClosed: 428, - connectionLost: 408, - connectionReplaced: 440, - timedOut: 408, - restartRequired: 515, - }, - fetchLatestWaWebVersion: vi - .fn() - .mockResolvedValue({ version: [2, 3000, 0] }), - normalizeMessageContent: vi.fn((content: unknown) => content), - makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), - useMultiFileAuthState: vi.fn().mockResolvedValue({ - state: { - creds: {}, - keys: {}, - }, - saveCreds: vi.fn(), - }), - }; -}); - -import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js'; -import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js'; - -// --- Test helpers --- - -function createTestOpts( - overrides?: Partial, -): WhatsAppChannelOpts { - return { - onMessage: vi.fn(), - onChatMetadata: vi.fn(), - registeredGroups: vi.fn(() => ({ - 'registered@g.us': { - name: 'Test Group', - folder: 'test-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - ...overrides, - }; -} - -function triggerConnection(state: string, extra?: Record) { - fakeSocket._ev.emit('connection.update', { connection: state, ...extra }); -} - -function triggerDisconnect(statusCode: number) { - fakeSocket._ev.emit('connection.update', { - connection: 'close', - lastDisconnect: { - error: { output: { statusCode } }, - }, - }); -} - -async function triggerMessages(messages: unknown[]) { - fakeSocket._ev.emit('messages.upsert', { messages }); - // Flush microtasks so the async messages.upsert handler completes - await new Promise((r) => setTimeout(r, 0)); -} - -// --- Tests --- - -describe('WhatsAppChannel', () => { - beforeEach(() => { - fakeSocket = createFakeSocket(); - vi.mocked(getLastGroupSync).mockReturnValue(null); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - /** - * Helper: start connect, flush microtasks so event handlers are registered, - * then trigger the connection open event. Returns the resolved promise. - */ - async function connectChannel(channel: WhatsAppChannel): Promise { - const p = channel.connect(); - // Flush microtasks so connectInternal completes its await and registers handlers - await new Promise((r) => setTimeout(r, 0)); - triggerConnection('open'); - return p; - } - - // --- Version fetch --- - - describe('version fetch', () => { - it('connects with fetched version', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - await connectChannel(channel); - - const { fetchLatestWaWebVersion } = - await import('@whiskeysockets/baileys'); - expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({}); - }); - - it('falls back gracefully when version fetch fails', async () => { - const { fetchLatestWaWebVersion } = - await import('@whiskeysockets/baileys'); - vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce( - new Error('network error'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - await connectChannel(channel); - - // Should still connect successfully despite fetch failure - expect(channel.isConnected()).toBe(true); - }); - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when connection opens', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - }); - - it('sets up LID to phone mapping on open', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The channel should have mapped the LID from sock.user - // We can verify by sending a message from a LID JID - // and checking the translated JID in the callback - }); - - it('flushes outgoing queue on reconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect - (channel as any).connected = false; - - // Queue a message while disconnected - await channel.sendMessage('test@g.us', 'Queued message'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - - // Reconnect - (channel as any).connected = true; - await (channel as any).flushOutgoingQueue(); - - // Group messages get prefixed when flushed - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { - text: 'Andy: Queued message', - }); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - expect(fakeSocket.end).toHaveBeenCalled(); - }); - }); - - // --- QR code and auth --- - - describe('authentication', () => { - it('exits process when QR code is emitted (no auth state)', async () => { - vi.useFakeTimers(); - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Start connect but don't await (it won't resolve - process exits) - channel.connect().catch(() => {}); - - // Flush microtasks so connectInternal registers handlers - await vi.advanceTimersByTimeAsync(0); - - // Emit QR code event - fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' }); - - // Advance timer past the 1000ms setTimeout before exit - await vi.advanceTimersByTimeAsync(1500); - - expect(mockExit).toHaveBeenCalledWith(1); - mockExit.mockRestore(); - vi.useRealTimers(); - }); - }); - - // --- Reconnection behavior --- - - describe('reconnection', () => { - it('reconnects on non-loggedOut disconnect', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - expect(channel.isConnected()).toBe(true); - - // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428) - triggerDisconnect(428); - - expect(channel.isConnected()).toBe(false); - // The channel should attempt to reconnect (calls connectInternal again) - }); - - it('exits on loggedOut disconnect', async () => { - const mockExit = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with loggedOut reason (401) - triggerDisconnect(401); - - expect(channel.isConnected()).toBe(false); - expect(mockExit).toHaveBeenCalledWith(0); - mockExit.mockRestore(); - }); - - it('retries reconnection after 5s on failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Disconnect with stream error 515 - triggerDisconnect(515); - - // The channel sets a 5s retry — just verify it doesn't crash - await new Promise((r) => setTimeout(r, 100)); - }); - }); - - // --- Message handling --- - - describe('message handling', () => { - it('delivers message for registered group', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-1', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello Andy' }, - pushName: 'Alice', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ - id: 'msg-1', - content: 'Hello Andy', - sender_name: 'Alice', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered groups', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-2', - remoteJid: 'unregistered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Hello' }, - pushName: 'Bob', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'unregistered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores status@broadcast messages', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-3', - remoteJid: 'status@broadcast', - fromMe: false, - }, - message: { conversation: 'Status update' }, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).not.toHaveBeenCalled(); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores messages with no content', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-4', - remoteJid: 'registered@g.us', - fromMe: false, - }, - message: null, - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('extracts text from extendedTextMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-5', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - extendedTextMessage: { text: 'A reply message' }, - }, - pushName: 'Charlie', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'A reply message' }), - ); - }); - - it('extracts caption from imageMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-6', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - imageMessage: { - caption: 'Check this photo', - mimetype: 'image/jpeg', - }, - }, - pushName: 'Diana', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Check this photo' }), - ); - }); - - it('extracts caption from videoMessage', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-7', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' }, - }, - pushName: 'Eve', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ content: 'Watch this' }), - ); - }); - - it('handles message with no extractable text (e.g. voice note without caption)', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-8', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { - audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true }, - }, - pushName: 'Frank', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Skipped — no text content to process - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('uses sender JID when pushName is absent', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-9', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'No push name' }, - // pushName is undefined - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'registered@g.us', - expect.objectContaining({ sender_name: '5551234' }), - ); - }); - }); - - // --- LID ↔ JID translation --- - - describe('LID to JID translation', () => { - it('translates known LID to phone JID', async () => { - const opts = createTestOpts({ - registeredGroups: vi.fn(() => ({ - '1234567890@s.whatsapp.net': { - name: 'Self Chat', - folder: 'self-chat', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net' - // Send a message from the LID - await triggerMessages([ - { - key: { - id: 'msg-lid', - remoteJid: '9876543210@lid', - fromMe: false, - }, - message: { conversation: 'From LID' }, - pushName: 'Self', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Should be translated to phone JID - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '1234567890@s.whatsapp.net', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - - it('passes through non-LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-normal', - remoteJid: 'registered@g.us', - participant: '5551234@s.whatsapp.net', - fromMe: false, - }, - message: { conversation: 'Normal JID' }, - pushName: 'Grace', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'registered@g.us', - expect.any(String), - undefined, - 'whatsapp', - true, - ); - }); - - it('passes through unknown LID JIDs unchanged', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await triggerMessages([ - { - key: { - id: 'msg-unknown-lid', - remoteJid: '0000000000@lid', - fromMe: false, - }, - message: { conversation: 'Unknown LID' }, - pushName: 'Unknown', - messageTimestamp: Math.floor(Date.now() / 1000), - }, - ]); - - // Unknown LID passes through unchanged - expect(opts.onChatMetadata).toHaveBeenCalledWith( - '0000000000@lid', - expect.any(String), - undefined, - 'whatsapp', - false, - ); - }); - }); - - // --- Outgoing message queue --- - - describe('outgoing message queue', () => { - it('sends message directly when connected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('test@g.us', 'Hello'); - // Group messages get prefixed with assistant name - expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { - text: 'Andy: Hello', - }); - }); - - it('prefixes direct chat messages on shared number', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.sendMessage('123@s.whatsapp.net', 'Hello'); - // Shared number: DMs also get prefixed (needed for self-chat distinction) - expect(fakeSocket.sendMessage).toHaveBeenCalledWith( - '123@s.whatsapp.net', - { text: 'Andy: Hello' }, - ); - }); - - it('queues message when disconnected', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Don't connect — channel starts disconnected - await channel.sendMessage('test@g.us', 'Queued'); - expect(fakeSocket.sendMessage).not.toHaveBeenCalled(); - }); - - it('queues message on send failure', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Make sendMessage fail - fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error')); - - await channel.sendMessage('test@g.us', 'Will fail'); - - // Should not throw, message queued for retry - // The queue should have the message - }); - - it('flushes multiple queued messages in order', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - // Queue messages while disconnected - await channel.sendMessage('test@g.us', 'First'); - await channel.sendMessage('test@g.us', 'Second'); - await channel.sendMessage('test@g.us', 'Third'); - - // Connect — flush happens automatically on open - await connectChannel(channel); - - // Give the async flush time to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3); - // Group messages get prefixed - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { - text: 'Andy: First', - }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { - text: 'Andy: Second', - }); - expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { - text: 'Andy: Third', - }); - }); - }); - - // --- Group metadata sync --- - - describe('group metadata sync', () => { - it('syncs group metadata on first connection', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Group One' }, - 'group2@g.us': { subject: 'Group Two' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Wait for async sync to complete - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One'); - expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two'); - expect(setLastGroupSync).toHaveBeenCalled(); - }); - - it('skips sync when synced recently', async () => { - // Last sync was 1 hour ago (within 24h threshold) - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await new Promise((r) => setTimeout(r, 50)); - - expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled(); - }); - - it('forces sync regardless of cache', async () => { - vi.mocked(getLastGroupSync).mockReturnValue( - new Date(Date.now() - 60 * 60 * 1000).toISOString(), - ); - - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group@g.us': { subject: 'Forced Group' }, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.syncGroupMetadata(true); - - expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled(); - expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group'); - }); - - it('handles group sync failure gracefully', async () => { - fakeSocket.groupFetchAllParticipating.mockRejectedValue( - new Error('Network timeout'), - ); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Should not throw - await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined(); - }); - - it('skips groups with no subject', async () => { - fakeSocket.groupFetchAllParticipating.mockResolvedValue({ - 'group1@g.us': { subject: 'Has Subject' }, - 'group2@g.us': { subject: '' }, - 'group3@g.us': {}, - }); - - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - // Clear any calls from the automatic sync on connect - vi.mocked(updateChatName).mockClear(); - - await channel.syncGroupMetadata(true); - - expect(updateChatName).toHaveBeenCalledTimes(1); - expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject'); - }); - }); - - // --- JID ownership --- - - describe('ownsJid', () => { - it('owns @g.us JIDs (WhatsApp groups)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(true); - }); - - it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true); - }); - - it('does not own Telegram JIDs', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('tg:12345')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); - }); - - // --- Typing indicator --- - - describe('setTyping', () => { - it('sends composing presence when typing', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', true); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( - 'composing', - 'test@g.us', - ); - }); - - it('sends paused presence when stopping', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - await channel.setTyping('test@g.us', false); - expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith( - 'paused', - 'test@g.us', - ); - }); - - it('handles typing indicator failure gracefully', async () => { - const opts = createTestOpts(); - const channel = new WhatsAppChannel(opts); - - await connectChannel(channel); - - fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed')); - - // Should not throw - await expect( - channel.setTyping('test@g.us', true), - ).resolves.toBeUndefined(); - }); - }); - - // --- Channel properties --- - - describe('channel properties', () => { - it('has name "whatsapp"', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect(channel.name).toBe('whatsapp'); - }); - - it('does not expose prefixAssistantName (prefix handled internally)', () => { - const channel = new WhatsAppChannel(createTestOpts()); - expect('prefixAssistantName' in channel).toBe(false); - }); - }); -}); diff --git a/.claude/skills/add-whatsapp/add/src/channels/whatsapp.ts b/.claude/skills/add-whatsapp/add/src/channels/whatsapp.ts deleted file mode 100644 index f7f27cb..0000000 --- a/.claude/skills/add-whatsapp/add/src/channels/whatsapp.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { exec } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import makeWASocket, { - Browsers, - DisconnectReason, - WASocket, - fetchLatestWaWebVersion, - makeCacheableSignalKeyStore, - normalizeMessageContent, - useMultiFileAuthState, -} from '@whiskeysockets/baileys'; - -import { - ASSISTANT_HAS_OWN_NUMBER, - ASSISTANT_NAME, - STORE_DIR, -} from '../config.js'; -import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js'; -import { logger } from '../logger.js'; -import { - Channel, - OnInboundMessage, - OnChatMetadata, - RegisteredGroup, -} from '../types.js'; -import { registerChannel, ChannelOpts } from './registry.js'; - -const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours - -export interface WhatsAppChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -export class WhatsAppChannel implements Channel { - name = 'whatsapp'; - - private sock!: WASocket; - private connected = false; - private lidToPhoneMap: Record = {}; - private outgoingQueue: Array<{ jid: string; text: string }> = []; - private flushing = false; - private groupSyncTimerStarted = false; - - private opts: WhatsAppChannelOpts; - - constructor(opts: WhatsAppChannelOpts) { - this.opts = opts; - } - - async connect(): Promise { - return new Promise((resolve, reject) => { - this.connectInternal(resolve).catch(reject); - }); - } - - private async connectInternal(onFirstOpen?: () => void): Promise { - const authDir = path.join(STORE_DIR, 'auth'); - fs.mkdirSync(authDir, { recursive: true }); - - const { state, saveCreds } = await useMultiFileAuthState(authDir); - - const { version } = await fetchLatestWaWebVersion({}).catch((err) => { - logger.warn( - { err }, - 'Failed to fetch latest WA Web version, using default', - ); - return { version: undefined }; - }); - this.sock = makeWASocket({ - version, - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, logger), - }, - printQRInTerminal: false, - logger, - browser: Browsers.macOS('Chrome'), - }); - - this.sock.ev.on('connection.update', (update) => { - const { connection, lastDisconnect, qr } = update; - - if (qr) { - const msg = - 'WhatsApp authentication required. Run /setup in Claude Code.'; - logger.error(msg); - exec( - `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`, - ); - setTimeout(() => process.exit(1), 1000); - } - - if (connection === 'close') { - this.connected = false; - const reason = ( - lastDisconnect?.error as { output?: { statusCode?: number } } - )?.output?.statusCode; - const shouldReconnect = reason !== DisconnectReason.loggedOut; - logger.info( - { - reason, - shouldReconnect, - queuedMessages: this.outgoingQueue.length, - }, - 'Connection closed', - ); - - if (shouldReconnect) { - logger.info('Reconnecting...'); - this.connectInternal().catch((err) => { - logger.error({ err }, 'Failed to reconnect, retrying in 5s'); - setTimeout(() => { - this.connectInternal().catch((err2) => { - logger.error({ err: err2 }, 'Reconnection retry failed'); - }); - }, 5000); - }); - } else { - logger.info('Logged out. Run /setup to re-authenticate.'); - process.exit(0); - } - } else if (connection === 'open') { - this.connected = true; - logger.info('Connected to WhatsApp'); - - // Announce availability so WhatsApp relays subsequent presence updates (typing indicators) - this.sock.sendPresenceUpdate('available').catch((err) => { - logger.warn({ err }, 'Failed to send presence update'); - }); - - // Build LID to phone mapping from auth state for self-chat translation - if (this.sock.user) { - const phoneUser = this.sock.user.id.split(':')[0]; - const lidUser = this.sock.user.lid?.split(':')[0]; - if (lidUser && phoneUser) { - this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`; - logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set'); - } - } - - // Flush any messages queued while disconnected - this.flushOutgoingQueue().catch((err) => - logger.error({ err }, 'Failed to flush outgoing queue'), - ); - - // Sync group metadata on startup (respects 24h cache) - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Initial group sync failed'), - ); - // Set up daily sync timer (only once) - if (!this.groupSyncTimerStarted) { - this.groupSyncTimerStarted = true; - setInterval(() => { - this.syncGroupMetadata().catch((err) => - logger.error({ err }, 'Periodic group sync failed'), - ); - }, GROUP_SYNC_INTERVAL_MS); - } - - // Signal first connection to caller - if (onFirstOpen) { - onFirstOpen(); - onFirstOpen = undefined; - } - } - }); - - this.sock.ev.on('creds.update', saveCreds); - - this.sock.ev.on('messages.upsert', async ({ messages }) => { - for (const msg of messages) { - try { - if (!msg.message) continue; - // Unwrap container types (viewOnceMessageV2, ephemeralMessage, - // editedMessage, etc.) so that conversation, extendedTextMessage, - // imageMessage, etc. are accessible at the top level. - const normalized = normalizeMessageContent(msg.message); - if (!normalized) continue; - const rawJid = msg.key.remoteJid; - if (!rawJid || rawJid === 'status@broadcast') continue; - - // Translate LID JID to phone JID if applicable - const chatJid = await this.translateJid(rawJid); - - const timestamp = new Date( - Number(msg.messageTimestamp) * 1000, - ).toISOString(); - - // Always notify about chat metadata for group discovery - const isGroup = chatJid.endsWith('@g.us'); - this.opts.onChatMetadata( - chatJid, - timestamp, - undefined, - 'whatsapp', - isGroup, - ); - - // Only deliver full message for registered groups - const groups = this.opts.registeredGroups(); - if (groups[chatJid]) { - const content = - normalized.conversation || - normalized.extendedTextMessage?.text || - normalized.imageMessage?.caption || - normalized.videoMessage?.caption || - ''; - - // Skip protocol messages with no text content (encryption keys, read receipts, etc.) - if (!content) continue; - - const sender = msg.key.participant || msg.key.remoteJid || ''; - const senderName = msg.pushName || sender.split('@')[0]; - - const fromMe = msg.key.fromMe || false; - // Detect bot messages: with own number, fromMe is reliable - // since only the bot sends from that number. - // With shared number, bot messages carry the assistant name prefix - // (even in DMs/self-chat) so we check for that. - const isBotMessage = ASSISTANT_HAS_OWN_NUMBER - ? fromMe - : content.startsWith(`${ASSISTANT_NAME}:`); - - this.opts.onMessage(chatJid, { - id: msg.key.id || '', - chat_jid: chatJid, - sender, - sender_name: senderName, - content, - timestamp, - is_from_me: fromMe, - is_bot_message: isBotMessage, - }); - } - } catch (err) { - logger.error( - { err, remoteJid: msg.key?.remoteJid }, - 'Error processing incoming message', - ); - } - } - }); - } - - async sendMessage(jid: string, text: string): Promise { - // Prefix bot messages with assistant name so users know who's speaking. - // On a shared number, prefix is also needed in DMs (including self-chat) - // to distinguish bot output from user messages. - // Skip only when the assistant has its own dedicated phone number. - const prefixed = ASSISTANT_HAS_OWN_NUMBER - ? text - : `${ASSISTANT_NAME}: ${text}`; - - if (!this.connected) { - this.outgoingQueue.push({ jid, text: prefixed }); - logger.info( - { jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, - 'WA disconnected, message queued', - ); - return; - } - try { - await this.sock.sendMessage(jid, { text: prefixed }); - logger.info({ jid, length: prefixed.length }, 'Message sent'); - } catch (err) { - // If send fails, queue it for retry on reconnect - this.outgoingQueue.push({ jid, text: prefixed }); - logger.warn( - { jid, err, queueSize: this.outgoingQueue.length }, - 'Failed to send, message queued', - ); - } - } - - isConnected(): boolean { - return this.connected; - } - - ownsJid(jid: string): boolean { - return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net'); - } - - async disconnect(): Promise { - this.connected = false; - this.sock?.end(undefined); - } - - async setTyping(jid: string, isTyping: boolean): Promise { - try { - const status = isTyping ? 'composing' : 'paused'; - logger.debug({ jid, status }, 'Sending presence update'); - await this.sock.sendPresenceUpdate(status, jid); - } catch (err) { - logger.debug({ jid, err }, 'Failed to update typing status'); - } - } - - async syncGroups(force: boolean): Promise { - return this.syncGroupMetadata(force); - } - - /** - * Sync group metadata from WhatsApp. - * Fetches all participating groups and stores their names in the database. - * Called on startup, daily, and on-demand via IPC. - */ - async syncGroupMetadata(force = false): Promise { - if (!force) { - const lastSync = getLastGroupSync(); - if (lastSync) { - const lastSyncTime = new Date(lastSync).getTime(); - if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) { - logger.debug({ lastSync }, 'Skipping group sync - synced recently'); - return; - } - } - } - - try { - logger.info('Syncing group metadata from WhatsApp...'); - const groups = await this.sock.groupFetchAllParticipating(); - - let count = 0; - for (const [jid, metadata] of Object.entries(groups)) { - if (metadata.subject) { - updateChatName(jid, metadata.subject); - count++; - } - } - - setLastGroupSync(); - logger.info({ count }, 'Group metadata synced'); - } catch (err) { - logger.error({ err }, 'Failed to sync group metadata'); - } - } - - private async translateJid(jid: string): Promise { - if (!jid.endsWith('@lid')) return jid; - const lidUser = jid.split('@')[0].split(':')[0]; - - // Check local cache first - const cached = this.lidToPhoneMap[lidUser]; - if (cached) { - logger.debug( - { lidJid: jid, phoneJid: cached }, - 'Translated LID to phone JID (cached)', - ); - return cached; - } - - // Query Baileys' signal repository for the mapping - try { - const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid); - if (pn) { - const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`; - this.lidToPhoneMap[lidUser] = phoneJid; - logger.info( - { lidJid: jid, phoneJid }, - 'Translated LID to phone JID (signalRepository)', - ); - return phoneJid; - } - } catch (err) { - logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository'); - } - - return jid; - } - - private async flushOutgoingQueue(): Promise { - if (this.flushing || this.outgoingQueue.length === 0) return; - this.flushing = true; - try { - logger.info( - { count: this.outgoingQueue.length }, - 'Flushing outgoing message queue', - ); - while (this.outgoingQueue.length > 0) { - const item = this.outgoingQueue.shift()!; - // Send directly — queued items are already prefixed by sendMessage - await this.sock.sendMessage(item.jid, { text: item.text }); - logger.info( - { jid: item.jid, length: item.text.length }, - 'Queued message sent', - ); - } - } finally { - this.flushing = false; - } - } -} - -registerChannel('whatsapp', (opts: ChannelOpts) => new WhatsAppChannel(opts)); diff --git a/.claude/skills/add-whatsapp/add/src/whatsapp-auth.ts b/.claude/skills/add-whatsapp/add/src/whatsapp-auth.ts deleted file mode 100644 index 48545d1..0000000 --- a/.claude/skills/add-whatsapp/add/src/whatsapp-auth.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * WhatsApp Authentication Script - * - * Run this during setup to authenticate with WhatsApp. - * Displays QR code, waits for scan, saves credentials, then exits. - * - * Usage: npx tsx src/whatsapp-auth.ts - */ -import fs from 'fs'; -import path from 'path'; -import pino from 'pino'; -import qrcode from 'qrcode-terminal'; -import readline from 'readline'; - -import makeWASocket, { - Browsers, - DisconnectReason, - fetchLatestWaWebVersion, - makeCacheableSignalKeyStore, - useMultiFileAuthState, -} from '@whiskeysockets/baileys'; - -const AUTH_DIR = './store/auth'; -const QR_FILE = './store/qr-data.txt'; -const STATUS_FILE = './store/auth-status.txt'; - -const logger = pino({ - level: 'warn', // Quiet logging - only show errors -}); - -// Check for --pairing-code flag and phone number -const usePairingCode = process.argv.includes('--pairing-code'); -const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone'); - -function askQuestion(prompt: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(prompt, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -async function connectSocket( - phoneNumber?: string, - isReconnect = false, -): Promise { - const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); - - if (state.creds.registered && !isReconnect) { - fs.writeFileSync(STATUS_FILE, 'already_authenticated'); - console.log('✓ Already authenticated with WhatsApp'); - console.log( - ' To re-authenticate, delete the store/auth folder and run again.', - ); - process.exit(0); - } - - const { version } = await fetchLatestWaWebVersion({}).catch((err) => { - logger.warn( - { err }, - 'Failed to fetch latest WA Web version, using default', - ); - return { version: undefined }; - }); - const sock = makeWASocket({ - version, - auth: { - creds: state.creds, - keys: makeCacheableSignalKeyStore(state.keys, logger), - }, - printQRInTerminal: false, - logger, - browser: Browsers.macOS('Chrome'), - }); - - if (usePairingCode && phoneNumber && !state.creds.me) { - // Request pairing code after a short delay for connection to initialize - // Only on first connect (not reconnect after 515) - setTimeout(async () => { - try { - const code = await sock.requestPairingCode(phoneNumber!); - console.log(`\n🔗 Your pairing code: ${code}\n`); - console.log(' 1. Open WhatsApp on your phone'); - console.log(' 2. Tap Settings → Linked Devices → Link a Device'); - console.log(' 3. Tap "Link with phone number instead"'); - console.log(` 4. Enter this code: ${code}\n`); - fs.writeFileSync(STATUS_FILE, `pairing_code:${code}`); - } catch (err: any) { - console.error('Failed to request pairing code:', err.message); - process.exit(1); - } - }, 3000); - } - - sock.ev.on('connection.update', (update) => { - const { connection, lastDisconnect, qr } = update; - - if (qr) { - // Write raw QR data to file so the setup skill can render it - fs.writeFileSync(QR_FILE, qr); - console.log('Scan this QR code with WhatsApp:\n'); - console.log(' 1. Open WhatsApp on your phone'); - console.log(' 2. Tap Settings → Linked Devices → Link a Device'); - console.log(' 3. Point your camera at the QR code below\n'); - qrcode.generate(qr, { small: true }); - } - - if (connection === 'close') { - const reason = (lastDisconnect?.error as any)?.output?.statusCode; - - if (reason === DisconnectReason.loggedOut) { - fs.writeFileSync(STATUS_FILE, 'failed:logged_out'); - console.log('\n✗ Logged out. Delete store/auth and try again.'); - process.exit(1); - } else if (reason === DisconnectReason.timedOut) { - fs.writeFileSync(STATUS_FILE, 'failed:qr_timeout'); - console.log('\n✗ QR code timed out. Please try again.'); - process.exit(1); - } else if (reason === 515) { - // 515 = stream error, often happens after pairing succeeds but before - // registration completes. Reconnect to finish the handshake. - console.log('\n⟳ Stream error (515) after pairing — reconnecting...'); - connectSocket(phoneNumber, true); - } else { - fs.writeFileSync(STATUS_FILE, `failed:${reason || 'unknown'}`); - console.log('\n✗ Connection failed. Please try again.'); - process.exit(1); - } - } - - if (connection === 'open') { - fs.writeFileSync(STATUS_FILE, 'authenticated'); - // Clean up QR file now that we're connected - try { - fs.unlinkSync(QR_FILE); - } catch {} - console.log('\n✓ Successfully authenticated with WhatsApp!'); - console.log(' Credentials saved to store/auth/'); - console.log(' You can now start the NanoClaw service.\n'); - - // Give it a moment to save credentials, then exit - setTimeout(() => process.exit(0), 1000); - } - }); - - sock.ev.on('creds.update', saveCreds); -} - -async function authenticate(): Promise { - fs.mkdirSync(AUTH_DIR, { recursive: true }); - - // Clean up any stale QR/status files from previous runs - try { - fs.unlinkSync(QR_FILE); - } catch {} - try { - fs.unlinkSync(STATUS_FILE); - } catch {} - - let phoneNumber = phoneArg; - if (usePairingCode && !phoneNumber) { - phoneNumber = await askQuestion( - 'Enter your phone number (with country code, no + or spaces, e.g. 14155551234): ', - ); - } - - console.log('Starting WhatsApp authentication...\n'); - - await connectSocket(phoneNumber); -} - -authenticate().catch((err) => { - console.error('Authentication failed:', err.message); - process.exit(1); -}); diff --git a/.claude/skills/add-whatsapp/manifest.yaml b/.claude/skills/add-whatsapp/manifest.yaml deleted file mode 100644 index de1a4cc..0000000 --- a/.claude/skills/add-whatsapp/manifest.yaml +++ /dev/null @@ -1,23 +0,0 @@ -skill: whatsapp -version: 1.0.0 -description: "WhatsApp channel via Baileys (Multi-Device Web API)" -core_version: 0.1.0 -adds: - - src/channels/whatsapp.ts - - src/channels/whatsapp.test.ts - - src/whatsapp-auth.ts - - setup/whatsapp-auth.ts -modifies: - - src/channels/index.ts - - setup/index.ts -structured: - npm_dependencies: - "@whiskeysockets/baileys": "^7.0.0-rc.9" - "qrcode": "^1.5.4" - "qrcode-terminal": "^0.12.0" - "@types/qrcode-terminal": "^0.12.0" - env_additions: - - ASSISTANT_HAS_OWN_NUMBER -conflicts: [] -depends: [] -test: "npx vitest run src/channels/whatsapp.test.ts" diff --git a/.claude/skills/add-whatsapp/modify/setup/index.ts b/.claude/skills/add-whatsapp/modify/setup/index.ts deleted file mode 100644 index d962923..0000000 --- a/.claude/skills/add-whatsapp/modify/setup/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Setup CLI entry point. - * Usage: npx tsx setup/index.ts --step [args...] - */ -import { logger } from '../src/logger.js'; -import { emitStatus } from './status.js'; - -const STEPS: Record< - string, - () => Promise<{ run: (args: string[]) => Promise }> -> = { - environment: () => import('./environment.js'), - channels: () => import('./channels.js'), - container: () => import('./container.js'), - 'whatsapp-auth': () => import('./whatsapp-auth.js'), - groups: () => import('./groups.js'), - register: () => import('./register.js'), - mounts: () => import('./mounts.js'), - service: () => import('./service.js'), - verify: () => import('./verify.js'), -}; - -async function main(): Promise { - const args = process.argv.slice(2); - const stepIdx = args.indexOf('--step'); - - if (stepIdx === -1 || !args[stepIdx + 1]) { - console.error( - `Usage: npx tsx setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`, - ); - process.exit(1); - } - - const stepName = args[stepIdx + 1]; - const stepArgs = args.filter( - (a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--', - ); - - const loader = STEPS[stepName]; - if (!loader) { - console.error(`Unknown step: ${stepName}`); - console.error(`Available steps: ${Object.keys(STEPS).join(', ')}`); - process.exit(1); - } - - try { - const mod = await loader(); - await mod.run(stepArgs); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error({ err, step: stepName }, 'Setup step failed'); - emitStatus(stepName.toUpperCase(), { - STATUS: 'failed', - ERROR: message, - }); - process.exit(1); - } -} - -main(); diff --git a/.claude/skills/add-whatsapp/modify/setup/index.ts.intent.md b/.claude/skills/add-whatsapp/modify/setup/index.ts.intent.md deleted file mode 100644 index 0a5feef..0000000 --- a/.claude/skills/add-whatsapp/modify/setup/index.ts.intent.md +++ /dev/null @@ -1 +0,0 @@ -Add `'whatsapp-auth': () => import('./whatsapp-auth.js'),` to the setup STEPS map so the WhatsApp authentication step is available during setup. diff --git a/.claude/skills/add-whatsapp/modify/src/channels/index.ts b/.claude/skills/add-whatsapp/modify/src/channels/index.ts deleted file mode 100644 index 0d15ba3..0000000 --- a/.claude/skills/add-whatsapp/modify/src/channels/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Channel self-registration barrel file. -// Each import triggers the channel module's registerChannel() call. - -// discord - -// gmail - -// slack - -// telegram - -// whatsapp -import './whatsapp.js'; diff --git a/.claude/skills/add-whatsapp/modify/src/channels/index.ts.intent.md b/.claude/skills/add-whatsapp/modify/src/channels/index.ts.intent.md deleted file mode 100644 index d4eea71..0000000 --- a/.claude/skills/add-whatsapp/modify/src/channels/index.ts.intent.md +++ /dev/null @@ -1,7 +0,0 @@ -# Intent: Add WhatsApp channel import - -Add `import './whatsapp.js';` to the channel barrel file so the WhatsApp -module self-registers with the channel registry on startup. - -This is an append-only change — existing import lines for other channels -must be preserved. diff --git a/.claude/skills/add-whatsapp/tests/whatsapp.test.ts b/.claude/skills/add-whatsapp/tests/whatsapp.test.ts deleted file mode 100644 index 619c91f..0000000 --- a/.claude/skills/add-whatsapp/tests/whatsapp.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('whatsapp skill package', () => { - const skillDir = path.resolve(__dirname, '..'); - - it('has a valid manifest', () => { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - expect(fs.existsSync(manifestPath)).toBe(true); - - const content = fs.readFileSync(manifestPath, 'utf-8'); - expect(content).toContain('skill: whatsapp'); - expect(content).toContain('version: 1.0.0'); - expect(content).toContain('@whiskeysockets/baileys'); - }); - - it('has all files declared in adds', () => { - const channelFile = path.join(skillDir, 'add', 'src', 'channels', 'whatsapp.ts'); - expect(fs.existsSync(channelFile)).toBe(true); - - const content = fs.readFileSync(channelFile, 'utf-8'); - expect(content).toContain('class WhatsAppChannel'); - expect(content).toContain('implements Channel'); - expect(content).toContain("registerChannel('whatsapp'"); - - // Test file for the channel - const testFile = path.join(skillDir, 'add', 'src', 'channels', 'whatsapp.test.ts'); - expect(fs.existsSync(testFile)).toBe(true); - - const testContent = fs.readFileSync(testFile, 'utf-8'); - expect(testContent).toContain("describe('WhatsAppChannel'"); - - // Auth script (runtime) - const authFile = path.join(skillDir, 'add', 'src', 'whatsapp-auth.ts'); - expect(fs.existsSync(authFile)).toBe(true); - - // Auth setup step - const setupAuthFile = path.join(skillDir, 'add', 'setup', 'whatsapp-auth.ts'); - expect(fs.existsSync(setupAuthFile)).toBe(true); - - const setupAuthContent = fs.readFileSync(setupAuthFile, 'utf-8'); - expect(setupAuthContent).toContain('WhatsApp interactive auth'); - }); - - it('has all files declared in modifies', () => { - // Channel barrel file - const indexFile = path.join(skillDir, 'modify', 'src', 'channels', 'index.ts'); - expect(fs.existsSync(indexFile)).toBe(true); - - const indexContent = fs.readFileSync(indexFile, 'utf-8'); - expect(indexContent).toContain("import './whatsapp.js'"); - - // Setup index (adds whatsapp-auth step) - const setupIndexFile = path.join(skillDir, 'modify', 'setup', 'index.ts'); - expect(fs.existsSync(setupIndexFile)).toBe(true); - - const setupIndexContent = fs.readFileSync(setupIndexFile, 'utf-8'); - expect(setupIndexContent).toContain("'whatsapp-auth'"); - }); - - it('has intent files for modified files', () => { - expect( - fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'index.ts.intent.md')), - ).toBe(true); - expect( - fs.existsSync(path.join(skillDir, 'modify', 'setup', 'index.ts.intent.md')), - ).toBe(true); - }); -}); 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 802ffd6..0000000 --- a/.claude/skills/convert-to-apple-container/SKILL.md +++ /dev/null @@ -1,183 +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 - -Read `.nanoclaw/state.yaml`. If `convert-to-apple-container` is in `applied_skills`, skip to Phase 3 (Verify). The code changes are already in place. - -### Check current runtime - -```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 - -Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md. - -### Initialize skills system (if needed) - -If `.nanoclaw/` directory doesn't exist yet: - -```bash -npx tsx scripts/apply-skill.ts --init -``` - -Or call `initSkillsSystem()` from `skills-engine/migrate.ts`. - -### Apply the skill - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/convert-to-apple-container -``` - -This deterministically: -- Replaces `src/container-runtime.ts` with the Apple Container implementation -- Replaces `src/container-runtime.test.ts` with Apple Container-specific tests -- Updates `src/container-runner.ts` with .env shadow mount fix and privilege dropping -- Updates `container/Dockerfile` with entrypoint that shadows .env via `mount --bind` -- Updates `container/build.sh` to default to `container` runtime -- Records the application in `.nanoclaw/state.yaml` - -If the apply reports merge conflicts, read the intent files: -- `modify/src/container-runtime.ts.intent.md` — what changed and invariants -- `modify/src/container-runner.ts.intent.md` — .env shadow and privilege drop changes -- `modify/container/Dockerfile.intent.md` — entrypoint changes for .env shadowing -- `modify/container/build.sh.intent.md` — what changed for build script - -### 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/convert-to-apple-container/manifest.yaml b/.claude/skills/convert-to-apple-container/manifest.yaml deleted file mode 100644 index 90b0156..0000000 --- a/.claude/skills/convert-to-apple-container/manifest.yaml +++ /dev/null @@ -1,15 +0,0 @@ -skill: convert-to-apple-container -version: 1.1.0 -description: "Switch container runtime from Docker to Apple Container (macOS)" -core_version: 0.1.0 -adds: [] -modifies: - - src/container-runtime.ts - - src/container-runtime.test.ts - - src/container-runner.ts - - container/build.sh - - container/Dockerfile -structured: {} -conflicts: [] -depends: [] -test: "npx vitest run src/container-runtime.test.ts" diff --git a/.claude/skills/convert-to-apple-container/modify/container/Dockerfile b/.claude/skills/convert-to-apple-container/modify/container/Dockerfile deleted file mode 100644 index 65763df..0000000 --- a/.claude/skills/convert-to-apple-container/modify/container/Dockerfile +++ /dev/null @@ -1,68 +0,0 @@ -# NanoClaw Agent Container -# Runs Claude Agent SDK in isolated Linux VM with browser automation - -FROM node:22-slim - -# Install system dependencies for Chromium -RUN apt-get update && apt-get install -y \ - chromium \ - fonts-liberation \ - fonts-noto-color-emoji \ - libgbm1 \ - libnss3 \ - libatk-bridge2.0-0 \ - libgtk-3-0 \ - libx11-xcb1 \ - libxcomposite1 \ - libxdamage1 \ - libxrandr2 \ - libasound2 \ - libpangocairo-1.0-0 \ - libcups2 \ - libdrm2 \ - libxshmfence1 \ - curl \ - git \ - && rm -rf /var/lib/apt/lists/* - -# Set Chromium path for agent-browser -ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium -ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium - -# Install agent-browser and claude-code globally -RUN npm install -g agent-browser @anthropic-ai/claude-code - -# Create app directory -WORKDIR /app - -# Copy package files first for better caching -COPY agent-runner/package*.json ./ - -# Install dependencies -RUN npm install - -# Copy source code -COPY agent-runner/ ./ - -# Build TypeScript -RUN npm run build - -# Create workspace directories -RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input - -# Create entrypoint script -# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it -# Follow-up messages arrive via IPC files in /workspace/ipc/input/ -# Apple Container only supports directory mounts (VirtioFS), so .env cannot be -# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts -# as root, uses mount --bind to shadow .env, then drops to the host user via setpriv. -RUN printf '#!/bin/bash\nset -e\n\n# Shadow .env so the agent cannot read host secrets (requires root)\nif [ "$(id -u)" = "0" ] && [ -f /workspace/project/.env ]; then\n mount --bind /dev/null /workspace/project/.env\nfi\n\n# Compile agent-runner\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\n\n# Capture stdin (secrets JSON) to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root (main-group containers)\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json /tmp/dist\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node /tmp/dist/index.js < /tmp/input.json\nfi\n\nexec node /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh - -# Set ownership to node user (non-root) for writable directories -RUN chown -R node:node /workspace && chmod 777 /home/node - -# Set working directory to group workspace -WORKDIR /workspace/group - -# Entry point reads JSON from stdin, outputs JSON to stdout -ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/.claude/skills/convert-to-apple-container/modify/container/Dockerfile.intent.md b/.claude/skills/convert-to-apple-container/modify/container/Dockerfile.intent.md deleted file mode 100644 index 6fd2e8a..0000000 --- a/.claude/skills/convert-to-apple-container/modify/container/Dockerfile.intent.md +++ /dev/null @@ -1,31 +0,0 @@ -# Intent: container/Dockerfile modifications - -## What changed -Updated the entrypoint script to shadow `.env` inside the container and drop privileges at runtime, replacing the Docker-style host-side file mount approach. - -## Why -Apple Container (VirtioFS) only supports directory mounts, not file mounts. The Docker approach of mounting `/dev/null` over `.env` from the host causes `VZErrorDomain Code=2 "A directory sharing device configuration is invalid"`. The fix moves the shadowing into the entrypoint using `mount --bind` (which works inside the Linux VM). - -## Key sections - -### Entrypoint script -- Added: `mount --bind /dev/null /workspace/project/.env` when running as root and `.env` exists -- Added: Privilege drop via `setpriv --reuid=$RUN_UID --regid=$RUN_GID --clear-groups` for main-group containers -- Added: `chown` of `/tmp/input.json` and `/tmp/dist` to target user before dropping privileges -- Removed: `USER node` directive — main containers start as root to perform the bind mount, then drop privileges in the entrypoint. Non-main containers still get `--user` from the host. - -### Dual-path execution -- Root path (main containers): shadow .env → compile → capture stdin → chown → setpriv drop → exec node -- Non-root path (other containers): compile → capture stdin → exec node - -## Invariants -- The entrypoint still reads JSON from stdin and runs the agent-runner -- The compiled output goes to `/tmp/dist` (read-only after build) -- `node_modules` is symlinked, not copied -- Non-main containers are unaffected (they arrive as non-root via `--user`) - -## Must-keep -- The `set -e` at the top -- The stdin capture to `/tmp/input.json` (required because setpriv can't forward stdin piping) -- The `chmod -R a-w /tmp/dist` (prevents agent from modifying its own runner) -- The `chown -R node:node /workspace` in the build step diff --git a/.claude/skills/convert-to-apple-container/modify/container/build.sh b/.claude/skills/convert-to-apple-container/modify/container/build.sh deleted file mode 100644 index fbdef31..0000000 --- a/.claude/skills/convert-to-apple-container/modify/container/build.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -# Build the NanoClaw agent container image - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" - -IMAGE_NAME="nanoclaw-agent" -TAG="${1:-latest}" -CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}" - -echo "Building NanoClaw agent container image..." -echo "Image: ${IMAGE_NAME}:${TAG}" - -${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" . - -echo "" -echo "Build complete!" -echo "Image: ${IMAGE_NAME}:${TAG}" -echo "" -echo "Test with:" -echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | ${CONTAINER_RUNTIME} run -i ${IMAGE_NAME}:${TAG}" diff --git a/.claude/skills/convert-to-apple-container/modify/container/build.sh.intent.md b/.claude/skills/convert-to-apple-container/modify/container/build.sh.intent.md deleted file mode 100644 index e7b2b97..0000000 --- a/.claude/skills/convert-to-apple-container/modify/container/build.sh.intent.md +++ /dev/null @@ -1,17 +0,0 @@ -# Intent: container/build.sh modifications - -## What changed -Changed the default container runtime from `docker` to `container` (Apple Container CLI). - -## Key sections -- `CONTAINER_RUNTIME` default: `docker` → `container` -- All build/run commands use `${CONTAINER_RUNTIME}` variable (unchanged) - -## Invariants -- The `CONTAINER_RUNTIME` environment variable override still works -- IMAGE_NAME and TAG logic unchanged -- Build and test echo commands unchanged - -## Must-keep -- The `CONTAINER_RUNTIME` env var override pattern -- The test command echo at the end diff --git a/.claude/skills/convert-to-apple-container/modify/src/container-runner.ts b/.claude/skills/convert-to-apple-container/modify/src/container-runner.ts deleted file mode 100644 index 0713db4..0000000 --- a/.claude/skills/convert-to-apple-container/modify/src/container-runner.ts +++ /dev/null @@ -1,701 +0,0 @@ -/** - * Container Runner for NanoClaw - * Spawns agent execution in containers and handles IPC - */ -import { ChildProcess, exec, spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { - CONTAINER_IMAGE, - CONTAINER_MAX_OUTPUT_SIZE, - CONTAINER_TIMEOUT, - CREDENTIAL_PROXY_PORT, - DATA_DIR, - GROUPS_DIR, - IDLE_TIMEOUT, - TIMEZONE, -} from './config.js'; -import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; -import { logger } from './logger.js'; -import { - CONTAINER_HOST_GATEWAY, - CONTAINER_RUNTIME_BIN, - hostGatewayArgs, - readonlyMountArgs, - stopContainer, -} from './container-runtime.js'; -import { detectAuthMode } from './credential-proxy.js'; -import { validateAdditionalMounts } from './mount-security.js'; -import { RegisteredGroup } from './types.js'; - -// Sentinel markers for robust output parsing (must match agent-runner) -const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; - -export interface ContainerInput { - prompt: string; - sessionId?: string; - groupFolder: string; - chatJid: string; - isMain: boolean; - isScheduledTask?: boolean; - assistantName?: string; -} - -export interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface VolumeMount { - hostPath: string; - containerPath: string; - readonly: boolean; -} - -function buildVolumeMounts( - group: RegisteredGroup, - isMain: boolean, -): VolumeMount[] { - const mounts: VolumeMount[] = []; - const projectRoot = process.cwd(); - const groupDir = resolveGroupFolderPath(group.folder); - - if (isMain) { - // Main gets the project root read-only. Writable paths the agent needs - // (group folder, IPC, .claude/) are mounted separately below. - // Read-only prevents the agent from modifying host application code - // (src/, dist/, package.json, etc.) which would bypass the sandbox - // entirely on next restart. - mounts.push({ - hostPath: projectRoot, - containerPath: '/workspace/project', - readonly: true, - }); - - // Main also gets its group folder as the working directory - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - } else { - // Other groups only get their own folder - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - - // Global memory directory (read-only for non-main) - // Only directory mounts are supported, not file mounts - const globalDir = path.join(GROUPS_DIR, 'global'); - if (fs.existsSync(globalDir)) { - mounts.push({ - hostPath: globalDir, - containerPath: '/workspace/global', - readonly: true, - }); - } - } - - // Per-group Claude sessions directory (isolated from other groups) - // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - '.claude', - ); - fs.mkdirSync(groupSessionsDir, { recursive: true }); - const settingsFile = path.join(groupSessionsDir, 'settings.json'); - if (!fs.existsSync(settingsFile)) { - fs.writeFileSync( - settingsFile, - JSON.stringify( - { - env: { - // Enable agent swarms (subagent orchestration) - // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - // Load CLAUDE.md from additional mounted directories - // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - // Enable Claude's memory feature (persists user preferences between sessions) - // https://code.claude.com/docs/en/memory#manage-auto-memory - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, - null, - 2, - ) + '\n', - ); - } - - // Sync skills from container/skills/ into each group's .claude/skills/ - const skillsSrc = path.join(process.cwd(), 'container', 'skills'); - const skillsDst = path.join(groupSessionsDir, 'skills'); - if (fs.existsSync(skillsSrc)) { - for (const skillDir of fs.readdirSync(skillsSrc)) { - const srcDir = path.join(skillsSrc, skillDir); - if (!fs.statSync(srcDir).isDirectory()) continue; - const dstDir = path.join(skillsDst, skillDir); - fs.cpSync(srcDir, dstDir, { recursive: true }); - } - } - mounts.push({ - hostPath: groupSessionsDir, - containerPath: '/home/node/.claude', - readonly: false, - }); - - // Per-group IPC namespace: each group gets its own IPC directory - // This prevents cross-group privilege escalation via IPC - const groupIpcDir = resolveGroupIpcPath(group.folder); - fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); - mounts.push({ - hostPath: groupIpcDir, - containerPath: '/workspace/ipc', - readonly: false, - }); - - // Copy agent-runner source into a per-group writable location so agents - // can customize it (add tools, change behavior) without affecting other - // groups. Recompiled on container startup via entrypoint.sh. - const agentRunnerSrc = path.join( - projectRoot, - 'container', - 'agent-runner', - 'src', - ); - const groupAgentRunnerDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - 'agent-runner-src', - ); - if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); - } - mounts.push({ - hostPath: groupAgentRunnerDir, - containerPath: '/app/src', - readonly: false, - }); - - // Additional mounts validated against external allowlist (tamper-proof from containers) - if (group.containerConfig?.additionalMounts) { - const validatedMounts = validateAdditionalMounts( - group.containerConfig.additionalMounts, - group.name, - isMain, - ); - mounts.push(...validatedMounts); - } - - return mounts; -} - -function buildContainerArgs( - mounts: VolumeMount[], - containerName: string, - isMain: boolean, -): string[] { - const args: string[] = ['run', '-i', '--rm', '--name', containerName]; - - // Pass host timezone so container's local time matches the user's - args.push('-e', `TZ=${TIMEZONE}`); - - // Route API traffic through the credential proxy (containers never see real secrets) - args.push( - '-e', - `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, - ); - - // Mirror the host's auth method with a placeholder value. - const authMode = detectAuthMode(); - if (authMode === 'api-key') { - args.push('-e', 'ANTHROPIC_API_KEY=placeholder'); - } else { - args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); - } - - // Runtime-specific args for host gateway resolution - args.push(...hostGatewayArgs()); - - // Run as host user so bind-mounted files are accessible. - // Skip when running as root (uid 0), as the container's node user (uid 1000), - // or when getuid is unavailable (native Windows without WSL). - const hostUid = process.getuid?.(); - const hostGid = process.getgid?.(); - if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { - if (isMain) { - // Main containers start as root so the entrypoint can mount --bind - // to shadow .env. Privileges are dropped via setpriv in entrypoint.sh. - args.push('-e', `RUN_UID=${hostUid}`); - args.push('-e', `RUN_GID=${hostGid}`); - } else { - args.push('--user', `${hostUid}:${hostGid}`); - } - args.push('-e', 'HOME=/home/node'); - } - - for (const mount of mounts) { - if (mount.readonly) { - args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); - } else { - args.push('-v', `${mount.hostPath}:${mount.containerPath}`); - } - } - - args.push(CONTAINER_IMAGE); - - return args; -} - -export async function runContainerAgent( - group: RegisteredGroup, - input: ContainerInput, - onProcess: (proc: ChildProcess, containerName: string) => void, - onOutput?: (output: ContainerOutput) => Promise, -): Promise { - const startTime = Date.now(); - - const groupDir = resolveGroupFolderPath(group.folder); - fs.mkdirSync(groupDir, { recursive: true }); - - const mounts = buildVolumeMounts(group, input.isMain); - const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); - const containerName = `nanoclaw-${safeName}-${Date.now()}`; - const containerArgs = buildContainerArgs(mounts, containerName, input.isMain); - - logger.debug( - { - group: group.name, - containerName, - mounts: mounts.map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ), - containerArgs: containerArgs.join(' '), - }, - 'Container mount configuration', - ); - - logger.info( - { - group: group.name, - containerName, - mountCount: mounts.length, - isMain: input.isMain, - }, - 'Spawning container agent', - ); - - const logsDir = path.join(groupDir, 'logs'); - fs.mkdirSync(logsDir, { recursive: true }); - - return new Promise((resolve) => { - const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - onProcess(container, containerName); - - let stdout = ''; - let stderr = ''; - let stdoutTruncated = false; - let stderrTruncated = false; - - container.stdin.write(JSON.stringify(input)); - container.stdin.end(); - - // Streaming output: parse OUTPUT_START/END marker pairs as they arrive - let parseBuffer = ''; - let newSessionId: string | undefined; - let outputChain = Promise.resolve(); - - container.stdout.on('data', (data) => { - const chunk = data.toString(); - - // Always accumulate for logging - if (!stdoutTruncated) { - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length; - if (chunk.length > remaining) { - stdout += chunk.slice(0, remaining); - stdoutTruncated = true; - logger.warn( - { group: group.name, size: stdout.length }, - 'Container stdout truncated due to size limit', - ); - } else { - stdout += chunk; - } - } - - // Stream-parse for output markers - if (onOutput) { - parseBuffer += chunk; - let startIdx: number; - while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { - const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); - if (endIdx === -1) break; // Incomplete pair, wait for more data - - const jsonStr = parseBuffer - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); - parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); - - try { - const parsed: ContainerOutput = JSON.parse(jsonStr); - if (parsed.newSessionId) { - newSessionId = parsed.newSessionId; - } - hadStreamingOutput = true; - // Activity detected — reset the hard timeout - resetTimeout(); - // Call onOutput for all markers (including null results) - // so idle timers start even for "silent" query completions. - outputChain = outputChain.then(() => onOutput(parsed)); - } catch (err) { - logger.warn( - { group: group.name, error: err }, - 'Failed to parse streamed output chunk', - ); - } - } - } - }); - - container.stderr.on('data', (data) => { - const chunk = data.toString(); - const lines = chunk.trim().split('\n'); - for (const line of lines) { - if (line) logger.debug({ container: group.folder }, line); - } - // Don't reset timeout on stderr — SDK writes debug logs continuously. - // Timeout only resets on actual output (OUTPUT_MARKER in stdout). - if (stderrTruncated) return; - const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length; - if (chunk.length > remaining) { - stderr += chunk.slice(0, remaining); - stderrTruncated = true; - logger.warn( - { group: group.name, size: stderr.length }, - 'Container stderr truncated due to size limit', - ); - } else { - stderr += chunk; - } - }); - - let timedOut = false; - let hadStreamingOutput = false; - const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; - // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the - // graceful _close sentinel has time to trigger before the hard kill fires. - const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000); - - const killOnTimeout = () => { - timedOut = true; - logger.error( - { group: group.name, containerName }, - 'Container timeout, stopping gracefully', - ); - exec(stopContainer(containerName), { timeout: 15000 }, (err) => { - if (err) { - logger.warn( - { group: group.name, containerName, err }, - 'Graceful stop failed, force killing', - ); - container.kill('SIGKILL'); - } - }); - }; - - let timeout = setTimeout(killOnTimeout, timeoutMs); - - // Reset the timeout whenever there's activity (streaming output) - const resetTimeout = () => { - clearTimeout(timeout); - timeout = setTimeout(killOnTimeout, timeoutMs); - }; - - container.on('close', (code) => { - clearTimeout(timeout); - const duration = Date.now() - startTime; - - if (timedOut) { - const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const timeoutLog = path.join(logsDir, `container-${ts}.log`); - fs.writeFileSync( - timeoutLog, - [ - `=== Container Run Log (TIMEOUT) ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `Container: ${containerName}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Had Streaming Output: ${hadStreamingOutput}`, - ].join('\n'), - ); - - // Timeout after output = idle cleanup, not failure. - // The agent already sent its response; this is just the - // container being reaped after the idle period expired. - if (hadStreamingOutput) { - logger.info( - { group: group.name, containerName, duration, code }, - 'Container timed out after output (idle cleanup)', - ); - outputChain.then(() => { - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - logger.error( - { group: group.name, containerName, duration, code }, - 'Container timed out with no output', - ); - - resolve({ - status: 'error', - result: null, - error: `Container timed out after ${configTimeout}ms`, - }); - return; - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logFile = path.join(logsDir, `container-${timestamp}.log`); - const isVerbose = - process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; - - const logLines = [ - `=== Container Run Log ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `IsMain: ${input.isMain}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Stdout Truncated: ${stdoutTruncated}`, - `Stderr Truncated: ${stderrTruncated}`, - ``, - ]; - - const isError = code !== 0; - - if (isVerbose || isError) { - logLines.push( - `=== Input ===`, - JSON.stringify(input, null, 2), - ``, - `=== Container Args ===`, - containerArgs.join(' '), - ``, - `=== Mounts ===`, - mounts - .map( - (m) => - `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, - ) - .join('\n'), - ``, - `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, - stderr, - ``, - `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, - stdout, - ); - } else { - logLines.push( - `=== Input Summary ===`, - `Prompt length: ${input.prompt.length} chars`, - `Session ID: ${input.sessionId || 'new'}`, - ``, - `=== Mounts ===`, - mounts - .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) - .join('\n'), - ``, - ); - } - - fs.writeFileSync(logFile, logLines.join('\n')); - logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); - - if (code !== 0) { - logger.error( - { - group: group.name, - code, - duration, - stderr, - stdout, - logFile, - }, - 'Container exited with error', - ); - - resolve({ - status: 'error', - result: null, - error: `Container exited with code ${code}: ${stderr.slice(-200)}`, - }); - return; - } - - // Streaming mode: wait for output chain to settle, return completion marker - if (onOutput) { - outputChain.then(() => { - logger.info( - { group: group.name, duration, newSessionId }, - 'Container completed (streaming mode)', - ); - resolve({ - status: 'success', - result: null, - newSessionId, - }); - }); - return; - } - - // Legacy mode: parse the last output marker pair from accumulated stdout - try { - // Extract JSON between sentinel markers for robust parsing - const startIdx = stdout.indexOf(OUTPUT_START_MARKER); - const endIdx = stdout.indexOf(OUTPUT_END_MARKER); - - let jsonLine: string; - if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - jsonLine = stdout - .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) - .trim(); - } else { - // Fallback: last non-empty line (backwards compatibility) - const lines = stdout.trim().split('\n'); - jsonLine = lines[lines.length - 1]; - } - - const output: ContainerOutput = JSON.parse(jsonLine); - - logger.info( - { - group: group.name, - duration, - status: output.status, - hasResult: !!output.result, - }, - 'Container completed', - ); - - resolve(output); - } catch (err) { - logger.error( - { - group: group.name, - stdout, - stderr, - error: err, - }, - 'Failed to parse container output', - ); - - resolve({ - status: 'error', - result: null, - error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, - }); - } - }); - - container.on('error', (err) => { - clearTimeout(timeout); - logger.error( - { group: group.name, containerName, error: err }, - 'Container spawn error', - ); - resolve({ - status: 'error', - result: null, - error: `Container spawn error: ${err.message}`, - }); - }); - }); -} - -export function writeTasksSnapshot( - groupFolder: string, - isMain: boolean, - tasks: Array<{ - id: string; - groupFolder: string; - prompt: string; - schedule_type: string; - schedule_value: string; - status: string; - next_run: string | null; - }>, -): void { - // Write filtered tasks to the group's IPC directory - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all tasks, others only see their own - const filteredTasks = isMain - ? tasks - : tasks.filter((t) => t.groupFolder === groupFolder); - - const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); - fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); -} - -export interface AvailableGroup { - jid: string; - name: string; - lastActivity: string; - isRegistered: boolean; -} - -/** - * Write available groups snapshot for the container to read. - * Only main group can see all available groups (for activation). - * Non-main groups only see their own registration status. - */ -export function writeGroupsSnapshot( - groupFolder: string, - isMain: boolean, - groups: AvailableGroup[], - registeredJids: Set, -): void { - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all groups; others see nothing (they can't activate groups) - const visibleGroups = isMain ? groups : []; - - const groupsFile = path.join(groupIpcDir, 'available_groups.json'); - fs.writeFileSync( - groupsFile, - JSON.stringify( - { - groups: visibleGroups, - lastSync: new Date().toISOString(), - }, - null, - 2, - ), - ); -} diff --git a/.claude/skills/convert-to-apple-container/modify/src/container-runner.ts.intent.md b/.claude/skills/convert-to-apple-container/modify/src/container-runner.ts.intent.md deleted file mode 100644 index 488658a..0000000 --- a/.claude/skills/convert-to-apple-container/modify/src/container-runner.ts.intent.md +++ /dev/null @@ -1,37 +0,0 @@ -# Intent: src/container-runner.ts modifications - -## What changed -Updated `buildContainerArgs` to support Apple Container's .env shadowing mechanism. The function now accepts an `isMain` parameter and uses it to decide how container user identity is configured. - -## Why -Apple Container (VirtioFS) only supports directory mounts, not file mounts. The previous approach of mounting `/dev/null` over `.env` from the host causes a `VZErrorDomain` crash. Instead, main-group containers now start as root so the entrypoint can `mount --bind /dev/null` over `.env` inside the Linux VM, then drop to the host user via `setpriv`. - -## Key sections - -### buildContainerArgs (signature change) -- Added: `isMain: boolean` parameter -- Main containers: passes `RUN_UID`/`RUN_GID` env vars instead of `--user`, so the container starts as root -- Non-main containers: unchanged, still uses `--user` flag - -### buildVolumeMounts -- Removed: the `/dev/null` → `/workspace/project/.env` shadow mount (was in the committed `37228a9` fix) -- The .env shadowing is now handled inside the container entrypoint instead - -### runContainerAgent (call site) -- Changed: `buildContainerArgs(mounts, containerName)` → `buildContainerArgs(mounts, containerName, input.isMain)` - -## Invariants -- All exported interfaces unchanged: `ContainerInput`, `ContainerOutput`, `runContainerAgent`, `writeTasksSnapshot`, `writeGroupsSnapshot`, `AvailableGroup` -- Non-main containers behave identically (still get `--user` flag) -- Mount list for non-main containers is unchanged -- Credentials injected by host-side credential proxy, never in container env or stdin -- Output parsing (streaming + legacy) unchanged - -## Must-keep -- The `isMain` parameter on `buildContainerArgs` (consumed by `runContainerAgent`) -- The `RUN_UID`/`RUN_GID` env vars for main containers (consumed by entrypoint.sh) -- The `--user` flag for non-main containers (file permission compatibility) -- `CONTAINER_HOST_GATEWAY` and `hostGatewayArgs()` imports from `container-runtime.js` -- `detectAuthMode()` import from `credential-proxy.js` -- `CREDENTIAL_PROXY_PORT` import from `config.js` -- Credential proxy env vars: `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN` diff --git a/.claude/skills/convert-to-apple-container/modify/src/container-runtime.test.ts b/.claude/skills/convert-to-apple-container/modify/src/container-runtime.test.ts deleted file mode 100644 index 79b77a3..0000000 --- a/.claude/skills/convert-to-apple-container/modify/src/container-runtime.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock logger -vi.mock('./logger.js', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -// Mock child_process — store the mock fn so tests can configure it -const mockExecSync = vi.fn(); -vi.mock('child_process', () => ({ - execSync: (...args: unknown[]) => mockExecSync(...args), -})); - -import { - CONTAINER_RUNTIME_BIN, - readonlyMountArgs, - stopContainer, - ensureContainerRuntimeRunning, - cleanupOrphans, -} from './container-runtime.js'; -import { logger } from './logger.js'; - -beforeEach(() => { - vi.clearAllMocks(); -}); - -// --- Pure functions --- - -describe('readonlyMountArgs', () => { - it('returns --mount flag with type=bind and readonly', () => { - const args = readonlyMountArgs('/host/path', '/container/path'); - expect(args).toEqual([ - '--mount', - 'type=bind,source=/host/path,target=/container/path,readonly', - ]); - }); -}); - -describe('stopContainer', () => { - it('returns stop command using CONTAINER_RUNTIME_BIN', () => { - expect(stopContainer('nanoclaw-test-123')).toBe( - `${CONTAINER_RUNTIME_BIN} stop nanoclaw-test-123`, - ); - }); -}); - -// --- ensureContainerRuntimeRunning --- - -describe('ensureContainerRuntimeRunning', () => { - it('does nothing when runtime is already running', () => { - mockExecSync.mockReturnValueOnce(''); - - ensureContainerRuntimeRunning(); - - expect(mockExecSync).toHaveBeenCalledTimes(1); - expect(mockExecSync).toHaveBeenCalledWith( - `${CONTAINER_RUNTIME_BIN} system status`, - { stdio: 'pipe' }, - ); - expect(logger.debug).toHaveBeenCalledWith('Container runtime already running'); - }); - - it('auto-starts when system status fails', () => { - // First call (system status) fails - mockExecSync.mockImplementationOnce(() => { - throw new Error('not running'); - }); - // Second call (system start) succeeds - mockExecSync.mockReturnValueOnce(''); - - ensureContainerRuntimeRunning(); - - expect(mockExecSync).toHaveBeenCalledTimes(2); - expect(mockExecSync).toHaveBeenNthCalledWith( - 2, - `${CONTAINER_RUNTIME_BIN} system start`, - { stdio: 'pipe', timeout: 30000 }, - ); - expect(logger.info).toHaveBeenCalledWith('Container runtime started'); - }); - - it('throws when both status and start fail', () => { - mockExecSync.mockImplementation(() => { - throw new Error('failed'); - }); - - expect(() => ensureContainerRuntimeRunning()).toThrow( - 'Container runtime is required but failed to start', - ); - expect(logger.error).toHaveBeenCalled(); - }); -}); - -// --- cleanupOrphans --- - -describe('cleanupOrphans', () => { - it('stops orphaned nanoclaw containers from JSON output', () => { - // Apple Container ls returns JSON - const lsOutput = JSON.stringify([ - { status: 'running', configuration: { id: 'nanoclaw-group1-111' } }, - { status: 'stopped', configuration: { id: 'nanoclaw-group2-222' } }, - { status: 'running', configuration: { id: 'nanoclaw-group3-333' } }, - { status: 'running', configuration: { id: 'other-container' } }, - ]); - mockExecSync.mockReturnValueOnce(lsOutput); - // stop calls succeed - mockExecSync.mockReturnValue(''); - - cleanupOrphans(); - - // ls + 2 stop calls (only running nanoclaw- containers) - expect(mockExecSync).toHaveBeenCalledTimes(3); - expect(mockExecSync).toHaveBeenNthCalledWith( - 2, - `${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`, - { stdio: 'pipe' }, - ); - expect(mockExecSync).toHaveBeenNthCalledWith( - 3, - `${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`, - { stdio: 'pipe' }, - ); - expect(logger.info).toHaveBeenCalledWith( - { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] }, - 'Stopped orphaned containers', - ); - }); - - it('does nothing when no orphans exist', () => { - mockExecSync.mockReturnValueOnce('[]'); - - cleanupOrphans(); - - expect(mockExecSync).toHaveBeenCalledTimes(1); - expect(logger.info).not.toHaveBeenCalled(); - }); - - it('warns and continues when ls fails', () => { - mockExecSync.mockImplementationOnce(() => { - throw new Error('container not available'); - }); - - cleanupOrphans(); // should not throw - - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ err: expect.any(Error) }), - 'Failed to clean up orphaned containers', - ); - }); - - it('continues stopping remaining containers when one stop fails', () => { - const lsOutput = JSON.stringify([ - { status: 'running', configuration: { id: 'nanoclaw-a-1' } }, - { status: 'running', configuration: { id: 'nanoclaw-b-2' } }, - ]); - mockExecSync.mockReturnValueOnce(lsOutput); - // First stop fails - mockExecSync.mockImplementationOnce(() => { - throw new Error('already stopped'); - }); - // Second stop succeeds - mockExecSync.mockReturnValueOnce(''); - - cleanupOrphans(); // should not throw - - expect(mockExecSync).toHaveBeenCalledTimes(3); - expect(logger.info).toHaveBeenCalledWith( - { count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] }, - 'Stopped orphaned containers', - ); - }); -}); diff --git a/.claude/skills/convert-to-apple-container/modify/src/container-runtime.ts b/.claude/skills/convert-to-apple-container/modify/src/container-runtime.ts deleted file mode 100644 index 2b4df9d..0000000 --- a/.claude/skills/convert-to-apple-container/modify/src/container-runtime.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Container runtime abstraction for NanoClaw. - * All runtime-specific logic lives here so swapping runtimes means changing one file. - */ -import { execSync } from 'child_process'; - -import { logger } from './logger.js'; - -/** The container runtime binary name. */ -export const CONTAINER_RUNTIME_BIN = 'container'; - -/** - * Hostname containers use to reach the host machine. - * Apple Container VMs access the host via the default gateway (192.168.64.1). - */ -export const CONTAINER_HOST_GATEWAY = '192.168.64.1'; - -/** - * CLI args needed for the container to resolve the host gateway. - * Apple Container provides host networking natively on macOS — no extra args needed. - */ -export function hostGatewayArgs(): string[] { - return []; -} - -/** Returns CLI args for a readonly bind mount. */ -export function readonlyMountArgs(hostPath: string, containerPath: string): string[] { - return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`]; -} - -/** Returns the shell command to stop a container by name. */ -export function stopContainer(name: string): string { - return `${CONTAINER_RUNTIME_BIN} stop ${name}`; -} - -/** Ensure the container runtime is running, starting it if needed. */ -export function ensureContainerRuntimeRunning(): void { - try { - execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' }); - logger.debug('Container runtime already running'); - } catch { - logger.info('Starting container runtime...'); - try { - execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 }); - logger.info('Container runtime started'); - } catch (err) { - logger.error({ err }, 'Failed to start container runtime'); - console.error( - '\n╔════════════════════════════════════════════════════════════════╗', - ); - console.error( - '║ FATAL: Container runtime failed to start ║', - ); - console.error( - '║ ║', - ); - console.error( - '║ Agents cannot run without a container runtime. To fix: ║', - ); - console.error( - '║ 1. Ensure Apple Container is installed ║', - ); - console.error( - '║ 2. Run: container system start ║', - ); - console.error( - '║ 3. Restart NanoClaw ║', - ); - console.error( - '╚════════════════════════════════════════════════════════════════╝\n', - ); - throw new Error('Container runtime is required but failed to start'); - } - } -} - -/** Kill orphaned NanoClaw containers from previous runs. */ -export function cleanupOrphans(): void { - try { - const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', - }); - const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]'); - const orphans = containers - .filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-')) - .map((c) => c.configuration.id); - for (const name of orphans) { - try { - execSync(stopContainer(name), { stdio: 'pipe' }); - } catch { /* already stopped */ } - } - if (orphans.length > 0) { - logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); - } - } catch (err) { - logger.warn({ err }, 'Failed to clean up orphaned containers'); - } -} diff --git a/.claude/skills/convert-to-apple-container/modify/src/container-runtime.ts.intent.md b/.claude/skills/convert-to-apple-container/modify/src/container-runtime.ts.intent.md deleted file mode 100644 index e43de33..0000000 --- a/.claude/skills/convert-to-apple-container/modify/src/container-runtime.ts.intent.md +++ /dev/null @@ -1,41 +0,0 @@ -# Intent: src/container-runtime.ts modifications - -## What changed -Replaced Docker runtime with Apple Container runtime. This is a full file replacement — the exported API is identical, only the implementation differs. - -## Key sections - -### CONTAINER_RUNTIME_BIN -- Changed: `'docker'` → `'container'` (the Apple Container CLI binary) - -### readonlyMountArgs -- Changed: Docker `-v host:container:ro` → Apple Container `--mount type=bind,source=...,target=...,readonly` - -### ensureContainerRuntimeRunning -- Changed: `docker info` → `container system status` for checking -- Added: auto-start via `container system start` when not running (Apple Container supports this; Docker requires manual start) -- Changed: error message references Apple Container instead of Docker - -### cleanupOrphans -- Changed: `docker ps --filter name=nanoclaw- --format '{{.Names}}'` → `container ls --format json` with JSON parsing -- Apple Container returns JSON with `{ status, configuration: { id } }` structure - -### CONTAINER_HOST_GATEWAY -- Set to `'192.168.64.1'` — the default gateway for Apple Container VMs to reach the host -- Docker uses `'host.docker.internal'` which is resolved differently - -### hostGatewayArgs -- Returns `[]` — Apple Container provides host networking natively on macOS -- Docker version returns `['--add-host=host.docker.internal:host-gateway']` on Linux - -## Invariants -- All exports remain identical: `CONTAINER_RUNTIME_BIN`, `CONTAINER_HOST_GATEWAY`, `readonlyMountArgs`, `stopContainer`, `hostGatewayArgs`, `ensureContainerRuntimeRunning`, `cleanupOrphans` -- `stopContainer` implementation is unchanged (` stop `) -- Logger usage pattern is unchanged -- Error handling pattern is unchanged - -## Must-keep -- The exported function signatures (consumed by container-runner.ts and index.ts) -- The error box-drawing output format -- The orphan cleanup logic (find + stop pattern) -- `CONTAINER_HOST_GATEWAY` must match the address the credential proxy is reachable at from within the VM diff --git a/.claude/skills/convert-to-apple-container/tests/convert-to-apple-container.test.ts b/.claude/skills/convert-to-apple-container/tests/convert-to-apple-container.test.ts deleted file mode 100644 index 33db430..0000000 --- a/.claude/skills/convert-to-apple-container/tests/convert-to-apple-container.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('convert-to-apple-container skill package', () => { - const skillDir = path.resolve(__dirname, '..'); - - it('has a valid manifest', () => { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - expect(fs.existsSync(manifestPath)).toBe(true); - - const content = fs.readFileSync(manifestPath, 'utf-8'); - expect(content).toContain('skill: convert-to-apple-container'); - expect(content).toContain('version: 1.0.0'); - expect(content).toContain('container-runtime.ts'); - expect(content).toContain('container/build.sh'); - }); - - it('has all modified files', () => { - const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts'); - expect(fs.existsSync(runtimeFile)).toBe(true); - - const content = fs.readFileSync(runtimeFile, 'utf-8'); - expect(content).toContain("CONTAINER_RUNTIME_BIN = 'container'"); - expect(content).toContain('system status'); - expect(content).toContain('system start'); - expect(content).toContain('ls --format json'); - - const testFile = path.join(skillDir, 'modify', 'src', 'container-runtime.test.ts'); - expect(fs.existsSync(testFile)).toBe(true); - - const testContent = fs.readFileSync(testFile, 'utf-8'); - expect(testContent).toContain('system status'); - expect(testContent).toContain('--mount'); - }); - - it('has intent files for modified sources', () => { - const runtimeIntent = path.join(skillDir, 'modify', 'src', 'container-runtime.ts.intent.md'); - expect(fs.existsSync(runtimeIntent)).toBe(true); - - const buildIntent = path.join(skillDir, 'modify', 'container', 'build.sh.intent.md'); - expect(fs.existsSync(buildIntent)).toBe(true); - }); - - it('has build.sh with Apple Container default', () => { - const buildFile = path.join(skillDir, 'modify', 'container', 'build.sh'); - expect(fs.existsSync(buildFile)).toBe(true); - - const content = fs.readFileSync(buildFile, 'utf-8'); - expect(content).toContain('CONTAINER_RUNTIME:-container'); - expect(content).not.toContain('CONTAINER_RUNTIME:-docker'); - }); - - it('uses Apple Container API patterns (not Docker)', () => { - const runtimeFile = path.join(skillDir, 'modify', 'src', 'container-runtime.ts'); - const content = fs.readFileSync(runtimeFile, 'utf-8'); - - // Apple Container patterns - expect(content).toContain('system status'); - expect(content).toContain('system start'); - expect(content).toContain('ls --format json'); - expect(content).toContain('type=bind,source='); - - // Should NOT contain Docker patterns - expect(content).not.toContain('docker info'); - expect(content).not.toContain("'-v'"); - expect(content).not.toContain('--filter name='); - }); -}); diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md index 95a4547..13b5b89 100644 --- a/.claude/skills/customize/SKILL.md +++ b/.claude/skills/customize/SKILL.md @@ -9,10 +9,15 @@ This skill helps users add capabilities or modify behavior. Use AskUserQuestion ## Workflow -1. **Understand the request** - Ask clarifying questions -2. **Plan the changes** - Identify files to modify -3. **Implement** - Make changes directly to the code -4. **Test guidance** - Tell user how to verify +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 ## Key Files diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index b21a083..ee481b9 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -11,6 +11,45 @@ Run setup steps automatically. Only pause when user action is required (channel **UX Note:** Use `AskUserQuestion` for all user-facing questions. +## 0. Git & Fork Setup + +Check the git remote configuration to ensure the user has a fork and upstream is configured. + +Run: +- `git remote -v` + +**Case A — `origin` points to `qwibitai/nanoclaw` (user cloned directly):** + +The user cloned instead of forking. AskUserQuestion: "You cloned NanoClaw directly. We recommend forking so you can push your customizations. Would you like to set up a fork?" +- Fork now (recommended) — walk them through it +- Continue without fork — they'll only have local changes + +If fork: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask them for their GitHub username. Run: +```bash +git remote rename origin upstream +git remote add origin https://github.com//nanoclaw.git +git push --force origin main +``` +Verify with `git remote -v`. + +If continue without fork: add upstream so they can still pull updates: +```bash +git remote add upstream https://github.com/qwibitai/nanoclaw.git +``` + +**Case B — `origin` points to user's fork, no `upstream` remote:** + +Add upstream: +```bash +git remote add upstream https://github.com/qwibitai/nanoclaw.git +``` + +**Case C — both `origin` (user's fork) and `upstream` (qwibitai) exist:** + +Already configured. Continue. + +**Verify:** `git remote -v` should show `origin` → user's repo, `upstream` → `qwibitai/nanoclaw.git`. + ## 1. Bootstrap (Node.js + Dependencies) Run `bash setup.sh` and parse the status block. @@ -83,7 +122,17 @@ AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key? **API key:** Tell user to add `ANTHROPIC_API_KEY=` to `.env`. -## 5. Set Up Channels +## 5. Install Skills Marketplace + +Install the official skills marketplace plugin so feature skills (channel integrations, add-ons) are available: + +```bash +claude plugin install nanoclaw-skills@nanoclaw-skills --scope project +``` + +This is hot-loaded — no restart needed. All feature skills become immediately available. + +## 6. Set Up Channels AskUserQuestion (multiSelect): Which messaging channels do you want to enable? - WhatsApp (authenticates via QR code or pairing code) @@ -101,22 +150,22 @@ For each selected channel, invoke its skill: - **Discord:** Invoke `/add-discord` Each skill will: -1. Install the channel code (via `apply-skill`) +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**, continue to step 6. +**After all channel skills complete**, continue to step 7. -## 6. Mount Allowlist +## 7. Mount Allowlist AskUserQuestion: Agent access to external directories? **No:** `npx tsx setup/index.ts --step mounts -- --empty` **Yes:** Collect paths/permissions. `npx tsx setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'` -## 7. Start Service +## 8. Start Service If service already running: unload first. - macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` @@ -146,23 +195,23 @@ Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` - Linux: check `systemctl --user status nanoclaw`. - Re-run the service step after fixing. -## 8. Verify +## 9. Verify Run `npx tsx setup/index.ts --step verify` and parse the status block. **If STATUS=failed, fix each:** - SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup) -- SERVICE=not_found → re-run step 7 +- 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 5 +- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 6 - MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log` ## Troubleshooting -**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), missing `.env` (step 4), missing channel credentials (re-invoke channel skill). +**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). **Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index e548955..b0b478c 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -194,7 +194,7 @@ Parse the diff output for lines starting with `+[BREAKING]`. Each such line is o ``` If no `[BREAKING]` lines are found: -- Skip this step silently. Proceed to Step 7. +- Skip this step silently. Proceed to Step 7 (skill updates check). If one or more `[BREAKING]` lines are found: - Display a warning header to the user: "This update includes breaking changes that may require action:" @@ -205,9 +205,20 @@ If one or more `[BREAKING]` lines are found: - "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. +- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check). -# Step 7: Summary + rollback instructions +# 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. + +# Step 8: Summary + rollback instructions Show: - Backup tag: the tag name created in Step 1 - New HEAD: `git rev-parse --short HEAD` diff --git a/.claude/skills/update-skills/SKILL.md b/.claude/skills/update-skills/SKILL.md new file mode 100644 index 0000000..cbbff39 --- /dev/null +++ b/.claude/skills/update-skills/SKILL.md @@ -0,0 +1,130 @@ +--- +name: update-skills +description: Check for and apply updates to installed skill branches from upstream. +--- + +# About + +Skills are distributed as git branches (`skill/*`). When you install a skill, you merge its branch into your repo. This skill checks upstream for newer commits on those skill branches and helps you update. + +Run `/update-skills` in Claude Code. + +## How it works + +**Preflight**: checks for clean working tree and upstream remote. + +**Detection**: fetches upstream, lists all `upstream/skill/*` branches, determines which ones you've previously merged (via merge-base), and checks if any have new commits. + +**Selection**: presents a list of skills with available updates. You pick which to update. + +**Update**: merges each selected skill branch, resolves conflicts if any, then validates with build + test. + +--- + +# Goal +Help users update their installed skill branches from upstream without losing local customizations. + +# Operating principles +- Never proceed with a dirty working tree. +- Only offer updates for skills the user has already merged (installed). +- Use git-native operations. Do not manually rewrite files except conflict markers. +- Keep token usage low: rely on `git` commands, only open files with actual conflicts. + +# Step 0: Preflight + +Run: +- `git status --porcelain` + +If output is non-empty: +- Tell the user to commit or stash first, then stop. + +Check remotes: +- `git remote -v` + +If `upstream` is missing: +- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`). +- `git remote add upstream ` + +Fetch: +- `git fetch upstream --prune` + +# Step 1: Detect installed skills with available updates + +List all upstream skill branches: +- `git branch -r --list 'upstream/skill/*'` + +For each `upstream/skill/`: +1. Check if the user has merged this skill branch before: + - `git merge-base --is-ancestor upstream/skill/~1 HEAD` — if this succeeds (exit 0) for any ancestor commit of the skill branch, the user has merged it at some point. A simpler check: `git log --oneline --merges --grep="skill/" HEAD` to see if there's a merge commit referencing this branch. + - Alternative: `MERGE_BASE=$(git merge-base HEAD upstream/skill/)` — if the merge base is NOT the initial commit and the merge base includes commits unique to the skill branch, it has been merged. + - Simplest reliable check: compare `git merge-base HEAD upstream/skill/` with `git merge-base HEAD upstream/main`. If the skill merge-base is strictly ahead of (or different from) the main merge-base, the user has merged this skill. +2. Check if there are new commits on the skill branch not yet in HEAD: + - `git log --oneline HEAD..upstream/skill/` + - If this produces output, there are updates available. + +Build three lists: +- **Updates available**: skills that are merged AND have new commits +- **Up to date**: skills that are merged and have no new commits +- **Not installed**: skills that have never been merged + +# Step 2: Present results + +If no skills have updates available: +- Tell the user all installed skills are up to date. List them. +- If there are uninstalled skills, mention them briefly (e.g., "3 other skills available in upstream that you haven't installed"). +- Stop here. + +If updates are available: +- Show the list of skills with updates, including the number of new commits for each: + ``` + skill/: 3 new commits + skill/: 1 new commit + ``` +- Also show skills that are up to date (for context). +- Use AskUserQuestion with `multiSelect: true` to let the user pick which skills to update. + - One option per skill with updates, labeled with the skill name and commit count. + - Add an option: "Skip — don't update any skills now" +- If user selects Skip, stop here. + +# Step 3: Apply updates + +For each selected skill (process one at a time): + +1. Tell the user which skill is being updated. +2. Run: `git merge upstream/skill/ --no-edit` +3. If the merge is clean, move to the next skill. +4. If conflicts occur: + - Run `git status` to identify conflicted files. + - For each conflicted file: + - Open the file. + - Resolve only conflict markers. + - Preserve intentional local customizations. + - `git add ` + - Complete the merge: `git commit --no-edit` + +If a merge fails badly (e.g., cannot resolve conflicts): +- `git merge --abort` +- Tell the user this skill could not be auto-updated and they should resolve it manually. +- Continue with the remaining skills. + +# Step 4: Validation + +After all selected skills are merged: +- `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). +- Do not refactor unrelated code. +- If unclear, ask the user. + +# Step 5: Summary + +Show: +- Skills updated (list) +- Skills skipped or failed (if any) +- New HEAD: `git rev-parse --short HEAD` +- Any conflicts that were resolved (list files) + +If the service is running, remind the user to restart it to pick up changes. diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md deleted file mode 100644 index 7620b0f..0000000 --- a/.claude/skills/use-local-whisper/SKILL.md +++ /dev/null @@ -1,128 +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 - -Read `.nanoclaw/state.yaml`. If `use-local-whisper` is in `applied_skills`, 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 - -```bash -npx tsx scripts/apply-skill.ts .claude/skills/use-local-whisper -``` - -This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API. - -### Validate - -```bash -npm test -npm run build -``` - -## Phase 3: Verify - -### Ensure launchd PATH includes Homebrew - -The NanoClaw launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH. - -Check the current PATH: -```bash -grep -A1 'PATH' ~/Library/LaunchAgents/com.nanoclaw.plist -``` - -If `/opt/homebrew/bin` is missing, add it to the `` value inside the `PATH` key in the plist. Then reload: -```bash -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -``` - -### Build and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -### Test - -Send a voice note in any registered group. The agent should receive it as `[Voice: ]`. - -### Check logs - -```bash -tail -f logs/nanoclaw.log | grep -i -E "voice|transcri|whisper" -``` - -Look for: -- `Transcribed voice message` — successful transcription -- `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH - -## Configuration - -Environment variables (optional, set in `.env`): - -| Variable | Default | Description | -|----------|---------|-------------| -| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | -| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | - -## Troubleshooting - -**"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually: -```bash -ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y -whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt -``` - -**Transcription works in dev but not as service**: The launchd plist PATH likely doesn't include `/opt/homebrew/bin`. See "Ensure launchd PATH includes Homebrew" in Phase 3. - -**Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing. - -**Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`. diff --git a/.claude/skills/use-local-whisper/manifest.yaml b/.claude/skills/use-local-whisper/manifest.yaml deleted file mode 100644 index 3ca356d..0000000 --- a/.claude/skills/use-local-whisper/manifest.yaml +++ /dev/null @@ -1,12 +0,0 @@ -skill: use-local-whisper -version: 1.0.0 -description: "Switch voice transcription from OpenAI Whisper API to local whisper.cpp (WhatsApp only)" -core_version: 0.1.0 -adds: [] -modifies: - - src/transcription.ts -structured: {} -conflicts: [] -depends: - - voice-transcription -test: "npx vitest run src/channels/whatsapp.test.ts" diff --git a/.claude/skills/use-local-whisper/modify/src/transcription.ts b/.claude/skills/use-local-whisper/modify/src/transcription.ts deleted file mode 100644 index 45f39fc..0000000 --- a/.claude/skills/use-local-whisper/modify/src/transcription.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { execFile } from 'child_process'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { promisify } from 'util'; - -import { downloadMediaMessage, WAMessage, WASocket } from '@whiskeysockets/baileys'; - -const execFileAsync = promisify(execFile); - -const WHISPER_BIN = process.env.WHISPER_BIN || 'whisper-cli'; -const WHISPER_MODEL = - process.env.WHISPER_MODEL || - path.join(process.cwd(), 'data', 'models', 'ggml-base.bin'); - -const FALLBACK_MESSAGE = '[Voice Message - transcription unavailable]'; - -async function transcribeWithWhisperCpp( - audioBuffer: Buffer, -): Promise { - const tmpDir = os.tmpdir(); - const id = `nanoclaw-voice-${Date.now()}`; - const tmpOgg = path.join(tmpDir, `${id}.ogg`); - const tmpWav = path.join(tmpDir, `${id}.wav`); - - try { - fs.writeFileSync(tmpOgg, audioBuffer); - - // Convert ogg/opus to 16kHz mono WAV (required by whisper.cpp) - await execFileAsync('ffmpeg', [ - '-i', tmpOgg, - '-ar', '16000', - '-ac', '1', - '-f', 'wav', - '-y', tmpWav, - ], { timeout: 30_000 }); - - const { stdout } = await execFileAsync(WHISPER_BIN, [ - '-m', WHISPER_MODEL, - '-f', tmpWav, - '--no-timestamps', - '-nt', - ], { timeout: 60_000 }); - - const transcript = stdout.trim(); - return transcript || null; - } catch (err) { - console.error('whisper.cpp transcription failed:', err); - return null; - } finally { - for (const f of [tmpOgg, tmpWav]) { - try { fs.unlinkSync(f); } catch { /* best effort cleanup */ } - } - } -} - -export async function transcribeAudioMessage( - msg: WAMessage, - sock: WASocket, -): Promise { - try { - const buffer = (await downloadMediaMessage( - msg, - 'buffer', - {}, - { - logger: console as any, - reuploadRequest: sock.updateMediaMessage, - }, - )) as Buffer; - - if (!buffer || buffer.length === 0) { - console.error('Failed to download audio message'); - return FALLBACK_MESSAGE; - } - - console.log(`Downloaded audio message: ${buffer.length} bytes`); - - const transcript = await transcribeWithWhisperCpp(buffer); - - if (!transcript) { - return FALLBACK_MESSAGE; - } - - console.log(`Transcribed voice message: ${transcript.length} chars`); - return transcript.trim(); - } catch (err) { - console.error('Transcription error:', err); - return FALLBACK_MESSAGE; - } -} - -export function isVoiceMessage(msg: WAMessage): boolean { - return msg.message?.audioMessage?.ptt === true; -} diff --git a/.claude/skills/use-local-whisper/modify/src/transcription.ts.intent.md b/.claude/skills/use-local-whisper/modify/src/transcription.ts.intent.md deleted file mode 100644 index 47dabf1..0000000 --- a/.claude/skills/use-local-whisper/modify/src/transcription.ts.intent.md +++ /dev/null @@ -1,39 +0,0 @@ -# Intent: src/transcription.ts modifications - -## What changed -Replaced the OpenAI Whisper API backend with local whisper.cpp CLI execution. Audio is converted from ogg/opus to 16kHz mono WAV via ffmpeg, then transcribed locally using whisper-cpp. No API key or network required. - -## Key sections - -### Imports -- Removed: `readEnvFile` from `./env.js` (no API key needed) -- Added: `execFile` from `child_process`, `fs`, `os`, `path`, `promisify` from `util` - -### Configuration -- Removed: `TranscriptionConfig` interface and `DEFAULT_CONFIG` (no model/enabled/fallback config) -- Added: `WHISPER_BIN` constant (env `WHISPER_BIN` or `'whisper-cli'`) -- Added: `WHISPER_MODEL` constant (env `WHISPER_MODEL` or `data/models/ggml-base.bin`) -- Added: `FALLBACK_MESSAGE` constant - -### transcribeWithWhisperCpp (replaces transcribeWithOpenAI) -- Writes audio buffer to temp .ogg file -- Converts to 16kHz mono WAV via ffmpeg -- Runs whisper-cpp CLI with `--no-timestamps -nt` flags -- Cleans up temp files in finally block -- Returns trimmed stdout or null on error - -### transcribeAudioMessage -- Same signature: `(msg: WAMessage, sock: WASocket) => Promise` -- Same download logic via `downloadMediaMessage` -- Calls `transcribeWithWhisperCpp` instead of `transcribeWithOpenAI` -- Same fallback behavior on error/null - -### isVoiceMessage -- Unchanged: `msg.message?.audioMessage?.ptt === true` - -## Invariants (must-keep) -- `transcribeAudioMessage` export signature unchanged -- `isVoiceMessage` export unchanged -- Fallback message strings unchanged: `[Voice Message - transcription unavailable]` -- downloadMediaMessage call pattern unchanged -- Error logging pattern unchanged diff --git a/.claude/skills/use-local-whisper/tests/use-local-whisper.test.ts b/.claude/skills/use-local-whisper/tests/use-local-whisper.test.ts deleted file mode 100644 index 580d44f..0000000 --- a/.claude/skills/use-local-whisper/tests/use-local-whisper.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('use-local-whisper skill package', () => { - const skillDir = path.resolve(__dirname, '..'); - - it('has a valid manifest', () => { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - expect(fs.existsSync(manifestPath)).toBe(true); - - const content = fs.readFileSync(manifestPath, 'utf-8'); - expect(content).toContain('skill: use-local-whisper'); - expect(content).toContain('version: 1.0.0'); - expect(content).toContain('src/transcription.ts'); - expect(content).toContain('voice-transcription'); - }); - - it('declares voice-transcription as a dependency', () => { - const content = fs.readFileSync( - path.join(skillDir, 'manifest.yaml'), - 'utf-8', - ); - expect(content).toContain('depends:'); - expect(content).toContain('voice-transcription'); - }); - - it('has no structured operations (no new npm deps needed)', () => { - const content = fs.readFileSync( - path.join(skillDir, 'manifest.yaml'), - 'utf-8', - ); - expect(content).toContain('structured: {}'); - }); - - it('has the modified transcription file', () => { - const filePath = path.join(skillDir, 'modify', 'src', 'transcription.ts'); - expect(fs.existsSync(filePath)).toBe(true); - }); - - it('has an intent file for the modified file', () => { - const intentPath = path.join(skillDir, 'modify', 'src', 'transcription.ts.intent.md'); - expect(fs.existsSync(intentPath)).toBe(true); - - const content = fs.readFileSync(intentPath, 'utf-8'); - expect(content).toContain('whisper.cpp'); - expect(content).toContain('transcribeAudioMessage'); - expect(content).toContain('isVoiceMessage'); - expect(content).toContain('Invariants'); - }); - - it('uses whisper-cli (not OpenAI) for transcription', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'transcription.ts'), - 'utf-8', - ); - - // Uses local whisper.cpp CLI - expect(content).toContain('whisper-cli'); - expect(content).toContain('execFileAsync'); - expect(content).toContain('WHISPER_BIN'); - expect(content).toContain('WHISPER_MODEL'); - expect(content).toContain('ggml-base.bin'); - - // Does NOT use OpenAI - expect(content).not.toContain('openai'); - expect(content).not.toContain('OpenAI'); - expect(content).not.toContain('OPENAI_API_KEY'); - expect(content).not.toContain('readEnvFile'); - }); - - it('preserves the public API (transcribeAudioMessage and isVoiceMessage)', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'transcription.ts'), - 'utf-8', - ); - - expect(content).toContain('export async function transcribeAudioMessage('); - expect(content).toContain('msg: WAMessage'); - expect(content).toContain('sock: WASocket'); - expect(content).toContain('Promise'); - expect(content).toContain('export function isVoiceMessage('); - expect(content).toContain('downloadMediaMessage'); - }); - - it('preserves fallback message strings', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'transcription.ts'), - 'utf-8', - ); - - expect(content).toContain('[Voice Message - transcription unavailable]'); - }); - - it('includes ffmpeg conversion step', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'transcription.ts'), - 'utf-8', - ); - - expect(content).toContain('ffmpeg'); - expect(content).toContain("'-ar', '16000'"); - expect(content).toContain("'-ac', '1'"); - }); - - it('cleans up temp files in finally block', () => { - const content = fs.readFileSync( - path.join(skillDir, 'modify', 'src', 'transcription.ts'), - 'utf-8', - ); - - expect(content).toContain('finally'); - expect(content).toContain('unlinkSync'); - }); -}); diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml new file mode 100644 index 0000000..3e15e25 --- /dev/null +++ b/.github/workflows/merge-forward-skills.yml @@ -0,0 +1,158 @@ +name: Merge-forward skill branches + +on: + push: + branches: [main] + +permissions: + contents: write + issues: write + +jobs: + merge-forward: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Merge main into each skill branch + id: merge + run: | + FAILED="" + SUCCEEDED="" + + # List all remote skill branches + SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) + + if [ -z "$SKILL_BRANCHES" ]; then + echo "No skill branches found." + exit 0 + fi + + for BRANCH in $SKILL_BRANCHES; do + SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') + echo "" + echo "=== Processing $BRANCH ===" + + # Checkout the skill branch + git checkout -B "$BRANCH" "origin/$BRANCH" + + # Attempt merge + if ! git merge main --no-edit; then + echo "::warning::Merge conflict in $BRANCH" + git merge --abort + FAILED="$FAILED $SKILL_NAME" + continue + fi + + # Check if there's anything new to push + if git diff --quiet "origin/$BRANCH"; then + echo "$BRANCH is already up to date with main." + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + continue + fi + + # Install deps and validate + npm ci + + if ! npm run build; then + echo "::warning::Build failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + if ! npm test 2>/dev/null; then + echo "::warning::Tests failed for $BRANCH" + git reset --hard "origin/$BRANCH" + FAILED="$FAILED $SKILL_NAME" + continue + fi + + # Push the updated branch + git push origin "$BRANCH" + SUCCEEDED="$SUCCEEDED $SKILL_NAME" + echo "$BRANCH merged and pushed successfully." + done + + echo "" + echo "=== Results ===" + echo "Succeeded: $SUCCEEDED" + echo "Failed: $FAILED" + + # Export for issue creation + echo "failed=$FAILED" >> "$GITHUB_OUTPUT" + echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" + + - name: Open issue for failed merges + if: steps.merge.outputs.failed != '' + uses: actions/github-script@v7 + with: + script: | + const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); + const sha = context.sha.substring(0, 7); + const body = [ + `The merge-forward workflow failed to merge \`main\` (${sha}) into the following skill branches:`, + '', + ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), + '', + 'Please resolve manually:', + '```bash', + ...failed.map(s => [ + `git checkout skill/${s}`, + `git merge main`, + `# resolve conflicts, then: git push`, + '' + ]).flat(), + '```', + '', + `Triggered by push to main: ${context.sha}` + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Merge-forward failed for ${failed.length} skill branch(es) after ${sha}`, + body, + labels: ['skill-maintenance'] + }); + + - name: Notify channel forks + if: always() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.FORK_DISPATCH_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const forks = [ + 'nanoclaw-whatsapp', + 'nanoclaw-telegram', + 'nanoclaw-discord', + 'nanoclaw-slack', + 'nanoclaw-gmail', + ]; + const sha = context.sha.substring(0, 7); + for (const repo of forks) { + try { + await github.rest.repos.createDispatchEvent({ + owner: 'qwibitai', + repo, + event_type: 'upstream-main-updated', + client_payload: { sha: context.sha }, + }); + console.log(`Notified ${repo}`); + } catch (e) { + console.log(`Failed to notify ${repo}: ${e.message}`); + } + } diff --git a/.github/workflows/skill-drift.yml b/.github/workflows/skill-drift.yml deleted file mode 100644 index 9bc7ed8..0000000 --- a/.github/workflows/skill-drift.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Skill Drift Detection - -# Runs after every push to main that touches source files. -# Validates every skill can still be cleanly applied, type-checked, and tested. -# If a skill drifts, attempts auto-fix via three-way merge of modify/ files, -# then opens a PR with the result (auto-fixed or with conflict markers). - -on: - push: - branches: [main] - paths: - - 'src/**' - - 'container/**' - - 'package.json' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - # ── Step 1: Check all skills against current main ───────────────────── - validate: - runs-on: ubuntu-latest - outputs: - drifted: ${{ steps.check.outputs.drifted }} - drifted_skills: ${{ steps.check.outputs.drifted_skills }} - results: ${{ steps.check.outputs.results }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - - name: Validate all skills against main - id: check - run: npx tsx scripts/validate-all-skills.ts - continue-on-error: true - - # ── Step 2: Auto-fix and create PR ──────────────────────────────────── - fix-drift: - needs: validate - if: needs.validate.outputs.drifted == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/create-github-app-token@v1 - id: app-token - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - uses: actions/checkout@v4 - with: - token: ${{ steps.app-token.outputs.token }} - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - - name: Attempt auto-fix via three-way merge - id: fix - run: | - SKILLS=$(echo '${{ needs.validate.outputs.drifted_skills }}' | jq -r '.[]') - npx tsx scripts/fix-skill-drift.ts $SKILLS - - - name: Create pull request - uses: peter-evans/create-pull-request@v7 - with: - token: ${{ steps.app-token.outputs.token }} - branch: ci/fix-skill-drift - delete-branch: true - title: 'fix(skills): auto-update drifted skills' - body: | - ## Skill Drift Detected - - A push to `main` (${{ github.sha }}) changed source files that caused - the following skills to fail validation: - - **Drifted:** ${{ needs.validate.outputs.drifted_skills }} - - ### Auto-fix results - - ${{ steps.fix.outputs.summary }} - - ### What to do - - 1. Review the changes to `.claude/skills/*/modify/` files - 2. If there are conflict markers (`<<<<<<<`), resolve them - 3. CI will run typecheck + tests on this PR automatically - 4. Merge when green - - --- - *Auto-generated by [skill-drift CI](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})* - labels: skill-drift,automated - commit-message: 'fix(skills): auto-update drifted skill modify/ files' diff --git a/.github/workflows/skill-pr.yml b/.github/workflows/skill-pr.yml deleted file mode 100644 index 7ecd71a..0000000 --- a/.github/workflows/skill-pr.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Skill PR Validation - -on: - pull_request: - branches: [main] - paths: - - '.claude/skills/**' - - 'skills-engine/**' - -jobs: - # ── Job 1: Policy gate ──────────────────────────────────────────────── - # Block PRs that add NEW skill files while also modifying source code. - # Skill PRs should contain instructions for Claude, not raw source edits. - policy-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Check for mixed skill + source changes - run: | - ADDED_SKILLS=$(git diff --name-only --diff-filter=A origin/main...HEAD \ - | grep '^\\.claude/skills/' || true) - CHANGED=$(git diff --name-only origin/main...HEAD) - SOURCE=$(echo "$CHANGED" \ - | grep -E '^src/|^container/|^package\.json|^package-lock\.json' || true) - - if [ -n "$ADDED_SKILLS" ] && [ -n "$SOURCE" ]; then - echo "::error::PRs that add new skills should not modify source files." - echo "" - echo "New skill files:" - echo "$ADDED_SKILLS" - echo "" - echo "Source files:" - echo "$SOURCE" - echo "" - echo "Please split into separate PRs. See CONTRIBUTING.md." - exit 1 - fi - - - name: Comment on failure - if: failure() - uses: actions/github-script@v7 - with: - script: | - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `This PR adds a skill while also modifying source code. A skill PR should not change source files—the skill should contain **instructions** for Claude to follow. - - If you're fixing a bug or simplifying code, please submit that as a separate PR. - - See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for details.` - }) - - # ── Job 2: Detect which skills changed ──────────────────────────────── - detect-changed: - runs-on: ubuntu-latest - outputs: - skills: ${{ steps.detect.outputs.skills }} - has_skills: ${{ steps.detect.outputs.has_skills }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Detect changed skills - id: detect - run: | - CHANGED_SKILLS=$(git diff --name-only origin/main...HEAD \ - | grep '^\\.claude/skills/' \ - | sed 's|^\.claude/skills/||' \ - | cut -d/ -f1 \ - | sort -u \ - | jq -R . | jq -s .) - echo "skills=$CHANGED_SKILLS" >> "$GITHUB_OUTPUT" - if [ "$CHANGED_SKILLS" = "[]" ]; then - echo "has_skills=false" >> "$GITHUB_OUTPUT" - else - echo "has_skills=true" >> "$GITHUB_OUTPUT" - fi - echo "Changed skills: $CHANGED_SKILLS" - - # ── Job 3: Validate each changed skill in isolation ─────────────────── - validate-skills: - needs: detect-changed - if: needs.detect-changed.outputs.has_skills == 'true' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - skill: ${{ fromJson(needs.detect-changed.outputs.skills) }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - - name: Initialize skills system - run: >- - npx tsx -e - "import { initNanoclawDir } from './skills-engine/index'; initNanoclawDir();" - - - name: Apply skill - run: npx tsx scripts/apply-skill.ts ".claude/skills/${{ matrix.skill }}" - - - name: Typecheck after apply - run: npx tsc --noEmit - - - name: Run skill tests - run: | - TEST_CMD=$(npx tsx -e " - import { parse } from 'yaml'; - import fs from 'fs'; - const m = parse(fs.readFileSync('.claude/skills/${{ matrix.skill }}/manifest.yaml', 'utf-8')); - if (m.test) console.log(m.test); - ") - if [ -n "$TEST_CMD" ]; then - echo "Running: $TEST_CMD" - eval "$TEST_CMD" - else - echo "No test command defined, skipping" - fi - - # ── Summary gate for branch protection ──────────────────────────────── - skill-validation-summary: - needs: - - policy-check - - detect-changed - - validate-skills - if: always() - runs-on: ubuntu-latest - steps: - - name: Check results - run: | - echo "policy-check: ${{ needs.policy-check.result }}" - echo "validate-skills: ${{ needs.validate-skills.result }}" - - if [ "${{ needs.policy-check.result }}" = "failure" ]; then - echo "::error::Policy check failed" - exit 1 - fi - if [ "${{ needs.validate-skills.result }}" = "failure" ]; then - echo "::error::Skill validation failed" - exit 1 - fi - echo "All skill checks passed" diff --git a/CLAUDE.md b/CLAUDE.md index c96b95d..90c8910 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ systemctl --user restart nanoclaw ## Troubleshooting -**WhatsApp not connecting after upgrade:** WhatsApp is now a separate skill, not bundled in core. Run `/add-whatsapp` (or `npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp && npm run build`) to install it. Existing auth credentials and groups are preserved. +**WhatsApp not connecting after upgrade:** WhatsApp is now a separate channel fork, not bundled in core. Run `/add-whatsapp` (or `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git && git fetch whatsapp main && git merge whatsapp/main && npm run build`) to install it. Existing auth credentials and groups are preserved. ## Container Build Cache diff --git a/README.md b/README.md index 19b99e7..e0e167d 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,24 @@ NanoClaw provides that same core functionality, but in a codebase small enough t ## Quick Start ```bash -git clone https://github.com/qwibitai/nanoclaw.git +gh repo fork qwibitai/nanoclaw --clone cd nanoclaw claude ``` +
+Without GitHub CLI + +1. Fork [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw) on GitHub (click the Fork button) +2. `git clone https://github.com//nanoclaw.git` +3. `cd nanoclaw` +4. `claude` + +
+ Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup and service configuration. -> **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal. +> **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal. If you don't have Claude Code installed, get it at [claude.com/product/claude-code](https://claude.com/product/claude-code). ## Philosophy @@ -98,7 +108,7 @@ The codebase is small enough that Claude can safely modify it. **Don't add features. Add skills.** -If you want to add Telegram support, don't create a PR that adds Telegram alongside WhatsApp. Instead, contribute a skill file (`.claude/skills/add-telegram/SKILL.md`) that teaches Claude Code how to transform a NanoClaw installation to use Telegram. +If you want to add Telegram support, don't create a PR that adds Telegram to the core codebase. Instead, fork NanoClaw, make the code changes on a branch, and open a PR. We'll create a `skill/telegram` branch from your PR that other users can merge into their fork. Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case. diff --git a/docs/skills-as-branches.md b/docs/skills-as-branches.md new file mode 100644 index 0000000..e1cace4 --- /dev/null +++ b/docs/skills-as-branches.md @@ -0,0 +1,662 @@ +# 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/scripts/apply-skill.ts b/scripts/apply-skill.ts deleted file mode 100644 index db31bdc..0000000 --- a/scripts/apply-skill.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { applySkill } from '../skills-engine/apply.js'; -import { initNanoclawDir } from '../skills-engine/init.js'; - -const args = process.argv.slice(2); - -// Handle --init flag: initialize .nanoclaw/ directory and exit -if (args.includes('--init')) { - initNanoclawDir(); - console.log(JSON.stringify({ success: true, action: 'init' })); - process.exit(0); -} - -const skillDir = args[0]; -if (!skillDir) { - console.error('Usage: tsx scripts/apply-skill.ts [--init] '); - process.exit(1); -} - -const result = await applySkill(skillDir); -console.log(JSON.stringify(result, null, 2)); - -if (!result.success) { - process.exit(1); -} diff --git a/scripts/fix-skill-drift.ts b/scripts/fix-skill-drift.ts deleted file mode 100644 index ffa5c35..0000000 --- a/scripts/fix-skill-drift.ts +++ /dev/null @@ -1,266 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Auto-fix drifted skills by three-way merging their modify/ files. - * - * For each drifted skill's `modifies` entry: - * 1. Find the commit where the skill's modify/ copy was last updated - * 2. Retrieve the source file at that commit (old base) - * 3. git merge-file - * - Clean merge → modify/ file is auto-updated - * - Conflicts → conflict markers left in place for human/Claude review - * - * The calling workflow should commit the resulting changes and create a PR. - * - * Sets GitHub Actions outputs: - * has_conflicts — "true" | "false" - * fixed_count — number of auto-fixed files - * conflict_count — number of files with unresolved conflict markers - * summary — human-readable summary for PR body - * - * Usage: npx tsx scripts/fix-skill-drift.ts add-telegram add-discord - */ -import { execFileSync, execSync } from 'child_process'; -import crypto from 'crypto'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import { parse } from 'yaml'; -import type { SkillManifest } from '../skills-engine/types.js'; - -interface FixResult { - skill: string; - file: string; - status: 'auto-fixed' | 'conflict' | 'skipped' | 'error'; - conflicts?: number; - reason?: string; -} - -function readManifest(skillDir: string): SkillManifest { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - return parse(fs.readFileSync(manifestPath, 'utf-8')) as SkillManifest; -} - -function fixSkill(skillName: string, projectRoot: string): FixResult[] { - const skillDir = path.join(projectRoot, '.claude', 'skills', skillName); - const manifest = readManifest(skillDir); - const results: FixResult[] = []; - - for (const relPath of manifest.modifies) { - const modifyPath = path.join(skillDir, 'modify', relPath); - const currentPath = path.join(projectRoot, relPath); - - if (!fs.existsSync(modifyPath)) { - results.push({ - skill: skillName, - file: relPath, - status: 'skipped', - reason: 'modify/ file not found', - }); - continue; - } - - if (!fs.existsSync(currentPath)) { - results.push({ - skill: skillName, - file: relPath, - status: 'skipped', - reason: 'source file not found on main', - }); - continue; - } - - // Find when the skill's modify file was last changed - let lastCommit: string; - try { - lastCommit = execSync(`git log -1 --format=%H -- "${modifyPath}"`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - } catch { - results.push({ - skill: skillName, - file: relPath, - status: 'skipped', - reason: 'no git history for modify file', - }); - continue; - } - - if (!lastCommit) { - results.push({ - skill: skillName, - file: relPath, - status: 'skipped', - reason: 'no commits found for modify file', - }); - continue; - } - - // Get the source file at that commit (the old base the skill was written against) - const tmpOldBase = path.join( - os.tmpdir(), - `nanoclaw-drift-base-${crypto.randomUUID()}`, - ); - try { - const oldBase = execSync(`git show "${lastCommit}:${relPath}"`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - fs.writeFileSync(tmpOldBase, oldBase); - } catch { - results.push({ - skill: skillName, - file: relPath, - status: 'skipped', - reason: `source file not found at commit ${lastCommit.slice(0, 7)}`, - }); - continue; - } - - // If old base == current main, the source hasn't changed since the skill was updated. - // The skill is already in sync for this file. - const currentContent = fs.readFileSync(currentPath, 'utf-8'); - const oldBaseContent = fs.readFileSync(tmpOldBase, 'utf-8'); - if (oldBaseContent === currentContent) { - fs.unlinkSync(tmpOldBase); - results.push({ - skill: skillName, - file: relPath, - status: 'skipped', - reason: 'source unchanged since skill update', - }); - continue; - } - - // Three-way merge: modify/file ← old_base → current_main - // git merge-file modifies first argument in-place - try { - execFileSync('git', ['merge-file', modifyPath, tmpOldBase, currentPath], { - stdio: 'pipe', - }); - results.push({ skill: skillName, file: relPath, status: 'auto-fixed' }); - } catch (err: any) { - const exitCode = err.status ?? -1; - if (exitCode > 0) { - // Positive exit code = number of conflicts, file has markers - results.push({ - skill: skillName, - file: relPath, - status: 'conflict', - conflicts: exitCode, - }); - } else { - results.push({ - skill: skillName, - file: relPath, - status: 'error', - reason: err.message, - }); - } - } finally { - try { - fs.unlinkSync(tmpOldBase); - } catch { - /* ignore */ - } - } - } - - return results; -} - -function setOutput(key: string, value: string): void { - const outputFile = process.env.GITHUB_OUTPUT; - if (!outputFile) return; - - if (value.includes('\n')) { - const delimiter = `ghadelim_${Date.now()}`; - fs.appendFileSync( - outputFile, - `${key}<<${delimiter}\n${value}\n${delimiter}\n`, - ); - } else { - fs.appendFileSync(outputFile, `${key}=${value}\n`); - } -} - -async function main(): Promise { - const projectRoot = process.cwd(); - const skillNames = process.argv.slice(2); - - if (skillNames.length === 0) { - console.error( - 'Usage: npx tsx scripts/fix-skill-drift.ts [skill2] ...', - ); - process.exit(1); - } - - console.log(`Attempting auto-fix for: ${skillNames.join(', ')}\n`); - - const allResults: FixResult[] = []; - - for (const skillName of skillNames) { - console.log(`--- ${skillName} ---`); - const results = fixSkill(skillName, projectRoot); - allResults.push(...results); - - for (const r of results) { - const icon = - r.status === 'auto-fixed' - ? 'FIXED' - : r.status === 'conflict' - ? `CONFLICT (${r.conflicts})` - : r.status === 'skipped' - ? 'SKIP' - : 'ERROR'; - const detail = r.reason ? ` -- ${r.reason}` : ''; - console.log(` ${icon} ${r.file}${detail}`); - } - } - - // Summary - const fixed = allResults.filter((r) => r.status === 'auto-fixed'); - const conflicts = allResults.filter((r) => r.status === 'conflict'); - const skipped = allResults.filter((r) => r.status === 'skipped'); - - console.log('\n=== Summary ==='); - console.log(` Auto-fixed: ${fixed.length}`); - console.log(` Conflicts: ${conflicts.length}`); - console.log(` Skipped: ${skipped.length}`); - - // Build markdown summary for PR body - const summaryLines: string[] = []; - for (const skillName of skillNames) { - const skillResults = allResults.filter((r) => r.skill === skillName); - const fixedFiles = skillResults.filter((r) => r.status === 'auto-fixed'); - const conflictFiles = skillResults.filter((r) => r.status === 'conflict'); - - summaryLines.push(`### ${skillName}`); - if (fixedFiles.length > 0) { - summaryLines.push( - `Auto-fixed: ${fixedFiles.map((r) => `\`${r.file}\``).join(', ')}`, - ); - } - if (conflictFiles.length > 0) { - summaryLines.push( - `Needs manual resolution: ${conflictFiles.map((r) => `\`${r.file}\``).join(', ')}`, - ); - } - if (fixedFiles.length === 0 && conflictFiles.length === 0) { - summaryLines.push('No modify/ files needed updating.'); - } - summaryLines.push(''); - } - - // GitHub outputs - setOutput('has_conflicts', conflicts.length > 0 ? 'true' : 'false'); - setOutput('fixed_count', String(fixed.length)); - setOutput('conflict_count', String(conflicts.length)); - setOutput('summary', summaryLines.join('\n')); -} - -main().catch((err) => { - console.error('Fatal error:', err); - process.exit(1); -}); diff --git a/scripts/rebase.ts b/scripts/rebase.ts deleted file mode 100644 index 047e07c..0000000 --- a/scripts/rebase.ts +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env npx tsx -import { rebase } from '../skills-engine/rebase.js'; - -async function main() { - const newBasePath = process.argv[2]; // optional - - if (newBasePath) { - console.log(`Rebasing with new base from: ${newBasePath}`); - } else { - console.log('Rebasing current state...'); - } - - const result = await rebase(newBasePath); - console.log(JSON.stringify(result, null, 2)); - - if (!result.success) { - process.exit(1); - } -} - -main(); diff --git a/scripts/run-migrations.ts b/scripts/run-migrations.ts index 355312a..b75c26e 100644 --- a/scripts/run-migrations.ts +++ b/scripts/run-migrations.ts @@ -3,7 +3,15 @@ import { execFileSync, execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; -import { compareSemver } from '../skills-engine/state.js'; +function compareSemver(a: string, b: string): number { + const partsA = a.split('.').map(Number); + const partsB = b.split('.').map(Number); + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const diff = (partsA[i] || 0) - (partsB[i] || 0); + if (diff !== 0) return diff; + } + return 0; +} // Resolve tsx binary once to avoid npx race conditions across migrations function resolveTsx(): string { diff --git a/scripts/uninstall-skill.ts b/scripts/uninstall-skill.ts deleted file mode 100644 index a3d6682..0000000 --- a/scripts/uninstall-skill.ts +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env npx tsx -import { uninstallSkill } from '../skills-engine/uninstall.js'; - -async function main() { - const skillName = process.argv[2]; - if (!skillName) { - console.error('Usage: npx tsx scripts/uninstall-skill.ts '); - process.exit(1); - } - - console.log(`Uninstalling skill: ${skillName}`); - const result = await uninstallSkill(skillName); - - if (result.customPatchWarning) { - console.warn(`\nWarning: ${result.customPatchWarning}`); - console.warn( - 'To proceed, remove the custom_patch from state.yaml and re-run.', - ); - process.exit(1); - } - - if (!result.success) { - console.error(`\nFailed: ${result.error}`); - process.exit(1); - } - - console.log(`\nSuccessfully uninstalled: ${skillName}`); - if (result.replayResults) { - console.log('Replay test results:'); - for (const [name, passed] of Object.entries(result.replayResults)) { - console.log(` ${name}: ${passed ? 'PASS' : 'FAIL'}`); - } - } -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/validate-all-skills.ts b/scripts/validate-all-skills.ts deleted file mode 100644 index 5208a90..0000000 --- a/scripts/validate-all-skills.ts +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Validate all skills by applying each in isolation against current main. - * - * For each skill: - * 1. Reset working tree to clean state - * 2. Initialize .nanoclaw/ (snapshot current source as base) - * 3. Apply skill via apply-skill.ts - * 4. Run tsc --noEmit (typecheck) - * 5. Run the skill's test command (from manifest.yaml) - * - * Sets GitHub Actions outputs: - * drifted — "true" | "false" - * drifted_skills — JSON array of drifted skill names, e.g. ["add-telegram"] - * results — JSON array of per-skill results - * - * Exit code 1 if any skill drifted, 0 otherwise. - * - * Usage: - * npx tsx scripts/validate-all-skills.ts # validate all - * npx tsx scripts/validate-all-skills.ts add-telegram # validate one - */ -import { execSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { parse } from 'yaml'; -import type { SkillManifest } from '../skills-engine/types.js'; - -interface SkillValidationResult { - name: string; - success: boolean; - failedStep?: 'apply' | 'typecheck' | 'test'; - error?: string; -} - -function discoverSkills( - skillsDir: string, -): { name: string; dir: string; manifest: SkillManifest }[] { - if (!fs.existsSync(skillsDir)) return []; - const results: { name: string; dir: string; manifest: SkillManifest }[] = []; - - for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const manifestPath = path.join(skillsDir, entry.name, 'manifest.yaml'); - if (!fs.existsSync(manifestPath)) continue; - const manifest = parse( - fs.readFileSync(manifestPath, 'utf-8'), - ) as SkillManifest; - results.push({ - name: entry.name, - dir: path.join(skillsDir, entry.name), - manifest, - }); - } - - return results; -} - -/** Restore tracked files and remove untracked skill artifacts. */ -function resetWorkingTree(): void { - execSync('git checkout -- .', { stdio: 'pipe' }); - // Remove untracked files added by skill application (e.g. src/channels/telegram.ts) - // but preserve node_modules to avoid costly reinstalls. - execSync('git clean -fd --exclude=node_modules', { stdio: 'pipe' }); - // Clean skills-system state directory - if (fs.existsSync('.nanoclaw')) { - fs.rmSync('.nanoclaw', { recursive: true, force: true }); - } -} - -function initNanoclaw(): void { - execSync( - 'npx tsx -e "import { initNanoclawDir } from \'./skills-engine/index\'; initNanoclawDir();"', - { stdio: 'pipe', timeout: 30_000 }, - ); -} - -/** Append a key=value to $GITHUB_OUTPUT (no-op locally). */ -function setOutput(key: string, value: string): void { - const outputFile = process.env.GITHUB_OUTPUT; - if (!outputFile) return; - - if (value.includes('\n')) { - const delimiter = `ghadelim_${Date.now()}`; - fs.appendFileSync( - outputFile, - `${key}<<${delimiter}\n${value}\n${delimiter}\n`, - ); - } else { - fs.appendFileSync(outputFile, `${key}=${value}\n`); - } -} - -function truncate(s: string, max = 300): string { - return s.length > max ? s.slice(0, max) + '...' : s; -} - -async function main(): Promise { - const projectRoot = process.cwd(); - const skillsDir = path.join(projectRoot, '.claude', 'skills'); - - // Allow filtering to specific skills via CLI args - const filterSkills = process.argv.slice(2); - - let skills = discoverSkills(skillsDir); - if (filterSkills.length > 0) { - skills = skills.filter((s) => filterSkills.includes(s.name)); - } - - if (skills.length === 0) { - console.log('No skills found to validate.'); - setOutput('drifted', 'false'); - setOutput('drifted_skills', '[]'); - setOutput('results', '[]'); - process.exit(0); - } - - console.log( - `Validating ${skills.length} skill(s): ${skills.map((s) => s.name).join(', ')}\n`, - ); - - const results: SkillValidationResult[] = []; - - for (const skill of skills) { - console.log(`--- ${skill.name} ---`); - - // Clean slate - resetWorkingTree(); - initNanoclaw(); - - // Step 1: Apply skill - try { - const applyOutput = execSync( - `npx tsx scripts/apply-skill.ts "${skill.dir}"`, - { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 120_000, - }, - ); - // parse stdout to verify success - try { - const parsed = JSON.parse(applyOutput); - if (!parsed.success) { - console.log(` FAIL (apply): ${truncate(parsed.error || 'unknown')}`); - results.push({ - name: skill.name, - success: false, - failedStep: 'apply', - error: parsed.error, - }); - continue; - } - } catch { - // Non-JSON stdout with exit 0 is treated as success - } - } catch (err: any) { - const stderr = err.stderr?.toString() || ''; - const stdout = err.stdout?.toString() || ''; - let error = 'Apply failed'; - try { - const parsed = JSON.parse(stdout); - error = parsed.error || error; - } catch { - error = stderr || stdout || err.message; - } - console.log(` FAIL (apply): ${truncate(error)}`); - results.push({ - name: skill.name, - success: false, - failedStep: 'apply', - error, - }); - continue; - } - console.log(' apply: OK'); - - // Step 2: Typecheck - try { - execSync('npx tsc --noEmit', { - stdio: 'pipe', - timeout: 120_000, - }); - } catch (err: any) { - const error = err.stdout?.toString() || err.message; - console.log(` FAIL (typecheck): ${truncate(error)}`); - results.push({ - name: skill.name, - success: false, - failedStep: 'typecheck', - error, - }); - continue; - } - console.log(' typecheck: OK'); - - // Step 3: Skill's own test command - if (skill.manifest.test) { - try { - execSync(skill.manifest.test, { - stdio: 'pipe', - timeout: 300_000, - }); - } catch (err: any) { - const error = - err.stdout?.toString() || err.stderr?.toString() || err.message; - console.log(` FAIL (test): ${truncate(error)}`); - results.push({ - name: skill.name, - success: false, - failedStep: 'test', - error, - }); - continue; - } - console.log(' test: OK'); - } - - console.log(' PASS'); - results.push({ name: skill.name, success: true }); - } - - // Restore clean state - resetWorkingTree(); - - // Summary - const drifted = results.filter((r) => !r.success); - const passed = results.filter((r) => r.success); - - console.log('\n=== Summary ==='); - for (const r of results) { - const status = r.success ? 'PASS' : 'FAIL'; - const detail = r.failedStep ? ` (${r.failedStep})` : ''; - console.log(` ${status} ${r.name}${detail}`); - } - console.log(`\n${passed.length} passed, ${drifted.length} failed`); - - // GitHub Actions outputs - setOutput('drifted', drifted.length > 0 ? 'true' : 'false'); - setOutput('drifted_skills', JSON.stringify(drifted.map((d) => d.name))); - setOutput('results', JSON.stringify(results)); - - if (drifted.length > 0) { - process.exit(1); - } -} - -main().catch((err) => { - console.error('Fatal error:', err); - process.exit(1); -}); diff --git a/skills-engine/__tests__/apply.test.ts b/skills-engine/__tests__/apply.test.ts deleted file mode 100644 index bb41f32..0000000 --- a/skills-engine/__tests__/apply.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { applySkill } from '../apply.js'; -import { - cleanup, - createMinimalState, - createSkillPackage, - createTempDir, - initGitRepo, - setupNanoclawDir, -} from './test-helpers.js'; -import { readState, writeState } from '../state.js'; - -describe('apply', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - setupNanoclawDir(tmpDir); - createMinimalState(tmpDir); - initGitRepo(tmpDir); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - it('rejects when min_skills_system_version is too high', async () => { - const skillDir = createSkillPackage(tmpDir, { - skill: 'future-skill', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - min_skills_system_version: '99.0.0', - }); - - const result = await applySkill(skillDir); - expect(result.success).toBe(false); - expect(result.error).toContain('99.0.0'); - }); - - it('executes post_apply commands on success', async () => { - const markerFile = path.join(tmpDir, 'post-apply-marker.txt'); - const skillDir = createSkillPackage(tmpDir, { - skill: 'post-test', - version: '1.0.0', - core_version: '1.0.0', - adds: ['src/newfile.ts'], - modifies: [], - addFiles: { 'src/newfile.ts': 'export const x = 1;' }, - post_apply: [`echo "applied" > "${markerFile}"`], - }); - - const result = await applySkill(skillDir); - expect(result.success).toBe(true); - expect(fs.existsSync(markerFile)).toBe(true); - expect(fs.readFileSync(markerFile, 'utf-8').trim()).toBe('applied'); - }); - - it('rolls back on post_apply failure', async () => { - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - const existingFile = path.join(tmpDir, 'src/existing.ts'); - fs.writeFileSync(existingFile, 'original content'); - - // Set up base for the modified file - const baseDir = path.join(tmpDir, '.nanoclaw', 'base', 'src'); - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync(path.join(baseDir, 'existing.ts'), 'original content'); - - const skillDir = createSkillPackage(tmpDir, { - skill: 'bad-post', - version: '1.0.0', - core_version: '1.0.0', - adds: ['src/added.ts'], - modifies: [], - addFiles: { 'src/added.ts': 'new file' }, - post_apply: ['false'], // always fails - }); - - const result = await applySkill(skillDir); - expect(result.success).toBe(false); - expect(result.error).toContain('post_apply'); - - // Added file should be cleaned up - expect(fs.existsSync(path.join(tmpDir, 'src/added.ts'))).toBe(false); - }); - - it('does not allow path_remap to write files outside project root', async () => { - const state = readState(); - state.path_remap = { 'src/newfile.ts': '../../outside.txt' }; - writeState(state); - - const skillDir = createSkillPackage(tmpDir, { - skill: 'remap-escape', - version: '1.0.0', - core_version: '1.0.0', - adds: ['src/newfile.ts'], - modifies: [], - addFiles: { 'src/newfile.ts': 'safe content' }, - }); - - const result = await applySkill(skillDir); - expect(result.success).toBe(true); - - // Remap escape is ignored; file remains constrained inside project root. - expect(fs.existsSync(path.join(tmpDir, 'src/newfile.ts'))).toBe(true); - expect(fs.existsSync(path.join(tmpDir, '..', 'outside.txt'))).toBe(false); - }); - - it('does not allow path_remap symlink targets to write outside project root', async () => { - const outsideDir = fs.mkdtempSync( - path.join(path.dirname(tmpDir), 'nanoclaw-remap-outside-'), - ); - const linkPath = path.join(tmpDir, 'link-out'); - - try { - fs.symlinkSync(outsideDir, linkPath); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'EPERM' || code === 'EACCES' || code === 'ENOSYS') { - fs.rmSync(outsideDir, { recursive: true, force: true }); - return; - } - fs.rmSync(outsideDir, { recursive: true, force: true }); - throw err; - } - - try { - const state = readState(); - state.path_remap = { 'src/newfile.ts': 'link-out/pwned.txt' }; - writeState(state); - - const skillDir = createSkillPackage(tmpDir, { - skill: 'remap-symlink-escape', - version: '1.0.0', - core_version: '1.0.0', - adds: ['src/newfile.ts'], - modifies: [], - addFiles: { 'src/newfile.ts': 'safe content' }, - }); - - const result = await applySkill(skillDir); - expect(result.success).toBe(true); - - expect(fs.existsSync(path.join(tmpDir, 'src/newfile.ts'))).toBe(true); - expect(fs.existsSync(path.join(outsideDir, 'pwned.txt'))).toBe(false); - } finally { - fs.rmSync(outsideDir, { recursive: true, force: true }); - } - }); -}); diff --git a/skills-engine/__tests__/backup.test.ts b/skills-engine/__tests__/backup.test.ts deleted file mode 100644 index aeeb6ee..0000000 --- a/skills-engine/__tests__/backup.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'fs'; -import path from 'path'; -import { createBackup, restoreBackup, clearBackup } from '../backup.js'; -import { createTempDir, setupNanoclawDir, cleanup } from './test-helpers.js'; - -describe('backup', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - setupNanoclawDir(tmpDir); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - it('createBackup copies files and restoreBackup puts them back', () => { - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, 'src', 'app.ts'), 'original content'); - - createBackup(['src/app.ts']); - - fs.writeFileSync(path.join(tmpDir, 'src', 'app.ts'), 'modified content'); - expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe( - 'modified content', - ); - - restoreBackup(); - expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe( - 'original content', - ); - }); - - it('createBackup skips missing files without error', () => { - expect(() => createBackup(['does-not-exist.ts'])).not.toThrow(); - }); - - it('clearBackup removes backup directory', () => { - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, 'src', 'app.ts'), 'content'); - createBackup(['src/app.ts']); - - const backupDir = path.join(tmpDir, '.nanoclaw', 'backup'); - expect(fs.existsSync(backupDir)).toBe(true); - - clearBackup(); - expect(fs.existsSync(backupDir)).toBe(false); - }); - - it('createBackup writes tombstone for non-existent files', () => { - createBackup(['src/newfile.ts']); - - const tombstone = path.join( - tmpDir, - '.nanoclaw', - 'backup', - 'src', - 'newfile.ts.tombstone', - ); - expect(fs.existsSync(tombstone)).toBe(true); - }); - - it('restoreBackup deletes files with tombstone markers', () => { - // Create backup first — file doesn't exist yet, so tombstone is written - createBackup(['src/added.ts']); - - // Now the file gets created (simulating skill apply) - const filePath = path.join(tmpDir, 'src', 'added.ts'); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, 'new content'); - expect(fs.existsSync(filePath)).toBe(true); - - // Restore should delete the file (tombstone means it didn't exist before) - restoreBackup(); - expect(fs.existsSync(filePath)).toBe(false); - }); - - it('restoreBackup is no-op when backup dir is empty or missing', () => { - clearBackup(); - expect(() => restoreBackup()).not.toThrow(); - }); -}); diff --git a/skills-engine/__tests__/constants.test.ts b/skills-engine/__tests__/constants.test.ts deleted file mode 100644 index 4ceeb3d..0000000 --- a/skills-engine/__tests__/constants.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - NANOCLAW_DIR, - STATE_FILE, - BASE_DIR, - BACKUP_DIR, - LOCK_FILE, - CUSTOM_DIR, - SKILLS_SCHEMA_VERSION, -} from '../constants.js'; - -describe('constants', () => { - const allConstants = { - NANOCLAW_DIR, - STATE_FILE, - BASE_DIR, - BACKUP_DIR, - LOCK_FILE, - CUSTOM_DIR, - SKILLS_SCHEMA_VERSION, - }; - - it('all constants are non-empty strings', () => { - for (const [name, value] of Object.entries(allConstants)) { - expect(value, `${name} should be a non-empty string`).toBeTruthy(); - expect(typeof value, `${name} should be a string`).toBe('string'); - } - }); - - it('path constants use forward slashes and .nanoclaw prefix', () => { - const pathConstants = [BASE_DIR, BACKUP_DIR, LOCK_FILE, CUSTOM_DIR]; - for (const p of pathConstants) { - expect(p).not.toContain('\\'); - expect(p).toMatch(/^\.nanoclaw\//); - } - }); - - it('NANOCLAW_DIR is .nanoclaw', () => { - expect(NANOCLAW_DIR).toBe('.nanoclaw'); - }); -}); diff --git a/skills-engine/__tests__/customize.test.ts b/skills-engine/__tests__/customize.test.ts deleted file mode 100644 index 1c055a2..0000000 --- a/skills-engine/__tests__/customize.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'fs'; -import path from 'path'; -import { - isCustomizeActive, - startCustomize, - commitCustomize, - abortCustomize, -} from '../customize.js'; -import { CUSTOM_DIR } from '../constants.js'; -import { - createTempDir, - setupNanoclawDir, - createMinimalState, - cleanup, - writeState, -} from './test-helpers.js'; -import { - readState, - recordSkillApplication, - computeFileHash, -} from '../state.js'; - -describe('customize', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - setupNanoclawDir(tmpDir); - createMinimalState(tmpDir); - fs.mkdirSync(path.join(tmpDir, CUSTOM_DIR), { recursive: true }); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - it('startCustomize creates pending.yaml and isCustomizeActive returns true', () => { - // Need at least one applied skill with file_hashes for snapshot - const trackedFile = path.join(tmpDir, 'src', 'app.ts'); - fs.mkdirSync(path.dirname(trackedFile), { recursive: true }); - fs.writeFileSync(trackedFile, 'export const x = 1;'); - recordSkillApplication('test-skill', '1.0.0', { - 'src/app.ts': computeFileHash(trackedFile), - }); - - expect(isCustomizeActive()).toBe(false); - startCustomize('test customization'); - expect(isCustomizeActive()).toBe(true); - - const pendingPath = path.join(tmpDir, CUSTOM_DIR, 'pending.yaml'); - expect(fs.existsSync(pendingPath)).toBe(true); - }); - - it('abortCustomize removes pending.yaml', () => { - const trackedFile = path.join(tmpDir, 'src', 'app.ts'); - fs.mkdirSync(path.dirname(trackedFile), { recursive: true }); - fs.writeFileSync(trackedFile, 'export const x = 1;'); - recordSkillApplication('test-skill', '1.0.0', { - 'src/app.ts': computeFileHash(trackedFile), - }); - - startCustomize('test'); - expect(isCustomizeActive()).toBe(true); - - abortCustomize(); - expect(isCustomizeActive()).toBe(false); - }); - - it('commitCustomize with no changes clears pending', () => { - const trackedFile = path.join(tmpDir, 'src', 'app.ts'); - fs.mkdirSync(path.dirname(trackedFile), { recursive: true }); - fs.writeFileSync(trackedFile, 'export const x = 1;'); - recordSkillApplication('test-skill', '1.0.0', { - 'src/app.ts': computeFileHash(trackedFile), - }); - - startCustomize('no-op'); - commitCustomize(); - - expect(isCustomizeActive()).toBe(false); - }); - - it('commitCustomize with changes creates patch and records in state', () => { - const trackedFile = path.join(tmpDir, 'src', 'app.ts'); - fs.mkdirSync(path.dirname(trackedFile), { recursive: true }); - fs.writeFileSync(trackedFile, 'export const x = 1;'); - recordSkillApplication('test-skill', '1.0.0', { - 'src/app.ts': computeFileHash(trackedFile), - }); - - startCustomize('add feature'); - - // Modify the tracked file - fs.writeFileSync(trackedFile, 'export const x = 2;\nexport const y = 3;'); - - commitCustomize(); - - expect(isCustomizeActive()).toBe(false); - const state = readState(); - expect(state.custom_modifications).toBeDefined(); - expect(state.custom_modifications!.length).toBeGreaterThan(0); - expect(state.custom_modifications![0].description).toBe('add feature'); - }); - - it('commitCustomize throws descriptive error on diff failure', () => { - const trackedFile = path.join(tmpDir, 'src', 'app.ts'); - fs.mkdirSync(path.dirname(trackedFile), { recursive: true }); - fs.writeFileSync(trackedFile, 'export const x = 1;'); - recordSkillApplication('test-skill', '1.0.0', { - 'src/app.ts': computeFileHash(trackedFile), - }); - - startCustomize('diff-error test'); - - // Modify the tracked file - fs.writeFileSync(trackedFile, 'export const x = 2;'); - - // Make the base file a directory to cause diff to exit with code 2 - const baseFilePath = path.join( - tmpDir, - '.nanoclaw', - 'base', - 'src', - 'app.ts', - ); - fs.mkdirSync(baseFilePath, { recursive: true }); - - expect(() => commitCustomize()).toThrow(/diff error/i); - }); - - it('startCustomize while active throws', () => { - const trackedFile = path.join(tmpDir, 'src', 'app.ts'); - fs.mkdirSync(path.dirname(trackedFile), { recursive: true }); - fs.writeFileSync(trackedFile, 'export const x = 1;'); - recordSkillApplication('test-skill', '1.0.0', { - 'src/app.ts': computeFileHash(trackedFile), - }); - - startCustomize('first'); - expect(() => startCustomize('second')).toThrow(); - }); -}); diff --git a/skills-engine/__tests__/file-ops.test.ts b/skills-engine/__tests__/file-ops.test.ts deleted file mode 100644 index bfb32e8..0000000 --- a/skills-engine/__tests__/file-ops.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'fs'; -import path from 'path'; -import { executeFileOps } from '../file-ops.js'; -import { createTempDir, cleanup } from './test-helpers.js'; - -function shouldSkipSymlinkTests(err: unknown): boolean { - return !!( - err && - typeof err === 'object' && - 'code' in err && - ((err as { code?: string }).code === 'EPERM' || - (err as { code?: string }).code === 'EACCES' || - (err as { code?: string }).code === 'ENOSYS') - ); -} - -describe('file-ops', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - it('rename success', () => { - fs.writeFileSync(path.join(tmpDir, 'old.ts'), 'content'); - const result = executeFileOps( - [{ type: 'rename', from: 'old.ts', to: 'new.ts' }], - tmpDir, - ); - expect(result.success).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'new.ts'))).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'old.ts'))).toBe(false); - }); - - it('move success', () => { - fs.writeFileSync(path.join(tmpDir, 'file.ts'), 'content'); - const result = executeFileOps( - [{ type: 'move', from: 'file.ts', to: 'sub/file.ts' }], - tmpDir, - ); - expect(result.success).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'sub', 'file.ts'))).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'file.ts'))).toBe(false); - }); - - it('delete success', () => { - fs.writeFileSync(path.join(tmpDir, 'remove-me.ts'), 'content'); - const result = executeFileOps( - [{ type: 'delete', path: 'remove-me.ts' }], - tmpDir, - ); - expect(result.success).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'remove-me.ts'))).toBe(false); - }); - - it('rename target exists produces error', () => { - fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'a'); - fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'b'); - const result = executeFileOps( - [{ type: 'rename', from: 'a.ts', to: 'b.ts' }], - tmpDir, - ); - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('delete missing file produces warning not error', () => { - const result = executeFileOps( - [{ type: 'delete', path: 'nonexistent.ts' }], - tmpDir, - ); - expect(result.success).toBe(true); - expect(result.warnings.length).toBeGreaterThan(0); - }); - - it('move creates destination directory', () => { - fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'content'); - const result = executeFileOps( - [{ type: 'move', from: 'src.ts', to: 'deep/nested/dir/src.ts' }], - tmpDir, - ); - expect(result.success).toBe(true); - expect( - fs.existsSync(path.join(tmpDir, 'deep', 'nested', 'dir', 'src.ts')), - ).toBe(true); - }); - - it('path escape produces error', () => { - fs.writeFileSync(path.join(tmpDir, 'file.ts'), 'content'); - const result = executeFileOps( - [{ type: 'rename', from: 'file.ts', to: '../../escaped.ts' }], - tmpDir, - ); - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('source missing produces error for rename', () => { - const result = executeFileOps( - [{ type: 'rename', from: 'missing.ts', to: 'new.ts' }], - tmpDir, - ); - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('move rejects symlink escape to outside project root', () => { - const outsideDir = createTempDir(); - - try { - fs.symlinkSync(outsideDir, path.join(tmpDir, 'linkdir')); - } catch (err) { - cleanup(outsideDir); - if (shouldSkipSymlinkTests(err)) return; - throw err; - } - - fs.writeFileSync(path.join(tmpDir, 'source.ts'), 'content'); - - const result = executeFileOps( - [{ type: 'move', from: 'source.ts', to: 'linkdir/pwned.ts' }], - tmpDir, - ); - - expect(result.success).toBe(false); - expect(result.errors.some((e) => e.includes('escapes project root'))).toBe( - true, - ); - expect(fs.existsSync(path.join(tmpDir, 'source.ts'))).toBe(true); - expect(fs.existsSync(path.join(outsideDir, 'pwned.ts'))).toBe(false); - - cleanup(outsideDir); - }); - - it('delete rejects symlink escape to outside project root', () => { - const outsideDir = createTempDir(); - const outsideFile = path.join(outsideDir, 'victim.ts'); - fs.writeFileSync(outsideFile, 'secret'); - - try { - fs.symlinkSync(outsideDir, path.join(tmpDir, 'linkdir')); - } catch (err) { - cleanup(outsideDir); - if (shouldSkipSymlinkTests(err)) return; - throw err; - } - - const result = executeFileOps( - [{ type: 'delete', path: 'linkdir/victim.ts' }], - tmpDir, - ); - - expect(result.success).toBe(false); - expect(result.errors.some((e) => e.includes('escapes project root'))).toBe( - true, - ); - expect(fs.existsSync(outsideFile)).toBe(true); - - cleanup(outsideDir); - }); -}); diff --git a/skills-engine/__tests__/lock.test.ts b/skills-engine/__tests__/lock.test.ts deleted file mode 100644 index 57840e6..0000000 --- a/skills-engine/__tests__/lock.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'fs'; -import path from 'path'; -import { acquireLock, releaseLock, isLocked } from '../lock.js'; -import { LOCK_FILE } from '../constants.js'; -import { createTempDir, cleanup } from './test-helpers.js'; - -describe('lock', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - fs.mkdirSync(path.join(tmpDir, '.nanoclaw'), { recursive: true }); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - it('acquireLock returns a release function', () => { - const release = acquireLock(); - expect(typeof release).toBe('function'); - expect(fs.existsSync(path.join(tmpDir, LOCK_FILE))).toBe(true); - release(); - }); - - it('releaseLock removes the lock file', () => { - acquireLock(); - expect(fs.existsSync(path.join(tmpDir, LOCK_FILE))).toBe(true); - releaseLock(); - expect(fs.existsSync(path.join(tmpDir, LOCK_FILE))).toBe(false); - }); - - it('acquire after release succeeds', () => { - const release1 = acquireLock(); - release1(); - const release2 = acquireLock(); - expect(typeof release2).toBe('function'); - release2(); - }); - - it('isLocked returns true when locked', () => { - const release = acquireLock(); - expect(isLocked()).toBe(true); - release(); - }); - - it('isLocked returns false when released', () => { - const release = acquireLock(); - release(); - expect(isLocked()).toBe(false); - }); - - it('isLocked returns false when no lock exists', () => { - expect(isLocked()).toBe(false); - }); -}); diff --git a/skills-engine/__tests__/manifest.test.ts b/skills-engine/__tests__/manifest.test.ts deleted file mode 100644 index b5f695a..0000000 --- a/skills-engine/__tests__/manifest.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'fs'; -import path from 'path'; -import { stringify } from 'yaml'; -import { - readManifest, - checkCoreVersion, - checkDependencies, - checkConflicts, - checkSystemVersion, -} from '../manifest.js'; -import { - createTempDir, - setupNanoclawDir, - createMinimalState, - createSkillPackage, - cleanup, - writeState, -} from './test-helpers.js'; -import { recordSkillApplication } from '../state.js'; - -describe('manifest', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - setupNanoclawDir(tmpDir); - createMinimalState(tmpDir); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - it('parses a valid manifest', () => { - const skillDir = createSkillPackage(tmpDir, { - skill: 'telegram', - version: '2.0.0', - core_version: '1.0.0', - adds: ['src/telegram.ts'], - modifies: ['src/config.ts'], - }); - const manifest = readManifest(skillDir); - expect(manifest.skill).toBe('telegram'); - expect(manifest.version).toBe('2.0.0'); - expect(manifest.adds).toEqual(['src/telegram.ts']); - expect(manifest.modifies).toEqual(['src/config.ts']); - }); - - it('throws on missing skill field', () => { - const dir = path.join(tmpDir, 'bad-pkg'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - }), - ); - expect(() => readManifest(dir)).toThrow(); - }); - - it('throws on missing version field', () => { - const dir = path.join(tmpDir, 'bad-pkg'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - core_version: '1.0.0', - adds: [], - modifies: [], - }), - ); - expect(() => readManifest(dir)).toThrow(); - }); - - it('throws on missing core_version field', () => { - const dir = path.join(tmpDir, 'bad-pkg'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - version: '1.0.0', - adds: [], - modifies: [], - }), - ); - expect(() => readManifest(dir)).toThrow(); - }); - - it('throws on missing adds field', () => { - const dir = path.join(tmpDir, 'bad-pkg'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - modifies: [], - }), - ); - expect(() => readManifest(dir)).toThrow(); - }); - - it('throws on missing modifies field', () => { - const dir = path.join(tmpDir, 'bad-pkg'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - }), - ); - expect(() => readManifest(dir)).toThrow(); - }); - - it('throws on path traversal in adds', () => { - const dir = path.join(tmpDir, 'bad-pkg'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: ['../etc/passwd'], - modifies: [], - }), - ); - expect(() => readManifest(dir)).toThrow('Invalid path'); - }); - - it('throws on path traversal in modifies', () => { - const dir = path.join(tmpDir, 'bad-pkg'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: ['../../secret.ts'], - }), - ); - expect(() => readManifest(dir)).toThrow('Invalid path'); - }); - - it('throws on absolute path in adds', () => { - const dir = path.join(tmpDir, 'bad-pkg'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: ['/etc/passwd'], - modifies: [], - }), - ); - expect(() => readManifest(dir)).toThrow('Invalid path'); - }); - - it('defaults conflicts and depends to empty arrays', () => { - const skillDir = createSkillPackage(tmpDir, { - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - }); - const manifest = readManifest(skillDir); - expect(manifest.conflicts).toEqual([]); - expect(manifest.depends).toEqual([]); - }); - - it('checkCoreVersion returns warning when manifest targets newer core', () => { - const skillDir = createSkillPackage(tmpDir, { - skill: 'test', - version: '1.0.0', - core_version: '2.0.0', - adds: [], - modifies: [], - }); - const manifest = readManifest(skillDir); - const result = checkCoreVersion(manifest); - expect(result.warning).toBeTruthy(); - }); - - it('checkCoreVersion returns no warning when versions match', () => { - const skillDir = createSkillPackage(tmpDir, { - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - }); - const manifest = readManifest(skillDir); - const result = checkCoreVersion(manifest); - expect(result.ok).toBe(true); - expect(result.warning).toBeFalsy(); - }); - - it('checkDependencies satisfied when deps present', () => { - recordSkillApplication('dep-skill', '1.0.0', {}); - const skillDir = createSkillPackage(tmpDir, { - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - depends: ['dep-skill'], - }); - const manifest = readManifest(skillDir); - const result = checkDependencies(manifest); - expect(result.ok).toBe(true); - expect(result.missing).toEqual([]); - }); - - it('checkDependencies missing when deps not present', () => { - const skillDir = createSkillPackage(tmpDir, { - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - depends: ['missing-skill'], - }); - const manifest = readManifest(skillDir); - const result = checkDependencies(manifest); - expect(result.ok).toBe(false); - expect(result.missing).toContain('missing-skill'); - }); - - it('checkConflicts ok when no conflicts', () => { - const skillDir = createSkillPackage(tmpDir, { - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - conflicts: [], - }); - const manifest = readManifest(skillDir); - const result = checkConflicts(manifest); - expect(result.ok).toBe(true); - expect(result.conflicting).toEqual([]); - }); - - it('checkConflicts detects conflicting skill', () => { - recordSkillApplication('bad-skill', '1.0.0', {}); - const skillDir = createSkillPackage(tmpDir, { - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - conflicts: ['bad-skill'], - }); - const manifest = readManifest(skillDir); - const result = checkConflicts(manifest); - expect(result.ok).toBe(false); - expect(result.conflicting).toContain('bad-skill'); - }); - - it('parses new optional fields (author, license, etc)', () => { - const dir = path.join(tmpDir, 'full-pkg'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - author: 'tester', - license: 'MIT', - min_skills_system_version: '0.1.0', - tested_with: ['telegram', 'discord'], - post_apply: ['echo done'], - }), - ); - const manifest = readManifest(dir); - expect(manifest.author).toBe('tester'); - expect(manifest.license).toBe('MIT'); - expect(manifest.min_skills_system_version).toBe('0.1.0'); - expect(manifest.tested_with).toEqual(['telegram', 'discord']); - expect(manifest.post_apply).toEqual(['echo done']); - }); - - it('checkSystemVersion passes when not set', () => { - const skillDir = createSkillPackage(tmpDir, { - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - }); - const manifest = readManifest(skillDir); - const result = checkSystemVersion(manifest); - expect(result.ok).toBe(true); - }); - - it('checkSystemVersion passes when engine is new enough', () => { - const dir = path.join(tmpDir, 'sys-ok'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - min_skills_system_version: '0.1.0', - }), - ); - const manifest = readManifest(dir); - const result = checkSystemVersion(manifest); - expect(result.ok).toBe(true); - }); - - it('checkSystemVersion fails when engine is too old', () => { - const dir = path.join(tmpDir, 'sys-fail'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'manifest.yaml'), - stringify({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - min_skills_system_version: '99.0.0', - }), - ); - const manifest = readManifest(dir); - const result = checkSystemVersion(manifest); - expect(result.ok).toBe(false); - expect(result.error).toContain('99.0.0'); - }); -}); diff --git a/skills-engine/__tests__/merge.test.ts b/skills-engine/__tests__/merge.test.ts deleted file mode 100644 index 7d6ebb6..0000000 --- a/skills-engine/__tests__/merge.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'fs'; -import path from 'path'; -import { isGitRepo, mergeFile } from '../merge.js'; -import { createTempDir, initGitRepo, cleanup } from './test-helpers.js'; - -describe('merge', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - it('isGitRepo returns true in a git repo', () => { - initGitRepo(tmpDir); - expect(isGitRepo()).toBe(true); - }); - - it('isGitRepo returns false outside a git repo', () => { - expect(isGitRepo()).toBe(false); - }); - - describe('mergeFile', () => { - beforeEach(() => { - initGitRepo(tmpDir); - }); - - it('clean merge with no overlapping changes', () => { - const base = path.join(tmpDir, 'base.txt'); - const current = path.join(tmpDir, 'current.txt'); - const skill = path.join(tmpDir, 'skill.txt'); - - fs.writeFileSync(base, 'line1\nline2\nline3\n'); - fs.writeFileSync(current, 'line1-modified\nline2\nline3\n'); - fs.writeFileSync(skill, 'line1\nline2\nline3-modified\n'); - - const result = mergeFile(current, base, skill); - expect(result.clean).toBe(true); - expect(result.exitCode).toBe(0); - - const merged = fs.readFileSync(current, 'utf-8'); - expect(merged).toContain('line1-modified'); - expect(merged).toContain('line3-modified'); - }); - - it('conflict with overlapping changes', () => { - const base = path.join(tmpDir, 'base.txt'); - const current = path.join(tmpDir, 'current.txt'); - const skill = path.join(tmpDir, 'skill.txt'); - - fs.writeFileSync(base, 'line1\nline2\nline3\n'); - fs.writeFileSync(current, 'line1-ours\nline2\nline3\n'); - fs.writeFileSync(skill, 'line1-theirs\nline2\nline3\n'); - - const result = mergeFile(current, base, skill); - expect(result.clean).toBe(false); - expect(result.exitCode).toBeGreaterThan(0); - - const merged = fs.readFileSync(current, 'utf-8'); - expect(merged).toContain('<<<<<<<'); - expect(merged).toContain('>>>>>>>'); - }); - }); -}); diff --git a/skills-engine/__tests__/path-remap.test.ts b/skills-engine/__tests__/path-remap.test.ts deleted file mode 100644 index e37b82c..0000000 --- a/skills-engine/__tests__/path-remap.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - loadPathRemap, - recordPathRemap, - resolvePathRemap, -} from '../path-remap.js'; -import { readState, writeState } from '../state.js'; -import { - cleanup, - createMinimalState, - createTempDir, - setupNanoclawDir, -} from './test-helpers.js'; - -describe('path-remap', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - setupNanoclawDir(tmpDir); - createMinimalState(tmpDir); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - describe('resolvePathRemap', () => { - it('returns remapped path when entry exists', () => { - const remap = { 'src/old.ts': 'src/new.ts' }; - expect(resolvePathRemap('src/old.ts', remap)).toBe('src/new.ts'); - }); - - it('returns original path when no remap entry', () => { - const remap = { 'src/old.ts': 'src/new.ts' }; - expect(resolvePathRemap('src/other.ts', remap)).toBe('src/other.ts'); - }); - - it('returns original path when remap is empty', () => { - expect(resolvePathRemap('src/file.ts', {})).toBe('src/file.ts'); - }); - - it('ignores remap entries that escape project root', () => { - const remap = { 'src/file.ts': '../../outside.txt' }; - expect(resolvePathRemap('src/file.ts', remap)).toBe('src/file.ts'); - }); - - it('ignores remap target that resolves through symlink outside project root', () => { - const outsideDir = fs.mkdtempSync( - path.join(path.dirname(tmpDir), 'nanoclaw-remap-outside-'), - ); - const linkPath = path.join(tmpDir, 'link-out'); - - try { - fs.symlinkSync(outsideDir, linkPath); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'EPERM' || code === 'EACCES' || code === 'ENOSYS') { - fs.rmSync(outsideDir, { recursive: true, force: true }); - return; - } - fs.rmSync(outsideDir, { recursive: true, force: true }); - throw err; - } - - try { - const remap = { 'src/file.ts': 'link-out/pwned.txt' }; - expect(resolvePathRemap('src/file.ts', remap)).toBe('src/file.ts'); - } finally { - fs.rmSync(outsideDir, { recursive: true, force: true }); - } - }); - - it('throws when requested path itself escapes project root', () => { - expect(() => resolvePathRemap('../../outside.txt', {})).toThrow( - /escapes project root/i, - ); - }); - }); - - describe('loadPathRemap', () => { - it('returns empty object when no remap in state', () => { - const remap = loadPathRemap(); - expect(remap).toEqual({}); - }); - - it('returns remap from state', () => { - recordPathRemap({ 'src/a.ts': 'src/b.ts' }); - const remap = loadPathRemap(); - expect(remap).toEqual({ 'src/a.ts': 'src/b.ts' }); - }); - - it('drops unsafe remap entries stored in state', () => { - const state = readState(); - state.path_remap = { - 'src/a.ts': 'src/b.ts', - 'src/evil.ts': '../../outside.txt', - }; - writeState(state); - - const remap = loadPathRemap(); - expect(remap).toEqual({ 'src/a.ts': 'src/b.ts' }); - }); - - it('drops symlink-based escape entries stored in state', () => { - const outsideDir = fs.mkdtempSync( - path.join(path.dirname(tmpDir), 'nanoclaw-remap-outside-'), - ); - const linkPath = path.join(tmpDir, 'link-out'); - - try { - fs.symlinkSync(outsideDir, linkPath); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'EPERM' || code === 'EACCES' || code === 'ENOSYS') { - fs.rmSync(outsideDir, { recursive: true, force: true }); - return; - } - fs.rmSync(outsideDir, { recursive: true, force: true }); - throw err; - } - - try { - const state = readState(); - state.path_remap = { - 'src/a.ts': 'src/b.ts', - 'src/evil.ts': 'link-out/pwned.txt', - }; - writeState(state); - - const remap = loadPathRemap(); - expect(remap).toEqual({ 'src/a.ts': 'src/b.ts' }); - } finally { - fs.rmSync(outsideDir, { recursive: true, force: true }); - } - }); - }); - - describe('recordPathRemap', () => { - it('records new remap entries', () => { - recordPathRemap({ 'src/old.ts': 'src/new.ts' }); - expect(loadPathRemap()).toEqual({ 'src/old.ts': 'src/new.ts' }); - }); - - it('merges with existing remap', () => { - recordPathRemap({ 'src/a.ts': 'src/b.ts' }); - recordPathRemap({ 'src/c.ts': 'src/d.ts' }); - expect(loadPathRemap()).toEqual({ - 'src/a.ts': 'src/b.ts', - 'src/c.ts': 'src/d.ts', - }); - }); - - it('overwrites existing key on conflict', () => { - recordPathRemap({ 'src/a.ts': 'src/b.ts' }); - recordPathRemap({ 'src/a.ts': 'src/c.ts' }); - expect(loadPathRemap()).toEqual({ 'src/a.ts': 'src/c.ts' }); - }); - - it('rejects unsafe remap entries', () => { - expect(() => - recordPathRemap({ 'src/a.ts': '../../outside.txt' }), - ).toThrow(/escapes project root/i); - }); - }); -}); diff --git a/skills-engine/__tests__/rebase.test.ts b/skills-engine/__tests__/rebase.test.ts deleted file mode 100644 index a7aaa3f..0000000 --- a/skills-engine/__tests__/rebase.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { parse } from 'yaml'; - -import { rebase } from '../rebase.js'; -import { - cleanup, - createMinimalState, - createTempDir, - initGitRepo, - setupNanoclawDir, - writeState, -} from './test-helpers.js'; - -describe('rebase', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - setupNanoclawDir(tmpDir); - createMinimalState(tmpDir); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - it('rebase with one skill: patch created, state updated, rebased_at set', async () => { - // Set up base file - const baseDir = path.join(tmpDir, '.nanoclaw', 'base', 'src'); - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync(path.join(baseDir, 'index.ts'), 'const x = 1;\n'); - - // Set up working tree with skill modification - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'index.ts'), - 'const x = 1;\nconst y = 2; // added by skill\n', - ); - - // Write state with applied skill - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [ - { - name: 'test-skill', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: { - 'src/index.ts': 'abc123', - }, - }, - ], - }); - - initGitRepo(tmpDir); - - const result = await rebase(); - - expect(result.success).toBe(true); - expect(result.filesInPatch).toBeGreaterThan(0); - expect(result.rebased_at).toBeDefined(); - expect(result.patchFile).toBeDefined(); - - // Verify patch file exists - const patchPath = path.join(tmpDir, '.nanoclaw', 'combined.patch'); - expect(fs.existsSync(patchPath)).toBe(true); - - const patchContent = fs.readFileSync(patchPath, 'utf-8'); - expect(patchContent).toContain('added by skill'); - - // Verify state was updated - const stateContent = fs.readFileSync( - path.join(tmpDir, '.nanoclaw', 'state.yaml'), - 'utf-8', - ); - const state = parse(stateContent); - expect(state.rebased_at).toBeDefined(); - expect(state.applied_skills).toHaveLength(1); - expect(state.applied_skills[0].name).toBe('test-skill'); - - // File hashes should be updated to actual current values - const currentHash = state.applied_skills[0].file_hashes['src/index.ts']; - expect(currentHash).toBeDefined(); - expect(currentHash).not.toBe('abc123'); // Should be recomputed - - // Working tree file should still have the skill's changes - const workingContent = fs.readFileSync( - path.join(tmpDir, 'src', 'index.ts'), - 'utf-8', - ); - expect(workingContent).toContain('added by skill'); - }); - - it('rebase flattens: base updated to match working tree', async () => { - // Set up base file (clean core) - const baseDir = path.join(tmpDir, '.nanoclaw', 'base', 'src'); - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync(path.join(baseDir, 'index.ts'), 'const x = 1;\n'); - - // Working tree has skill modification - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'index.ts'), - 'const x = 1;\nconst y = 2; // skill\n', - ); - - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [ - { - name: 'my-skill', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: { - 'src/index.ts': 'oldhash', - }, - }, - ], - }); - - initGitRepo(tmpDir); - - const result = await rebase(); - expect(result.success).toBe(true); - - // Base should now include the skill's changes (flattened) - const baseContent = fs.readFileSync( - path.join(tmpDir, '.nanoclaw', 'base', 'src', 'index.ts'), - 'utf-8', - ); - expect(baseContent).toContain('skill'); - expect(baseContent).toBe('const x = 1;\nconst y = 2; // skill\n'); - }); - - it('rebase with multiple skills + custom mods: all collapsed into single patch', async () => { - // Set up base files - const baseDir = path.join(tmpDir, '.nanoclaw', 'base'); - fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true }); - fs.writeFileSync(path.join(baseDir, 'src', 'index.ts'), 'const x = 1;\n'); - fs.writeFileSync( - path.join(baseDir, 'src', 'config.ts'), - 'export const port = 3000;\n', - ); - - // Set up working tree with modifications from multiple skills - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'index.ts'), - 'const x = 1;\nconst y = 2; // skill-a\n', - ); - fs.writeFileSync( - path.join(tmpDir, 'src', 'config.ts'), - 'export const port = 3000;\nexport const host = "0.0.0.0"; // skill-b\n', - ); - // File added by skill - fs.writeFileSync( - path.join(tmpDir, 'src', 'plugin.ts'), - 'export const plugin = true;\n', - ); - - // Write state with multiple skills and custom modifications - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [ - { - name: 'skill-a', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: { - 'src/index.ts': 'hash-a1', - }, - }, - { - name: 'skill-b', - version: '2.0.0', - applied_at: new Date().toISOString(), - file_hashes: { - 'src/config.ts': 'hash-b1', - 'src/plugin.ts': 'hash-b2', - }, - }, - ], - custom_modifications: [ - { - description: 'tweaked config', - applied_at: new Date().toISOString(), - files_modified: ['src/config.ts'], - patch_file: '.nanoclaw/custom/001-tweaked-config.patch', - }, - ], - }); - - initGitRepo(tmpDir); - - const result = await rebase(); - - expect(result.success).toBe(true); - expect(result.filesInPatch).toBeGreaterThanOrEqual(2); - - // Verify combined patch includes changes from both skills - const patchContent = fs.readFileSync( - path.join(tmpDir, '.nanoclaw', 'combined.patch'), - 'utf-8', - ); - expect(patchContent).toContain('skill-a'); - expect(patchContent).toContain('skill-b'); - - // Verify state: custom_modifications should be cleared - const stateContent = fs.readFileSync( - path.join(tmpDir, '.nanoclaw', 'state.yaml'), - 'utf-8', - ); - const state = parse(stateContent); - expect(state.custom_modifications).toBeUndefined(); - expect(state.rebased_at).toBeDefined(); - - // applied_skills should still be present (informational) - expect(state.applied_skills).toHaveLength(2); - - // Base should be flattened — include all skill changes - const baseIndex = fs.readFileSync( - path.join(tmpDir, '.nanoclaw', 'base', 'src', 'index.ts'), - 'utf-8', - ); - expect(baseIndex).toContain('skill-a'); - - const baseConfig = fs.readFileSync( - path.join(tmpDir, '.nanoclaw', 'base', 'src', 'config.ts'), - 'utf-8', - ); - expect(baseConfig).toContain('skill-b'); - }); - - it('rebase with new base: base updated, changes merged', async () => { - // Set up current base (multi-line so changes don't conflict) - const baseDir = path.join(tmpDir, '.nanoclaw', 'base'); - fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(baseDir, 'src', 'index.ts'), - 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n', - ); - - // Working tree: skill adds at bottom - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'index.ts'), - 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nskill change\n', - ); - - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [ - { - name: 'my-skill', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: { - 'src/index.ts': 'oldhash', - }, - }, - ], - }); - - initGitRepo(tmpDir); - - // New base: core update at top - const newBase = path.join(tmpDir, 'new-core'); - fs.mkdirSync(path.join(newBase, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(newBase, 'src', 'index.ts'), - 'core v2 header\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n', - ); - - const result = await rebase(newBase); - - expect(result.success).toBe(true); - expect(result.patchFile).toBeDefined(); - - // Verify base was updated to new core - const baseContent = fs.readFileSync( - path.join(tmpDir, '.nanoclaw', 'base', 'src', 'index.ts'), - 'utf-8', - ); - expect(baseContent).toContain('core v2 header'); - - // Working tree should have both core v2 and skill changes merged - const workingContent = fs.readFileSync( - path.join(tmpDir, 'src', 'index.ts'), - 'utf-8', - ); - expect(workingContent).toContain('core v2 header'); - expect(workingContent).toContain('skill change'); - - // State should reflect rebase - const stateContent = fs.readFileSync( - path.join(tmpDir, '.nanoclaw', 'state.yaml'), - 'utf-8', - ); - const state = parse(stateContent); - expect(state.rebased_at).toBeDefined(); - }); - - it('rebase with new base: conflict returns backupPending', async () => { - // Set up current base — short file so changes overlap - const baseDir = path.join(tmpDir, '.nanoclaw', 'base'); - fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true }); - fs.writeFileSync(path.join(baseDir, 'src', 'index.ts'), 'const x = 1;\n'); - - // Working tree: skill replaces the same line - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'index.ts'), - 'const x = 42; // skill override\n', - ); - - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [ - { - name: 'my-skill', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: { - 'src/index.ts': 'oldhash', - }, - }, - ], - }); - - initGitRepo(tmpDir); - - // New base: also changes the same line — guaranteed conflict - const newBase = path.join(tmpDir, 'new-core'); - fs.mkdirSync(path.join(newBase, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(newBase, 'src', 'index.ts'), - 'const x = 999; // core v2\n', - ); - - const result = await rebase(newBase); - - expect(result.success).toBe(false); - expect(result.mergeConflicts).toContain('src/index.ts'); - expect(result.backupPending).toBe(true); - expect(result.error).toContain('Merge conflicts'); - - // combined.patch should still exist - expect(result.patchFile).toBeDefined(); - const patchPath = path.join(tmpDir, '.nanoclaw', 'combined.patch'); - expect(fs.existsSync(patchPath)).toBe(true); - - // Working tree should have conflict markers (not rolled back) - const workingContent = fs.readFileSync( - path.join(tmpDir, 'src', 'index.ts'), - 'utf-8', - ); - expect(workingContent).toContain('<<<<<<<'); - expect(workingContent).toContain('>>>>>>>'); - - // State should NOT be updated yet (conflicts pending) - const stateContent = fs.readFileSync( - path.join(tmpDir, '.nanoclaw', 'state.yaml'), - 'utf-8', - ); - const state = parse(stateContent); - expect(state.rebased_at).toBeUndefined(); - }); - - it('error when no skills applied', async () => { - // State has no applied skills (created by createMinimalState) - initGitRepo(tmpDir); - - const result = await rebase(); - - expect(result.success).toBe(false); - expect(result.error).toContain('No skills applied'); - expect(result.filesInPatch).toBe(0); - }); -}); diff --git a/skills-engine/__tests__/replay.test.ts b/skills-engine/__tests__/replay.test.ts deleted file mode 100644 index 9d0aa34..0000000 --- a/skills-engine/__tests__/replay.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { findSkillDir, replaySkills } from '../replay.js'; -import { - cleanup, - createMinimalState, - createSkillPackage, - createTempDir, - initGitRepo, - setupNanoclawDir, -} from './test-helpers.js'; - -describe('replay', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - setupNanoclawDir(tmpDir); - createMinimalState(tmpDir); - initGitRepo(tmpDir); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - describe('findSkillDir', () => { - it('finds skill directory by name', () => { - const skillsRoot = path.join(tmpDir, '.claude', 'skills', 'telegram'); - fs.mkdirSync(skillsRoot, { recursive: true }); - const { stringify } = require('yaml'); - fs.writeFileSync( - path.join(skillsRoot, 'manifest.yaml'), - stringify({ - skill: 'telegram', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - }), - ); - - const result = findSkillDir('telegram', tmpDir); - expect(result).toBe(skillsRoot); - }); - - it('returns null for missing skill', () => { - const result = findSkillDir('nonexistent', tmpDir); - expect(result).toBeNull(); - }); - - it('returns null when .claude/skills does not exist', () => { - const result = findSkillDir('anything', tmpDir); - expect(result).toBeNull(); - }); - }); - - describe('replaySkills', () => { - it('replays a single skill from base', async () => { - // Set up base file - const baseDir = path.join(tmpDir, '.nanoclaw', 'base', 'src'); - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync(path.join(baseDir, 'config.ts'), 'base content\n'); - - // Set up current file (will be overwritten by replay) - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'config.ts'), - 'modified content\n', - ); - - // Create skill package - const skillDir = createSkillPackage(tmpDir, { - skill: 'telegram', - version: '1.0.0', - core_version: '1.0.0', - adds: ['src/telegram.ts'], - modifies: ['src/config.ts'], - addFiles: { 'src/telegram.ts': 'telegram code\n' }, - modifyFiles: { 'src/config.ts': 'base content\ntelegram config\n' }, - }); - - const result = await replaySkills({ - skills: ['telegram'], - skillDirs: { telegram: skillDir }, - projectRoot: tmpDir, - }); - - expect(result.success).toBe(true); - expect(result.perSkill.telegram.success).toBe(true); - - // Added file should exist - expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(true); - expect( - fs.readFileSync(path.join(tmpDir, 'src', 'telegram.ts'), 'utf-8'), - ).toBe('telegram code\n'); - - // Modified file should be merged from base - const config = fs.readFileSync( - path.join(tmpDir, 'src', 'config.ts'), - 'utf-8', - ); - expect(config).toContain('telegram config'); - }); - - it('replays two skills in order', async () => { - // Set up base - const baseDir = path.join(tmpDir, '.nanoclaw', 'base', 'src'); - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync( - path.join(baseDir, 'config.ts'), - 'line1\nline2\nline3\nline4\nline5\n', - ); - - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'config.ts'), - 'line1\nline2\nline3\nline4\nline5\n', - ); - - // Skill 1 adds at top - const skill1Dir = createSkillPackage(tmpDir, { - skill: 'telegram', - version: '1.0.0', - core_version: '1.0.0', - adds: ['src/telegram.ts'], - modifies: ['src/config.ts'], - addFiles: { 'src/telegram.ts': 'tg code' }, - modifyFiles: { - 'src/config.ts': - 'telegram import\nline1\nline2\nline3\nline4\nline5\n', - }, - dirName: 'skill-pkg-tg', - }); - - // Skill 2 adds at bottom - const skill2Dir = createSkillPackage(tmpDir, { - skill: 'discord', - version: '1.0.0', - core_version: '1.0.0', - adds: ['src/discord.ts'], - modifies: ['src/config.ts'], - addFiles: { 'src/discord.ts': 'dc code' }, - modifyFiles: { - 'src/config.ts': - 'line1\nline2\nline3\nline4\nline5\ndiscord import\n', - }, - dirName: 'skill-pkg-dc', - }); - - const result = await replaySkills({ - skills: ['telegram', 'discord'], - skillDirs: { telegram: skill1Dir, discord: skill2Dir }, - projectRoot: tmpDir, - }); - - expect(result.success).toBe(true); - expect(result.perSkill.telegram.success).toBe(true); - expect(result.perSkill.discord.success).toBe(true); - - // Both added files should exist - expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'src', 'discord.ts'))).toBe(true); - - // Config should have both changes - const config = fs.readFileSync( - path.join(tmpDir, 'src', 'config.ts'), - 'utf-8', - ); - expect(config).toContain('telegram import'); - expect(config).toContain('discord import'); - }); - - it('stops on first conflict and does not process later skills', async () => { - // After reset, current=base. Skill 1 merges cleanly (changes line 1). - // Skill 2 also changes line 1 differently → conflict with skill 1's result. - // Skill 3 should NOT be processed due to break-on-conflict. - const baseDir = path.join(tmpDir, '.nanoclaw', 'base', 'src'); - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync(path.join(baseDir, 'config.ts'), 'line1\n'); - - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, 'src', 'config.ts'), 'line1\n'); - - // Skill 1: changes line 1 — merges cleanly since current=base after reset - const skill1Dir = createSkillPackage(tmpDir, { - skill: 'skill-a', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: ['src/config.ts'], - modifyFiles: { 'src/config.ts': 'line1-from-skill-a\n' }, - dirName: 'skill-pkg-a', - }); - - // Skill 2: also changes line 1 differently → conflict with skill-a's result - const skill2Dir = createSkillPackage(tmpDir, { - skill: 'skill-b', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: ['src/config.ts'], - modifyFiles: { 'src/config.ts': 'line1-from-skill-b\n' }, - dirName: 'skill-pkg-b', - }); - - // Skill 3: adds a new file — should be skipped - const skill3Dir = createSkillPackage(tmpDir, { - skill: 'skill-c', - version: '1.0.0', - core_version: '1.0.0', - adds: ['src/newfile.ts'], - modifies: [], - addFiles: { 'src/newfile.ts': 'should not appear' }, - dirName: 'skill-pkg-c', - }); - - const result = await replaySkills({ - skills: ['skill-a', 'skill-b', 'skill-c'], - skillDirs: { - 'skill-a': skill1Dir, - 'skill-b': skill2Dir, - 'skill-c': skill3Dir, - }, - projectRoot: tmpDir, - }); - - expect(result.success).toBe(false); - expect(result.mergeConflicts).toBeDefined(); - expect(result.mergeConflicts!.length).toBeGreaterThan(0); - // Skill B caused the conflict - expect(result.perSkill['skill-b']?.success).toBe(false); - // Skill C should NOT have been processed - expect(result.perSkill['skill-c']).toBeUndefined(); - }); - - it('returns error for missing skill dir', async () => { - const result = await replaySkills({ - skills: ['missing'], - skillDirs: {}, - projectRoot: tmpDir, - }); - - expect(result.success).toBe(false); - expect(result.error).toContain('missing'); - expect(result.perSkill.missing.success).toBe(false); - }); - - it('resets files to base before replay', async () => { - // Set up base - const baseDir = path.join(tmpDir, '.nanoclaw', 'base', 'src'); - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync(path.join(baseDir, 'config.ts'), 'base content\n'); - - // Current has drift - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'config.ts'), - 'drifted content\n', - ); - - // Also a stale added file - fs.writeFileSync( - path.join(tmpDir, 'src', 'stale-add.ts'), - 'should be removed', - ); - - const skillDir = createSkillPackage(tmpDir, { - skill: 'skill1', - version: '1.0.0', - core_version: '1.0.0', - adds: ['src/stale-add.ts'], - modifies: ['src/config.ts'], - addFiles: { 'src/stale-add.ts': 'fresh add' }, - modifyFiles: { 'src/config.ts': 'base content\nskill addition\n' }, - }); - - const result = await replaySkills({ - skills: ['skill1'], - skillDirs: { skill1: skillDir }, - projectRoot: tmpDir, - }); - - expect(result.success).toBe(true); - - // The added file should have the fresh content (not stale) - expect( - fs.readFileSync(path.join(tmpDir, 'src', 'stale-add.ts'), 'utf-8'), - ).toBe('fresh add'); - }); - }); -}); diff --git a/skills-engine/__tests__/run-migrations.test.ts b/skills-engine/__tests__/run-migrations.test.ts deleted file mode 100644 index bc208ac..0000000 --- a/skills-engine/__tests__/run-migrations.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { execFileSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { cleanup, createTempDir } from './test-helpers.js'; - -describe('run-migrations', () => { - let tmpDir: string; - let newCoreDir: string; - const scriptPath = path.resolve('scripts/run-migrations.ts'); - const tsxBin = path.resolve('node_modules/.bin/tsx'); - - beforeEach(() => { - tmpDir = createTempDir(); - newCoreDir = path.join(tmpDir, 'new-core'); - fs.mkdirSync(newCoreDir, { recursive: true }); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - function createMigration(version: string, code: string): void { - const migDir = path.join(newCoreDir, 'migrations', version); - fs.mkdirSync(migDir, { recursive: true }); - fs.writeFileSync(path.join(migDir, 'index.ts'), code); - } - - function runMigrations( - from: string, - to: string, - ): { stdout: string; exitCode: number } { - try { - const stdout = execFileSync(tsxBin, [scriptPath, from, to, newCoreDir], { - cwd: tmpDir, - encoding: 'utf-8', - stdio: 'pipe', - timeout: 30_000, - }); - return { stdout, exitCode: 0 }; - } catch (err: any) { - return { stdout: err.stdout ?? '', exitCode: err.status ?? 1 }; - } - } - - it('outputs empty results when no migrations directory exists', () => { - const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0'); - const result = JSON.parse(stdout); - - expect(exitCode).toBe(0); - expect(result.migrationsRun).toBe(0); - expect(result.results).toEqual([]); - }); - - it('outputs empty results when migrations dir exists but is empty', () => { - fs.mkdirSync(path.join(newCoreDir, 'migrations'), { recursive: true }); - - const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0'); - const result = JSON.parse(stdout); - - expect(exitCode).toBe(0); - expect(result.migrationsRun).toBe(0); - }); - - it('runs migrations in the correct version range', () => { - // Create a marker file when the migration runs - createMigration( - '1.1.0', - ` -import fs from 'fs'; -import path from 'path'; -const root = process.argv[2]; -fs.writeFileSync(path.join(root, 'migrated-1.1.0'), 'done'); -`, - ); - createMigration( - '1.2.0', - ` -import fs from 'fs'; -import path from 'path'; -const root = process.argv[2]; -fs.writeFileSync(path.join(root, 'migrated-1.2.0'), 'done'); -`, - ); - // This one should NOT run (outside range) - createMigration( - '2.1.0', - ` -import fs from 'fs'; -import path from 'path'; -const root = process.argv[2]; -fs.writeFileSync(path.join(root, 'migrated-2.1.0'), 'done'); -`, - ); - - const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0'); - const result = JSON.parse(stdout); - - expect(exitCode).toBe(0); - expect(result.migrationsRun).toBe(2); - expect(result.results[0].version).toBe('1.1.0'); - expect(result.results[0].success).toBe(true); - expect(result.results[1].version).toBe('1.2.0'); - expect(result.results[1].success).toBe(true); - - // Verify the migrations actually ran - expect(fs.existsSync(path.join(tmpDir, 'migrated-1.1.0'))).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'migrated-1.2.0'))).toBe(true); - // 2.1.0 is outside range - expect(fs.existsSync(path.join(tmpDir, 'migrated-2.1.0'))).toBe(false); - }); - - it('excludes the from-version (only runs > from)', () => { - createMigration( - '1.0.0', - ` -import fs from 'fs'; -import path from 'path'; -const root = process.argv[2]; -fs.writeFileSync(path.join(root, 'migrated-1.0.0'), 'done'); -`, - ); - createMigration( - '1.1.0', - ` -import fs from 'fs'; -import path from 'path'; -const root = process.argv[2]; -fs.writeFileSync(path.join(root, 'migrated-1.1.0'), 'done'); -`, - ); - - const { stdout } = runMigrations('1.0.0', '1.1.0'); - const result = JSON.parse(stdout); - - expect(result.migrationsRun).toBe(1); - expect(result.results[0].version).toBe('1.1.0'); - // 1.0.0 should NOT have run - expect(fs.existsSync(path.join(tmpDir, 'migrated-1.0.0'))).toBe(false); - }); - - it('includes the to-version (<= to)', () => { - createMigration( - '2.0.0', - ` -import fs from 'fs'; -import path from 'path'; -const root = process.argv[2]; -fs.writeFileSync(path.join(root, 'migrated-2.0.0'), 'done'); -`, - ); - - const { stdout } = runMigrations('1.0.0', '2.0.0'); - const result = JSON.parse(stdout); - - expect(result.migrationsRun).toBe(1); - expect(result.results[0].version).toBe('2.0.0'); - expect(result.results[0].success).toBe(true); - }); - - it('runs migrations in semver ascending order', () => { - // Create them in non-sorted order - for (const v of ['1.3.0', '1.1.0', '1.2.0']) { - createMigration( - v, - ` -import fs from 'fs'; -import path from 'path'; -const root = process.argv[2]; -const log = path.join(root, 'migration-order.log'); -const existing = fs.existsSync(log) ? fs.readFileSync(log, 'utf-8') : ''; -fs.writeFileSync(log, existing + '${v}\\n'); -`, - ); - } - - const { stdout } = runMigrations('1.0.0', '2.0.0'); - const result = JSON.parse(stdout); - - expect(result.migrationsRun).toBe(3); - expect(result.results.map((r: any) => r.version)).toEqual([ - '1.1.0', - '1.2.0', - '1.3.0', - ]); - - // Verify execution order from the log file - const log = fs.readFileSync( - path.join(tmpDir, 'migration-order.log'), - 'utf-8', - ); - expect(log.trim()).toBe('1.1.0\n1.2.0\n1.3.0'); - }); - - it('reports failure and exits non-zero when a migration throws', () => { - createMigration( - '1.1.0', - `throw new Error('migration failed intentionally');`, - ); - - const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0'); - const result = JSON.parse(stdout); - - expect(exitCode).toBe(1); - expect(result.migrationsRun).toBe(1); - expect(result.results[0].success).toBe(false); - expect(result.results[0].error).toBeDefined(); - }); - - it('ignores non-semver directories in migrations/', () => { - fs.mkdirSync(path.join(newCoreDir, 'migrations', 'README'), { - recursive: true, - }); - fs.mkdirSync(path.join(newCoreDir, 'migrations', 'utils'), { - recursive: true, - }); - createMigration( - '1.1.0', - ` -import fs from 'fs'; -import path from 'path'; -const root = process.argv[2]; -fs.writeFileSync(path.join(root, 'migrated-1.1.0'), 'done'); -`, - ); - - const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0'); - const result = JSON.parse(stdout); - - expect(exitCode).toBe(0); - expect(result.migrationsRun).toBe(1); - expect(result.results[0].version).toBe('1.1.0'); - }); -}); diff --git a/skills-engine/__tests__/state.test.ts b/skills-engine/__tests__/state.test.ts deleted file mode 100644 index e4cdbb1..0000000 --- a/skills-engine/__tests__/state.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'fs'; -import path from 'path'; -import { - readState, - writeState, - recordSkillApplication, - computeFileHash, - compareSemver, - recordCustomModification, - getCustomModifications, -} from '../state.js'; -import { - createTempDir, - setupNanoclawDir, - createMinimalState, - writeState as writeStateHelper, - cleanup, -} from './test-helpers.js'; - -describe('state', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - setupNanoclawDir(tmpDir); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - it('readState/writeState roundtrip', () => { - const state = { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [], - }; - writeState(state); - const result = readState(); - expect(result.skills_system_version).toBe('0.1.0'); - expect(result.core_version).toBe('1.0.0'); - expect(result.applied_skills).toEqual([]); - }); - - it('readState throws when no state file exists', () => { - expect(() => readState()).toThrow(); - }); - - it('readState throws when version is newer than current', () => { - writeStateHelper(tmpDir, { - skills_system_version: '99.0.0', - core_version: '1.0.0', - applied_skills: [], - }); - expect(() => readState()).toThrow(); - }); - - it('recordSkillApplication adds a skill', () => { - createMinimalState(tmpDir); - recordSkillApplication('my-skill', '1.0.0', { 'src/foo.ts': 'abc123' }); - const state = readState(); - expect(state.applied_skills).toHaveLength(1); - expect(state.applied_skills[0].name).toBe('my-skill'); - expect(state.applied_skills[0].version).toBe('1.0.0'); - expect(state.applied_skills[0].file_hashes).toEqual({ - 'src/foo.ts': 'abc123', - }); - }); - - it('re-applying same skill replaces it', () => { - createMinimalState(tmpDir); - recordSkillApplication('my-skill', '1.0.0', { 'a.ts': 'hash1' }); - recordSkillApplication('my-skill', '2.0.0', { 'a.ts': 'hash2' }); - const state = readState(); - expect(state.applied_skills).toHaveLength(1); - expect(state.applied_skills[0].version).toBe('2.0.0'); - expect(state.applied_skills[0].file_hashes).toEqual({ 'a.ts': 'hash2' }); - }); - - it('computeFileHash produces consistent sha256', () => { - const filePath = path.join(tmpDir, 'hashtest.txt'); - fs.writeFileSync(filePath, 'hello world'); - const hash1 = computeFileHash(filePath); - const hash2 = computeFileHash(filePath); - expect(hash1).toBe(hash2); - expect(hash1).toMatch(/^[a-f0-9]{64}$/); - }); - - describe('compareSemver', () => { - it('1.0.0 < 1.1.0', () => { - expect(compareSemver('1.0.0', '1.1.0')).toBeLessThan(0); - }); - - it('0.9.0 < 0.10.0', () => { - expect(compareSemver('0.9.0', '0.10.0')).toBeLessThan(0); - }); - - it('1.0.0 = 1.0.0', () => { - expect(compareSemver('1.0.0', '1.0.0')).toBe(0); - }); - }); - - it('recordCustomModification adds to array', () => { - createMinimalState(tmpDir); - recordCustomModification('tweak', ['src/a.ts'], 'custom/001-tweak.patch'); - const mods = getCustomModifications(); - expect(mods).toHaveLength(1); - expect(mods[0].description).toBe('tweak'); - expect(mods[0].files_modified).toEqual(['src/a.ts']); - expect(mods[0].patch_file).toBe('custom/001-tweak.patch'); - }); - - it('getCustomModifications returns empty when none recorded', () => { - createMinimalState(tmpDir); - const mods = getCustomModifications(); - expect(mods).toEqual([]); - }); -}); diff --git a/skills-engine/__tests__/structured.test.ts b/skills-engine/__tests__/structured.test.ts deleted file mode 100644 index 1d98f27..0000000 --- a/skills-engine/__tests__/structured.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'fs'; -import path from 'path'; -import { - areRangesCompatible, - mergeNpmDependencies, - mergeEnvAdditions, - mergeDockerComposeServices, -} from '../structured.js'; -import { createTempDir, cleanup } from './test-helpers.js'; - -describe('structured', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - describe('areRangesCompatible', () => { - it('identical versions are compatible', () => { - const result = areRangesCompatible('^1.0.0', '^1.0.0'); - expect(result.compatible).toBe(true); - }); - - it('compatible ^ ranges resolve to higher', () => { - const result = areRangesCompatible('^1.0.0', '^1.1.0'); - expect(result.compatible).toBe(true); - expect(result.resolved).toBe('^1.1.0'); - }); - - it('incompatible major ^ ranges', () => { - const result = areRangesCompatible('^1.0.0', '^2.0.0'); - expect(result.compatible).toBe(false); - }); - - it('compatible ~ ranges', () => { - const result = areRangesCompatible('~1.0.0', '~1.0.3'); - expect(result.compatible).toBe(true); - expect(result.resolved).toBe('~1.0.3'); - }); - - it('mismatched prefixes are incompatible', () => { - const result = areRangesCompatible('^1.0.0', '~1.0.0'); - expect(result.compatible).toBe(false); - }); - - it('handles double-digit version parts numerically', () => { - // ^1.9.0 vs ^1.10.0 — 10 > 9 numerically, but "9" > "10" as strings - const result = areRangesCompatible('^1.9.0', '^1.10.0'); - expect(result.compatible).toBe(true); - expect(result.resolved).toBe('^1.10.0'); - }); - - it('handles double-digit patch versions', () => { - const result = areRangesCompatible('~1.0.9', '~1.0.10'); - expect(result.compatible).toBe(true); - expect(result.resolved).toBe('~1.0.10'); - }); - }); - - describe('mergeNpmDependencies', () => { - it('adds new dependencies', () => { - const pkgPath = path.join(tmpDir, 'package.json'); - fs.writeFileSync( - pkgPath, - JSON.stringify( - { - name: 'test', - dependencies: { existing: '^1.0.0' }, - }, - null, - 2, - ), - ); - - mergeNpmDependencies(pkgPath, { newdep: '^2.0.0' }); - - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - expect(pkg.dependencies.newdep).toBe('^2.0.0'); - expect(pkg.dependencies.existing).toBe('^1.0.0'); - }); - - it('resolves compatible ^ ranges', () => { - const pkgPath = path.join(tmpDir, 'package.json'); - fs.writeFileSync( - pkgPath, - JSON.stringify( - { - name: 'test', - dependencies: { dep: '^1.0.0' }, - }, - null, - 2, - ), - ); - - mergeNpmDependencies(pkgPath, { dep: '^1.1.0' }); - - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - expect(pkg.dependencies.dep).toBe('^1.1.0'); - }); - - it('sorts devDependencies after merge', () => { - const pkgPath = path.join(tmpDir, 'package.json'); - fs.writeFileSync( - pkgPath, - JSON.stringify( - { - name: 'test', - dependencies: {}, - devDependencies: { zlib: '^1.0.0', acorn: '^2.0.0' }, - }, - null, - 2, - ), - ); - - mergeNpmDependencies(pkgPath, { middle: '^1.0.0' }); - - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - const devKeys = Object.keys(pkg.devDependencies); - expect(devKeys).toEqual(['acorn', 'zlib']); - }); - - it('throws on incompatible major versions', () => { - const pkgPath = path.join(tmpDir, 'package.json'); - fs.writeFileSync( - pkgPath, - JSON.stringify( - { - name: 'test', - dependencies: { dep: '^1.0.0' }, - }, - null, - 2, - ), - ); - - expect(() => mergeNpmDependencies(pkgPath, { dep: '^2.0.0' })).toThrow(); - }); - }); - - describe('mergeEnvAdditions', () => { - it('adds new variables', () => { - const envPath = path.join(tmpDir, '.env.example'); - fs.writeFileSync(envPath, 'EXISTING_VAR=value\n'); - - mergeEnvAdditions(envPath, ['NEW_VAR']); - - const content = fs.readFileSync(envPath, 'utf-8'); - expect(content).toContain('NEW_VAR='); - expect(content).toContain('EXISTING_VAR=value'); - }); - - it('skips existing variables', () => { - const envPath = path.join(tmpDir, '.env.example'); - fs.writeFileSync(envPath, 'MY_VAR=original\n'); - - mergeEnvAdditions(envPath, ['MY_VAR']); - - const content = fs.readFileSync(envPath, 'utf-8'); - // Should not add duplicate - only 1 occurrence of MY_VAR= - const matches = content.match(/MY_VAR=/g); - expect(matches).toHaveLength(1); - }); - - it('recognizes lowercase and mixed-case env vars as existing', () => { - const envPath = path.join(tmpDir, '.env.example'); - fs.writeFileSync(envPath, 'my_lower_var=value\nMixed_Case=abc\n'); - - mergeEnvAdditions(envPath, ['my_lower_var', 'Mixed_Case']); - - const content = fs.readFileSync(envPath, 'utf-8'); - // Should not add duplicates - const lowerMatches = content.match(/my_lower_var=/g); - expect(lowerMatches).toHaveLength(1); - const mixedMatches = content.match(/Mixed_Case=/g); - expect(mixedMatches).toHaveLength(1); - }); - - it('creates file if it does not exist', () => { - const envPath = path.join(tmpDir, '.env.example'); - mergeEnvAdditions(envPath, ['NEW_VAR']); - - expect(fs.existsSync(envPath)).toBe(true); - const content = fs.readFileSync(envPath, 'utf-8'); - expect(content).toContain('NEW_VAR='); - }); - }); - - describe('mergeDockerComposeServices', () => { - it('adds new services', () => { - const composePath = path.join(tmpDir, 'docker-compose.yaml'); - fs.writeFileSync( - composePath, - 'version: "3"\nservices:\n web:\n image: nginx\n', - ); - - mergeDockerComposeServices(composePath, { - redis: { image: 'redis:7' }, - }); - - const content = fs.readFileSync(composePath, 'utf-8'); - expect(content).toContain('redis'); - }); - - it('skips existing services', () => { - const composePath = path.join(tmpDir, 'docker-compose.yaml'); - fs.writeFileSync( - composePath, - 'version: "3"\nservices:\n web:\n image: nginx\n', - ); - - mergeDockerComposeServices(composePath, { - web: { image: 'apache' }, - }); - - const content = fs.readFileSync(composePath, 'utf-8'); - expect(content).toContain('nginx'); - }); - - it('throws on port collision', () => { - const composePath = path.join(tmpDir, 'docker-compose.yaml'); - fs.writeFileSync( - composePath, - 'version: "3"\nservices:\n web:\n image: nginx\n ports:\n - "8080:80"\n', - ); - - expect(() => - mergeDockerComposeServices(composePath, { - api: { image: 'node', ports: ['8080:3000'] }, - }), - ).toThrow(); - }); - }); -}); diff --git a/skills-engine/__tests__/test-helpers.ts b/skills-engine/__tests__/test-helpers.ts deleted file mode 100644 index bd3db0b..0000000 --- a/skills-engine/__tests__/test-helpers.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { execSync } from 'child_process'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { stringify } from 'yaml'; - -export function createTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-test-')); -} - -export function setupNanoclawDir(tmpDir: string): void { - fs.mkdirSync(path.join(tmpDir, '.nanoclaw', 'base', 'src'), { - recursive: true, - }); - fs.mkdirSync(path.join(tmpDir, '.nanoclaw', 'backup'), { recursive: true }); -} - -export function writeState(tmpDir: string, state: any): void { - const statePath = path.join(tmpDir, '.nanoclaw', 'state.yaml'); - fs.writeFileSync(statePath, stringify(state), 'utf-8'); -} - -export function createMinimalState(tmpDir: string): void { - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [], - }); -} - -export function createSkillPackage( - tmpDir: string, - opts: { - skill?: string; - version?: string; - core_version?: string; - adds?: string[]; - modifies?: string[]; - addFiles?: Record; - modifyFiles?: Record; - conflicts?: string[]; - depends?: string[]; - test?: string; - structured?: any; - file_ops?: any[]; - post_apply?: string[]; - min_skills_system_version?: string; - dirName?: string; - }, -): string { - const skillDir = path.join(tmpDir, opts.dirName ?? 'skill-pkg'); - fs.mkdirSync(skillDir, { recursive: true }); - - const manifest: Record = { - skill: opts.skill ?? 'test-skill', - version: opts.version ?? '1.0.0', - description: 'Test skill', - core_version: opts.core_version ?? '1.0.0', - adds: opts.adds ?? [], - modifies: opts.modifies ?? [], - conflicts: opts.conflicts ?? [], - depends: opts.depends ?? [], - test: opts.test, - structured: opts.structured, - file_ops: opts.file_ops, - }; - if (opts.post_apply) manifest.post_apply = opts.post_apply; - if (opts.min_skills_system_version) - manifest.min_skills_system_version = opts.min_skills_system_version; - - fs.writeFileSync(path.join(skillDir, 'manifest.yaml'), stringify(manifest)); - - if (opts.addFiles) { - const addDir = path.join(skillDir, 'add'); - for (const [relPath, content] of Object.entries(opts.addFiles)) { - const fullPath = path.join(addDir, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); - } - } - - if (opts.modifyFiles) { - const modDir = path.join(skillDir, 'modify'); - for (const [relPath, content] of Object.entries(opts.modifyFiles)) { - const fullPath = path.join(modDir, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); - } - } - - return skillDir; -} - -export function initGitRepo(dir: string): void { - execSync('git init', { cwd: dir, stdio: 'pipe' }); - execSync('git config user.email "test@test.com"', { - cwd: dir, - stdio: 'pipe', - }); - execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' }); - execSync('git config rerere.enabled true', { cwd: dir, stdio: 'pipe' }); - fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules\n'); - execSync('git add -A && git commit -m "init"', { cwd: dir, stdio: 'pipe' }); -} - -export function cleanup(dir: string): void { - fs.rmSync(dir, { recursive: true, force: true }); -} diff --git a/skills-engine/__tests__/uninstall.test.ts b/skills-engine/__tests__/uninstall.test.ts deleted file mode 100644 index 7bb24fd..0000000 --- a/skills-engine/__tests__/uninstall.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { stringify } from 'yaml'; - -import { uninstallSkill } from '../uninstall.js'; -import { - cleanup, - createTempDir, - initGitRepo, - setupNanoclawDir, - writeState, -} from './test-helpers.js'; - -describe('uninstall', () => { - let tmpDir: string; - const originalCwd = process.cwd(); - - beforeEach(() => { - tmpDir = createTempDir(); - setupNanoclawDir(tmpDir); - initGitRepo(tmpDir); - process.chdir(tmpDir); - }); - - afterEach(() => { - process.chdir(originalCwd); - cleanup(tmpDir); - }); - - function setupSkillPackage( - name: string, - opts: { - adds?: Record; - modifies?: Record; - modifiesBase?: Record; - } = {}, - ): void { - const skillDir = path.join(tmpDir, '.claude', 'skills', name); - fs.mkdirSync(skillDir, { recursive: true }); - - const addsList = Object.keys(opts.adds ?? {}); - const modifiesList = Object.keys(opts.modifies ?? {}); - - fs.writeFileSync( - path.join(skillDir, 'manifest.yaml'), - stringify({ - skill: name, - version: '1.0.0', - core_version: '1.0.0', - adds: addsList, - modifies: modifiesList, - }), - ); - - if (opts.adds) { - const addDir = path.join(skillDir, 'add'); - for (const [relPath, content] of Object.entries(opts.adds)) { - const fullPath = path.join(addDir, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); - } - } - - if (opts.modifies) { - const modDir = path.join(skillDir, 'modify'); - for (const [relPath, content] of Object.entries(opts.modifies)) { - const fullPath = path.join(modDir, relPath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); - } - } - } - - it('returns error for non-applied skill', async () => { - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [], - }); - - const result = await uninstallSkill('nonexistent'); - expect(result.success).toBe(false); - expect(result.error).toContain('not applied'); - }); - - it('blocks uninstall after rebase', async () => { - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - rebased_at: new Date().toISOString(), - applied_skills: [ - { - name: 'telegram', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: { 'src/config.ts': 'abc' }, - }, - ], - }); - - const result = await uninstallSkill('telegram'); - expect(result.success).toBe(false); - expect(result.error).toContain('Cannot uninstall'); - expect(result.error).toContain('after rebase'); - }); - - it('returns custom patch warning', async () => { - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [ - { - name: 'telegram', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: {}, - custom_patch: '.nanoclaw/custom/001.patch', - custom_patch_description: 'My tweak', - }, - ], - }); - - const result = await uninstallSkill('telegram'); - expect(result.success).toBe(false); - expect(result.customPatchWarning).toContain('custom patch'); - expect(result.customPatchWarning).toContain('My tweak'); - }); - - it('uninstalls only skill → files reset to base', async () => { - // Set up base - const baseDir = path.join(tmpDir, '.nanoclaw', 'base', 'src'); - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync(path.join(baseDir, 'config.ts'), 'base config\n'); - - // Set up current files (as if skill was applied) - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'config.ts'), - 'base config\ntelegram config\n', - ); - fs.writeFileSync( - path.join(tmpDir, 'src', 'telegram.ts'), - 'telegram code\n', - ); - - // Set up skill package in .claude/skills/ - setupSkillPackage('telegram', { - adds: { 'src/telegram.ts': 'telegram code\n' }, - modifies: { - 'src/config.ts': 'base config\ntelegram config\n', - }, - }); - - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [ - { - name: 'telegram', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: { - 'src/config.ts': 'abc', - 'src/telegram.ts': 'def', - }, - }, - ], - }); - - const result = await uninstallSkill('telegram'); - expect(result.success).toBe(true); - expect(result.skill).toBe('telegram'); - - // config.ts should be reset to base - expect( - fs.readFileSync(path.join(tmpDir, 'src', 'config.ts'), 'utf-8'), - ).toBe('base config\n'); - - // telegram.ts (add-only) should be removed - expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(false); - }); - - it('uninstalls one of two → other preserved', async () => { - // Set up base - const baseDir = path.join(tmpDir, '.nanoclaw', 'base', 'src'); - fs.mkdirSync(baseDir, { recursive: true }); - fs.writeFileSync( - path.join(baseDir, 'config.ts'), - 'line1\nline2\nline3\nline4\nline5\n', - ); - - // Current has both skills applied - fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, 'src', 'config.ts'), - 'telegram import\nline1\nline2\nline3\nline4\nline5\ndiscord import\n', - ); - fs.writeFileSync(path.join(tmpDir, 'src', 'telegram.ts'), 'tg code\n'); - fs.writeFileSync(path.join(tmpDir, 'src', 'discord.ts'), 'dc code\n'); - - // Set up both skill packages - setupSkillPackage('telegram', { - adds: { 'src/telegram.ts': 'tg code\n' }, - modifies: { - 'src/config.ts': 'telegram import\nline1\nline2\nline3\nline4\nline5\n', - }, - }); - - setupSkillPackage('discord', { - adds: { 'src/discord.ts': 'dc code\n' }, - modifies: { - 'src/config.ts': 'line1\nline2\nline3\nline4\nline5\ndiscord import\n', - }, - }); - - writeState(tmpDir, { - skills_system_version: '0.1.0', - core_version: '1.0.0', - applied_skills: [ - { - name: 'telegram', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: { - 'src/config.ts': 'abc', - 'src/telegram.ts': 'def', - }, - }, - { - name: 'discord', - version: '1.0.0', - applied_at: new Date().toISOString(), - file_hashes: { - 'src/config.ts': 'ghi', - 'src/discord.ts': 'jkl', - }, - }, - ], - }); - - const result = await uninstallSkill('telegram'); - expect(result.success).toBe(true); - - // discord.ts should still exist - expect(fs.existsSync(path.join(tmpDir, 'src', 'discord.ts'))).toBe(true); - - // telegram.ts should be gone - expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(false); - - // config should have discord import but not telegram - const config = fs.readFileSync( - path.join(tmpDir, 'src', 'config.ts'), - 'utf-8', - ); - expect(config).toContain('discord import'); - expect(config).not.toContain('telegram import'); - }); -}); diff --git a/skills-engine/apply.ts b/skills-engine/apply.ts deleted file mode 100644 index 219d025..0000000 --- a/skills-engine/apply.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { execSync } from 'child_process'; -import crypto from 'crypto'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import { clearBackup, createBackup, restoreBackup } from './backup.js'; -import { NANOCLAW_DIR, STATE_FILE } from './constants.js'; -import { copyDir } from './fs-utils.js'; -import { isCustomizeActive } from './customize.js'; -import { initNanoclawDir } from './init.js'; -import { executeFileOps } from './file-ops.js'; -import { acquireLock } from './lock.js'; -import { - checkConflicts, - checkCoreVersion, - checkDependencies, - checkSystemVersion, - readManifest, -} from './manifest.js'; -import { loadPathRemap, resolvePathRemap } from './path-remap.js'; -import { mergeFile } from './merge.js'; -import { - computeFileHash, - readState, - recordSkillApplication, - writeState, -} from './state.js'; -import { - mergeDockerComposeServices, - mergeEnvAdditions, - mergeNpmDependencies, - runNpmInstall, -} from './structured.js'; -import { ApplyResult } from './types.js'; - -export async function applySkill(skillDir: string): Promise { - const projectRoot = process.cwd(); - const manifest = readManifest(skillDir); - - // --- Pre-flight checks --- - // Auto-initialize skills system if state file doesn't exist - const statePath = path.join(projectRoot, NANOCLAW_DIR, STATE_FILE); - if (!fs.existsSync(statePath)) { - initNanoclawDir(); - } - const currentState = readState(); - - // Check skills system version compatibility - const sysCheck = checkSystemVersion(manifest); - if (!sysCheck.ok) { - return { - success: false, - skill: manifest.skill, - version: manifest.version, - error: sysCheck.error, - }; - } - - // Check core version compatibility - const coreCheck = checkCoreVersion(manifest); - if (coreCheck.warning) { - console.log(`Warning: ${coreCheck.warning}`); - } - - // Block if customize session is active - if (isCustomizeActive()) { - return { - success: false, - skill: manifest.skill, - version: manifest.version, - error: - 'A customize session is active. Run commitCustomize() or abortCustomize() first.', - }; - } - - const deps = checkDependencies(manifest); - if (!deps.ok) { - return { - success: false, - skill: manifest.skill, - version: manifest.version, - error: `Missing dependencies: ${deps.missing.join(', ')}`, - }; - } - - const conflicts = checkConflicts(manifest); - if (!conflicts.ok) { - return { - success: false, - skill: manifest.skill, - version: manifest.version, - error: `Conflicting skills: ${conflicts.conflicting.join(', ')}`, - }; - } - - // Load path remap for renamed core files - const pathRemap = loadPathRemap(); - - // Detect drift for modified files - const driftFiles: string[] = []; - for (const relPath of manifest.modifies) { - const resolvedPath = resolvePathRemap(relPath, pathRemap); - const currentPath = path.join(projectRoot, resolvedPath); - const basePath = path.join(projectRoot, NANOCLAW_DIR, 'base', resolvedPath); - - if (fs.existsSync(currentPath) && fs.existsSync(basePath)) { - const currentHash = computeFileHash(currentPath); - const baseHash = computeFileHash(basePath); - if (currentHash !== baseHash) { - driftFiles.push(relPath); - } - } - } - - if (driftFiles.length > 0) { - console.log(`Drift detected in: ${driftFiles.join(', ')}`); - console.log('Three-way merge will be used to reconcile changes.'); - } - - // --- Acquire lock --- - const releaseLock = acquireLock(); - - // Track added files so we can remove them on rollback - const addedFiles: string[] = []; - - try { - // --- Backup --- - const filesToBackup = [ - ...manifest.modifies.map((f) => - path.join(projectRoot, resolvePathRemap(f, pathRemap)), - ), - ...manifest.adds.map((f) => - path.join(projectRoot, resolvePathRemap(f, pathRemap)), - ), - ...(manifest.file_ops || []) - .filter((op) => op.from) - .map((op) => - path.join(projectRoot, resolvePathRemap(op.from!, pathRemap)), - ), - path.join(projectRoot, 'package.json'), - path.join(projectRoot, 'package-lock.json'), - path.join(projectRoot, '.env.example'), - path.join(projectRoot, 'docker-compose.yml'), - ]; - createBackup(filesToBackup); - - // --- File operations (before copy adds, per architecture doc) --- - if (manifest.file_ops && manifest.file_ops.length > 0) { - const fileOpsResult = executeFileOps(manifest.file_ops, projectRoot); - if (!fileOpsResult.success) { - restoreBackup(); - clearBackup(); - return { - success: false, - skill: manifest.skill, - version: manifest.version, - error: `File operations failed: ${fileOpsResult.errors.join('; ')}`, - }; - } - } - - // --- Copy new files from add/ --- - const addDir = path.join(skillDir, 'add'); - if (fs.existsSync(addDir)) { - for (const relPath of manifest.adds) { - const resolvedDest = resolvePathRemap(relPath, pathRemap); - const destPath = path.join(projectRoot, resolvedDest); - if (!fs.existsSync(destPath)) { - addedFiles.push(destPath); - } - // Copy individual file with remap (can't use copyDir when paths differ) - const srcPath = path.join(addDir, relPath); - if (fs.existsSync(srcPath)) { - fs.mkdirSync(path.dirname(destPath), { recursive: true }); - fs.copyFileSync(srcPath, destPath); - } - } - } - - // --- Merge modified files --- - const mergeConflicts: string[] = []; - - for (const relPath of manifest.modifies) { - const resolvedPath = resolvePathRemap(relPath, pathRemap); - const currentPath = path.join(projectRoot, resolvedPath); - const basePath = path.join( - projectRoot, - NANOCLAW_DIR, - 'base', - resolvedPath, - ); - // skillPath uses original relPath — skill packages are never mutated - const skillPath = path.join(skillDir, 'modify', relPath); - - if (!fs.existsSync(skillPath)) { - throw new Error(`Skill modified file not found: ${skillPath}`); - } - - if (!fs.existsSync(currentPath)) { - // File doesn't exist yet — just copy from skill - fs.mkdirSync(path.dirname(currentPath), { recursive: true }); - fs.copyFileSync(skillPath, currentPath); - continue; - } - - if (!fs.existsSync(basePath)) { - // No base — use current as base (first-time apply) - fs.mkdirSync(path.dirname(basePath), { recursive: true }); - fs.copyFileSync(currentPath, basePath); - } - - // Three-way merge: current ← base → skill - // git merge-file modifies the first argument in-place, so use a temp copy - const tmpCurrent = path.join( - os.tmpdir(), - `nanoclaw-merge-${crypto.randomUUID()}-${path.basename(relPath)}`, - ); - fs.copyFileSync(currentPath, tmpCurrent); - - const result = mergeFile(tmpCurrent, basePath, skillPath); - - if (result.clean) { - fs.copyFileSync(tmpCurrent, currentPath); - fs.unlinkSync(tmpCurrent); - } else { - // Conflict — copy markers to working tree - fs.copyFileSync(tmpCurrent, currentPath); - fs.unlinkSync(tmpCurrent); - mergeConflicts.push(relPath); - } - } - - if (mergeConflicts.length > 0) { - // Bug 4 fix: Preserve backup when returning with conflicts - return { - success: false, - skill: manifest.skill, - version: manifest.version, - mergeConflicts, - backupPending: true, - untrackedChanges: driftFiles.length > 0 ? driftFiles : undefined, - error: `Merge conflicts in: ${mergeConflicts.join(', ')}. Resolve manually then run recordSkillApplication(). Call clearBackup() after resolution or restoreBackup() + clearBackup() to abort.`, - }; - } - - // --- Structured operations --- - if (manifest.structured?.npm_dependencies) { - const pkgPath = path.join(projectRoot, 'package.json'); - mergeNpmDependencies(pkgPath, manifest.structured.npm_dependencies); - } - - if (manifest.structured?.env_additions) { - const envPath = path.join(projectRoot, '.env.example'); - mergeEnvAdditions(envPath, manifest.structured.env_additions); - } - - if (manifest.structured?.docker_compose_services) { - const composePath = path.join(projectRoot, 'docker-compose.yml'); - mergeDockerComposeServices( - composePath, - manifest.structured.docker_compose_services, - ); - } - - // Run npm install if dependencies were added - if ( - manifest.structured?.npm_dependencies && - Object.keys(manifest.structured.npm_dependencies).length > 0 - ) { - runNpmInstall(); - } - - // --- Post-apply commands --- - if (manifest.post_apply && manifest.post_apply.length > 0) { - for (const cmd of manifest.post_apply) { - try { - execSync(cmd, { stdio: 'pipe', cwd: projectRoot, timeout: 120_000 }); - } catch (postErr: any) { - // Rollback on post_apply failure - for (const f of addedFiles) { - try { - if (fs.existsSync(f)) fs.unlinkSync(f); - } catch { - /* best effort */ - } - } - restoreBackup(); - clearBackup(); - return { - success: false, - skill: manifest.skill, - version: manifest.version, - error: `post_apply command failed: ${cmd} — ${postErr.message}`, - }; - } - } - } - - // --- Update state --- - const fileHashes: Record = {}; - for (const relPath of [...manifest.adds, ...manifest.modifies]) { - const resolvedPath = resolvePathRemap(relPath, pathRemap); - const absPath = path.join(projectRoot, resolvedPath); - if (fs.existsSync(absPath)) { - fileHashes[resolvedPath] = computeFileHash(absPath); - } - } - - // Store structured outcomes including the test command - const outcomes: Record = manifest.structured - ? { ...manifest.structured } - : {}; - if (manifest.test) { - outcomes.test = manifest.test; - } - - recordSkillApplication( - manifest.skill, - manifest.version, - fileHashes, - Object.keys(outcomes).length > 0 ? outcomes : undefined, - ); - - // --- Bug 3 fix: Execute test command if defined --- - if (manifest.test) { - try { - execSync(manifest.test, { - stdio: 'pipe', - cwd: projectRoot, - timeout: 120_000, - }); - } catch (testErr: any) { - // Tests failed — remove added files, restore backup and undo state - for (const f of addedFiles) { - try { - if (fs.existsSync(f)) fs.unlinkSync(f); - } catch { - /* best effort */ - } - } - restoreBackup(); - // Re-read state and remove the skill we just recorded - const state = readState(); - state.applied_skills = state.applied_skills.filter( - (s) => s.name !== manifest.skill, - ); - writeState(state); - - clearBackup(); - return { - success: false, - skill: manifest.skill, - version: manifest.version, - error: `Tests failed: ${testErr.message}`, - }; - } - } - - // --- Cleanup --- - clearBackup(); - - return { - success: true, - skill: manifest.skill, - version: manifest.version, - untrackedChanges: driftFiles.length > 0 ? driftFiles : undefined, - }; - } catch (err) { - // Remove newly added files before restoring backup - for (const f of addedFiles) { - try { - if (fs.existsSync(f)) fs.unlinkSync(f); - } catch { - /* best effort */ - } - } - restoreBackup(); - clearBackup(); - throw err; - } finally { - releaseLock(); - } -} diff --git a/skills-engine/backup.ts b/skills-engine/backup.ts deleted file mode 100644 index d9fa307..0000000 --- a/skills-engine/backup.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { BACKUP_DIR } from './constants.js'; - -const TOMBSTONE_SUFFIX = '.tombstone'; - -function getBackupDir(): string { - return path.join(process.cwd(), BACKUP_DIR); -} - -export function createBackup(filePaths: string[]): void { - const backupDir = getBackupDir(); - fs.mkdirSync(backupDir, { recursive: true }); - - for (const filePath of filePaths) { - const absPath = path.resolve(filePath); - const relativePath = path.relative(process.cwd(), absPath); - const backupPath = path.join(backupDir, relativePath); - fs.mkdirSync(path.dirname(backupPath), { recursive: true }); - - if (fs.existsSync(absPath)) { - fs.copyFileSync(absPath, backupPath); - } else { - // File doesn't exist yet — write a tombstone so restore can delete it - fs.writeFileSync(backupPath + TOMBSTONE_SUFFIX, '', 'utf-8'); - } - } -} - -export function restoreBackup(): void { - const backupDir = getBackupDir(); - if (!fs.existsSync(backupDir)) return; - - const walk = (dir: string) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath); - } else if (entry.name.endsWith(TOMBSTONE_SUFFIX)) { - // Tombstone: delete the corresponding project file - const tombRelPath = path.relative(backupDir, fullPath); - const originalRelPath = tombRelPath.slice(0, -TOMBSTONE_SUFFIX.length); - const originalPath = path.join(process.cwd(), originalRelPath); - if (fs.existsSync(originalPath)) { - fs.unlinkSync(originalPath); - } - } else { - const relativePath = path.relative(backupDir, fullPath); - const originalPath = path.join(process.cwd(), relativePath); - fs.mkdirSync(path.dirname(originalPath), { recursive: true }); - fs.copyFileSync(fullPath, originalPath); - } - } - }; - - walk(backupDir); -} - -export function clearBackup(): void { - const backupDir = getBackupDir(); - if (fs.existsSync(backupDir)) { - fs.rmSync(backupDir, { recursive: true, force: true }); - } -} diff --git a/skills-engine/constants.ts b/skills-engine/constants.ts deleted file mode 100644 index 93bd5e1..0000000 --- a/skills-engine/constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const NANOCLAW_DIR = '.nanoclaw'; -export const STATE_FILE = 'state.yaml'; -export const BASE_DIR = '.nanoclaw/base'; -export const BACKUP_DIR = '.nanoclaw/backup'; -export const LOCK_FILE = '.nanoclaw/lock'; -export const CUSTOM_DIR = '.nanoclaw/custom'; -export const SKILLS_SCHEMA_VERSION = '0.1.0'; - -// Top-level paths to include in base snapshot and upstream extraction. -// Add new entries here when new root-level directories/files need tracking. -export const BASE_INCLUDES = [ - 'src/', - 'package.json', - '.env.example', - 'container/', -]; diff --git a/skills-engine/customize.ts b/skills-engine/customize.ts deleted file mode 100644 index e7ec330..0000000 --- a/skills-engine/customize.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { execFileSync, execSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { parse, stringify } from 'yaml'; - -import { BASE_DIR, CUSTOM_DIR } from './constants.js'; -import { - computeFileHash, - readState, - recordCustomModification, -} from './state.js'; - -interface PendingCustomize { - description: string; - started_at: string; - file_hashes: Record; -} - -function getPendingPath(): string { - return path.join(process.cwd(), CUSTOM_DIR, 'pending.yaml'); -} - -export function isCustomizeActive(): boolean { - return fs.existsSync(getPendingPath()); -} - -export function startCustomize(description: string): void { - if (isCustomizeActive()) { - throw new Error( - 'A customize session is already active. Commit or abort it first.', - ); - } - - const state = readState(); - - // Collect all file hashes from applied skills - const fileHashes: Record = {}; - for (const skill of state.applied_skills) { - for (const [relativePath, hash] of Object.entries(skill.file_hashes)) { - fileHashes[relativePath] = hash; - } - } - - const pending: PendingCustomize = { - description, - started_at: new Date().toISOString(), - file_hashes: fileHashes, - }; - - const customDir = path.join(process.cwd(), CUSTOM_DIR); - fs.mkdirSync(customDir, { recursive: true }); - fs.writeFileSync(getPendingPath(), stringify(pending), 'utf-8'); -} - -export function commitCustomize(): void { - const pendingPath = getPendingPath(); - if (!fs.existsSync(pendingPath)) { - throw new Error('No active customize session. Run startCustomize() first.'); - } - - const pending = parse( - fs.readFileSync(pendingPath, 'utf-8'), - ) as PendingCustomize; - const cwd = process.cwd(); - - // Find files that changed - const changedFiles: string[] = []; - for (const relativePath of Object.keys(pending.file_hashes)) { - const fullPath = path.join(cwd, relativePath); - if (!fs.existsSync(fullPath)) { - // File was deleted — counts as changed - changedFiles.push(relativePath); - continue; - } - const currentHash = computeFileHash(fullPath); - if (currentHash !== pending.file_hashes[relativePath]) { - changedFiles.push(relativePath); - } - } - - if (changedFiles.length === 0) { - console.log( - 'No files changed during customize session. Nothing to commit.', - ); - fs.unlinkSync(pendingPath); - return; - } - - // Generate unified diff for each changed file - const baseDir = path.join(cwd, BASE_DIR); - let combinedPatch = ''; - - for (const relativePath of changedFiles) { - const basePath = path.join(baseDir, relativePath); - const currentPath = path.join(cwd, relativePath); - - // Use /dev/null if either side doesn't exist - const oldPath = fs.existsSync(basePath) ? basePath : '/dev/null'; - const newPath = fs.existsSync(currentPath) ? currentPath : '/dev/null'; - - try { - const diff = execFileSync('diff', ['-ruN', oldPath, newPath], { - encoding: 'utf-8', - }); - combinedPatch += diff; - } catch (err: unknown) { - const execErr = err as { status?: number; stdout?: string }; - if (execErr.status === 1 && execErr.stdout) { - // diff exits 1 when files differ — that's expected - combinedPatch += execErr.stdout; - } else if (execErr.status === 2) { - throw new Error( - `diff error for ${relativePath}: diff exited with status 2 (check file permissions or encoding)`, - ); - } else { - throw err; - } - } - } - - if (!combinedPatch.trim()) { - console.log('Diff was empty despite hash changes. Nothing to commit.'); - fs.unlinkSync(pendingPath); - return; - } - - // Determine sequence number - const state = readState(); - const existingCount = state.custom_modifications?.length ?? 0; - const seqNum = String(existingCount + 1).padStart(3, '0'); - - // Sanitize description for filename - const sanitized = pending.description - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); - const patchFilename = `${seqNum}-${sanitized}.patch`; - const patchRelPath = path.join(CUSTOM_DIR, patchFilename); - const patchFullPath = path.join(cwd, patchRelPath); - - fs.writeFileSync(patchFullPath, combinedPatch, 'utf-8'); - recordCustomModification(pending.description, changedFiles, patchRelPath); - fs.unlinkSync(pendingPath); -} - -export function abortCustomize(): void { - const pendingPath = getPendingPath(); - if (fs.existsSync(pendingPath)) { - fs.unlinkSync(pendingPath); - } -} diff --git a/skills-engine/file-ops.ts b/skills-engine/file-ops.ts deleted file mode 100644 index 6d656c5..0000000 --- a/skills-engine/file-ops.ts +++ /dev/null @@ -1,191 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import type { FileOperation, FileOpsResult } from './types.js'; - -function isWithinRoot(rootPath: string, targetPath: string): boolean { - return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep); -} - -function nearestExistingPathOrSymlink(candidateAbsPath: string): string { - let current = candidateAbsPath; - while (true) { - try { - fs.lstatSync(current); - return current; - } catch { - const parent = path.dirname(current); - if (parent === current) { - throw new Error(`Invalid file operation path: "${candidateAbsPath}"`); - } - current = parent; - } - } -} - -function resolveRealPathWithSymlinkAwareAnchor( - candidateAbsPath: string, -): string { - const anchorPath = nearestExistingPathOrSymlink(candidateAbsPath); - const anchorStat = fs.lstatSync(anchorPath); - let realAnchor: string; - - if (anchorStat.isSymbolicLink()) { - const linkTarget = fs.readlinkSync(anchorPath); - const linkResolved = path.resolve(path.dirname(anchorPath), linkTarget); - realAnchor = fs.realpathSync(linkResolved); - } else { - realAnchor = fs.realpathSync(anchorPath); - } - - const relativeRemainder = path.relative(anchorPath, candidateAbsPath); - return relativeRemainder - ? path.resolve(realAnchor, relativeRemainder) - : realAnchor; -} - -function safePath(projectRoot: string, relativePath: string): string | null { - if (typeof relativePath !== 'string' || relativePath.trim() === '') { - return null; - } - - const root = path.resolve(projectRoot); - const resolved = path.resolve(root, relativePath); - if (!isWithinRoot(root, resolved)) { - return null; - } - if (resolved === root) { - return null; - } - - const realRoot = fs.realpathSync(root); - const realParent = resolveRealPathWithSymlinkAwareAnchor( - path.dirname(resolved), - ); - if (!isWithinRoot(realRoot, realParent)) { - return null; - } - - return resolved; -} - -export function executeFileOps( - ops: FileOperation[], - projectRoot: string, -): FileOpsResult { - const result: FileOpsResult = { - success: true, - executed: [], - warnings: [], - errors: [], - }; - - const root = path.resolve(projectRoot); - - for (const op of ops) { - switch (op.type) { - case 'rename': { - if (!op.from || !op.to) { - result.errors.push(`rename: requires 'from' and 'to'`); - result.success = false; - return result; - } - const fromPath = safePath(root, op.from); - const toPath = safePath(root, op.to); - if (!fromPath) { - result.errors.push(`rename: path escapes project root: ${op.from}`); - result.success = false; - return result; - } - if (!toPath) { - result.errors.push(`rename: path escapes project root: ${op.to}`); - result.success = false; - return result; - } - if (!fs.existsSync(fromPath)) { - result.errors.push(`rename: source does not exist: ${op.from}`); - result.success = false; - return result; - } - if (fs.existsSync(toPath)) { - result.errors.push(`rename: target already exists: ${op.to}`); - result.success = false; - return result; - } - fs.renameSync(fromPath, toPath); - result.executed.push(op); - break; - } - - case 'delete': { - if (!op.path) { - result.errors.push(`delete: requires 'path'`); - result.success = false; - return result; - } - const delPath = safePath(root, op.path); - if (!delPath) { - result.errors.push(`delete: path escapes project root: ${op.path}`); - result.success = false; - return result; - } - if (!fs.existsSync(delPath)) { - result.warnings.push( - `delete: file does not exist (skipped): ${op.path}`, - ); - result.executed.push(op); - break; - } - fs.unlinkSync(delPath); - result.executed.push(op); - break; - } - - case 'move': { - if (!op.from || !op.to) { - result.errors.push(`move: requires 'from' and 'to'`); - result.success = false; - return result; - } - const srcPath = safePath(root, op.from); - const dstPath = safePath(root, op.to); - if (!srcPath) { - result.errors.push(`move: path escapes project root: ${op.from}`); - result.success = false; - return result; - } - if (!dstPath) { - result.errors.push(`move: path escapes project root: ${op.to}`); - result.success = false; - return result; - } - if (!fs.existsSync(srcPath)) { - result.errors.push(`move: source does not exist: ${op.from}`); - result.success = false; - return result; - } - if (fs.existsSync(dstPath)) { - result.errors.push(`move: target already exists: ${op.to}`); - result.success = false; - return result; - } - const dstDir = path.dirname(dstPath); - if (!fs.existsSync(dstDir)) { - fs.mkdirSync(dstDir, { recursive: true }); - } - fs.renameSync(srcPath, dstPath); - result.executed.push(op); - break; - } - - default: { - result.errors.push( - `unknown operation type: ${(op as FileOperation).type}`, - ); - result.success = false; - return result; - } - } - } - - return result; -} diff --git a/skills-engine/fs-utils.ts b/skills-engine/fs-utils.ts deleted file mode 100644 index a957752..0000000 --- a/skills-engine/fs-utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -/** - * Recursively copy a directory tree from src to dest. - * Creates destination directories as needed. - */ -export function copyDir(src: string, dest: string): void { - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - - if (entry.isDirectory()) { - fs.mkdirSync(destPath, { recursive: true }); - copyDir(srcPath, destPath); - } else { - fs.mkdirSync(path.dirname(destPath), { recursive: true }); - fs.copyFileSync(srcPath, destPath); - } - } -} diff --git a/skills-engine/index.ts b/skills-engine/index.ts deleted file mode 100644 index ab6285e..0000000 --- a/skills-engine/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -export { applySkill } from './apply.js'; -export { clearBackup, createBackup, restoreBackup } from './backup.js'; -export { - BACKUP_DIR, - BASE_DIR, - SKILLS_SCHEMA_VERSION, - CUSTOM_DIR, - LOCK_FILE, - NANOCLAW_DIR, - STATE_FILE, -} from './constants.js'; -export { - abortCustomize, - commitCustomize, - isCustomizeActive, - startCustomize, -} from './customize.js'; -export { executeFileOps } from './file-ops.js'; -export { initNanoclawDir } from './init.js'; -export { acquireLock, isLocked, releaseLock } from './lock.js'; -export { - checkConflicts, - checkCoreVersion, - checkDependencies, - checkSystemVersion, - readManifest, -} from './manifest.js'; -export { isGitRepo, mergeFile } from './merge.js'; -export { - loadPathRemap, - recordPathRemap, - resolvePathRemap, -} from './path-remap.js'; -export { rebase } from './rebase.js'; -export { findSkillDir, replaySkills } from './replay.js'; -export type { ReplayOptions, ReplayResult } from './replay.js'; -export { uninstallSkill } from './uninstall.js'; -export { initSkillsSystem, migrateExisting } from './migrate.js'; -export { - compareSemver, - computeFileHash, - getAppliedSkills, - getCustomModifications, - readState, - recordCustomModification, - recordSkillApplication, - writeState, -} from './state.js'; -export { - areRangesCompatible, - mergeDockerComposeServices, - mergeEnvAdditions, - mergeNpmDependencies, - runNpmInstall, -} from './structured.js'; -export type { - AppliedSkill, - ApplyResult, - CustomModification, - FileOpsResult, - FileOperation, - MergeResult, - RebaseResult, - SkillManifest, - SkillState, - UninstallResult, -} from './types.js'; diff --git a/skills-engine/init.ts b/skills-engine/init.ts deleted file mode 100644 index 9f43b5d..0000000 --- a/skills-engine/init.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { execSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { - BACKUP_DIR, - BASE_DIR, - BASE_INCLUDES, - NANOCLAW_DIR, -} from './constants.js'; -import { isGitRepo } from './merge.js'; -import { writeState } from './state.js'; -import { SkillState } from './types.js'; - -// Directories/files to always exclude from base snapshot -const BASE_EXCLUDES = [ - 'node_modules', - '.nanoclaw', - '.git', - 'dist', - 'data', - 'groups', - 'store', - 'logs', -]; - -export function initNanoclawDir(): void { - const projectRoot = process.cwd(); - const nanoclawDir = path.join(projectRoot, NANOCLAW_DIR); - const baseDir = path.join(projectRoot, BASE_DIR); - - // Create structure - fs.mkdirSync(path.join(projectRoot, BACKUP_DIR), { recursive: true }); - - // Clean existing base - if (fs.existsSync(baseDir)) { - fs.rmSync(baseDir, { recursive: true, force: true }); - } - fs.mkdirSync(baseDir, { recursive: true }); - - // Snapshot all included paths - for (const include of BASE_INCLUDES) { - const srcPath = path.join(projectRoot, include); - if (!fs.existsSync(srcPath)) continue; - - const destPath = path.join(baseDir, include); - const stat = fs.statSync(srcPath); - - if (stat.isDirectory()) { - copyDirFiltered(srcPath, destPath, BASE_EXCLUDES); - } else { - fs.mkdirSync(path.dirname(destPath), { recursive: true }); - fs.copyFileSync(srcPath, destPath); - } - } - - // Create initial state - const coreVersion = getCoreVersion(projectRoot); - const initialState: SkillState = { - skills_system_version: '0.1.0', - core_version: coreVersion, - applied_skills: [], - }; - writeState(initialState); - - // Enable git rerere if in a git repo - if (isGitRepo()) { - try { - execSync('git config --local rerere.enabled true', { stdio: 'pipe' }); - } catch { - // Non-fatal - } - } -} - -function copyDirFiltered(src: string, dest: string, excludes: string[]): void { - fs.mkdirSync(dest, { recursive: true }); - - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - if (excludes.includes(entry.name)) continue; - - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - - if (entry.isDirectory()) { - copyDirFiltered(srcPath, destPath, excludes); - } else { - fs.copyFileSync(srcPath, destPath); - } - } -} - -function getCoreVersion(projectRoot: string): string { - try { - const pkgPath = path.join(projectRoot, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - return pkg.version || '0.0.0'; - } catch { - return '0.0.0'; - } -} diff --git a/skills-engine/lock.ts b/skills-engine/lock.ts deleted file mode 100644 index 20814c4..0000000 --- a/skills-engine/lock.ts +++ /dev/null @@ -1,106 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { LOCK_FILE } from './constants.js'; - -const STALE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes - -interface LockInfo { - pid: number; - timestamp: number; -} - -function getLockPath(): string { - return path.join(process.cwd(), LOCK_FILE); -} - -function isStale(lock: LockInfo): boolean { - return Date.now() - lock.timestamp > STALE_TIMEOUT_MS; -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -export function acquireLock(): () => void { - const lockPath = getLockPath(); - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - - const lockInfo: LockInfo = { pid: process.pid, timestamp: Date.now() }; - - try { - // Atomic creation — fails if file already exists - fs.writeFileSync(lockPath, JSON.stringify(lockInfo), { flag: 'wx' }); - return () => releaseLock(); - } catch { - // Lock file exists — check if it's stale or from a dead process - try { - const existing: LockInfo = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); - if (!isStale(existing) && isProcessAlive(existing.pid)) { - throw new Error( - `Operation in progress (pid ${existing.pid}, started ${new Date(existing.timestamp).toISOString()}). If this is stale, delete ${LOCK_FILE}`, - ); - } - // Stale or dead process — overwrite - } catch (err) { - if ( - err instanceof Error && - err.message.startsWith('Operation in progress') - ) { - throw err; - } - // Corrupt or unreadable — overwrite - } - - try { - fs.unlinkSync(lockPath); - } catch { - /* already gone */ - } - try { - fs.writeFileSync(lockPath, JSON.stringify(lockInfo), { flag: 'wx' }); - } catch { - throw new Error( - 'Lock contention: another process acquired the lock. Retry.', - ); - } - return () => releaseLock(); - } -} - -export function releaseLock(): void { - const lockPath = getLockPath(); - if (fs.existsSync(lockPath)) { - try { - const lock: LockInfo = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); - // Only release our own lock - if (lock.pid === process.pid) { - fs.unlinkSync(lockPath); - } - } catch { - // Corrupt or missing — safe to remove - try { - fs.unlinkSync(lockPath); - } catch { - // Already gone - } - } - } -} - -export function isLocked(): boolean { - const lockPath = getLockPath(); - if (!fs.existsSync(lockPath)) return false; - - try { - const lock: LockInfo = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); - return !isStale(lock) && isProcessAlive(lock.pid); - } catch { - return false; - } -} diff --git a/skills-engine/manifest.ts b/skills-engine/manifest.ts deleted file mode 100644 index 5522901..0000000 --- a/skills-engine/manifest.ts +++ /dev/null @@ -1,104 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { parse } from 'yaml'; - -import { SKILLS_SCHEMA_VERSION } from './constants.js'; -import { getAppliedSkills, readState, compareSemver } from './state.js'; -import { SkillManifest } from './types.js'; - -export function readManifest(skillDir: string): SkillManifest { - const manifestPath = path.join(skillDir, 'manifest.yaml'); - if (!fs.existsSync(manifestPath)) { - throw new Error(`Manifest not found: ${manifestPath}`); - } - - const content = fs.readFileSync(manifestPath, 'utf-8'); - const manifest = parse(content) as SkillManifest; - - // Validate required fields - const required = [ - 'skill', - 'version', - 'core_version', - 'adds', - 'modifies', - ] as const; - for (const field of required) { - if (manifest[field] === undefined) { - throw new Error(`Manifest missing required field: ${field}`); - } - } - - // Defaults - manifest.conflicts = manifest.conflicts || []; - manifest.depends = manifest.depends || []; - manifest.file_ops = manifest.file_ops || []; - - // Validate paths don't escape project root - const allPaths = [...manifest.adds, ...manifest.modifies]; - for (const p of allPaths) { - if (p.includes('..') || path.isAbsolute(p)) { - throw new Error( - `Invalid path in manifest: ${p} (must be relative without "..")`, - ); - } - } - - return manifest; -} - -export function checkCoreVersion(manifest: SkillManifest): { - ok: boolean; - warning?: string; -} { - const state = readState(); - const cmp = compareSemver(manifest.core_version, state.core_version); - if (cmp > 0) { - return { - ok: true, - warning: `Skill targets core ${manifest.core_version} but current core is ${state.core_version}. The merge might still work but there's a compatibility risk.`, - }; - } - return { ok: true }; -} - -export function checkDependencies(manifest: SkillManifest): { - ok: boolean; - missing: string[]; -} { - const applied = getAppliedSkills(); - const appliedNames = new Set(applied.map((s) => s.name)); - const missing = manifest.depends.filter((dep) => !appliedNames.has(dep)); - return { ok: missing.length === 0, missing }; -} - -export function checkSystemVersion(manifest: SkillManifest): { - ok: boolean; - error?: string; -} { - if (!manifest.min_skills_system_version) { - return { ok: true }; - } - const cmp = compareSemver( - manifest.min_skills_system_version, - SKILLS_SCHEMA_VERSION, - ); - if (cmp > 0) { - return { - ok: false, - error: `Skill requires skills system version ${manifest.min_skills_system_version} but current is ${SKILLS_SCHEMA_VERSION}. Update your skills engine.`, - }; - } - return { ok: true }; -} - -export function checkConflicts(manifest: SkillManifest): { - ok: boolean; - conflicting: string[]; -} { - const applied = getAppliedSkills(); - const appliedNames = new Set(applied.map((s) => s.name)); - const conflicting = manifest.conflicts.filter((c) => appliedNames.has(c)); - return { ok: conflicting.length === 0, conflicting }; -} diff --git a/skills-engine/merge.ts b/skills-engine/merge.ts deleted file mode 100644 index 11cd54a..0000000 --- a/skills-engine/merge.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { execFileSync, execSync } from 'child_process'; - -import { MergeResult } from './types.js'; - -export function isGitRepo(): boolean { - try { - execSync('git rev-parse --git-dir', { stdio: 'pipe' }); - return true; - } catch { - return false; - } -} - -/** - * Run git merge-file to three-way merge files. - * Modifies currentPath in-place. - * Returns { clean: true, exitCode: 0 } on clean merge, - * { clean: false, exitCode: N } on conflict (N = number of conflicts). - */ -export function mergeFile( - currentPath: string, - basePath: string, - skillPath: string, -): MergeResult { - try { - execFileSync('git', ['merge-file', currentPath, basePath, skillPath], { - stdio: 'pipe', - }); - return { clean: true, exitCode: 0 }; - } catch (err: any) { - const exitCode = err.status ?? 1; - if (exitCode > 0) { - // Positive exit code = number of conflicts - return { clean: false, exitCode }; - } - // Negative exit code = error - throw new Error(`git merge-file failed: ${err.message}`); - } -} diff --git a/skills-engine/migrate.ts b/skills-engine/migrate.ts deleted file mode 100644 index d604c23..0000000 --- a/skills-engine/migrate.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { execFileSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { BASE_DIR, CUSTOM_DIR, NANOCLAW_DIR } from './constants.js'; -import { initNanoclawDir } from './init.js'; -import { recordCustomModification } from './state.js'; - -export function initSkillsSystem(): void { - initNanoclawDir(); - console.log('Skills system initialized. .nanoclaw/ directory created.'); -} - -export function migrateExisting(): void { - const projectRoot = process.cwd(); - - // First, do a fresh init - initNanoclawDir(); - - // Then, diff current files against base to capture modifications - const baseSrcDir = path.join(projectRoot, BASE_DIR, 'src'); - const srcDir = path.join(projectRoot, 'src'); - const customDir = path.join(projectRoot, CUSTOM_DIR); - const patchRelPath = path.join(CUSTOM_DIR, 'migration.patch'); - - try { - let diff: string; - try { - diff = execFileSync('diff', ['-ruN', baseSrcDir, srcDir], { - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, - }); - } catch (err: unknown) { - // diff exits 1 when files differ — that's expected - const execErr = err as { status?: number; stdout?: string }; - if (execErr.status === 1 && execErr.stdout) { - diff = execErr.stdout; - } else { - throw err; - } - } - - if (diff.trim()) { - fs.mkdirSync(customDir, { recursive: true }); - fs.writeFileSync(path.join(projectRoot, patchRelPath), diff, 'utf-8'); - - // Extract modified file paths from the diff - const filesModified = [...diff.matchAll(/^diff -ruN .+ (.+)$/gm)] - .map((m) => path.relative(projectRoot, m[1])) - .filter((f) => !f.startsWith('.nanoclaw')); - - // Record in state so the patch is visible to the tracking system - recordCustomModification( - 'Pre-skills migration', - filesModified, - patchRelPath, - ); - - console.log( - 'Custom modifications captured in .nanoclaw/custom/migration.patch', - ); - } else { - console.log('No custom modifications detected.'); - } - } catch { - console.log('Could not generate diff. Continuing with clean base.'); - } - - console.log('Migration complete. Skills system ready.'); -} diff --git a/skills-engine/path-remap.ts b/skills-engine/path-remap.ts deleted file mode 100644 index 2de54dc..0000000 --- a/skills-engine/path-remap.ts +++ /dev/null @@ -1,125 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { readState, writeState } from './state.js'; - -function isWithinRoot(rootPath: string, targetPath: string): boolean { - return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep); -} - -function nearestExistingPathOrSymlink(candidateAbsPath: string): string { - let current = candidateAbsPath; - while (true) { - try { - fs.lstatSync(current); - return current; - } catch { - const parent = path.dirname(current); - if (parent === current) { - throw new Error(`Invalid remap path: "${candidateAbsPath}"`); - } - current = parent; - } - } -} - -function toSafeProjectRelativePath( - candidatePath: string, - projectRoot: string, -): string { - if (typeof candidatePath !== 'string' || candidatePath.trim() === '') { - throw new Error(`Invalid remap path: "${candidatePath}"`); - } - - const root = path.resolve(projectRoot); - const realRoot = fs.realpathSync(root); - const resolved = path.resolve(root, candidatePath); - if (!resolved.startsWith(root + path.sep) && resolved !== root) { - throw new Error(`Path remap escapes project root: "${candidatePath}"`); - } - if (resolved === root) { - throw new Error(`Path remap points to project root: "${candidatePath}"`); - } - - // Detect symlink escapes by resolving the nearest existing ancestor/symlink. - const anchorPath = nearestExistingPathOrSymlink(resolved); - const anchorStat = fs.lstatSync(anchorPath); - let realAnchor: string; - - if (anchorStat.isSymbolicLink()) { - const linkTarget = fs.readlinkSync(anchorPath); - const linkResolved = path.resolve(path.dirname(anchorPath), linkTarget); - realAnchor = fs.realpathSync(linkResolved); - } else { - realAnchor = fs.realpathSync(anchorPath); - } - - const relativeRemainder = path.relative(anchorPath, resolved); - const realResolved = relativeRemainder - ? path.resolve(realAnchor, relativeRemainder) - : realAnchor; - - if (!isWithinRoot(realRoot, realResolved)) { - throw new Error( - `Path remap escapes project root via symlink: "${candidatePath}"`, - ); - } - - return path.relative(realRoot, realResolved); -} - -function sanitizeRemapEntries( - remap: Record, - mode: 'throw' | 'drop', -): Record { - const projectRoot = process.cwd(); - const sanitized: Record = {}; - - for (const [from, to] of Object.entries(remap)) { - try { - const safeFrom = toSafeProjectRelativePath(from, projectRoot); - const safeTo = toSafeProjectRelativePath(to, projectRoot); - sanitized[safeFrom] = safeTo; - } catch (err) { - if (mode === 'throw') { - throw err; - } - } - } - - return sanitized; -} - -export function resolvePathRemap( - relPath: string, - remap: Record, -): string { - const projectRoot = process.cwd(); - const safeRelPath = toSafeProjectRelativePath(relPath, projectRoot); - const remapped = remap[safeRelPath] ?? remap[relPath]; - - if (remapped === undefined) { - return safeRelPath; - } - - // Fail closed: if remap target is invalid, ignore remap and keep original path. - try { - return toSafeProjectRelativePath(remapped, projectRoot); - } catch { - return safeRelPath; - } -} - -export function loadPathRemap(): Record { - const state = readState(); - const remap = state.path_remap ?? {}; - return sanitizeRemapEntries(remap, 'drop'); -} - -export function recordPathRemap(remap: Record): void { - const state = readState(); - const existing = sanitizeRemapEntries(state.path_remap ?? {}, 'drop'); - const incoming = sanitizeRemapEntries(remap, 'throw'); - state.path_remap = { ...existing, ...incoming }; - writeState(state); -} diff --git a/skills-engine/rebase.ts b/skills-engine/rebase.ts deleted file mode 100644 index 7b5d830..0000000 --- a/skills-engine/rebase.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { execFileSync } from 'child_process'; -import crypto from 'crypto'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import { clearBackup, createBackup, restoreBackup } from './backup.js'; -import { BASE_DIR, NANOCLAW_DIR } from './constants.js'; -import { copyDir } from './fs-utils.js'; -import { acquireLock } from './lock.js'; -import { mergeFile } from './merge.js'; -import { computeFileHash, readState, writeState } from './state.js'; -import type { RebaseResult } from './types.js'; - -function walkDir(dir: string, root: string): string[] { - const results: string[] = []; - if (!fs.existsSync(dir)) return results; - - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...walkDir(fullPath, root)); - } else { - results.push(path.relative(root, fullPath)); - } - } - return results; -} - -function collectTrackedFiles(state: ReturnType): Set { - const tracked = new Set(); - - for (const skill of state.applied_skills) { - for (const relPath of Object.keys(skill.file_hashes)) { - tracked.add(relPath); - } - } - - if (state.custom_modifications) { - for (const mod of state.custom_modifications) { - for (const relPath of mod.files_modified) { - tracked.add(relPath); - } - } - } - - return tracked; -} - -export async function rebase(newBasePath?: string): Promise { - const projectRoot = process.cwd(); - const state = readState(); - - if (state.applied_skills.length === 0) { - return { - success: false, - filesInPatch: 0, - error: 'No skills applied. Nothing to rebase.', - }; - } - - const releaseLock = acquireLock(); - - try { - const trackedFiles = collectTrackedFiles(state); - const baseAbsDir = path.join(projectRoot, BASE_DIR); - - // Include base dir files - const baseFiles = walkDir(baseAbsDir, baseAbsDir); - for (const f of baseFiles) { - trackedFiles.add(f); - } - - // Backup - const filesToBackup: string[] = []; - for (const relPath of trackedFiles) { - const absPath = path.join(projectRoot, relPath); - if (fs.existsSync(absPath)) filesToBackup.push(absPath); - const baseFilePath = path.join(baseAbsDir, relPath); - if (fs.existsSync(baseFilePath)) filesToBackup.push(baseFilePath); - } - const stateFilePath = path.join(projectRoot, NANOCLAW_DIR, 'state.yaml'); - filesToBackup.push(stateFilePath); - createBackup(filesToBackup); - - try { - // Generate unified diff: base vs working tree (archival record) - let combinedPatch = ''; - let filesInPatch = 0; - - for (const relPath of trackedFiles) { - const basePath = path.join(baseAbsDir, relPath); - const workingPath = path.join(projectRoot, relPath); - - const oldPath = fs.existsSync(basePath) ? basePath : '/dev/null'; - const newPath = fs.existsSync(workingPath) ? workingPath : '/dev/null'; - - if (oldPath === '/dev/null' && newPath === '/dev/null') continue; - - try { - const diff = execFileSync('diff', ['-ruN', oldPath, newPath], { - encoding: 'utf-8', - }); - if (diff.trim()) { - combinedPatch += diff; - filesInPatch++; - } - } catch (err: unknown) { - const execErr = err as { status?: number; stdout?: string }; - if (execErr.status === 1 && execErr.stdout) { - combinedPatch += execErr.stdout; - filesInPatch++; - } else { - throw err; - } - } - } - - // Save combined patch - const patchPath = path.join(projectRoot, NANOCLAW_DIR, 'combined.patch'); - fs.writeFileSync(patchPath, combinedPatch, 'utf-8'); - - if (newBasePath) { - // --- Rebase with new base: three-way merge with resolution model --- - - // Save current working tree content before overwriting - const savedContent: Record = {}; - for (const relPath of trackedFiles) { - const workingPath = path.join(projectRoot, relPath); - if (fs.existsSync(workingPath)) { - savedContent[relPath] = fs.readFileSync(workingPath, 'utf-8'); - } - } - - const absNewBase = path.resolve(newBasePath); - - // Replace base - if (fs.existsSync(baseAbsDir)) { - fs.rmSync(baseAbsDir, { recursive: true, force: true }); - } - fs.mkdirSync(baseAbsDir, { recursive: true }); - copyDir(absNewBase, baseAbsDir); - - // Copy new base to working tree - copyDir(absNewBase, projectRoot); - - // Three-way merge per file: new-base ← old-base → saved-working-tree - const mergeConflicts: string[] = []; - - for (const relPath of trackedFiles) { - const newBaseSrc = path.join(absNewBase, relPath); - const currentPath = path.join(projectRoot, relPath); - const saved = savedContent[relPath]; - - if (!saved) continue; // No working tree content to merge - if (!fs.existsSync(newBaseSrc)) { - // File only existed in working tree, not in new base — restore it - fs.mkdirSync(path.dirname(currentPath), { recursive: true }); - fs.writeFileSync(currentPath, saved); - continue; - } - - const newBaseContent = fs.readFileSync(newBaseSrc, 'utf-8'); - if (newBaseContent === saved) continue; // No diff - - // Find old base content from backup - const oldBasePath = path.join( - projectRoot, - '.nanoclaw', - 'backup', - BASE_DIR, - relPath, - ); - if (!fs.existsSync(oldBasePath)) { - // No old base — keep saved content - fs.writeFileSync(currentPath, saved); - continue; - } - - // Three-way merge: current(new base) ← old-base → saved(modifications) - const tmpSaved = path.join( - os.tmpdir(), - `nanoclaw-rebase-${crypto.randomUUID()}-${path.basename(relPath)}`, - ); - fs.writeFileSync(tmpSaved, saved); - - const result = mergeFile(currentPath, oldBasePath, tmpSaved); - fs.unlinkSync(tmpSaved); - - if (!result.clean) { - mergeConflicts.push(relPath); - } - } - - if (mergeConflicts.length > 0) { - // Return with backup pending for Claude Code / user resolution - return { - success: false, - patchFile: patchPath, - filesInPatch, - mergeConflicts, - backupPending: true, - error: `Merge conflicts in: ${mergeConflicts.join(', ')}. Resolve manually then call clearBackup(), or restoreBackup() + clearBackup() to abort.`, - }; - } - } else { - // --- Rebase without new base: flatten into base --- - // Update base to current working tree state (all skills baked in) - for (const relPath of trackedFiles) { - const workingPath = path.join(projectRoot, relPath); - const basePath = path.join(baseAbsDir, relPath); - - if (fs.existsSync(workingPath)) { - fs.mkdirSync(path.dirname(basePath), { recursive: true }); - fs.copyFileSync(workingPath, basePath); - } else if (fs.existsSync(basePath)) { - // File was removed by skills — remove from base too - fs.unlinkSync(basePath); - } - } - } - - // Update state - const now = new Date().toISOString(); - - for (const skill of state.applied_skills) { - const updatedHashes: Record = {}; - for (const relPath of Object.keys(skill.file_hashes)) { - const absPath = path.join(projectRoot, relPath); - if (fs.existsSync(absPath)) { - updatedHashes[relPath] = computeFileHash(absPath); - } - } - skill.file_hashes = updatedHashes; - } - - delete state.custom_modifications; - state.rebased_at = now; - writeState(state); - - clearBackup(); - - return { - success: true, - patchFile: patchPath, - filesInPatch, - rebased_at: now, - }; - } catch (err) { - restoreBackup(); - clearBackup(); - throw err; - } - } finally { - releaseLock(); - } -} diff --git a/skills-engine/replay.ts b/skills-engine/replay.ts deleted file mode 100644 index 4f2f5e2..0000000 --- a/skills-engine/replay.ts +++ /dev/null @@ -1,270 +0,0 @@ -import crypto from 'crypto'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import { BASE_DIR, NANOCLAW_DIR } from './constants.js'; -import { copyDir } from './fs-utils.js'; -import { readManifest } from './manifest.js'; -import { mergeFile } from './merge.js'; -import { loadPathRemap, resolvePathRemap } from './path-remap.js'; -import { - mergeDockerComposeServices, - mergeEnvAdditions, - mergeNpmDependencies, - runNpmInstall, -} from './structured.js'; - -export interface ReplayOptions { - skills: string[]; - skillDirs: Record; - projectRoot?: string; -} - -export interface ReplayResult { - success: boolean; - perSkill: Record; - mergeConflicts?: string[]; - error?: string; -} - -/** - * Scan .claude/skills/ for a directory whose manifest.yaml has skill: . - */ -export function findSkillDir( - skillName: string, - projectRoot?: string, -): string | null { - const root = projectRoot ?? process.cwd(); - const skillsRoot = path.join(root, '.claude', 'skills'); - if (!fs.existsSync(skillsRoot)) return null; - - for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const dir = path.join(skillsRoot, entry.name); - const manifestPath = path.join(dir, 'manifest.yaml'); - if (!fs.existsSync(manifestPath)) continue; - - try { - const manifest = readManifest(dir); - if (manifest.skill === skillName) return dir; - } catch { - // Skip invalid manifests - } - } - - return null; -} - -/** - * Replay a list of skills from clean base state. - * Used by uninstall (replay-without) and rebase. - */ -export async function replaySkills( - options: ReplayOptions, -): Promise { - const projectRoot = options.projectRoot ?? process.cwd(); - const baseDir = path.join(projectRoot, BASE_DIR); - const pathRemap = loadPathRemap(); - - const perSkill: Record = {}; - const allMergeConflicts: string[] = []; - - // 1. Collect all files touched by any skill in the list - const allTouchedFiles = new Set(); - for (const skillName of options.skills) { - const skillDir = options.skillDirs[skillName]; - if (!skillDir) { - perSkill[skillName] = { - success: false, - error: `Skill directory not found for: ${skillName}`, - }; - return { - success: false, - perSkill, - error: `Missing skill directory for: ${skillName}`, - }; - } - - const manifest = readManifest(skillDir); - for (const f of manifest.adds) allTouchedFiles.add(f); - for (const f of manifest.modifies) allTouchedFiles.add(f); - } - - // 2. Reset touched files to clean base - for (const relPath of allTouchedFiles) { - const resolvedPath = resolvePathRemap(relPath, pathRemap); - const currentPath = path.join(projectRoot, resolvedPath); - const basePath = path.join(baseDir, resolvedPath); - - if (fs.existsSync(basePath)) { - // Restore from base - fs.mkdirSync(path.dirname(currentPath), { recursive: true }); - fs.copyFileSync(basePath, currentPath); - } else if (fs.existsSync(currentPath)) { - // Add-only file not in base — remove it - fs.unlinkSync(currentPath); - } - } - - // Replay each skill in order - // Collect structured ops for batch application - const allNpmDeps: Record = {}; - const allEnvAdditions: string[] = []; - const allDockerServices: Record = {}; - let hasNpmDeps = false; - - for (const skillName of options.skills) { - const skillDir = options.skillDirs[skillName]; - try { - const manifest = readManifest(skillDir); - - // Execute file_ops - if (manifest.file_ops && manifest.file_ops.length > 0) { - const { executeFileOps } = await import('./file-ops.js'); - const fileOpsResult = executeFileOps(manifest.file_ops, projectRoot); - if (!fileOpsResult.success) { - perSkill[skillName] = { - success: false, - error: `File operations failed: ${fileOpsResult.errors.join('; ')}`, - }; - return { - success: false, - perSkill, - error: `File ops failed for ${skillName}`, - }; - } - } - - // Copy add/ files - const addDir = path.join(skillDir, 'add'); - if (fs.existsSync(addDir)) { - for (const relPath of manifest.adds) { - const resolvedDest = resolvePathRemap(relPath, pathRemap); - const destPath = path.join(projectRoot, resolvedDest); - const srcPath = path.join(addDir, relPath); - if (fs.existsSync(srcPath)) { - fs.mkdirSync(path.dirname(destPath), { recursive: true }); - fs.copyFileSync(srcPath, destPath); - } - } - } - - // Three-way merge modify/ files - const skillConflicts: string[] = []; - - for (const relPath of manifest.modifies) { - const resolvedPath = resolvePathRemap(relPath, pathRemap); - const currentPath = path.join(projectRoot, resolvedPath); - const basePath = path.join(baseDir, resolvedPath); - const skillPath = path.join(skillDir, 'modify', relPath); - - if (!fs.existsSync(skillPath)) { - skillConflicts.push(relPath); - continue; - } - - if (!fs.existsSync(currentPath)) { - fs.mkdirSync(path.dirname(currentPath), { recursive: true }); - fs.copyFileSync(skillPath, currentPath); - continue; - } - - if (!fs.existsSync(basePath)) { - fs.mkdirSync(path.dirname(basePath), { recursive: true }); - fs.copyFileSync(currentPath, basePath); - } - - const tmpCurrent = path.join( - os.tmpdir(), - `nanoclaw-replay-${crypto.randomUUID()}-${path.basename(relPath)}`, - ); - fs.copyFileSync(currentPath, tmpCurrent); - - const result = mergeFile(tmpCurrent, basePath, skillPath); - - if (result.clean) { - fs.copyFileSync(tmpCurrent, currentPath); - fs.unlinkSync(tmpCurrent); - } else { - fs.copyFileSync(tmpCurrent, currentPath); - fs.unlinkSync(tmpCurrent); - skillConflicts.push(resolvedPath); - } - } - - if (skillConflicts.length > 0) { - allMergeConflicts.push(...skillConflicts); - perSkill[skillName] = { - success: false, - error: `Merge conflicts: ${skillConflicts.join(', ')}`, - }; - // Stop on first conflict — later skills would merge against conflict markers - break; - } else { - perSkill[skillName] = { success: true }; - } - - // Collect structured ops - if (manifest.structured?.npm_dependencies) { - Object.assign(allNpmDeps, manifest.structured.npm_dependencies); - hasNpmDeps = true; - } - if (manifest.structured?.env_additions) { - allEnvAdditions.push(...manifest.structured.env_additions); - } - if (manifest.structured?.docker_compose_services) { - Object.assign( - allDockerServices, - manifest.structured.docker_compose_services, - ); - } - } catch (err) { - perSkill[skillName] = { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - return { - success: false, - perSkill, - error: `Replay failed for ${skillName}: ${err instanceof Error ? err.message : String(err)}`, - }; - } - } - - if (allMergeConflicts.length > 0) { - return { - success: false, - perSkill, - mergeConflicts: allMergeConflicts, - error: `Unresolved merge conflicts: ${allMergeConflicts.join(', ')}`, - }; - } - - // 4. Apply aggregated structured operations (only if no conflicts) - if (hasNpmDeps) { - const pkgPath = path.join(projectRoot, 'package.json'); - mergeNpmDependencies(pkgPath, allNpmDeps); - } - - if (allEnvAdditions.length > 0) { - const envPath = path.join(projectRoot, '.env.example'); - mergeEnvAdditions(envPath, allEnvAdditions); - } - - if (Object.keys(allDockerServices).length > 0) { - const composePath = path.join(projectRoot, 'docker-compose.yml'); - mergeDockerComposeServices(composePath, allDockerServices); - } - - // 5. Run npm install if any deps - if (hasNpmDeps) { - try { - runNpmInstall(); - } catch { - // npm install failure is non-fatal for replay - } - } - - return { success: true, perSkill }; -} diff --git a/skills-engine/state.ts b/skills-engine/state.ts deleted file mode 100644 index 6754116..0000000 --- a/skills-engine/state.ts +++ /dev/null @@ -1,119 +0,0 @@ -import crypto from 'crypto'; -import fs from 'fs'; -import path from 'path'; - -import { parse, stringify } from 'yaml'; - -import { - SKILLS_SCHEMA_VERSION, - NANOCLAW_DIR, - STATE_FILE, -} from './constants.js'; -import { AppliedSkill, CustomModification, SkillState } from './types.js'; - -function getStatePath(): string { - return path.join(process.cwd(), NANOCLAW_DIR, STATE_FILE); -} - -export function readState(): SkillState { - const statePath = getStatePath(); - if (!fs.existsSync(statePath)) { - throw new Error( - '.nanoclaw/state.yaml not found. Run initSkillsSystem() first.', - ); - } - const content = fs.readFileSync(statePath, 'utf-8'); - const state = parse(content) as SkillState; - - if (compareSemver(state.skills_system_version, SKILLS_SCHEMA_VERSION) > 0) { - throw new Error( - `state.yaml version ${state.skills_system_version} is newer than tooling version ${SKILLS_SCHEMA_VERSION}. Update your skills engine.`, - ); - } - - return state; -} - -export function writeState(state: SkillState): void { - const statePath = getStatePath(); - fs.mkdirSync(path.dirname(statePath), { recursive: true }); - const content = stringify(state, { sortMapEntries: true }); - // Write to temp file then atomic rename to prevent corruption on crash - const tmpPath = statePath + '.tmp'; - fs.writeFileSync(tmpPath, content, 'utf-8'); - fs.renameSync(tmpPath, statePath); -} - -export function recordSkillApplication( - skillName: string, - version: string, - fileHashes: Record, - structuredOutcomes?: Record, -): void { - const state = readState(); - - // Remove previous application of same skill if exists - state.applied_skills = state.applied_skills.filter( - (s) => s.name !== skillName, - ); - - state.applied_skills.push({ - name: skillName, - version, - applied_at: new Date().toISOString(), - file_hashes: fileHashes, - structured_outcomes: structuredOutcomes, - }); - - writeState(state); -} - -export function getAppliedSkills(): AppliedSkill[] { - const state = readState(); - return state.applied_skills; -} - -export function recordCustomModification( - description: string, - filesModified: string[], - patchFile: string, -): void { - const state = readState(); - - if (!state.custom_modifications) { - state.custom_modifications = []; - } - - const mod: CustomModification = { - description, - applied_at: new Date().toISOString(), - files_modified: filesModified, - patch_file: patchFile, - }; - - state.custom_modifications.push(mod); - writeState(state); -} - -export function getCustomModifications(): CustomModification[] { - const state = readState(); - return state.custom_modifications || []; -} - -export function computeFileHash(filePath: string): string { - const content = fs.readFileSync(filePath); - return crypto.createHash('sha256').update(content).digest('hex'); -} - -/** - * Compare two semver strings. Returns negative if a < b, 0 if equal, positive if a > b. - */ -export function compareSemver(a: string, b: string): number { - const partsA = a.split('.').map(Number); - const partsB = b.split('.').map(Number); - for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { - const diff = (partsA[i] || 0) - (partsB[i] || 0); - if (diff !== 0) return diff; - } - return 0; -} diff --git a/skills-engine/structured.ts b/skills-engine/structured.ts deleted file mode 100644 index 2d64171..0000000 --- a/skills-engine/structured.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { execSync } from 'child_process'; -import fs from 'fs'; -import { parse, stringify } from 'yaml'; - -interface PackageJson { - dependencies?: Record; - devDependencies?: Record; - [key: string]: unknown; -} - -interface DockerComposeFile { - version?: string; - services?: Record; - [key: string]: unknown; -} - -function compareVersionParts(a: string[], b: string[]): number { - const len = Math.max(a.length, b.length); - for (let i = 0; i < len; i++) { - const aNum = parseInt(a[i] ?? '0', 10); - const bNum = parseInt(b[i] ?? '0', 10); - if (aNum !== bNum) return aNum - bNum; - } - return 0; -} - -export function areRangesCompatible( - existing: string, - requested: string, -): { compatible: boolean; resolved: string } { - if (existing === requested) { - return { compatible: true, resolved: existing }; - } - - // Both start with ^ - if (existing.startsWith('^') && requested.startsWith('^')) { - const eParts = existing.slice(1).split('.'); - const rParts = requested.slice(1).split('.'); - if (eParts[0] !== rParts[0]) { - return { compatible: false, resolved: existing }; - } - // Same major — take the higher version - const resolved = - compareVersionParts(eParts, rParts) >= 0 ? existing : requested; - return { compatible: true, resolved }; - } - - // Both start with ~ - if (existing.startsWith('~') && requested.startsWith('~')) { - const eParts = existing.slice(1).split('.'); - const rParts = requested.slice(1).split('.'); - if (eParts[0] !== rParts[0] || eParts[1] !== rParts[1]) { - return { compatible: false, resolved: existing }; - } - // Same major.minor — take higher patch - const resolved = - compareVersionParts(eParts, rParts) >= 0 ? existing : requested; - return { compatible: true, resolved }; - } - - // Mismatched prefixes or anything else (exact, >=, *, etc.) - return { compatible: false, resolved: existing }; -} - -export function mergeNpmDependencies( - packageJsonPath: string, - newDeps: Record, -): void { - const content = fs.readFileSync(packageJsonPath, 'utf-8'); - const pkg: PackageJson = JSON.parse(content); - - pkg.dependencies = pkg.dependencies || {}; - - for (const [name, version] of Object.entries(newDeps)) { - // Check both dependencies and devDependencies to avoid duplicates - const existing = pkg.dependencies[name] ?? pkg.devDependencies?.[name]; - if (existing && existing !== version) { - const result = areRangesCompatible(existing, version); - if (!result.compatible) { - throw new Error( - `Dependency conflict: ${name} is already at ${existing}, skill wants ${version}`, - ); - } - pkg.dependencies[name] = result.resolved; - } else { - pkg.dependencies[name] = version; - } - } - - // Sort dependencies for deterministic output - pkg.dependencies = Object.fromEntries( - Object.entries(pkg.dependencies).sort(([a], [b]) => a.localeCompare(b)), - ); - - if (pkg.devDependencies) { - pkg.devDependencies = Object.fromEntries( - Object.entries(pkg.devDependencies).sort(([a], [b]) => - a.localeCompare(b), - ), - ); - } - - fs.writeFileSync( - packageJsonPath, - JSON.stringify(pkg, null, 2) + '\n', - 'utf-8', - ); -} - -export function mergeEnvAdditions( - envExamplePath: string, - additions: string[], -): void { - let content = ''; - if (fs.existsSync(envExamplePath)) { - content = fs.readFileSync(envExamplePath, 'utf-8'); - } - - const existingVars = new Set(); - for (const line of content.split('\n')) { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/); - if (match) existingVars.add(match[1]); - } - - const newVars = additions.filter((v) => !existingVars.has(v)); - if (newVars.length === 0) return; - - if (content && !content.endsWith('\n')) content += '\n'; - content += '\n# Added by skill\n'; - for (const v of newVars) { - content += `${v}=\n`; - } - - fs.writeFileSync(envExamplePath, content, 'utf-8'); -} - -function extractHostPort(portMapping: string): string | null { - const str = String(portMapping); - const parts = str.split(':'); - if (parts.length >= 2) { - return parts[0]; - } - return null; -} - -export function mergeDockerComposeServices( - composePath: string, - services: Record, -): void { - let compose: DockerComposeFile; - - if (fs.existsSync(composePath)) { - const content = fs.readFileSync(composePath, 'utf-8'); - compose = (parse(content) as DockerComposeFile) || {}; - } else { - compose = { version: '3' }; - } - - compose.services = compose.services || {}; - - // Collect host ports from existing services - const usedPorts = new Set(); - for (const [, svc] of Object.entries(compose.services)) { - const service = svc as Record; - if (Array.isArray(service.ports)) { - for (const p of service.ports) { - const host = extractHostPort(String(p)); - if (host) usedPorts.add(host); - } - } - } - - // Add new services, checking for port collisions - for (const [name, definition] of Object.entries(services)) { - if (compose.services[name]) continue; // skip existing - - const svc = definition as Record; - if (Array.isArray(svc.ports)) { - for (const p of svc.ports) { - const host = extractHostPort(String(p)); - if (host && usedPorts.has(host)) { - throw new Error( - `Port collision: host port ${host} from service "${name}" is already in use`, - ); - } - if (host) usedPorts.add(host); - } - } - - compose.services[name] = definition; - } - - fs.writeFileSync(composePath, stringify(compose), 'utf-8'); -} - -export function runNpmInstall(): void { - execSync('npm install --legacy-peer-deps', { - stdio: 'inherit', - cwd: process.cwd(), - }); -} diff --git a/skills-engine/tsconfig.json b/skills-engine/tsconfig.json deleted file mode 100644 index cb99957..0000000 --- a/skills-engine/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "noEmit": true - }, - "include": ["**/*.ts"], - "exclude": ["__tests__"] -} diff --git a/skills-engine/types.ts b/skills-engine/types.ts deleted file mode 100644 index 57a7524..0000000 --- a/skills-engine/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -export interface SkillManifest { - skill: string; - version: string; - description: string; - core_version: string; - adds: string[]; - modifies: string[]; - structured?: { - npm_dependencies?: Record; - env_additions?: string[]; - docker_compose_services?: Record; - }; - file_ops?: FileOperation[]; - conflicts: string[]; - depends: string[]; - test?: string; - author?: string; - license?: string; - min_skills_system_version?: string; - tested_with?: string[]; - post_apply?: string[]; -} - -export interface SkillState { - skills_system_version: string; - core_version: string; - applied_skills: AppliedSkill[]; - custom_modifications?: CustomModification[]; - path_remap?: Record; - rebased_at?: string; -} - -export interface AppliedSkill { - name: string; - version: string; - applied_at: string; - file_hashes: Record; - structured_outcomes?: Record; - custom_patch?: string; - custom_patch_description?: string; -} - -export interface ApplyResult { - success: boolean; - skill: string; - version: string; - mergeConflicts?: string[]; - backupPending?: boolean; - untrackedChanges?: string[]; - error?: string; -} - -export interface MergeResult { - clean: boolean; - exitCode: number; -} - -export interface FileOperation { - type: 'rename' | 'delete' | 'move'; - from?: string; - to?: string; - path?: string; -} - -export interface FileOpsResult { - success: boolean; - executed: FileOperation[]; - warnings: string[]; - errors: string[]; -} - -export interface CustomModification { - description: string; - applied_at: string; - files_modified: string[]; - patch_file: string; -} - -export interface UninstallResult { - success: boolean; - skill: string; - customPatchWarning?: string; - replayResults?: Record; - error?: string; -} - -export interface RebaseResult { - success: boolean; - patchFile?: string; - filesInPatch: number; - rebased_at?: string; - mergeConflicts?: string[]; - backupPending?: boolean; - error?: string; -} diff --git a/skills-engine/uninstall.ts b/skills-engine/uninstall.ts deleted file mode 100644 index 947574b..0000000 --- a/skills-engine/uninstall.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { execFileSync, execSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -import { clearBackup, createBackup, restoreBackup } from './backup.js'; -import { BASE_DIR, NANOCLAW_DIR } from './constants.js'; -import { acquireLock } from './lock.js'; -import { loadPathRemap, resolvePathRemap } from './path-remap.js'; -import { computeFileHash, readState, writeState } from './state.js'; -import { findSkillDir, replaySkills } from './replay.js'; -import type { UninstallResult } from './types.js'; - -export async function uninstallSkill( - skillName: string, -): Promise { - const projectRoot = process.cwd(); - const state = readState(); - - // 1. Block after rebase — skills are baked into base - if (state.rebased_at) { - return { - success: false, - skill: skillName, - error: - 'Cannot uninstall individual skills after rebase. The base includes all skill modifications. To remove a skill, start from a clean core and re-apply the skills you want.', - }; - } - - // 2. Verify skill exists - const skillEntry = state.applied_skills.find((s) => s.name === skillName); - if (!skillEntry) { - return { - success: false, - skill: skillName, - error: `Skill "${skillName}" is not applied.`, - }; - } - - // 3. Check for custom patch — warn but don't block - if (skillEntry.custom_patch) { - return { - success: false, - skill: skillName, - customPatchWarning: `Skill "${skillName}" has a custom patch (${skillEntry.custom_patch_description ?? 'no description'}). Uninstalling will lose these customizations. Re-run with confirmation to proceed.`, - }; - } - - // 4. Acquire lock - const releaseLock = acquireLock(); - - try { - // 4. Backup all files touched by any applied skill - const allTouchedFiles = new Set(); - for (const skill of state.applied_skills) { - for (const filePath of Object.keys(skill.file_hashes)) { - allTouchedFiles.add(filePath); - } - } - if (state.custom_modifications) { - for (const mod of state.custom_modifications) { - for (const f of mod.files_modified) { - allTouchedFiles.add(f); - } - } - } - - const filesToBackup = [...allTouchedFiles].map((f) => - path.join(projectRoot, f), - ); - createBackup(filesToBackup); - - // 5. Build remaining skill list (original order, minus removed) - const remainingSkills = state.applied_skills - .filter((s) => s.name !== skillName) - .map((s) => s.name); - - // 6. Locate all skill dirs - const skillDirs: Record = {}; - for (const name of remainingSkills) { - const dir = findSkillDir(name, projectRoot); - if (!dir) { - restoreBackup(); - clearBackup(); - return { - success: false, - skill: skillName, - error: `Cannot find skill package for "${name}" in .claude/skills/. All remaining skills must be available for replay.`, - }; - } - skillDirs[name] = dir; - } - - // 7. Reset files exclusive to the removed skill; replaySkills handles the rest - const baseDir = path.join(projectRoot, BASE_DIR); - const pathRemap = loadPathRemap(); - - const remainingSkillFiles = new Set(); - for (const skill of state.applied_skills) { - if (skill.name === skillName) continue; - for (const filePath of Object.keys(skill.file_hashes)) { - remainingSkillFiles.add(filePath); - } - } - - const removedSkillFiles = Object.keys(skillEntry.file_hashes); - for (const filePath of removedSkillFiles) { - if (remainingSkillFiles.has(filePath)) continue; // replaySkills handles it - const resolvedPath = resolvePathRemap(filePath, pathRemap); - const currentPath = path.join(projectRoot, resolvedPath); - const basePath = path.join(baseDir, resolvedPath); - - if (fs.existsSync(basePath)) { - fs.mkdirSync(path.dirname(currentPath), { recursive: true }); - fs.copyFileSync(basePath, currentPath); - } else if (fs.existsSync(currentPath)) { - // Add-only file not in base — remove - fs.unlinkSync(currentPath); - } - } - - // 8. Replay remaining skills on clean base - const replayResult = await replaySkills({ - skills: remainingSkills, - skillDirs, - projectRoot, - }); - - // 9. Check replay result before proceeding - if (!replayResult.success) { - restoreBackup(); - clearBackup(); - return { - success: false, - skill: skillName, - error: `Replay failed: ${replayResult.error}`, - }; - } - - // 10. Re-apply standalone custom_modifications - if (state.custom_modifications) { - for (const mod of state.custom_modifications) { - const patchPath = path.join(projectRoot, mod.patch_file); - if (fs.existsSync(patchPath)) { - try { - execFileSync('git', ['apply', '--3way', patchPath], { - stdio: 'pipe', - cwd: projectRoot, - }); - } catch { - // Custom patch failure is non-fatal but noted - } - } - } - } - - // 11. Run skill tests - const replayResults: Record = {}; - for (const skill of state.applied_skills) { - if (skill.name === skillName) continue; - const outcomes = skill.structured_outcomes as - | Record - | undefined; - if (!outcomes?.test) continue; - - try { - execSync(outcomes.test as string, { - stdio: 'pipe', - cwd: projectRoot, - timeout: 120_000, - }); - replayResults[skill.name] = true; - } catch { - replayResults[skill.name] = false; - } - } - - // Check for test failures - const testFailures = Object.entries(replayResults).filter( - ([, passed]) => !passed, - ); - if (testFailures.length > 0) { - restoreBackup(); - clearBackup(); - return { - success: false, - skill: skillName, - replayResults, - error: `Tests failed after uninstall: ${testFailures.map(([n]) => n).join(', ')}`, - }; - } - - // 11. Update state - state.applied_skills = state.applied_skills.filter( - (s) => s.name !== skillName, - ); - - // Update file hashes for remaining skills - for (const skill of state.applied_skills) { - const newHashes: Record = {}; - for (const filePath of Object.keys(skill.file_hashes)) { - const absPath = path.join(projectRoot, filePath); - if (fs.existsSync(absPath)) { - newHashes[filePath] = computeFileHash(absPath); - } - } - skill.file_hashes = newHashes; - } - - writeState(state); - - // 12. Cleanup - clearBackup(); - - return { - success: true, - skill: skillName, - replayResults: - Object.keys(replayResults).length > 0 ? replayResults : undefined, - }; - } catch (err) { - restoreBackup(); - clearBackup(); - return { - success: false, - skill: skillName, - error: err instanceof Error ? err.message : String(err), - }; - } finally { - releaseLock(); - } -} diff --git a/vitest.config.ts b/vitest.config.ts index 354e6a5..a456d1c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'skills-engine/**/*.test.ts'], + include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], }, });