diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..f276799 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"bedd47ed-bfa0-41da-9a03-93d41159b4cd","pid":24606,"acquiredAt":1776194767342} \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json index b4106ff..c4beb6f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,10 +1,5 @@ { "sandbox": { - "network": { - "allowedDomains": [ - "npm.registry.com", - "us.i.posthog.com" - ] - } + "enabled": false } } diff --git a/.claude/skills/add-compact/SKILL.md b/.claude/skills/add-compact/SKILL.md deleted file mode 100644 index 0c46165..0000000 --- a/.claude/skills/add-compact/SKILL.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -name: add-compact -description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only. ---- - -# Add /compact Command - -Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts. - -**Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context. - -## Phase 1: Pre-flight - -Check if `src/session-commands.ts` exists: - -```bash -test -f src/session-commands.ts && echo "Already applied" || echo "Not applied" -``` - -If already applied, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -Merge the skill branch: - -```bash -git fetch upstream skill/compact -git merge upstream/skill/compact -``` - -> **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly. - -This adds: -- `src/session-commands.ts` (extract and authorize session commands) -- `src/session-commands.test.ts` (unit tests for command parsing and auth) -- Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`) -- Slash command handling in `container/agent-runner/src/index.ts` - -### Validate - -```bash -npm test -npm run build -``` - -### Rebuild container - -```bash -./container/build.sh -``` - -### Restart service - -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 3: Verify - -### Integration Test - -1. Start NanoClaw in dev mode: `npm run dev` -2. From the **main group** (self-chat), send exactly: `/compact` -3. Verify: - - The agent acknowledges compaction (e.g., "Conversation compacted.") - - The session continues — send a follow-up message and verify the agent responds coherently - - A conversation archive is written to `groups/{folder}/conversations/` (by the PreCompact hook) - - Container logs show `Compact boundary observed` (confirms SDK actually compacted) - - If `compact_boundary` was NOT observed, the response says "compact_boundary was not observed" -4. From a **non-main group** as a non-admin user, send: `@ /compact` -5. Verify: - - The bot responds with "Session commands require admin access." - - No compaction occurs, no container is spawned for the command -6. From a **non-main group** as the admin (device owner / `is_from_me`), send: `@ /compact` -7. Verify: - - Compaction proceeds normally (same behavior as main group) -8. While an **active container** is running for the main group, send `/compact` -9. Verify: - - The active container is signaled to close (authorized senders only — untrusted senders cannot kill in-flight work) - - Compaction proceeds via a new container once the active one exits - - The command is not dropped (no cursor race) -10. Send a normal message, then `/compact`, then another normal message in quick succession (same polling batch): -11. Verify: - - Pre-compact messages are sent to the agent first (check container logs for two `runAgent` calls) - - Compaction proceeds after pre-compact messages are processed - - Messages **after** `/compact` in the batch are preserved (cursor advances to `/compact`'s timestamp only) and processed on the next poll cycle -12. From a **non-main group** as a non-admin user, send `@ /compact`: -13. Verify: - - Denial message is sent ("Session commands require admin access.") - - The `/compact` is consumed (cursor advanced) — it does NOT replay on future polls - - Other messages in the same batch are also consumed (cursor is a high-water mark — this is an accepted tradeoff for the narrow edge case of denied `/compact` + other messages in the same polling interval) - - No container is killed or interrupted -14. From a **non-main group** (with `requiresTrigger` enabled) as a non-admin user, send bare `/compact` (no trigger prefix): -15. Verify: - - No denial message is sent (trigger policy prevents untrusted bot responses) - - The `/compact` is consumed silently - - Note: in groups where `requiresTrigger` is `false`, a denial message IS sent because the sender is considered reachable -16. After compaction, verify **no auto-compaction** behavior — only manual `/compact` triggers it - -### Validation on Fresh Clone - -```bash -git clone /tmp/nanoclaw-test -cd /tmp/nanoclaw-test -claude # then run /add-compact -npm run build -npm test -./container/build.sh -# Manual: send /compact from main group, verify compaction + continuation -# Manual: send @ /compact from non-main as non-admin, verify denial -# Manual: send @ /compact from non-main as admin, verify allowed -# Manual: verify no auto-compaction behavior -``` - -## Security Constraints - -- **Main-group or trusted/admin sender only.** The main group is the user's private self-chat and is trusted (see `docs/SECURITY.md`). Non-main groups are untrusted — a careless or malicious user could wipe the agent's short-term memory. However, the device owner (`is_from_me`) is always trusted and can compact from any group. -- **No auto-compaction.** This skill implements manual compaction only. Automatic threshold-based compaction is a separate concern and should be a separate skill. -- **No config file.** NanoClaw's philosophy is customization through code changes, not configuration sprawl. -- **Transcript archived before compaction.** The existing `PreCompact` hook in the agent-runner archives the full transcript to `conversations/` before the SDK compacts it. -- **Session continues after compaction.** This is not a destructive reset. The conversation continues with summarized context. - -## What This Does NOT Do - -- No automatic compaction threshold (add separately if desired) -- No `/clear` command (separate skill, separate semantics — `/clear` is a destructive reset) -- No cross-group compaction (each group's session is isolated) -- No changes to the container image, Dockerfile, or build script - -## Troubleshooting - -- **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied. -- **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded. -- **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt. diff --git a/.claude/skills/add-dashboard/SKILL.md b/.claude/skills/add-dashboard/SKILL.md new file mode 100644 index 0000000..c891563 --- /dev/null +++ b/.claude/skills/add-dashboard/SKILL.md @@ -0,0 +1,138 @@ +--- +name: add-dashboard +description: Add a monitoring dashboard to NanoClaw. Installs @nanoco/nanoclaw-dashboard and a pusher that sends periodic JSON snapshots. +--- + +# /add-dashboard — NanoClaw Dashboard + +Adds a local monitoring dashboard showing agent groups, sessions, channels, users, token usage, context windows, message activity, and real-time logs. + +## Architecture + +``` +NanoClaw (pusher) Dashboard (npm package) +┌──────────┐ POST JSON ┌──────────────┐ +│ collects │ ────────────────→ │ /api/ingest │ +│ DB data │ every 60s │ in-memory │ +│ tails │ ────────────────→ │ /api/logs/ │ +│ log file │ every 2s │ push │ +└──────────┘ │ serves UI │ + └──────────────┘ +``` + +## Steps + +### 1. Install the npm package + +```bash +pnpm install @nanoco/nanoclaw-dashboard +``` + +### 2. Copy the pusher module + +Copy the resource file into src: + +``` +.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts +``` + +### 3. Add exports to src/db/index.ts + +Add these two export blocks if not already present: + +```typescript +// After the messaging-groups exports, add: +export { + getMessagingGroupsByAgentGroup, +} from './messaging-groups.js'; + +// Before the credentials exports, add: +export { + createDestination, + getDestinations, + getDestinationByName, + getDestinationByTarget, + hasDestination, + deleteDestination, +} from './agent-destinations.js'; +``` + +### 4. Wire into src/index.ts + +Add the `readEnvFile` import at the top if not already present: + +```typescript +import { readEnvFile } from './env.js'; +``` + +Add after step 7 (OneCLI approval handler), before the `log.info('NanoClaw running')` line: + +```typescript + // 8. Dashboard (optional) + const dashboardEnv = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']); + const dashboardSecret = process.env.DASHBOARD_SECRET || dashboardEnv.DASHBOARD_SECRET; + const dashboardPort = parseInt(process.env.DASHBOARD_PORT || dashboardEnv.DASHBOARD_PORT || '3100', 10); + if (dashboardSecret) { + const { startDashboard } = await import('@nanoco/nanoclaw-dashboard'); + const { startDashboardPusher } = await import('./dashboard-pusher.js'); + startDashboard({ port: dashboardPort, secret: dashboardSecret }); + startDashboardPusher({ port: dashboardPort, secret: dashboardSecret, intervalMs: 60000 }); + } else { + log.info('Dashboard disabled (no DASHBOARD_SECRET)'); + } +``` + +### 5. Add environment variables to .env + +``` +DASHBOARD_SECRET= +DASHBOARD_PORT=3100 +``` + +Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes(16).toString('hex'))"` + +### 6. Build and restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# or: launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +### 7. Verify + +```bash +curl -s http://localhost:3100/api/status +curl -s -H "Authorization: Bearer " http://localhost:3100/api/overview +``` + +Open `http://localhost:3100/dashboard` in a browser. + +## Dashboard Pages + +| Page | Shows | +|------|-------| +| Overview | Stats, token usage + cache hit rate, context windows, activity chart | +| Agent Groups | Sessions, wirings, destinations, members, admins | +| Sessions | Status, container state, context window usage bars | +| Channels | Live/offline status, messaging groups, sender policies | +| Messages | Per-session inbound/outbound messages | +| Users | Privilege hierarchy: owner > admin > member | +| Logs | Real-time log streaming with level filter | + +## Troubleshooting + +- **"No data yet"**: Wait 60s for first push, or check logs for push errors +- **401 errors**: Verify `DASHBOARD_SECRET` matches in `.env` +- **Port conflict**: Change `DASHBOARD_PORT` in `.env` +- **No logs**: Check `logs/nanoclaw.log` exists + +## Removal + +```bash +pnpm uninstall @nanoco/nanoclaw-dashboard +rm src/dashboard-pusher.ts +# Remove the dashboard block from src/index.ts +# Remove DASHBOARD_SECRET and DASHBOARD_PORT from .env +pnpm run build +``` diff --git a/.claude/skills/add-dashboard/resources/dashboard-pusher.ts b/.claude/skills/add-dashboard/resources/dashboard-pusher.ts new file mode 100644 index 0000000..5e1cc21 --- /dev/null +++ b/.claude/skills/add-dashboard/resources/dashboard-pusher.ts @@ -0,0 +1,495 @@ +/** + * Dashboard pusher — collects NanoClaw state and POSTs a JSON + * snapshot to the dashboard's /api/ingest endpoint every interval. + */ +import fs from 'fs'; +import path from 'path'; +import http from 'http'; +import Database from 'better-sqlite3'; + +import { getAllAgentGroups, getAgentGroup } from './db/agent-groups.js'; +import { getSessionsByAgentGroup } from './db/sessions.js'; +import { getAllMessagingGroups, getMessagingGroupAgents } from './db/messaging-groups.js'; +import { getDestinations } from './db/agent-destinations.js'; +import { getMembers } from './db/agent-group-members.js'; +import { getAllUsers, getUser } from './db/users.js'; +import { getUserRoles, getAdminsOfAgentGroup } from './db/user-roles.js'; +import { getUserDmsForUser } from './db/user-dms.js'; +import { getActiveAdapters, getRegisteredChannelNames } from './channels/channel-registry.js'; +import { DATA_DIR, ASSISTANT_NAME } from './config.js'; +import { getDb } from './db/connection.js'; +import { log } from './log.js'; + +interface PusherConfig { + port: number; + secret: string; + intervalMs?: number; +} + +let timer: ReturnType | null = null; +let logTimer: ReturnType | null = null; +let logOffset = 0; + +export function startDashboardPusher(config: PusherConfig): void { + const interval = config.intervalMs || 60000; + + // Push immediately on start, then on interval + push(config).catch((err) => log.error('Dashboard push failed', { err })); + timer = setInterval(() => { + push(config).catch((err) => log.error('Dashboard push failed', { err })); + }, interval); + + // Start log file tailing + startLogTail(config); + + log.info('Dashboard pusher started', { intervalMs: interval }); +} + +export function stopDashboardPusher(): void { + if (timer) { + clearInterval(timer); + timer = null; + } + if (logTimer) { + clearInterval(logTimer); + logTimer = null; + } +} + +/** Fire-and-forget POST to the dashboard. */ +function postJson(config: PusherConfig, urlPath: string, data: unknown): void { + const body = JSON.stringify(data); + const req = http.request({ + hostname: '127.0.0.1', + port: config.port, + path: urlPath, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Authorization: `Bearer ${config.secret}`, + }, + }); + req.on('error', () => {}); + req.write(body); + req.end(); +} + +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function startLogTail(config: PusherConfig): void { + const logFile = path.resolve(process.cwd(), 'logs', 'nanoclaw.log'); + if (!fs.existsSync(logFile)) return; + + // Send last 200 lines as backfill + try { + const allLines = fs.readFileSync(logFile, 'utf-8').split('\n').filter((l) => l.trim()); + logOffset = fs.statSync(logFile).size; + const tail = allLines.slice(-200).map((l) => l.replace(ANSI_RE, '')); + if (tail.length > 0) postJson(config, '/api/logs/push', { lines: tail }); + } catch { return; } + + // Poll every 2s for new lines + logTimer = setInterval(() => { + try { + const stat = fs.statSync(logFile); + if (stat.size <= logOffset) { logOffset = stat.size; return; } + const buf = Buffer.alloc(stat.size - logOffset); + const fd = fs.openSync(logFile, 'r'); + fs.readSync(fd, buf, 0, buf.length, logOffset); + fs.closeSync(fd); + logOffset = stat.size; + const lines = buf.toString().split('\n').filter((l) => l.trim()).map((l) => l.replace(ANSI_RE, '')); + if (lines.length > 0) postJson(config, '/api/logs/push', { lines }); + } catch { /* ignore */ } + }, 2000); +} + +async function push(config: PusherConfig): Promise { + const snapshot = collectSnapshot(); + postJson(config, '/api/ingest', snapshot); + log.debug('Dashboard snapshot pushed'); +} + +function collectSnapshot(): Record { + return { + timestamp: new Date().toISOString(), + assistant_name: ASSISTANT_NAME, + uptime: Math.floor(process.uptime()), + agent_groups: collectAgentGroups(), + sessions: collectSessions(), + channels: collectChannels(), + users: collectUsers(), + tokens: collectTokens(), + context_windows: collectContextWindows(), + activity: collectActivity(), + messages: collectMessages(), + }; +} + +function collectAgentGroups() { + return getAllAgentGroups().map((g) => { + const sessions = getSessionsByAgentGroup(g.id); + const running = sessions.filter((s) => s.container_status === 'running' || s.container_status === 'idle'); + const destinations = getDestinations(g.id); + const members = getMembers(g.id).map((m) => { + const user = getUser(m.user_id); + return { ...m, display_name: user?.display_name ?? null }; + }); + const admins = getAdminsOfAgentGroup(g.id).map((a) => { + const user = getUser(a.user_id); + return { ...a, display_name: user?.display_name ?? null }; + }); + + // Wirings + const db = getDb(); + const wirings = db + .prepare( + `SELECT mga.*, mg.channel_type, mg.platform_id, mg.name as mg_name, mg.is_group, mg.unknown_sender_policy + FROM messaging_group_agents mga + JOIN messaging_groups mg ON mg.id = mga.messaging_group_id + WHERE mga.agent_group_id = ?`, + ) + .all(g.id) as Array>; + + return { + id: g.id, + name: g.name, + folder: g.folder, + agent_provider: g.agent_provider, + container_config: g.container_config ? JSON.parse(g.container_config) : null, + sessionCount: sessions.length, + runningSessions: running.length, + wirings, + destinations, + members, + admins, + created_at: g.created_at, + }; + }); +} + +function collectSessions() { + const db = getDb(); + return db + .prepare( + `SELECT s.*, ag.name as agent_group_name, ag.folder as agent_group_folder, + mg.channel_type, mg.platform_id, mg.name as messaging_group_name + FROM sessions s + LEFT JOIN agent_groups ag ON ag.id = s.agent_group_id + LEFT JOIN messaging_groups mg ON mg.id = s.messaging_group_id + ORDER BY s.last_active DESC NULLS LAST`, + ) + .all() as Array>; +} + +function collectChannels() { + const messagingGroups = getAllMessagingGroups(); + const liveAdapters = getActiveAdapters().map((a) => a.channelType); + const registeredChannels = getRegisteredChannelNames(); + + const byType: Record = {}; + + for (const mg of messagingGroups) { + if (!byType[mg.channel_type]) { + byType[mg.channel_type] = { + channelType: mg.channel_type, + isLive: liveAdapters.includes(mg.channel_type), + isRegistered: registeredChannels.includes(mg.channel_type), + groups: [], + }; + } + + const agents = getMessagingGroupAgents(mg.id).map((a) => { + const group = getAgentGroup(a.agent_group_id); + return { agent_group_id: a.agent_group_id, agent_group_name: group?.name ?? null, priority: a.priority }; + }); + + byType[mg.channel_type].groups.push({ + messagingGroup: { + id: mg.id, + platform_id: mg.platform_id, + name: mg.name, + is_group: mg.is_group, + unknown_sender_policy: (mg as unknown as Record).unknown_sender_policy ?? 'strict', + }, + agents, + }); + } + + // Include live adapters with no messaging groups + for (const ct of liveAdapters) { + if (!byType[ct]) { + byType[ct] = { channelType: ct, isLive: true, isRegistered: true, groups: [] }; + } + } + + return Object.values(byType).sort((a, b) => a.channelType.localeCompare(b.channelType)); +} + +function collectUsers() { + return getAllUsers().map((u) => { + const roles = getUserRoles(u.id); + const dms = getUserDmsForUser(u.id); + + const db = getDb(); + const memberships = db + .prepare( + `SELECT agm.agent_group_id, ag.name as agent_group_name + FROM agent_group_members agm + JOIN agent_groups ag ON ag.id = agm.agent_group_id + WHERE agm.user_id = ?`, + ) + .all(u.id) as Array>; + + let privilege = 'none'; + if (roles.some((r) => r.role === 'owner')) privilege = 'owner'; + else if (roles.some((r) => r.role === 'admin' && !r.agent_group_id)) privilege = 'global_admin'; + else if (roles.some((r) => r.role === 'admin')) privilege = 'admin'; + else if (memberships.length > 0) privilege = 'member'; + + return { + id: u.id, + kind: u.kind, + display_name: u.display_name, + privilege, + roles, + memberships, + dmChannels: dms.map((d) => ({ channel_type: d.channel_type })), + created_at: u.created_at, + }; + }); +} + +function collectTokens() { + const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); + const allEntries: Array<{ model: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; agentGroupId: string }> = []; + const agentGroups = getAllAgentGroups(); + const nameMap = new Map(agentGroups.map((g) => [g.id, g.name])); + + if (fs.existsSync(sessionsDir)) { + for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { + const entries = scanJsonlTokens(path.join(sessionsDir, agDir)); + allEntries.push(...entries.map((e) => ({ ...e, agentGroupId: agDir }))); + } + } + + const byModel: Record = {}; + const byGroup: Record = {}; + const totals = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }; + + for (const e of allEntries) { + if (!byModel[e.model]) byModel[e.model] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }; + byModel[e.model].requests++; + byModel[e.model].inputTokens += e.inputTokens; + byModel[e.model].outputTokens += e.outputTokens; + byModel[e.model].cacheReadTokens += e.cacheReadTokens; + byModel[e.model].cacheCreationTokens += e.cacheCreationTokens; + + if (!byGroup[e.agentGroupId]) byGroup[e.agentGroupId] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, name: nameMap.get(e.agentGroupId) || e.agentGroupId }; + byGroup[e.agentGroupId].requests++; + byGroup[e.agentGroupId].inputTokens += e.inputTokens; + byGroup[e.agentGroupId].outputTokens += e.outputTokens; + byGroup[e.agentGroupId].cacheReadTokens += e.cacheReadTokens; + byGroup[e.agentGroupId].cacheCreationTokens += e.cacheCreationTokens; + + totals.requests++; + totals.inputTokens += e.inputTokens; + totals.outputTokens += e.outputTokens; + totals.cacheReadTokens += e.cacheReadTokens; + totals.cacheCreationTokens += e.cacheCreationTokens; + } + + return { totals, byModel, byGroup }; +} + +function scanJsonlTokens(agentDir: string) { + const claudeDir = path.join(agentDir, '.claude-shared', 'projects'); + if (!fs.existsSync(claudeDir)) return []; + + const entries: Array<{ model: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number }> = []; + + const walk = (dir: string): void => { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.jsonl')) { + try { + for (const line of fs.readFileSync(full, 'utf-8').split('\n')) { + if (!line.trim()) continue; + try { + const r = JSON.parse(line); + if (r.type === 'assistant' && r.message?.usage) { + const u = r.message.usage; + entries.push({ + model: r.message.model || 'unknown', + inputTokens: u.input_tokens || 0, + outputTokens: u.output_tokens || 0, + cacheReadTokens: u.cache_read_input_tokens || 0, + cacheCreationTokens: u.cache_creation_input_tokens || 0, + }); + } + } catch { /* skip line */ } + } + } catch { /* skip file */ } + } + } + } catch { /* skip dir */ } + }; + walk(claudeDir); + return entries; +} + +function collectContextWindows() { + const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); + if (!fs.existsSync(sessionsDir)) return []; + + const results: unknown[] = []; + const agentGroups = getAllAgentGroups(); + const nameMap = new Map(agentGroups.map((g) => [g.id, g.name])); + + for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { + const claudeDir = path.join(sessionsDir, agDir, '.claude-shared', 'projects'); + if (!fs.existsSync(claudeDir)) continue; + + // Find most recent JSONL + const jsonlFiles: string[] = []; + const walk = (dir: string): void => { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.jsonl')) jsonlFiles.push(full); + } + } catch { /* skip */ } + }; + walk(claudeDir); + if (jsonlFiles.length === 0) continue; + + jsonlFiles.sort((a, b) => { + try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; } + }); + + // Read last assistant turn from newest file + const content = fs.readFileSync(jsonlFiles[0], 'utf-8'); + const lines = content.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + if (!lines[i].trim()) continue; + try { + const r = JSON.parse(lines[i]); + if (r.type === 'assistant' && r.message?.usage) { + const u = r.message.usage; + const model = r.message.model || 'unknown'; + const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); + const max = 200000; + results.push({ + agentGroupId: agDir, + agentGroupName: nameMap.get(agDir), + sessionId: path.basename(jsonlFiles[0], '.jsonl'), + model, + contextTokens: ctx, + outputTokens: u.output_tokens || 0, + cacheReadTokens: u.cache_read_input_tokens || 0, + cacheCreationTokens: u.cache_creation_input_tokens || 0, + maxContext: max, + usagePercent: max > 0 ? Math.round((ctx / max) * 100) : 0, + timestamp: r.timestamp || '', + }); + break; + } + } catch { /* skip */ } + } + } + + return results; +} + +function collectActivity() { + const now = Date.now(); + const buckets: Record = {}; + + for (let i = 0; i < 24; i++) { + const key = new Date(now - i * 3600000).toISOString().slice(0, 13); + buckets[key] = { inbound: 0, outbound: 0 }; + } + + const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); + if (!fs.existsSync(sessionsDir)) return toBucketArray(buckets); + + const cutoff = new Date(now - 86400000).toISOString(); + + try { + for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { + const agPath = path.join(sessionsDir, agDir); + for (const sessDir of fs.readdirSync(agPath).filter((d) => d.startsWith('sess-'))) { + for (const [dbName, direction] of [['outbound.db', 'outbound'], ['inbound.db', 'inbound']] as const) { + const dbPath = path.join(agPath, sessDir, dbName); + if (!fs.existsSync(dbPath)) continue; + try { + const db = new Database(dbPath, { readonly: true }); + const table = direction === 'outbound' ? 'messages_out' : 'messages_in'; + const rows = db.prepare(`SELECT timestamp FROM ${table} WHERE timestamp > ?`).all(cutoff) as { timestamp: string }[]; + for (const row of rows) { + const key = row.timestamp.slice(0, 13); + if (buckets[key]) buckets[key][direction]++; + } + db.close(); + } catch { /* skip */ } + } + } + } + } catch { /* skip */ } + + return toBucketArray(buckets); +} + +function toBucketArray(buckets: Record) { + return Object.entries(buckets) + .map(([hour, counts]) => ({ hour, ...counts })) + .sort((a, b) => a.hour.localeCompare(b.hour)); +} + +function collectMessages() { + const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); + if (!fs.existsSync(sessionsDir)) return []; + + const results: Array<{ agentGroupId: string; sessionId: string; inbound: unknown[]; outbound: unknown[] }> = []; + const limit = 50; + + try { + for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { + const agPath = path.join(sessionsDir, agDir); + for (const sessDir of fs.readdirSync(agPath).filter((d) => d.startsWith('sess-'))) { + const inbound: unknown[] = []; + const outbound: unknown[] = []; + + const inDbPath = path.join(agPath, sessDir, 'inbound.db'); + if (fs.existsSync(inDbPath)) { + try { + const db = new Database(inDbPath, { readonly: true }); + const rows = db.prepare('SELECT * FROM messages_in ORDER BY seq DESC LIMIT ?').all(limit); + inbound.push(...(rows as unknown[]).reverse()); + db.close(); + } catch { /* skip */ } + } + + const outDbPath = path.join(agPath, sessDir, 'outbound.db'); + if (fs.existsSync(outDbPath)) { + try { + const db = new Database(outDbPath, { readonly: true }); + const rows = db.prepare('SELECT * FROM messages_out ORDER BY seq DESC LIMIT ?').all(limit); + outbound.push(...(rows as unknown[]).reverse()); + db.close(); + } catch { /* skip */ } + } + + if (inbound.length > 0 || outbound.length > 0) { + results.push({ agentGroupId: agDir, sessionId: sessDir, inbound, outbound }); + } + } + } + } catch { /* skip */ } + + return results; +} diff --git a/.claude/skills/add-discord/REMOVE.md b/.claude/skills/add-discord/REMOVE.md new file mode 100644 index 0000000..702e55d --- /dev/null +++ b/.claude/skills/add-discord/REMOVE.md @@ -0,0 +1,7 @@ +# Remove Discord + +1. Comment out `import './discord.js'` in `src/channels/index.ts` +2. Remove `DISCORD_BOT_TOKEN` from `.env` +3. Rebuild and restart + +No package to uninstall — Discord is built in. diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index e46bd3e..6d3ccc8 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -1,203 +1,98 @@ --- name: add-discord -description: Add Discord bot channel integration to NanoClaw. +description: Add Discord bot channel integration via Chat SDK. --- # Add Discord Channel -This skill adds Discord support to NanoClaw, then walks through interactive setup. +Adds Discord bot support via the Chat SDK bridge. -## Phase 1: Pre-flight +## Install -### Check if already applied +NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter in from the `channels` branch. -Check if `src/channels/discord.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. +### Pre-flight (idempotent) -### Ask the user +Skip to **Credentials** if all of these are already in place: -Use `AskUserQuestion` to collect configuration: +- `src/channels/discord.ts` exists +- `src/channels/index.ts` contains `import './discord.js';` +- `@chat-adapter/discord` is listed in `package.json` dependencies -AskUserQuestion: Do you have a Discord bot token, or do you need to create one? +Otherwise continue. Every step below is safe to re-run. -If they have one, collect it now. If not, we'll create one in Phase 3. - -## Phase 2: Apply Code Changes - -### Ensure channel remote +### 1. Fetch the channels branch ```bash -git remote -v +git fetch origin channels ``` -If `discord` is missing, add it: +### 2. Copy the adapter ```bash -git remote add discord https://github.com/qwibitai/nanoclaw-discord.git +git show origin/channels:src/channels/discord.ts > src/channels/discord.ts ``` -### Merge the skill branch +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './discord.js'; +``` + +### 4. Install the adapter package (pinned) ```bash -git fetch discord main -git merge discord/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} +pnpm install @chat-adapter/discord@4.26.0 ``` -This merges in: -- `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`) -- `src/channels/discord.test.ts` (unit tests with discord.js mock) -- `import './discord.js'` appended to the channel barrel file `src/channels/index.ts` -- `discord.js` npm dependency in `package.json` -- `DISCORD_BOT_TOKEN` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes +### 5. Build ```bash -npm install -npm run build -npx vitest run src/channels/discord.test.ts +pnpm run build ``` -All tests must pass (including the new Discord tests) and build must be clean before proceeding. +## Credentials -## Phase 3: Setup +### Create Discord Bot -### Create Discord Bot (if needed) - -If the user doesn't have a bot token, tell them: - -> I need you to create a Discord bot: -> -> 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) -> 2. Click **New Application** and give it a name (e.g., "Andy Assistant") -> 3. Go to the **Bot** tab on the left sidebar -> 4. Click **Reset Token** to generate a new bot token — copy it immediately (you can only see it once) -> 5. Under **Privileged Gateway Intents**, enable: -> - **Message Content Intent** (required to read message text) -> - **Server Members Intent** (optional, for member display names) -> 6. Go to **OAuth2** > **URL Generator**: -> - Scopes: select `bot` -> - Bot Permissions: select `Send Messages`, `Read Message History`, `View Channels` -> - Copy the generated URL and open it in your browser to invite the bot to your server - -Wait for the user to provide the token. +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) +2. Click **New Application** and give it a name (e.g., "NanoClaw Assistant") +3. From the **General Information** tab, copy the **Application ID** and **Public Key** +4. Go to the **Bot** tab and click **Add Bot** if needed +5. Copy the Bot Token (click **Reset Token** if you need a new one — you can only see it once) +6. Under **Privileged Gateway Intents**, enable **Message Content Intent** +7. Go to **OAuth2** > **URL Generator**: + - Scopes: select `bot` + - Bot Permissions: select `Send Messages`, `Read Message History`, `Add Reactions`, `Attach Files`, `Use Slash Commands` +8. Copy the generated URL and open it in your browser to invite the bot to your server ### Configure environment +All three values are required — the adapter will fail to start without `DISCORD_PUBLIC_KEY` and `DISCORD_APPLICATION_ID`. + Add to `.env`: ```bash -DISCORD_BOT_TOKEN= +DISCORD_BOT_TOKEN=your-bot-token +DISCORD_APPLICATION_ID=your-application-id +DISCORD_PUBLIC_KEY=your-public-key ``` -Channels auto-enable when their credentials are present — no extra configuration needed. +Sync to container: `mkdir -p data/env && cp .env data/env/env` -Sync to container environment: +## Next Steps -```bash -mkdir -p data/env && cp .env data/env/env -``` +If you're in the middle of `/setup`, return to the setup flow now. -The container reads environment from `data/env/env`, not `.env` directly. +Otherwise, run `/manage-channels` to wire this channel to an agent group. -### Build and restart +## Channel Info -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -## Phase 4: Registration - -### Get Channel ID - -Tell the user: - -> To get the channel ID for registration: -> -> 1. In Discord, go to **User Settings** > **Advanced** > Enable **Developer Mode** -> 2. Right-click the text channel you want the bot to respond in -> 3. Click **Copy Channel ID** -> -> The channel ID will be a long number like `1234567890123456`. - -Wait for the user to provide the channel ID (format: `dc:1234567890123456`). - -### Register the channel - -The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. - -For a main channel (responds to all messages): - -```bash -npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main -``` - -For additional channels (trigger-only): - -```bash -npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_" --trigger "@${ASSISTANT_NAME}" --channel discord -``` - -## Phase 5: Verify - -### Test the connection - -Tell the user: - -> Send a message in your registered Discord channel: -> - For main channel: Any message works -> - For non-main: @mention the bot in Discord -> -> The bot should respond within a few seconds. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Troubleshooting - -### Bot not responding - -1. Check `DISCORD_BOT_TOKEN` is set in `.env` AND synced to `data/env/env` -2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'dc:%'"` -3. For non-main channels: message must include trigger pattern (@mention the bot) -4. Service is running: `launchctl list | grep nanoclaw` -5. Verify the bot has been invited to the server (check OAuth2 URL was used) - -### Bot only responds to @mentions - -This is the default behavior for non-main channels (`requiresTrigger: true`). To change: -- Update the registered group's `requiresTrigger` to `false` -- Or register the channel as the main channel - -### Message Content Intent not enabled - -If the bot connects but can't read messages, ensure: -1. Go to [Discord Developer Portal](https://discord.com/developers/applications) -2. Select your application > **Bot** tab -3. Under **Privileged Gateway Intents**, enable **Message Content Intent** -4. Restart NanoClaw - -### Getting Channel ID - -If you can't copy the channel ID: -- Ensure **Developer Mode** is enabled: User Settings > Advanced > Developer Mode -- Right-click the channel name in the server sidebar > Copy Channel ID - -## After Setup - -The Discord bot supports: -- Text messages in registered channels -- Attachment descriptions (images, videos, files shown as placeholders) -- Reply context (shows who the user is replying to) -- @mention translation (Discord `<@botId>` → NanoClaw trigger format) -- Message splitting for responses over 2000 characters -- Typing indicators while the agent processes +- **type**: `discord` +- **terminology**: Discord has "servers" (also called "guilds") containing "channels." Text channels start with #. The bot can also receive direct messages. +- **how-to-find-id**: Enable Developer Mode in Discord (Settings > App Settings > Advanced > Developer Mode). Then right-click a server and select "Copy Server ID" for the guild ID, and right-click the text channel and select "Copy Channel ID." The platform ID format used in registration is `discord:{guildId}:{channelId}` — both IDs are required. +- **supports-threads**: yes +- **typical-use**: Interactive chat — server channels or direct messages +- **default-isolation**: Same agent group for your personal server. Separate agent group for servers with different communities or where different members have different information boundaries. diff --git a/.claude/skills/add-discord/VERIFY.md b/.claude/skills/add-discord/VERIFY.md new file mode 100644 index 0000000..0db2e5a --- /dev/null +++ b/.claude/skills/add-discord/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Discord + +Send a message in a channel where the bot has access, or DM the bot directly. The bot should respond within a few seconds. diff --git a/.claude/skills/add-emacs/SKILL.md b/.claude/skills/add-emacs/SKILL.md index 09bdbdd..82a5098 100644 --- a/.claude/skills/add-emacs/SKILL.md +++ b/.claude/skills/add-emacs/SKILL.md @@ -1,12 +1,11 @@ --- name: add-emacs -description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed. +description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Local HTTP bridge — no bot token or external service needed. --- # Add Emacs Channel -This skill adds Emacs support to NanoClaw, then walks through interactive setup. -Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. +Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. ## What you can do with this @@ -15,95 +14,99 @@ Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+. - **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node - **Draft writing** — send org prose; receive revisions or continuations in place - **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it -- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR") -## Phase 1: Pre-flight +## Install -### Check if already applied +NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and the Lisp client in from the `channels` branch. Native HTTP bridge — no Chat SDK, no adapter package. -Check if `src/channels/emacs.ts` exists: +### Pre-flight (idempotent) + +Skip to **Enable** if all of these are already in place: + +- `src/channels/emacs.ts` exists +- `emacs/nanoclaw.el` exists +- `src/channels/index.ts` contains `import './emacs.js';` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch ```bash -test -f src/channels/emacs.ts && echo "already applied" || echo "not applied" +git fetch origin channels ``` -If it exists, skip to Phase 3 (Setup). The code changes are already in place. - -## Phase 2: Apply Code Changes - -### Ensure the upstream remote +### 2. Copy the adapter and Lisp client ```bash -git remote -v +mkdir -p emacs +git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts +git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts +git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el ``` -If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing, -add it: +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './emacs.js'; +``` + +### 4. Build ```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git +pnpm run build ``` -### Merge the skill branch +No npm package to install — the adapter uses only Node builtins (`http`). + +## Enable + +The adapter is gated by `EMACS_ENABLED` so the HTTP port isn't opened on hosts that aren't running Emacs. Add to `.env`: ```bash -git fetch upstream skill/emacs -git merge upstream/skill/emacs +EMACS_ENABLED=true +EMACS_CHANNEL_PORT=8766 # optional — change only if 8766 is taken +EMACS_AUTH_TOKEN= # optional — set to a random string to lock the endpoint +EMACS_PLATFORM_ID=default # optional — only change if you want a non-default chat id ``` -If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming -version and continuing: +Generate an auth token (recommended even on single-user machines — prevents other local processes from poking the endpoint): ```bash -git checkout --theirs package-lock.json -git add package-lock.json -git merge --continue +node -e "console.log(require('crypto').randomBytes(16).toString('hex'))" ``` -For any other conflict, read the conflicted file and reconcile both sides manually. +## Wire the channel -This adds: -- `src/channels/emacs.ts` — `EmacsBridgeChannel` HTTP server (port 8766) -- `src/channels/emacs.test.ts` — unit tests -- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`) -- `import './emacs.js'` appended to `src/channels/index.ts` +Emacs is a single-user, single-chat channel. One host = one messaging group with `platform_id = "default"`. -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. +### If this is your first agent group -### Validate code changes +Run `/init-first-agent` — pick **Emacs** as the channel, use any short handle as the "user id" (e.g. your OS username), and the skill will create the agent group, wire the channel, and write a welcome message that the agent delivers back to your Emacs buffer. + +### Otherwise — wire to an existing agent group + +Run the `register` step directly. The `EMACS_PLATFORM_ID` (default `default`) becomes the messaging group's platform id: ```bash -npm run build -npx vitest run src/channels/emacs.test.ts +pnpm exec tsx setup/index.ts --step register -- \ + --platform-id "default" --name "Emacs" \ + --folder "" --channel "emacs" \ + --session-mode "agent-shared" \ + --assistant-name "" ``` -Build must be clean and tests must pass before proceeding. +`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent. -## Phase 3: Setup +## Configure Emacs -### Configure environment (optional) - -The channel works out of the box with defaults. Add to `.env` only if you need non-defaults: - -```bash -EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use -EMACS_AUTH_TOKEN= # optional — locks the endpoint to Emacs only -``` - -If you change or add values, sync to the container environment: - -```bash -mkdir -p data/env && cp .env data/env/env -``` - -### Configure Emacs - -The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed. +`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager. AskUserQuestion: Which Emacs distribution are you using? -- **Doom Emacs** - config.el with map! keybindings -- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs -- **Vanilla Emacs / other** - init.el with global-set-key +- **Doom Emacs** — `config.el` with `map!` keybindings +- **Spacemacs** — `dotspacemacs/user-config` in `~/.spacemacs` +- **Vanilla Emacs / other** — `init.el` with `global-set-key` **Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`): @@ -117,7 +120,7 @@ AskUserQuestion: Which Emacs distribution are you using? :desc "Send org" "o" #'nanoclaw-org-send) ``` -Then reload: `M-x doom/reload` +Reload: `M-x doom/reload` **Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`: @@ -129,9 +132,9 @@ Then reload: `M-x doom/reload` (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send) ``` -Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. +Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. -**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`): +**Vanilla Emacs** — add to `~/.emacs.d/init.el`: ```elisp ;; NanoClaw — personal AI assistant channel @@ -141,61 +144,75 @@ Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs. (global-set-key (kbd "C-c n o") #'nanoclaw-org-send) ``` -Then reload: `M-x eval-buffer` or restart Emacs. +Reload: `M-x eval-buffer` or restart Emacs. -If `EMACS_AUTH_TOKEN` was set, also add (any distribution): +Replace `~/src/nanoclaw/emacs/nanoclaw.el` with your actual NanoClaw checkout path. + +If `EMACS_AUTH_TOKEN` is set, also add (any distribution): ```elisp (setq nanoclaw-auth-token "") ``` -If `EMACS_CHANNEL_PORT` was changed from the default, also add: +If you changed `EMACS_CHANNEL_PORT` from the default: ```elisp (setq nanoclaw-port ) ``` -### Restart NanoClaw +## Restart NanoClaw ```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw +pnpm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux ``` -## Phase 4: Verify +## Verify -### Test the HTTP endpoint +### HTTP endpoint ```bash -curl -s "http://localhost:8766/api/messages?since=0" +curl -s http://localhost:8766/api/messages?since=0 ``` -Expected: `{"messages":[]}` - -If you set `EMACS_AUTH_TOKEN`: +Expected: `{"messages":[]}`. With an auth token: ```bash -curl -s -H "Authorization: Bearer " "http://localhost:8766/api/messages?since=0" +curl -s -H "Authorization: Bearer " http://localhost:8766/api/messages?since=0 ``` -### Test from Emacs +### From Emacs Tell the user: > 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`) -> 2. Type a message and press `RET` -> 3. A response from Andy should appear within a few seconds +> 2. Type a message and press `C-c C-c` to send (RET inserts newlines) +> 3. A response should appear within a few seconds > > For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o` -### Check logs if needed +### Log line -```bash -tail -f logs/nanoclaw.log -``` +`tail -f logs/nanoclaw.log` should show `Emacs channel listening` at startup. -Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent. +## Channel Info + +- **type**: `emacs` +- **terminology**: Single local buffer. There are no "groups" or separate chats — one host = one chat, addressed by a `platform_id` string (default `default`). +- **how-to-find-id**: The platform id is whatever you set in `EMACS_PLATFORM_ID` (default `default`). User handles are arbitrary; your OS username or first name is fine (e.g. `emacs:`). +- **supports-threads**: no +- **typical-use**: Single developer talking to the assistant from within Emacs, alongside whatever other channel they use (Slack, Telegram, Discord). +- **default-isolation**: Same agent group as the primary DM, with `session-mode = agent-shared` so a conversation started elsewhere continues in Emacs. Pick a separate folder only if you specifically want an Emacs-only persona. + +### Features + +- Interactive chat buffer (`nanoclaw-chat`) with markdown → org-mode rendering +- Org integration (`nanoclaw-org-send`) — sends the current subtree or region; reply lands as a child heading +- Optional bearer-token auth for the local endpoint +- Single-user: the adapter exposes exactly one messaging group per host + +Not applicable (design): multi-user channels, threads, cold DM initiation, typing indicators, attachments. ## Troubleshooting @@ -205,66 +222,53 @@ Look for `Emacs channel listening` at startup and `Emacs message received` when Error: listen EADDRINUSE: address already in use :::8766 ``` -Either a stale NanoClaw process is running, or 8766 is taken by another app. - -Find and kill the stale process: +Either a stale NanoClaw is running or another app has the port. Kill stale process or change port: ```bash lsof -ti :8766 | xargs kill -9 +# or set EMACS_CHANNEL_PORT in .env and mirror in Emacs config (nanoclaw-port) ``` -Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config. +### Adapter not starting + +If `grep "Emacs channel listening" logs/nanoclaw.log` returns nothing, check that `EMACS_ENABLED=true` is in `.env` and that the adapter import is present: + +```bash +grep -q '^EMACS_ENABLED=true' .env && echo "enabled" || echo "not enabled" +grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "not imported" +``` ### No response from agent -Check: -1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) -2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"` -3. Logs show activity: `tail -50 logs/nanoclaw.log` +1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) +2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"` +3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20` -If the group is not registered, it will be created automatically on the next NanoClaw restart. +If no messaging group row exists, run the `register` command above. ### Auth token mismatch (401 Unauthorized) -Verify the token in Emacs matches `.env`: - ```elisp -;; M-x describe-variable RET nanoclaw-auth-token RET +M-x describe-variable RET nanoclaw-auth-token RET ``` -Must exactly match `EMACS_AUTH_TOKEN` in `.env`. +Must match `EMACS_AUTH_TOKEN` in `.env`. If you didn't set one server-side, clear it in Emacs too: + +```elisp +(setq nanoclaw-auth-token nil) +``` ### nanoclaw.el not loading -Check the path is correct: - ```bash ls ~/src/nanoclaw/emacs/nanoclaw.el ``` If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config. -## After Setup - -If running `npm run dev` while the service is active: - -```bash -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -npm run dev -# When done testing: -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist - -# Linux: -# systemctl --user stop nanoclaw -# npm run dev -# systemctl --user start nanoclaw -``` - ## Agent Formatting -The Emacs bridge converts markdown → org-mode automatically. Agents should -output standard markdown — **not** org-mode syntax. The conversion handles: +The Emacs bridge converts markdown → org-mode automatically. Agents should output standard markdown, **not** org-mode syntax: | Markdown | Org-mode | |----------|----------| @@ -274,16 +278,19 @@ output standard markdown — **not** org-mode syntax. The conversion handles: | `` `code` `` | `~code~` | | ` ```lang ` | `#+begin_src lang` | -If an agent outputs org-mode directly, bold/italic/etc. will be double-converted -and render incorrectly. +If an agent outputs org-mode directly, markers get double-converted and render incorrectly. ## Removal -To remove the Emacs channel: +```bash +rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el +# Remove the `import './emacs.js';` line from src/channels/index.ts +# Remove EMACS_* lines from .env +pnpm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux -1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el` -2. Remove `import './emacs.js'` from `src/channels/index.ts` -3. Remove the NanoClaw block from your Emacs config file -4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"` -5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set -6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) \ No newline at end of file +# Remove the NanoClaw block from your Emacs config +# Optionally clean up the messaging group: +sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';" +``` diff --git a/.claude/skills/add-gchat/REMOVE.md b/.claude/skills/add-gchat/REMOVE.md new file mode 100644 index 0000000..104ad2d --- /dev/null +++ b/.claude/skills/add-gchat/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Google Chat Channel + +1. Comment out `import './gchat.js'` in `src/channels/index.ts` +2. Remove `GCHAT_CREDENTIALS` from `.env` +3. `pnpm uninstall @chat-adapter/gchat` +4. Rebuild and restart diff --git a/.claude/skills/add-gchat/SKILL.md b/.claude/skills/add-gchat/SKILL.md new file mode 100644 index 0000000..c4d8dfd --- /dev/null +++ b/.claude/skills/add-gchat/SKILL.md @@ -0,0 +1,92 @@ +--- +name: add-gchat +description: Add Google Chat channel integration via Chat SDK. +--- + +# Add Google Chat Channel + +Adds Google Chat support via the Chat SDK bridge. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/gchat.ts` exists +- `src/channels/index.ts` contains `import './gchat.js';` +- `@chat-adapter/gchat` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './gchat.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/gchat@4.26.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +> 1. Go to [Google Cloud Console](https://console.cloud.google.com) +> 2. Create or select a project +> 3. Enable the **Google Chat API** +> 4. Go to **Google Chat API** > **Configuration**: +> - App name and description +> - Connection settings: select **HTTP endpoint URL** and set to `https://your-domain/webhook/gchat` +> 5. Create a **Service Account**: +> - Go to **IAM & Admin** > **Service Accounts** > **Create Service Account** +> - Grant the Chat Bot role +> - Create a JSON key and download it + +### Configure environment + +Add the service account JSON as a single-line string to `.env`: + +```bash +GCHAT_CREDENTIALS={"type":"service_account","project_id":"...","private_key":"...","client_email":"..."} +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `gchat` +- **terminology**: Google Chat has "spaces." A space can be a group conversation or a direct message with the bot. +- **how-to-find-id**: Open the space in Google Chat, look at the URL — the space ID is the segment after `/space/` (e.g. `spaces/AAAA...`). Or use the Google Chat API to list spaces. +- **supports-threads**: yes +- **typical-use**: Interactive chat — team spaces or direct messages +- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive contexts. diff --git a/.claude/skills/add-gchat/VERIFY.md b/.claude/skills/add-gchat/VERIFY.md new file mode 100644 index 0000000..fc131a4 --- /dev/null +++ b/.claude/skills/add-gchat/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Google Chat Channel + +Add the bot to a Google Chat space, then send a message or @mention the bot. The bot should respond within a few seconds. diff --git a/.claude/skills/add-github/REMOVE.md b/.claude/skills/add-github/REMOVE.md new file mode 100644 index 0000000..c41df6f --- /dev/null +++ b/.claude/skills/add-github/REMOVE.md @@ -0,0 +1,6 @@ +# Remove GitHub Channel + +1. Comment out `import './github.js'` in `src/channels/index.ts` +2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env` +3. `pnpm uninstall @chat-adapter/github` +4. Rebuild and restart diff --git a/.claude/skills/add-github/SKILL.md b/.claude/skills/add-github/SKILL.md new file mode 100644 index 0000000..78366f3 --- /dev/null +++ b/.claude/skills/add-github/SKILL.md @@ -0,0 +1,148 @@ +--- +name: add-github +description: Add GitHub channel integration via Chat SDK. PR and issue comment threads as conversations. +--- + +# Add GitHub Channel + +Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads. + +## Prerequisites + +You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/github.ts` exists +- `src/channels/index.ts` contains `import './github.js';` +- `@chat-adapter/github` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/github.ts > src/channels/github.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './github.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/github@4.26.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +### 1. Create a Personal Access Token for the bot account + +Log in as your **bot account**, then: + +1. Go to [Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens) +2. Create a **Fine-grained token** with: + - Repository access: select the repos you want the bot to monitor + - Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write) +3. Copy the token + +### 2. Set up a webhook on each repo + +On each repo (logged in as the repo owner/admin): + +1. Go to **Settings** > **Webhooks** > **Add webhook** +2. Payload URL: `https://your-domain/webhook/github` (the shared webhook server, default port 3000) +3. Content type: `application/json` +4. Secret: generate a random string (e.g. `openssl rand -hex 20`) +5. Events: select **Issue comments** and **Pull request review comments** + +### 3. Configure environment + +Add to `.env`: + +```bash +GITHUB_TOKEN=github_pat_... +GITHUB_WEBHOOK_SECRET=your-webhook-secret +GITHUB_BOT_USERNAME=your-bot-username +``` + +`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment. + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Wiring + +Ask the user: **Is this a private or public repo?** + +- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through. +- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below). + +Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually: + +```sql +-- Create messaging group (one per repo) +INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at) +VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '', datetime('now')); + +-- Wire to agent group +INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) +VALUES ('mga-github-myrepo', 'mg-github-myrepo', '', '', 'all', 'per-thread', 10, datetime('now')); +``` + +Replace `` with `public` or `strict` based on the user's choice above. + +### Adding members (for strict mode) + +When using `strict`, add each GitHub user who should be able to trigger the agent: + +```sql +-- Add user (kind = 'github', id = 'github:') +INSERT OR IGNORE INTO users (id, kind, display_name, created_at) +VALUES ('github:', 'github', '', datetime('now')); + +-- Grant membership to the agent group +INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id) +VALUES ('github:', ''); +``` + +To find a GitHub user's numeric ID: `gh api users/ --jq .id` + +Use `per-thread` session mode so each PR/issue gets its own agent session. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel. + +## Channel Info + +- **type**: `github` +- **terminology**: GitHub has "repositories" containing "pull requests" and "issues." Each PR or issue comment thread is a separate conversation. +- **how-to-find-id**: The platform ID is `github:owner/repo` (e.g. `github:acme/backend`). Each PR/issue becomes its own thread automatically. +- **supports-threads**: yes (PR and issue comment threads are native conversations) +- **typical-use**: Webhook-driven — the agent receives PR and issue comment events and responds in comment threads when @-mentioned. After the first mention, the thread is subscribed and the agent responds to all follow-up comments. +- **default-isolation**: Use `per-thread` session mode. Each PR or issue gets its own isolated agent session. Typically wire to a dedicated agent group if the repo contains sensitive code. diff --git a/.claude/skills/add-github/VERIFY.md b/.claude/skills/add-github/VERIFY.md new file mode 100644 index 0000000..61840b7 --- /dev/null +++ b/.claude/skills/add-github/VERIFY.md @@ -0,0 +1,3 @@ +# Verify GitHub Channel + +@mention the bot in a PR comment or issue comment. The bot should respond within a few seconds. diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md deleted file mode 100644 index 099ec5b..0000000 --- a/.claude/skills/add-gmail/SKILL.md +++ /dev/null @@ -1,236 +0,0 @@ ---- -name: add-gmail -description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration. ---- - -# Add Gmail Integration - -This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/channels/gmail.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. - -### Ask the user - -Use `AskUserQuestion`: - -AskUserQuestion: Should incoming emails be able to trigger the agent? - -- **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically -- **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added. - -## Phase 2: Apply Code Changes - -### Ensure channel remote - -```bash -git remote -v -``` - -If `gmail` is missing, add it: - -```bash -git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git -``` - -### Merge the skill branch - -```bash -git fetch gmail main -git merge gmail/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`) -- `src/channels/gmail.test.ts` (unit tests) -- `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts` -- Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts` -- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts` -- `googleapis` npm dependency in `package.json` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Add email handling instructions (Channel mode only) - -If the user chose channel mode, append the following to `groups/main/CLAUDE.md` (before the formatting section): - -```markdown -## Email Notifications - -When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email. -``` - -### Validate code changes - -```bash -npm install -npm run build -npx vitest run src/channels/gmail.test.ts -``` - -All tests must pass (including the new Gmail tests) and build must be clean before proceeding. - -## Phase 3: Setup - -### Check existing Gmail credentials - -```bash -ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found" -``` - -If `credentials.json` already exists with real tokens (not `onecli-managed` values), skip to "Build and restart" below. - -### GCP Project Setup - -Check if OneCLI is configured: - -```bash -grep -q 'ONECLI_URL=.' .env 2>/dev/null && echo "onecli" || echo "manual" -``` - -**If OneCLI:** Tell the user to open `${ONECLI_URL}/connections?connect=gmail` to set up their Gmail connection. The dashboard walks them through creating a Google Cloud OAuth app and authorizing it. Ask them to let you know when done. - -Once the user confirms, run: - -```bash -onecli apps get --provider gmail -``` - -Check that `config.hasCredentials` is `true` or `connection` is not null. The response `hint` field has instructions and a docs URL for what stub credential files to create under `~/.gmail-mcp/`. Follow the hint — never overwrite existing files that don't contain `onecli-managed` values. - -**If manual:** Tell the user: - -> I need you to set up Google Cloud OAuth credentials: -> -> 1. Open https://console.cloud.google.com — create a new project or select existing -> 2. Go to **APIs & Services > Library**, search "Gmail API", click **Enable** -> 3. Go to **APIs & Services > Credentials**, click **+ CREATE CREDENTIALS > OAuth client ID** -> - If prompted for consent screen: choose "External", fill in app name and email, save -> - Application type: **Desktop app**, name: anything (e.g., "NanoClaw Gmail") -> 4. Click **DOWNLOAD JSON** and save as `gcp-oauth.keys.json` -> -> Where did you save the file? (Give me the full path, or paste the file contents here) - -If user provides a path, copy it: - -```bash -mkdir -p ~/.gmail-mcp -cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json -``` - -If user pastes JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`. - -### OAuth Authorization - -Tell the user: - -> I'm going to run Gmail authorization. A browser window will open — sign in and grant access. If you see an "app isn't verified" warning, click "Advanced" then "Go to [app name] (unsafe)" — this is normal for personal OAuth apps. - -Run the authorization: - -```bash -npx -y @gongrzhe/server-gmail-autoauth-mcp auth -``` - -If that fails (some versions don't have an auth subcommand), try `timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`. - -### Build and restart - -Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server): - -```bash -rm -r data/sessions/*/agent-runner-src 2>/dev/null || true -``` - -Rebuild the container (agent-runner changed): - -```bash -cd container && ./build.sh -``` - -Then compile and restart: - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Verify - -### Test tool access (both modes) - -Tell the user: - -> Gmail is connected! Send this in your main channel: -> -> `@Andy check my recent emails` or `@Andy list my Gmail labels` - -### Test channel mode (Channel mode only) - -Tell the user to send themselves a test email. The agent should pick it up within a minute. Monitor: `tail -f logs/nanoclaw.log | grep -iE "(gmail|email)"`. - -Once verified, offer filter customization via `AskUserQuestion` — by default, only emails in the Primary inbox trigger the agent (Promotions, Social, Updates, and Forums are excluded). The user can keep this default or narrow further by sender, label, or keywords. No code changes needed for filters. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Troubleshooting - -### Gmail connection not responding - -Test directly: - -```bash -npx -y @gongrzhe/server-gmail-autoauth-mcp -``` - -### OAuth token expired - -Re-authorize: - -```bash -rm ~/.gmail-mcp/credentials.json -npx -y @gongrzhe/server-gmail-autoauth-mcp -``` - -### Container can't access Gmail - -- Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount -- Check container logs: `cat groups/main/logs/container-*.log | tail -50` - -### Emails not being detected (Channel mode only) - -- By default, the channel polls unread Primary inbox emails (`is:unread category:primary`) -- Check logs for Gmail polling errors - -## Removal - -### Tool-only mode - -1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` -2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` -3. Rebuild and restart -4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` -5. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) - -### Channel mode - -1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts` -2. Remove `import './gmail.js'` from `src/channels/index.ts` -3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` -4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` -5. Uninstall: `npm uninstall googleapis` -6. Rebuild and restart -7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` -8. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md deleted file mode 100644 index 072bf7b..0000000 --- a/.claude/skills/add-image-vision/SKILL.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -name: add-image-vision -description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks. ---- - -# Image Vision Skill - -Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks. - -## Phase 1: Pre-flight - -1. Check if `src/image.ts` exists — skip to Phase 3 if already applied -2. Confirm `sharp` is installable (native bindings require build tools) - -**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/image-vision -git merge whatsapp/skill/image-vision || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `src/image.ts` (image download, resize via sharp, base64 encoding) -- `src/image.test.ts` (8 unit tests) -- Image attachment handling in `src/channels/whatsapp.ts` -- Image passing to agent in `src/index.ts` and `src/container-runner.ts` -- Image content block support in `container/agent-runner/src/index.ts` -- `sharp` npm dependency in `package.json` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -npm install -npm run build -npx vitest run src/image.test.ts -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Configure - -1. Rebuild the container (agent-runner changes need a rebuild): - ```bash - ./container/build.sh - ``` - -2. Sync agent-runner source to group caches: - ```bash - for dir in data/sessions/*/agent-runner-src/; do - cp container/agent-runner/src/*.ts "$dir" - done - ``` - -3. Restart the service: - ```bash - launchctl kickstart -k gui/$(id -u)/com.nanoclaw - ``` - -## Phase 4: Verify - -1. Send an image in a registered WhatsApp group -2. Check the agent responds with understanding of the image content -3. Check logs for "Processed image attachment": - ```bash - tail -50 groups/*/logs/container-*.log - ``` - -## Troubleshooting - -- **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections. -- **"Image - processing failed"**: Sharp may not be installed correctly. Run `npm ls sharp` to verify. -- **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches. diff --git a/.claude/skills/add-imessage/REMOVE.md b/.claude/skills/add-imessage/REMOVE.md new file mode 100644 index 0000000..684b2d1 --- /dev/null +++ b/.claude/skills/add-imessage/REMOVE.md @@ -0,0 +1,6 @@ +# Remove iMessage Channel + +1. Comment out `import './imessage.js'` in `src/channels/index.ts` +2. Remove iMessage env vars (`IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, `IMESSAGE_API_KEY`) from `.env` +3. `pnpm uninstall chat-adapter-imessage` +4. Rebuild and restart diff --git a/.claude/skills/add-imessage/SKILL.md b/.claude/skills/add-imessage/SKILL.md new file mode 100644 index 0000000..7ee87aa --- /dev/null +++ b/.claude/skills/add-imessage/SKILL.md @@ -0,0 +1,113 @@ +--- +name: add-imessage +description: Add iMessage channel integration via Chat SDK. Local (macOS) or remote (Photon API) mode. +--- + +# Add iMessage Channel + +Adds iMessage support via the Chat SDK bridge. Two modes: local (macOS with Full Disk Access) or remote (Photon API). + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/imessage.ts` exists +- `src/channels/index.ts` contains `import './imessage.js';` +- `chat-adapter-imessage` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './imessage.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install chat-adapter-imessage@0.1.1 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +### Local Mode (macOS) + +Requirements: macOS with Full Disk Access granted to the Node.js binary. + +The Node binary path is buried deep (e.g. `~/.nvm/versions/node/v22.x.x/bin/node`). To make it easy, open the folder in Finder so the user can drag the file into System Settings: + +```bash +open "$(dirname "$(which node)")" +``` + +Then tell the user: + +1. Open **System Settings** > **Privacy & Security** > **Full Disk Access** +2. Click **+**, then drag the `node` file from the Finder window that just opened +3. Toggle it on + +Stop and wait for the user to confirm before continuing. + +### Remote Mode (Photon API) + +1. Set up a [Photon](https://photon.im) account +2. Get your server URL and API key + +### Configure environment + +**Local mode** -- add to `.env`: + +```bash +IMESSAGE_ENABLED=true +IMESSAGE_LOCAL=true +``` + +**Remote mode** -- add to `.env`: + +```bash +IMESSAGE_LOCAL=false +IMESSAGE_SERVER_URL=https://your-photon-server.com +IMESSAGE_API_KEY=your-api-key +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `imessage` +- **terminology**: iMessage has "conversations." Each conversation is with a contact identified by phone number or email address. Group chats are also supported. +- **how-to-find-id**: The platform ID is the contact's phone number (e.g. `+15551234567`) or email address. For group chats, the ID is assigned by iMessage internally. +- **supports-threads**: no +- **typical-use**: Interactive 1:1 chat — personal messaging +- **default-isolation**: Same agent group if you're the only person messaging the bot across iMessage and other channels. Separate agent group if different contacts should have information isolation. diff --git a/.claude/skills/add-imessage/VERIFY.md b/.claude/skills/add-imessage/VERIFY.md new file mode 100644 index 0000000..4fa4755 --- /dev/null +++ b/.claude/skills/add-imessage/VERIFY.md @@ -0,0 +1,3 @@ +# Verify iMessage Channel + +Send an iMessage to the account running NanoClaw. The bot should respond within a few seconds. diff --git a/.claude/skills/add-karpathy-llm-wiki/SKILL.md b/.claude/skills/add-karpathy-llm-wiki/SKILL.md index 9c728a8..12b9b37 100644 --- a/.claude/skills/add-karpathy-llm-wiki/SKILL.md +++ b/.claude/skills/add-karpathy-llm-wiki/SKILL.md @@ -19,7 +19,7 @@ AskUserQuestion: "Which group should have the wiki?" 2. **Dedicated group** — create a new group just for the wiki 3. **Other** — pick an existing group -If dedicated: ask which channel and chat, then register with `npx tsx setup/index.ts --step register`. +If dedicated: ask which channel and chat, then register with `pnpm exec tsx setup/index.ts --step register`. ## Step 3: Design collaboratively @@ -74,7 +74,7 @@ AskUserQuestion: "Want periodic wiki health checks?" If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database: ```bash -npx tsx -e " +pnpm exec tsx -e " const Database = require('better-sqlite3'); const { CronExpressionParser } = require('cron-parser'); const db = new Database('store/messages.db'); @@ -101,7 +101,7 @@ Use the group's `folder` and `chat_jid` from the registered groups table. Cron e ## Step 6: Build and restart ```bash -npm run build +pnpm run build ./container/build.sh launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw diff --git a/.claude/skills/add-linear/REMOVE.md b/.claude/skills/add-linear/REMOVE.md new file mode 100644 index 0000000..4b95024 --- /dev/null +++ b/.claude/skills/add-linear/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Linear Channel + +1. Comment out `import './linear.js'` in `src/channels/index.ts` +2. Remove `LINEAR_API_KEY` and `LINEAR_WEBHOOK_SECRET` from `.env` +3. `pnpm uninstall @chat-adapter/linear` +4. Rebuild and restart diff --git a/.claude/skills/add-linear/SKILL.md b/.claude/skills/add-linear/SKILL.md new file mode 100644 index 0000000..dc657af --- /dev/null +++ b/.claude/skills/add-linear/SKILL.md @@ -0,0 +1,168 @@ +--- +name: add-linear +description: Add Linear channel integration via Chat SDK. Issue comment threads as conversations. +--- + +# Add Linear Channel + +Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed. + +## Prerequisites + +**Recommended:** Create a Linear **OAuth application** so the agent posts as an app identity, not as you. This prevents the adapter from filtering your own comments as self-messages. + +1. Go to [Linear Settings > API > OAuth Applications](https://linear.app/settings/api/applications/new) +2. Create an app (e.g. "NanoClaw Bot") + - Developer URL: your repo URL (e.g. `https://github.com/your-org/nanoclaw`) + - Callback URL: `http://localhost` +3. After creating, click the app and enable **Client credentials** under grant types +4. Copy the **Client ID** and **Client Secret** + +**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work). + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned). + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/linear.ts` exists +- `src/channels/index.ts` contains `import './linear.js';` +- `@chat-adapter/linear` is listed in `package.json` dependencies +- `src/channels/chat-sdk-bridge.ts` contains `catchAll` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/linear.ts > src/channels/linear.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './linear.js'; +``` + +### 4. Patch the Chat SDK bridge for catch-all message forwarding + +Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`: + +**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface: + +```typescript + /** + * Forward ALL messages in unsubscribed threads, not just @-mentions. + * Use for platforms where the bot identity can't be @-mentioned (e.g. + * Linear OAuth apps). The thread is auto-subscribed on first message. + */ + catchAll?: boolean; +``` + +**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block): + +```typescript + // Catch-all for platforms where @-mention isn't possible (e.g. Linear + // OAuth apps). Forward every unsubscribed message and auto-subscribe. + if (config.catchAll) { + chat.onNewMessage(/.*/, async (thread, message) => { + const channelId = adapter.channelIdFromThreadId(thread.id); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + await thread.subscribe(); + }); + } +``` + +### 5. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/linear@4.26.0 +``` + +### 6. Build + +```bash +pnpm run build +``` + +## Credentials + +### 1. Set up a webhook + +1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook** +2. Label: `NanoClaw` +3. URL: `https://your-domain/webhook/linear` (the shared webhook server, default port 3000) +4. Team: select the team you want to monitor +5. Events: check **Comment** +6. Save — copy the **signing secret** + +Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal. + +### 2. Configure environment + +Add to `.env`: + +```bash +# OAuth app (recommended) +LINEAR_CLIENT_ID=your-client-id +LINEAR_CLIENT_SECRET=your-client-secret + +# OR Personal API key (simpler, but agent posts as you) +# LINEAR_API_KEY=lin_api_... + +LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret +LINEAR_BOT_USERNAME=NanoClaw Bot +LINEAR_TEAM_KEY=ENG +``` + +- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key) +- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group. + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Wiring + +Ask the user: **Is this a private or public Linear workspace?** + +- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment. +- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example). + +Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually: + +```sql +-- Create messaging group (one per team) +INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at) +VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now')); + +-- Wire to agent group +INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) +VALUES ('mga-linear-eng', 'mg-linear-eng', '', '', 'all', 'per-thread', 10, datetime('now')); +``` + +The `platform_id` must be `linear:` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel. + +## Channel Info + +- **type**: `linear` +- **terminology**: Linear has "teams" containing "issues." Each issue's comment thread is a separate conversation. +- **how-to-find-id**: The platform ID is `linear:` (e.g. `linear:ENG`). Find your team key in Linear under Settings > Teams. Each issue becomes its own thread automatically. +- **supports-threads**: yes (issue comment threads are native conversations) +- **typical-use**: Webhook-driven — the agent receives all issue comment events and responds automatically. No @-mention needed (Linear OAuth apps can't be @-mentioned). +- **default-isolation**: Use `per-thread` session mode. Each issue comment thread gets its own isolated agent session. diff --git a/.claude/skills/add-linear/VERIFY.md b/.claude/skills/add-linear/VERIFY.md new file mode 100644 index 0000000..8a2581a --- /dev/null +++ b/.claude/skills/add-linear/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Linear Channel + +@mention the bot in a Linear issue comment. The bot should respond within a few seconds. diff --git a/.claude/skills/add-matrix/REMOVE.md b/.claude/skills/add-matrix/REMOVE.md new file mode 100644 index 0000000..b64aa56 --- /dev/null +++ b/.claude/skills/add-matrix/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Matrix Channel + +1. Comment out `import './matrix.js'` in `src/channels/index.ts` +2. Remove `MATRIX_BASE_URL`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_BOT_USERNAME` from `.env` +3. `pnpm uninstall @beeper/chat-adapter-matrix` +4. Rebuild and restart diff --git a/.claude/skills/add-matrix/SKILL.md b/.claude/skills/add-matrix/SKILL.md new file mode 100644 index 0000000..cf6da75 --- /dev/null +++ b/.claude/skills/add-matrix/SKILL.md @@ -0,0 +1,148 @@ +--- +name: add-matrix +description: Add Matrix channel integration via Chat SDK. Works with any Matrix homeserver. +--- + +# Add Matrix Channel + +Adds Matrix support via the Chat SDK bridge. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Matrix adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/matrix.ts` exists +- `src/channels/index.ts` contains `import './matrix.js';` +- `@beeper/chat-adapter-matrix` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/matrix.ts > src/channels/matrix.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './matrix.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @beeper/chat-adapter-matrix@0.2.0 +``` + +### 5. Patch matrix-js-sdk ESM imports + +The adapter's published dist references `matrix-js-sdk/lib/...` without `.js` +extensions, which fails under Node 22 strict ESM resolution. Add the missing +extensions (idempotent — safe to re-run): + +```bash +node -e ' + const fs = require("fs"), path = require("path"); + const root = "node_modules/.pnpm"; + const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@")); + if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); } + const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js"); + fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace( + /from "(matrix-js-sdk\/lib\/[^"]+?)(? **Help & About** > **Access Token** (under Advanced). Or via API: + +```bash +curl -XPOST 'https://matrix.org/_matrix/client/r0/login' \ + -d '{"type":"m.login.password","user":"andybot","password":"..."}' +``` + +```bash +MATRIX_BASE_URL=https://matrix.org +MATRIX_ACCESS_TOKEN=your-access-token +MATRIX_USER_ID=@andybot:matrix.org +MATRIX_BOT_USERNAME=Andy +``` + +### Optional settings + +```bash +MATRIX_INVITE_AUTOJOIN=true # Auto-accept room invites (default: true) +MATRIX_INVITE_AUTOJOIN_ALLOWLIST=@you:matrix.org # Only accept invites from these users +MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing +MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts +``` + +### Configure environment + +Add the chosen env vars to `.env`, then sync: + +```bash +mkdir -p data/env && cp .env data/env/env +``` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `matrix` +- **terminology**: Matrix has "rooms." A room can be a group chat or a direct message. Rooms have internal IDs (like `!abc123:matrix.org`) and optional aliases (like `#general:matrix.org`). +- **how-to-find-id**: For DMs, use the bot's `openDM` to resolve the room automatically. For group rooms, in Element click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`. +- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability) +- **typical-use**: Interactive chat — rooms or direct messages. Requires a separate bot account (the agent cannot DM users from their own account). +- **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts. diff --git a/.claude/skills/add-matrix/VERIFY.md b/.claude/skills/add-matrix/VERIFY.md new file mode 100644 index 0000000..f483abb --- /dev/null +++ b/.claude/skills/add-matrix/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Matrix Channel + +Invite the bot to a Matrix room and send a message. The bot should respond within a few seconds. diff --git a/.claude/skills/add-ollama-provider/SKILL.md b/.claude/skills/add-ollama-provider/SKILL.md new file mode 100644 index 0000000..83f7e5a --- /dev/null +++ b/.claude/skills/add-ollama-provider/SKILL.md @@ -0,0 +1,179 @@ +--- +name: add-ollama-provider +description: Route a NanoClaw agent group to a local Ollama model instead of the Anthropic API. Ollama speaks the Anthropic API natively (v1/messages), so no provider code changes are needed — just env var overrides and a model setting. Use when the user wants to run their agent locally, cut API costs, or experiment with open-weight models. See docs/ollama.md for background. +--- + +# Add Ollama Provider + +Routes an agent group to a local Ollama instance instead of the Anthropic API. +See `docs/ollama.md` for how this works and the tradeoffs involved. + +## Prerequisites + +1. **Ollama is installed and running** on the host — verify: `curl -s http://localhost:11434/api/tags` +2. **A model is pulled** — e.g. `ollama pull gemma4` or `ollama pull qwen3-coder` +3. **The agent group already exists** — run `/init-first-agent` first if needed + +## 1. Check source support + +The feature requires two fields in `ContainerConfig` (`env` and `blockedHosts`) and their +corresponding wiring in `container-runner.ts`. Check if already present: + +```bash +grep -c 'blockedHosts' src/container-config.ts src/container-runner.ts +``` + +If either count is 0, apply the changes in steps 1a and 1b. Otherwise skip to step 2. + +### 1a. Extend ContainerConfig + +In `src/container-config.ts`, add to the `ContainerConfig` interface: + +```typescript +env?: Record; +blockedHosts?: string[]; +``` + +And in `readContainerConfig`, add inside the returned object: + +```typescript +env: raw.env, +blockedHosts: raw.blockedHosts, +``` + +### 1b. Wire into container-runner + +In `src/container-runner.ts`, after the `NANOCLAW_MCP_SERVERS` block, add: + +```typescript +// Per-agent-group env overrides — applied last to win over OneCLI values. +if (containerConfig.env) { + for (const [key, value] of Object.entries(containerConfig.env)) { + args.push('-e', `${key}=${value}`); + } +} + +// Blocked hosts: resolve to 0.0.0.0 so they are unreachable inside the container. +if (containerConfig.blockedHosts) { + for (const host of containerConfig.blockedHosts) { + args.push('--add-host', `${host}:0.0.0.0`); + } +} +``` + +### 1c. Fix home directory permissions (if not already done) + +The container may run as your host uid (not uid 1000). Check the Dockerfile: + +```bash +grep 'chmod.*home/node' container/Dockerfile +``` + +If it shows `chmod 755`, change it to `chmod 777` so any uid can write there. +Then rebuild the container image: `./container/build.sh` + +## 2. Identify the setup + +Ask the user (plain text, not AskUserQuestion): + +1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"` +2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'` +3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts. + +Record as `FOLDER`, `MODEL`, and `BLOCK_ANTHROPIC`. + +## 3. Configure container.json + +Read `groups//container.json`. Add (or merge into) an `env` block and optionally `blockedHosts`: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://host.docker.internal:11434", + "ANTHROPIC_API_KEY": "ollama", + "NO_PROXY": "host.docker.internal", + "no_proxy": "host.docker.internal" + }, + "blockedHosts": ["api.anthropic.com"] +} +``` + +Omit `blockedHosts` if the user declined step 2. + +**Why these vars:** `ANTHROPIC_BASE_URL` redirects the Anthropic SDK to Ollama. +`ANTHROPIC_API_KEY=ollama` satisfies the SDK's key requirement (Ollama ignores it). +`NO_PROXY` bypasses the OneCLI HTTPS proxy for requests to `host.docker.internal` +so they reach Ollama directly instead of going through the credential gateway. + +## 4. Set the model + +Read the agent group's shared Claude settings: + +```bash +# Find the agent group ID +AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='';") +SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json +``` + +Add `"model": ""` to that settings file. Create the file if it doesn't exist: + +```json +{ + "model": "gemma4:latest" +} +``` + +If the file already has content, merge the `model` key in — don't overwrite existing keys. + +**Why here and not container.json:** Claude Code reads its model from its own settings +file, not from env vars. This file is bind-mounted into the container as `~/.claude/settings.json`. + +## 5. Build and restart + +```bash +export PATH="/opt/homebrew/bin:$PATH" +pnpm run build +launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist +# Linux: systemctl --user restart nanoclaw +``` + +## 6. Verify + +Send a message to the agent. Then confirm: + +```bash +# Ollama shows the model as active +curl -s http://localhost:11434/api/ps | grep '"name"' + +# Container has the right env vars +CTR=$(docker ps --filter "name=nanoclaw-v2-" --format "{{.Names}}" | head -1) +docker inspect "$CTR" --format '{{json .HostConfig.ExtraHosts}}' +docker exec "$CTR" env | grep ANTHROPIC +``` + +Expected: `api.anthropic.com:0.0.0.0` in ExtraHosts, `ANTHROPIC_BASE_URL=http://host.docker.internal:11434`. + +## Reverting to Claude + +To switch back to the Anthropic API: + +1. Remove the `env` and `blockedHosts` keys from `groups//container.json` +2. Remove `"model"` from the shared settings file +3. Restart the service + +No rebuild needed — both files are read at container spawn time. + +## Troubleshooting + +**Agent hangs, no response:** Ollama may be loading the model cold (large models take 10–30s). +Watch `curl -s http://localhost:11434/api/ps` — the model appears once loaded. + +**"model not found" error in container logs:** The model name in settings.json doesn't match +what Ollama has. Run `ollama list` on the host and use the exact name shown. + +**Responses claim to be Claude:** The model was trained on data that includes Claude conversations. +Add a line to `groups//CLAUDE.md` telling it what model it runs on. + +**Agent responds but Ollama shows no activity:** `NO_PROXY` may not have taken effect for +`http_proxy` (lowercase). Add both `NO_PROXY` and `no_proxy` to the env block. diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md index aa69295..8c7abca 100644 --- a/.claude/skills/add-ollama-tool/SKILL.md +++ b/.claude/skills/add-ollama-tool/SKILL.md @@ -87,7 +87,7 @@ done ### Validate code changes ```bash -npm run build +pnpm run build ./container/build.sh ``` diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md new file mode 100644 index 0000000..08a558f --- /dev/null +++ b/.claude/skills/add-opencode/SKILL.md @@ -0,0 +1,229 @@ +--- +name: add-opencode +description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers. +--- + +# OpenCode agent provider + +NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`). + +Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image. + +## Install + +### Pre-flight + +If all of the following are already present, skip to **Configuration**: + +- `src/providers/opencode.ts` +- `container/agent-runner/src/providers/opencode.ts` +- `import './opencode.js';` line in `src/providers/index.ts` +- `import './opencode.js';` line in `container/agent-runner/src/providers/index.ts` +- `@opencode-ai/sdk` in `container/agent-runner/package.json` +- `opencode-ai@${OPENCODE_VERSION}` in the pnpm global-install block in `container/Dockerfile` + +Missing pieces — continue below. All steps are idempotent; re-running is safe. + +### 1. Fetch the providers branch + +```bash +git fetch origin providers +``` + +### 2. Copy the OpenCode source files + +Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed): + +```bash +git show origin/providers:src/providers/opencode.ts > src/providers/opencode.ts +git show origin/providers:container/agent-runner/src/providers/opencode.ts > container/agent-runner/src/providers/opencode.ts +git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.ts > container/agent-runner/src/providers/mcp-to-opencode.ts +git show origin/providers:container/agent-runner/src/providers/mcp-to-opencode.test.ts > container/agent-runner/src/providers/mcp-to-opencode.test.ts +git show origin/providers:container/agent-runner/src/providers/opencode.factory.test.ts > container/agent-runner/src/providers/opencode.factory.test.ts +``` + +### 3. Append the self-registration imports + +Each barrel gets one line appended at the end — skip if the line is already present. + +`src/providers/index.ts`: + +```typescript +import './opencode.js'; +``` + +`container/agent-runner/src/providers/index.ts`: + +```typescript +import './opencode.js'; +``` + +### 4. Add the agent-runner dependency + +Pinned. Bump deliberately, not with `bun update`. Use `1.4.17` — must match the `opencode-ai` CLI version pinned in step 5. The 1.14.x SDK has a completely different API and is **incompatible** with the current provider code. + +```bash +cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 && cd - +``` + +### 5. Add `opencode-ai` to the container Dockerfile + +Two edits to `container/Dockerfile`, both idempotent (skip if already present): + +**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG VERCEL_VERSION=latest`: + +```dockerfile +ARG OPENCODE_VERSION=1.4.17 +``` + +> **Do not use `latest`** — the CLI and SDK must be the same version. `latest` silently upgrades the CLI to 1.14.x which has a breaking session API change (UUID session IDs → `ses_` prefix) incompatible with SDK 1.4.x. + +**(b)** In the `pnpm install -g` block (around line 80), append `"opencode-ai@${OPENCODE_VERSION}"` to the list: + +```dockerfile + pnpm install -g \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + "agent-browser@${AGENT_BROWSER_VERSION}" \ + "vercel@${VERCEL_VERSION}" \ + "opencode-ai@${OPENCODE_VERSION}" +``` + +### 6. Build + +```bash +pnpm run build # host +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck +./container/build.sh # agent image +``` + +> **Build cache gotcha:** The container buildkit caches COPY steps aggressively. If provider files were already present in the build context before, the new files may not be picked up. If you see "Unknown provider: opencode" after the build, prune the builder and rebuild: +> ```bash +> docker builder prune -f && ./container/build.sh +> ``` + +### 7. Propagate to existing per-group overlays + +Each agent group has a live source overlay at `data/v2-sessions//agent-runner-src/providers/` that **overrides the image at runtime**. This overlay is created when the group is first wired and never auto-updated by image rebuilds. Any group that already existed before this skill ran needs the new files copied in manually. + +```bash +for overlay in data/v2-sessions/*/agent-runner-src/providers/; do + [ -d "$overlay" ] || continue + cp container/agent-runner/src/providers/opencode.ts "$overlay" + cp container/agent-runner/src/providers/mcp-to-opencode.ts "$overlay" + cp container/agent-runner/src/providers/index.ts "$overlay" + echo "Updated: $overlay" +done +``` + +## Configuration + +### Host `.env` (typical) + +Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs. + +These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below). + +- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`. +- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`. +- `OPENCODE_SMALL_MODEL` — optional second model for lighter tasks; defaults to `OPENCODE_MODEL` if unset. +- `ANTHROPIC_BASE_URL` — **required for non-`anthropic` providers.** The opencode container provider passes this as the `baseURL` for the upstream provider config so requests route through OneCLI's credential proxy or directly to the provider's API. Set it to the provider's API base URL (e.g. `https://api.deepseek.com/v1`, `https://openrouter.ai/api/v1`). + +Credentials: register provider API keys in OneCLI with the matching `--host-pattern` (e.g. `api.deepseek.com`, `openrouter.ai`). OneCLI injects them via `HTTPS_PROXY` in the container — the key never lives in `.env` or the container environment. + +After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned: + +```bash +# Find the agent id and secret id, then: +onecli agents set-secrets --id --secret-ids , +``` + +Always include existing secret IDs in the list — `set-secrets` replaces, not appends. + +#### Example: DeepSeek + +```env +OPENCODE_PROVIDER=deepseek +OPENCODE_MODEL=deepseek/deepseek-chat +OPENCODE_SMALL_MODEL=deepseek/deepseek-chat +ANTHROPIC_BASE_URL=https://api.deepseek.com/v1 +``` + +Register the key: +```bash +onecli secrets create --name "DeepSeek" --type generic \ + --value YOUR_KEY --host-pattern "api.deepseek.com" \ + --header-name "Authorization" --value-format "Bearer {value}" +``` + +#### Example: OpenRouter + +```env +OPENCODE_PROVIDER=openrouter +OPENCODE_MODEL=openrouter/anthropic/claude-sonnet-4 +OPENCODE_SMALL_MODEL=openrouter/anthropic/claude-haiku-4.5 +ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 +``` + +Register the key: +```bash +onecli secrets create --name "OpenRouter" --type generic \ + --value YOUR_KEY --host-pattern "openrouter.ai" \ + --header-name "Authorization" --value-format "Bearer {value}" +``` + +#### Example: Anthropic (no ANTHROPIC_BASE_URL needed) + +When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container — the proxy + placeholder key pattern is unchanged and `ANTHROPIC_BASE_URL` is not required. + +```env +OPENCODE_PROVIDER=anthropic +OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514 +OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001 +``` + +#### OpenCode Zen (`x-api-key`, not Bearer) + +Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working. + +**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)). + +**Host `.env` (typical Zen shape):** + +```env +OPENCODE_PROVIDER=opencode +OPENCODE_MODEL=opencode/big-pickle +OPENCODE_SMALL_MODEL=opencode/big-pickle +ANTHROPIC_BASE_URL=https://opencode.ai/zen/v1 +``` + +Use a real Zen model id from the docs; `big-pickle` is one example. + +**OneCLI:** register the Zen key with **`x-api-key`**, not Bearer: + +```bash +onecli secrets create --name "OpenCode Zen" --type generic \ + --value YOUR_ZEN_KEY --host-pattern opencode.ai \ + --header-name "x-api-key" --value-format "{value}" +``` + +### Per group / per session + +Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). + +Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers. + +## Operational notes + +- OpenCode keeps a local **`opencode serve`** process and SSE subscription; the provider tears down with **`stream.return`** and **SIGKILL** on the server process on **`abort()`** / shared runtime reset to avoid MCP/zombie hangs. +- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder. +- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI). + +## Verify + +```bash +grep -q "./opencode.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK" +grep -q "./opencode.js" src/providers/index.ts && echo "host barrel: OK" +grep -q "@opencode-ai/sdk" container/agent-runner/package.json && echo "agent-runner dep: OK" +grep -q "opencode-ai@" container/Dockerfile && echo "Dockerfile install: OK" +cd container/agent-runner && bun test src/providers/ && cd - +``` diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index f4c1982..a9dff8f 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -232,7 +232,7 @@ echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Containe Rebuild the main app and restart: ```bash -npm run build +pnpm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` @@ -286,5 +286,5 @@ To remove Parallel AI integration: 1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env` 2. Revert changes to container-runner.ts and agent-runner/src/index.ts 3. Remove Web Research Tools section from groups/main/CLAUDE.md -4. Rebuild: `./container/build.sh && npm run build` +4. Rebuild: `./container/build.sh && pnpm run build` 5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md deleted file mode 100644 index a01e530..0000000 --- a/.claude/skills/add-pdf-reader/SKILL.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -name: add-pdf-reader -description: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Handles WhatsApp attachments, URLs, and local files. ---- - -# Add PDF Reader - -Adds PDF reading capability to all container agents using poppler-utils (pdftotext/pdfinfo). PDFs sent as WhatsApp attachments are auto-downloaded to the group workspace. - -## Phase 1: Pre-flight - -1. Check if `container/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied -2. Confirm WhatsApp is installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/pdf-reader -git merge whatsapp/skill/pdf-reader || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `container/skills/pdf-reader/SKILL.md` (agent-facing documentation) -- `container/skills/pdf-reader/pdf-reader` (CLI script) -- `poppler-utils` in `container/Dockerfile` -- PDF attachment download in `src/channels/whatsapp.ts` -- PDF tests in `src/channels/whatsapp.test.ts` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate - -```bash -npm run build -npx vitest run src/channels/whatsapp.test.ts -``` - -### Rebuild container - -```bash -./container/build.sh -``` - -### Restart service - -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 3: Verify - -### Test PDF extraction - -Send a PDF file in any registered WhatsApp chat. The agent should: -1. Download the PDF to `attachments/` -2. Respond acknowledging the PDF -3. Be able to extract text when asked - -### Test URL fetching - -Ask the agent to read a PDF from a URL. It should use `pdf-reader fetch `. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log | grep -i pdf -``` - -Look for: -- `Downloaded PDF attachment` — successful download -- `Failed to download PDF attachment` — media download issue - -## Troubleshooting - -### Agent says pdf-reader command not found - -Container needs rebuilding. Run `./container/build.sh` and restart the service. - -### PDF text extraction is empty - -The PDF may be scanned (image-based). pdftotext only handles text-based PDFs. Consider using the agent-browser to open the PDF visually instead. - -### WhatsApp PDF not detected - -Verify the message has `documentMessage` with `mimetype: application/pdf`. Some file-sharing apps send PDFs as generic files without the correct mimetype. diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md deleted file mode 100644 index de86768..0000000 --- a/.claude/skills/add-reactions/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: add-reactions -description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions. ---- - -# Add Reactions - -This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/status-tracker.ts` exists: - -```bash -test -f src/status-tracker.ts && echo "Already applied" || echo "Not applied" -``` - -If already applied, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/reactions -git merge whatsapp/skill/reactions || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This adds: -- `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes) -- `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry) -- `src/status-tracker.test.ts` (unit tests for StatusTracker) -- `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool) -- Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `container/agent-runner/src/ipc-mcp-stdio.ts` - -### Run database migration - -```bash -npx tsx scripts/migrate-reactions.ts -``` - -### Validate code changes - -```bash -npm test -npm run build -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Verify - -### Build and restart - -```bash -npm run build -``` - -Linux: -```bash -systemctl --user restart nanoclaw -``` - -macOS: -```bash -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -### Test receiving reactions - -1. Send a message from your phone -2. React to it with an emoji on WhatsApp -3. Check the database: - -```bash -sqlite3 store/messages.db "SELECT * FROM reactions ORDER BY timestamp DESC LIMIT 5;" -``` - -### Test sending reactions - -Ask the agent to react to a message via the `react_to_message` MCP tool. Check your phone — the reaction should appear on the message. - -## Troubleshooting - -### Reactions not appearing in database - -- Check NanoClaw logs for `Failed to process reaction` errors -- Verify the chat is registered -- Confirm the service is running - -### Migration fails - -- Ensure `store/messages.db` exists and is accessible -- If "table reactions already exists", the migration already ran — skip it - -### Agent can't send reactions - -- Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat -- Verify WhatsApp is connected: check logs for connection status diff --git a/.claude/skills/add-resend/REMOVE.md b/.claude/skills/add-resend/REMOVE.md new file mode 100644 index 0000000..4c00004 --- /dev/null +++ b/.claude/skills/add-resend/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Resend Email Channel + +1. Comment out `import './resend.js'` in `src/channels/index.ts` +2. Remove `RESEND_API_KEY`, `RESEND_FROM_ADDRESS`, `RESEND_FROM_NAME`, `RESEND_WEBHOOK_SECRET` from `.env` +3. `pnpm uninstall @resend/chat-sdk-adapter` +4. Rebuild and restart diff --git a/.claude/skills/add-resend/SKILL.md b/.claude/skills/add-resend/SKILL.md new file mode 100644 index 0000000..59ff577 --- /dev/null +++ b/.claude/skills/add-resend/SKILL.md @@ -0,0 +1,93 @@ +--- +name: add-resend +description: Add Resend (email) channel integration via Chat SDK. +--- + +# Add Resend Email Channel + +Connect NanoClaw to email via Resend for async email conversations. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Resend adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/resend.ts` exists +- `src/channels/index.ts` contains `import './resend.js';` +- `@resend/chat-sdk-adapter` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/resend.ts > src/channels/resend.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './resend.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @resend/chat-sdk-adapter@0.1.1 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +1. Go to [resend.com](https://resend.com) and create an account. +2. Add and verify your sending domain. +3. Go to **API Keys** and create a new key. +4. Set up a webhook: + - Go to **Webhooks** > **Add webhook**. + - URL: `https://your-domain/webhook/resend`. + - Events: select **email.received**. + - Copy the signing secret. + +### Configure environment + +Add to `.env`: + +```bash +RESEND_API_KEY=re_... +RESEND_FROM_ADDRESS=bot@yourdomain.com +RESEND_FROM_NAME=NanoClaw +RESEND_WEBHOOK_SECRET=your-webhook-secret +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `resend` +- **terminology**: Resend handles email. Each email thread (identified by subject/In-Reply-To headers) is a separate conversation. The "from address" is the bot's identity. +- **how-to-find-id**: The platform ID is the from email address (e.g. `bot@yourdomain.com`). Each sender's email thread becomes its own conversation. +- **supports-threads**: yes (via email threading headers -- replies to the same thread stay together) +- **typical-use**: Async communication -- email conversations with longer response expectations +- **default-isolation**: Same agent group if you want your agent to handle email alongside other channels. Separate agent group if email contains sensitive correspondence that shouldn't be accessible from other channels. diff --git a/.claude/skills/add-resend/VERIFY.md b/.claude/skills/add-resend/VERIFY.md new file mode 100644 index 0000000..983197e --- /dev/null +++ b/.claude/skills/add-resend/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Resend Email Channel + +Send an email to the configured from address. The bot should respond via email within a few seconds. diff --git a/.claude/skills/add-slack/REMOVE.md b/.claude/skills/add-slack/REMOVE.md new file mode 100644 index 0000000..3ac402a --- /dev/null +++ b/.claude/skills/add-slack/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Slack + +1. Comment out `import './slack.js'` in `src/channels/index.ts` +2. Remove `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` from `.env` +3. `pnpm uninstall @chat-adapter/slack` +4. Rebuild and restart diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 4c86e19..318de7b 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -1,80 +1,82 @@ --- name: add-slack -description: Add Slack as a channel. Can replace WhatsApp entirely or run alongside it. Uses Socket Mode (no public URL needed). +description: Add Slack channel integration via Chat SDK. --- # Add Slack Channel -This skill adds Slack support to NanoClaw, then walks through interactive setup. +Adds Slack support via the Chat SDK bridge. -## Phase 1: Pre-flight +## Install -### Check if already applied +NanoClaw doesn't ship channels in trunk. This skill copies the Slack adapter in from the `channels` branch. -Check if `src/channels/slack.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. +### Pre-flight (idempotent) -### Ask the user +Skip to **Credentials** if all of these are already in place: -**Do they already have a Slack app configured?** If yes, collect the Bot Token and App Token now. If no, we'll create one in Phase 3. +- `src/channels/slack.ts` exists +- `src/channels/index.ts` contains `import './slack.js';` +- `@chat-adapter/slack` is listed in `package.json` dependencies -## Phase 2: Apply Code Changes +Otherwise continue. Every step below is safe to re-run. -### Ensure channel remote +### 1. Fetch the channels branch ```bash -git remote -v +git fetch origin channels ``` -If `slack` is missing, add it: +### 2. Copy the adapter ```bash -git remote add slack https://github.com/qwibitai/nanoclaw-slack.git +git show origin/channels:src/channels/slack.ts > src/channels/slack.ts ``` -### Merge the skill branch +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './slack.js'; +``` + +### 4. Install the adapter package (pinned) ```bash -git fetch slack main -git merge slack/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} +pnpm install @chat-adapter/slack@4.26.0 ``` -This merges in: -- `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`) -- `src/channels/slack.test.ts` (46 unit tests) -- `import './slack.js'` appended to the channel barrel file `src/channels/index.ts` -- `@slack/bolt` npm dependency in `package.json` -- `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes +### 5. Build ```bash -npm install -npm run build -npx vitest run src/channels/slack.test.ts +pnpm run build ``` -All tests must pass (including the new Slack tests) and build must be clean before proceeding. +## Credentials -## Phase 3: Setup +### Create Slack App -### Create Slack App (if needed) +1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch** +2. Name it (e.g., "NanoClaw") and select your workspace +3. Go to **OAuth & Permissions** and add Bot Token Scopes: + - `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` +4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`) +5. Go to **Basic Information** and copy the **Signing Secret** -If the user doesn't have a Slack app, share [SLACK_SETUP.md](SLACK_SETUP.md) which has step-by-step instructions with screenshots guidance, troubleshooting, and a token reference table. +### Enable DMs -Quick summary of what's needed: -1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) -2. Enable Socket Mode and generate an App-Level Token (`xapp-...`) -3. Subscribe to bot events: `message.channels`, `message.groups`, `message.im` -4. Add OAuth scopes: `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read` -5. Install to workspace and copy the Bot Token (`xoxb-...`) +6. Go to **App Home** and enable the **Messages Tab** +7. Check **"Allow users to send Slash commands and messages from the messages tab"** -Wait for the user to provide both tokens. +### Event Subscriptions + +8. Go to **Event Subscriptions** and toggle **Enable Events** +9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save +10. Under **Subscribe to bot events**, add: + - `message.channels`, `message.groups`, `message.im`, `app_mention` +11. Click **Save Changes** +12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions ### Configure environment @@ -82,126 +84,29 @@ Add to `.env`: ```bash SLACK_BOT_TOKEN=xoxb-your-bot-token -SLACK_APP_TOKEN=xapp-your-app-token +SLACK_SIGNING_SECRET=your-signing-secret ``` -Channels auto-enable when their credentials are present — no extra configuration needed. +Sync to container: `mkdir -p data/env && cp .env data/env/env` -Sync to container environment: +### Webhook server -```bash -mkdir -p data/env && cp .env data/env/env -``` +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. -The container reads environment from `data/env/env`, not `.env` directly. +If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`. -### Build and restart +## Next Steps -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` +If you're in the middle of `/setup`, return to the setup flow now. -## Phase 4: Registration +Otherwise, run `/manage-channels` to wire this channel to an agent group. -### Get Channel ID +## Channel Info -Tell the user: - -> 1. Add the bot to a Slack channel (right-click channel → **View channel details** → **Integrations** → **Add apps**) -> 2. In that channel, the channel ID is in the URL when you open it in a browser: `https://app.slack.com/client/T.../C0123456789` — the `C...` part is the channel ID -> 3. Alternatively, right-click the channel name → **Copy link** — the channel ID is the last path segment -> -> The JID format for NanoClaw is: `slack:C0123456789` - -Wait for the user to provide the channel ID. - -### Register the channel - -The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. - -For a main channel (responds to all messages): - -```bash -npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_main" --trigger "@${ASSISTANT_NAME}" --channel slack --no-trigger-required --is-main -``` - -For additional channels (trigger-only): - -```bash -npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_" --trigger "@${ASSISTANT_NAME}" --channel slack -``` - -## Phase 5: Verify - -### Test the connection - -Tell the user: - -> Send a message in your registered Slack channel: -> - For main channel: Any message works -> - For non-main: `@ hello` (using the configured trigger word) -> -> The bot should respond within a few seconds. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Troubleshooting - -### Bot not responding - -1. Check `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are set in `.env` AND synced to `data/env/env` -2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'slack:%'"` -3. For non-main channels: message must include trigger pattern -4. Service is running: `launchctl list | grep nanoclaw` - -### Bot connected but not receiving messages - -1. Verify Socket Mode is enabled in the Slack app settings -2. Verify the bot is subscribed to the correct events (`message.channels`, `message.groups`, `message.im`) -3. Verify the bot has been added to the channel -4. Check that the bot has the required OAuth scopes - -### Bot not seeing messages in channels - -By default, bots only see messages in channels they've been explicitly added to. Make sure to: -1. Add the bot to each channel you want it to monitor -2. Check the bot has `channels:history` and/or `groups:history` scopes - -### "missing_scope" errors - -If the bot logs `missing_scope` errors: -1. Go to **OAuth & Permissions** in your Slack app settings -2. Add the missing scope listed in the error message -3. **Reinstall the app** to your workspace — scope changes require reinstallation -4. Copy the new Bot Token (it changes on reinstall) and update `.env` -5. Sync: `mkdir -p data/env && cp .env data/env/env` -6. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` - -### Getting channel ID - -If the channel ID is hard to find: -- In Slack desktop: right-click channel → **Copy link** → extract the `C...` ID from the URL -- In Slack web: the URL shows `https://app.slack.com/client/TXXXXXXX/C0123456789` -- Via API: `curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" "https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'` - -## After Setup - -The Slack channel supports: -- **Public channels** — Bot must be added to the channel -- **Private channels** — Bot must be invited to the channel -- **Direct messages** — Users can DM the bot directly -- **Multi-channel** — Can run alongside WhatsApp or other channels (auto-enabled by credentials) - -## Known Limitations - -- **Threads are flattened** — Threaded replies are delivered to the agent as regular channel messages. The agent sees them but has no awareness they originated in a thread. Responses always go to the channel, not back into the thread. Users in a thread will need to check the main channel for the bot's reply. Full thread-aware routing (respond in-thread) requires pipeline-wide changes: database schema, `NewMessage` type, `Channel.sendMessage` interface, and routing logic. -- **No typing indicator** — Slack's Bot API does not expose a typing indicator endpoint. The `setTyping()` method is a no-op. Users won't see "bot is typing..." while the agent works. -- **Message splitting is naive** — Long messages are split at a fixed 4000-character boundary, which may break mid-word or mid-sentence. A smarter split (on paragraph or sentence boundaries) would improve readability. -- **No file/image handling** — The bot only processes text content. File uploads, images, and rich message blocks are not forwarded to the agent. -- **Channel metadata sync is unbounded** — `syncChannelMetadata()` paginates through all channels the bot is a member of, but has no upper bound or timeout. Workspaces with thousands of channels may experience slow startup. -- **Workspace admin policies not detected** — If the Slack workspace restricts bot app installation, the setup will fail at the "Install to Workspace" step with no programmatic detection or guidance. See SLACK_SETUP.md troubleshooting section. +- **type**: `slack` +- **terminology**: Slack has "workspaces" containing "channels." Channels can be public (#general) or private. The bot can also receive direct messages. +- **platform-id-format**: `slack:{channelId}` for channels (e.g., `slack:C0123ABC`), `slack:{dmId}` for DMs (e.g., `slack:D0ARWEBLV63`) +- **how-to-find-id**: Right-click a channel name > "View channel details" — the Channel ID is at the bottom (starts with C). For DMs, the ID starts with D. Or copy the channel link — the ID is the last segment of the URL. +- **supports-threads**: yes +- **typical-use**: Interactive chat — team channels or direct messages +- **default-isolation**: Same agent group for channels where you're the primary user. Separate agent group for channels with different teams or sensitive contexts. diff --git a/.claude/skills/add-slack/VERIFY.md b/.claude/skills/add-slack/VERIFY.md new file mode 100644 index 0000000..23eb994 --- /dev/null +++ b/.claude/skills/add-slack/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Slack + +Add the bot to a Slack channel, then send a message or @mention the bot. The bot should respond within a few seconds. diff --git a/.claude/skills/add-teams/REMOVE.md b/.claude/skills/add-teams/REMOVE.md new file mode 100644 index 0000000..caa87b8 --- /dev/null +++ b/.claude/skills/add-teams/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Microsoft Teams Channel + +1. Comment out `import './teams.js'` in `src/channels/index.ts` +2. Remove `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` from `.env` +3. `pnpm uninstall @chat-adapter/teams` +4. Rebuild and restart diff --git a/.claude/skills/add-teams/SKILL.md b/.claude/skills/add-teams/SKILL.md new file mode 100644 index 0000000..10bce29 --- /dev/null +++ b/.claude/skills/add-teams/SKILL.md @@ -0,0 +1,207 @@ +--- +name: add-teams +description: Add Microsoft Teams channel integration via Chat SDK. +--- + +# Add Microsoft Teams Channel + +Connect NanoClaw to Microsoft Teams for interactive chat in team channels, group chats, and direct messages. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Teams adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/teams.ts` exists +- `src/channels/index.ts` contains `import './teams.js';` +- `@chat-adapter/teams` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/teams.ts > src/channels/teams.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './teams.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/teams@4.26.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +### Step 1: Create an Azure AD App Registration + +1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration** +2. Name it (e.g., "NanoClaw") +3. Supported account types: **Single tenant** (your org only) or **Multi tenant** (any org) +4. Click **Register** +5. Copy the **Application (client) ID** and **Directory (tenant) ID** from the Overview page + +### Step 2: Create a Client Secret + +1. In the App Registration, go to **Certificates & secrets** +2. Click **New client secret**, description "nanoclaw", expiry 180 days +3. Click **Add** and **copy the Value immediately** (shown only once) + +### Step 3: Create an Azure Bot + +1. Go to Azure Portal > search **Azure Bot** > **Create** +2. Fill in: + - **Bot handle**: unique name (e.g., "nanoclaw-bot") + - **Type of App**: match your app registration (Single or Multi Tenant) + - **Creation type**: **Use existing app registration** + - **App ID**: paste from Step 1 + - **App tenant ID**: paste from Step 1 (Single Tenant only) +3. Click **Review + create** > **Create** + +Or use Azure CLI: + +```bash +az group create --name nanoclaw-rg --location eastus +az bot create \ + --resource-group nanoclaw-rg \ + --name nanoclaw-bot \ + --app-type SingleTenant \ + --appid YOUR_APP_ID \ + --tenant-id YOUR_TENANT_ID \ + --endpoint "https://your-domain/api/webhooks/teams" +``` + +### Step 4: Configure Messaging Endpoint + +1. Go to your Azure Bot resource > **Configuration** +2. Set **Messaging endpoint** to `https://your-domain/api/webhooks/teams` +3. Click **Apply** + +### Step 5: Enable Teams Channel + +1. In the Azure Bot resource, go to **Channels** +2. Click **Microsoft Teams** > Accept terms > **Apply** + +Or via CLI: + +```bash +az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot +``` + +### Step 6: Create and Sideload Teams App + +Create a `manifest.json`: + +```json +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", + "manifestVersion": "1.16", + "version": "1.0.0", + "id": "YOUR_APP_ID", + "packageName": "com.nanoclaw.bot", + "developer": { + "name": "NanoClaw", + "websiteUrl": "https://your-domain", + "privacyUrl": "https://your-domain", + "termsOfUseUrl": "https://your-domain" + }, + "name": { "short": "NanoClaw", "full": "NanoClaw Assistant" }, + "description": { + "short": "NanoClaw assistant bot", + "full": "NanoClaw personal assistant powered by Claude." + }, + "icons": { "outline": "outline.png", "color": "color.png" }, + "accentColor": "#4A90D9", + "bots": [{ + "botId": "YOUR_APP_ID", + "scopes": ["personal", "team", "groupchat"], + "supportsFiles": false, + "isNotificationOnly": false + }], + "permissions": ["identity", "messageTeamMembers"], + "validDomains": ["your-domain"] +} +``` + +Create two icon PNGs (32x32 `outline.png`, 192x192 `color.png`), zip all three files together. + +**Sideload in Teams:** +1. Open Teams > **Apps** > **Manage your apps** +2. Click **Upload an app** > **Upload a custom app** +3. Select the zip file + +Sideloading requires Teams admin access. Free personal Teams does NOT support sideloading. Use a Microsoft 365 Business account or developer tenant. + +### Step 7: Receive All Messages (Optional) + +By default, the bot only receives messages when @-mentioned. To receive all messages in a channel without @-mention, add RSC permissions to `manifest.json`: + +```json +{ + "authorization": { + "permissions": { + "resourceSpecific": [ + { "name": "ChannelMessage.Read.Group", "type": "Application" } + ] + } + } +} +``` + +### Configure environment + +Add to `.env`: + +```bash +TEAMS_APP_ID=your-app-id +TEAMS_APP_PASSWORD=your-client-secret +# For Single Tenant only: +TEAMS_APP_TENANT_ID=your-tenant-id +TEAMS_APP_TYPE=SingleTenant +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### Webhook server + +The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/api/webhooks/teams` for Teams and other webhook-based adapters. This port must be publicly reachable from the internet for Azure Bot Service to deliver activities. + +For local development without a public URL, use a tunnel (e.g., `ngrok http 3000`) and update the messaging endpoint in Azure Bot Configuration. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `teams` +- **terminology**: Teams has "teams" containing "channels." The bot can also receive DMs (personal scope) and group chat messages. Channels support threaded replies. +- **platform-id-format**: `teams:{base64-encoded-conversation-id}:{base64-encoded-service-url}` — auto-generated by the adapter, not human-readable. Use the auto-created messaging group ID for wiring. +- **how-to-find-id**: Send a message to the bot in the channel. NanoClaw auto-creates a messaging group and logs the platform ID. Use that messaging group ID for wiring. +- **supports-threads**: yes (channels only; DMs and group chats are flat) +- **typical-use**: Team collaboration with the bot in channels; personal assistant via DMs +- **default-isolation**: Separate agent group per team. DMs can share an agent group with your main channel for unified personal memory. diff --git a/.claude/skills/add-teams/VERIFY.md b/.claude/skills/add-teams/VERIFY.md new file mode 100644 index 0000000..f0b9a9a --- /dev/null +++ b/.claude/skills/add-teams/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Microsoft Teams Channel + +Add the bot to a Teams channel or send it a direct message. The bot should respond within a few seconds. diff --git a/.claude/skills/add-telegram-swarm/SKILL.md b/.claude/skills/add-telegram-swarm/SKILL.md deleted file mode 100644 index ac4922c..0000000 --- a/.claude/skills/add-telegram-swarm/SKILL.md +++ /dev/null @@ -1,384 +0,0 @@ ---- -name: add-telegram-swarm -description: Add Agent Swarm (Teams) support to Telegram. Each subagent gets its own bot identity in the group. Requires Telegram channel to be set up first (use /add-telegram). Triggers on "agent swarm", "agent teams telegram", "telegram swarm", "bot pool". ---- - -# Add Agent Swarm to Telegram - -This skill adds Agent Teams (Swarm) support to an existing Telegram channel. Each subagent in a team gets its own bot identity in the Telegram group, so users can visually distinguish which agent is speaking. - -**Prerequisite**: Telegram must already be set up via the `/add-telegram` skill. If `src/telegram.ts` does not exist or `TELEGRAM_BOT_TOKEN` is not configured, tell the user to run `/add-telegram` first. - -## How It Works - -- The **main bot** receives messages and sends lead agent responses (already set up by `/add-telegram`) -- **Pool bots** are send-only — each gets a Grammy `Api` instance (no polling) -- When a subagent calls `send_message` with a `sender` parameter, the host assigns a pool bot and renames it to match the sender's role -- Messages appear in Telegram from different bot identities - -``` -Subagent calls send_message(text: "Found 3 results", sender: "Researcher") - → MCP writes IPC file with sender field - → Host IPC watcher picks it up - → Assigns pool bot #2 to "Researcher" (round-robin, stable per-group) - → Renames pool bot #2 to "Researcher" via setMyName - → Sends message via pool bot #2's Api instance - → Appears in Telegram from "Researcher" bot -``` - -## Prerequisites - -### 1. Create Pool Bots - -Tell the user: - -> I need you to create 3-5 Telegram bots to use as the agent pool. These will be renamed dynamically to match agent roles. -> -> 1. Open Telegram and search for `@BotFather` -> 2. Send `/newbot` for each bot: -> - Give them any placeholder name (e.g., "Bot 1", "Bot 2") -> - Usernames like `myproject_swarm_1_bot`, `myproject_swarm_2_bot`, etc. -> 3. Copy all the tokens -> 4. Add all bots to your Telegram group(s) where you want agent teams - -Wait for user to provide the tokens. - -### 2. Disable Group Privacy for Pool Bots - -Tell the user: - -> **Important**: Each pool bot needs Group Privacy disabled so it can send messages in groups. -> -> For each pool bot in `@BotFather`: -> 1. Send `/mybots` and select the bot -> 2. Go to **Bot Settings** > **Group Privacy** > **Turn off** -> -> Then add all pool bots to your Telegram group(s). - -## Implementation - -### Step 1: Update Configuration - -Read `src/config.ts` and add the bot pool config near the other Telegram exports: - -```typescript -export const TELEGRAM_BOT_POOL = (process.env.TELEGRAM_BOT_POOL || '') - .split(',') - .map((t) => t.trim()) - .filter(Boolean); -``` - -### Step 2: Add Bot Pool to Telegram Module - -Read `src/telegram.ts` and add the following: - -1. **Update imports** — add `Api` to the Grammy import: - -```typescript -import { Api, Bot } from 'grammy'; -``` - -2. **Add pool state** after the existing `let bot` declaration: - -```typescript -// Bot pool for agent teams: send-only Api instances (no polling) -const poolApis: Api[] = []; -// Maps "{groupFolder}:{senderName}" → pool Api index for stable assignment -const senderBotMap = new Map(); -let nextPoolIndex = 0; -``` - -3. **Add pool functions** — place these before the `isTelegramConnected` function: - -```typescript -/** - * Initialize send-only Api instances for the bot pool. - * Each pool bot can send messages but doesn't poll for updates. - */ -export async function initBotPool(tokens: string[]): Promise { - for (const token of tokens) { - try { - const api = new Api(token); - const me = await api.getMe(); - poolApis.push(api); - logger.info( - { username: me.username, id: me.id, poolSize: poolApis.length }, - 'Pool bot initialized', - ); - } catch (err) { - logger.error({ err }, 'Failed to initialize pool bot'); - } - } - if (poolApis.length > 0) { - logger.info({ count: poolApis.length }, 'Telegram bot pool ready'); - } -} - -/** - * Send a message via a pool bot assigned to the given sender name. - * Assigns bots round-robin on first use; subsequent messages from the - * same sender in the same group always use the same bot. - * On first assignment, renames the bot to match the sender's role. - */ -export async function sendPoolMessage( - chatId: string, - text: string, - sender: string, - groupFolder: string, -): Promise { - if (poolApis.length === 0) { - // No pool bots — fall back to main bot - await sendTelegramMessage(chatId, text); - return; - } - - const key = `${groupFolder}:${sender}`; - let idx = senderBotMap.get(key); - if (idx === undefined) { - idx = nextPoolIndex % poolApis.length; - nextPoolIndex++; - senderBotMap.set(key, idx); - // Rename the bot to match the sender's role, then wait for Telegram to propagate - try { - await poolApis[idx].setMyName(sender); - await new Promise((r) => setTimeout(r, 2000)); - logger.info({ sender, groupFolder, poolIndex: idx }, 'Assigned and renamed pool bot'); - } catch (err) { - logger.warn({ sender, err }, 'Failed to rename pool bot (sending anyway)'); - } - } - - const api = poolApis[idx]; - try { - const numericId = chatId.replace(/^tg:/, ''); - const MAX_LENGTH = 4096; - if (text.length <= MAX_LENGTH) { - await api.sendMessage(numericId, text); - } else { - for (let i = 0; i < text.length; i += MAX_LENGTH) { - await api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH)); - } - } - logger.info({ chatId, sender, poolIndex: idx, length: text.length }, 'Pool message sent'); - } catch (err) { - logger.error({ chatId, sender, err }, 'Failed to send pool message'); - } -} -``` - -### Step 3: Add sender Parameter to MCP Tool - -Read `container/agent-runner/src/ipc-mcp-stdio.ts` and update the `send_message` tool to accept an optional `sender` parameter: - -Change the tool's schema from: -```typescript -{ text: z.string().describe('The message text to send') }, -``` - -To: -```typescript -{ - text: z.string().describe('The message text to send'), - sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'), -}, -``` - -And update the handler to include `sender` in the IPC data: - -```typescript -async (args) => { - const data: Record = { - type: 'message', - chatJid, - text: args.text, - sender: args.sender || undefined, - groupFolder, - timestamp: new Date().toISOString(), - }; - - writeIpcFile(MESSAGES_DIR, data); - - return { content: [{ type: 'text' as const, text: 'Message sent.' }] }; - }, -``` - -### Step 4: Update Host IPC Routing - -Read `src/ipc.ts` and make these changes: - -1. **Add imports** — add `sendPoolMessage` and `initBotPool` from the Telegram swarm module, and `TELEGRAM_BOT_POOL` from config. - -2. **Update IPC message routing** — in `src/ipc.ts`, find where the `sendMessage` dependency is called to deliver IPC messages (inside `processIpcFiles`). The `sendMessage` is passed in via the `IpcDeps` parameter. Wrap it to route Telegram swarm messages through the bot pool: - -```typescript -if (data.sender && data.chatJid.startsWith('tg:')) { - await sendPoolMessage( - data.chatJid, - data.text, - data.sender, - sourceGroup, - ); -} else { - await deps.sendMessage(data.chatJid, data.text); -} -``` - -Note: The assistant name prefix is handled by `formatOutbound()` in the router — Telegram channels have `prefixAssistantName = false` so no prefix is added for `tg:` JIDs. - -3. **Initialize pool in `main()` in `src/index.ts`** — after creating the Telegram channel, add: - -```typescript -if (TELEGRAM_BOT_POOL.length > 0) { - await initBotPool(TELEGRAM_BOT_POOL); -} -``` - -### Step 5: Update CLAUDE.md Files - -#### 5a. Add global message formatting rules - -Read `groups/global/CLAUDE.md` and add a Message Formatting section: - -```markdown -## Message Formatting - -NEVER use markdown. Only use WhatsApp/Telegram formatting: -- *single asterisks* for bold (NEVER **double asterisks**) -- _underscores_ for italic -- • bullet points -- ```triple backticks``` for code - -No ## headings. No [links](url). No **double stars**. -``` - -#### 5b. Update existing group CLAUDE.md headings - -In any group CLAUDE.md that has a "WhatsApp Formatting" section (e.g. `groups/main/CLAUDE.md`), rename the heading to reflect multi-channel support: - -``` -## WhatsApp Formatting (and other messaging apps) -``` - -#### 5c. Add Agent Teams instructions to Telegram groups - -For each Telegram group that will use agent teams, create or update its `groups/{folder}/CLAUDE.md` with these instructions. Read the existing CLAUDE.md first (or `groups/global/CLAUDE.md` as a base) and add the Agent Teams section: - -```markdown -## Agent Teams - -When creating a team to tackle a complex task, follow these rules: - -### CRITICAL: Follow the user's prompt exactly - -Create *exactly* the team the user asked for — same number of agents, same roles, same names. Do NOT add extra agents, rename roles, or use generic names like "Researcher 1". If the user says "a marine biologist, a physicist, and Alexander Hamilton", create exactly those three agents with those exact names. - -### Team member instructions - -Each team member MUST be instructed to: - -1. *Share progress in the group* via `mcp__nanoclaw__send_message` with a `sender` parameter matching their exact role/character name (e.g., `sender: "Marine Biologist"` or `sender: "Alexander Hamilton"`). This makes their messages appear from a dedicated bot in the Telegram group. -2. *Also communicate with teammates* via `SendMessage` as normal for coordination. -3. Keep group messages *short* — 2-4 sentences max per message. Break longer content into multiple `send_message` calls. No walls of text. -4. Use the `sender` parameter consistently — always the same name so the bot identity stays stable. -5. NEVER use markdown formatting. Use ONLY WhatsApp/Telegram formatting: single *asterisks* for bold (NOT **double**), _underscores_ for italic, • for bullets, ```backticks``` for code. No ## headings, no [links](url), no **double asterisks**. - -### Example team creation prompt - -When creating a teammate, include instructions like: - -\``` -You are the Marine Biologist. When you have findings or updates for the user, send them to the group using mcp__nanoclaw__send_message with sender set to "Marine Biologist". Keep each message short (2-4 sentences max). Use emojis for strong reactions. ONLY use single *asterisks* for bold (never **double**), _underscores_ for italic, • for bullets. No markdown. Also communicate with teammates via SendMessage. -\``` - -### Lead agent behavior - -As the lead agent who created the team: - -- You do NOT need to react to or relay every teammate message. The user sees those directly from the teammate bots. -- Send your own messages only to comment, share thoughts, synthesize, or direct the team. -- When processing an internal update from a teammate that doesn't need a user-facing response, wrap your *entire* output in `` tags. -- Focus on high-level coordination and the final synthesis. -``` - -### Step 6: Update Environment - -Add pool tokens to `.env`: - -```bash -TELEGRAM_BOT_POOL=TOKEN1,TOKEN2,TOKEN3,... -``` - -**Important**: Sync to all required locations: - -```bash -cp .env data/env/env -``` - -Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.nanoclaw.plist`) in the `EnvironmentVariables` dict if using launchd. - -### Step 7: Rebuild and Restart - -```bash -npm run build -./container/build.sh # Required — MCP tool changed -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -# Linux: -# systemctl --user restart nanoclaw -``` - -Must use `unload/load` (macOS) or `restart` (Linux) because the service env vars changed. - -### Step 8: Test - -Tell the user: - -> Send a message in your Telegram group asking for a multi-agent task, e.g.: -> "Assemble a team of a researcher and a coder to build me a hello world app" -> -> You should see: -> - The lead agent (main bot) acknowledging and creating the team -> - Each subagent messaging from a different bot, renamed to their role -> - Short, scannable messages from each agent -> -> Check logs: `tail -f logs/nanoclaw.log | grep -i pool` - -## Architecture Notes - -- Pool bots use Grammy's `Api` class — lightweight, no polling, just send -- Bot names are set via `setMyName` — changes are global to the bot, not per-chat -- A 2-second delay after `setMyName` allows Telegram to propagate the name change before the first message -- Sender→bot mapping is stable within a group (keyed as `{groupFolder}:{senderName}`) -- Mapping resets on service restart — pool bots get reassigned fresh -- If pool runs out, bots are reused (round-robin wraps) - -## Troubleshooting - -### Pool bots not sending messages - -1. Verify tokens: `curl -s "https://api.telegram.org/botTOKEN/getMe"` -2. Check pool initialized: `grep "Pool bot" logs/nanoclaw.log` -3. Ensure all pool bots are members of the Telegram group -4. Check Group Privacy is disabled for each pool bot - -### Bot names not updating - -Telegram caches bot names client-side. The 2-second delay after `setMyName` helps, but users may need to restart their Telegram client to see updated names immediately. - -### Subagents not using send_message - -Check the group's `CLAUDE.md` has the Agent Teams instructions. The lead agent reads this when creating teammates and must include the `send_message` + `sender` instructions in each teammate's prompt. - -## Removal - -To remove Agent Swarm support while keeping basic Telegram: - -1. Remove `TELEGRAM_BOT_POOL` from `src/config.ts` -2. Remove pool code from `src/telegram.ts` (`poolApis`, `senderBotMap`, `initBotPool`, `sendPoolMessage`) -3. Remove pool routing from IPC handler in `src/index.ts` (revert to plain `sendMessage`) -4. Remove `initBotPool` call from `main()` -5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts` -6. Remove Agent Teams section from group CLAUDE.md files -7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit -8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux) diff --git a/.claude/skills/add-telegram/REMOVE.md b/.claude/skills/add-telegram/REMOVE.md new file mode 100644 index 0000000..3d6d2b8 --- /dev/null +++ b/.claude/skills/add-telegram/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Telegram + +1. Comment out `import './telegram.js'` in `src/channels/index.ts` +2. Remove `TELEGRAM_BOT_TOKEN` from `.env` +3. `pnpm uninstall @chat-adapter/telegram` +4. Rebuild and restart diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md index 609f394..f605b41 100644 --- a/.claude/skills/add-telegram/SKILL.md +++ b/.claude/skills/add-telegram/SKILL.md @@ -1,214 +1,108 @@ --- name: add-telegram -description: Add Telegram as a channel. Can replace WhatsApp entirely or run alongside it. Also configurable as a control-only channel (triggers actions) or passive channel (receives notifications only). +description: Add Telegram channel integration via Chat SDK. --- # Add Telegram Channel -This skill adds Telegram support to NanoClaw, then walks through interactive setup. +Adds Telegram bot support via the Chat SDK bridge. -## Phase 1: Pre-flight +## Install -### Check if already applied +NanoClaw doesn't ship channels in trunk. This skill copies the Telegram adapter, its formatting/pairing helpers, their tests, and the `pair-telegram` setup step in from the `channels` branch. -Check if `src/channels/telegram.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. +### Pre-flight (idempotent) -### Ask the user +Skip to **Credentials** if all of these are already in place: -Use `AskUserQuestion` to collect configuration: +- `src/channels/telegram.ts`, `telegram-pairing.ts`, `telegram-markdown-sanitize.ts` (and their `.test.ts` siblings) all exist +- `src/channels/index.ts` contains `import './telegram.js';` +- `setup/pair-telegram.ts` exists and `setup/index.ts`'s `STEPS` map contains `'pair-telegram':` +- `@chat-adapter/telegram` is listed in `package.json` dependencies -AskUserQuestion: Do you have a Telegram bot token, or do you need to create one? +Otherwise continue. Every step below is safe to re-run. -If they have one, collect it now. If not, we'll create one in Phase 3. - -## Phase 2: Apply Code Changes - -### Ensure channel remote +### 1. Fetch the channels branch ```bash -git remote -v +git fetch origin channels ``` -If `telegram` is missing, add it: +### 2. Copy the adapter, helpers, tests, and setup step ```bash -git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git +git show origin/channels:src/channels/telegram.ts > src/channels/telegram.ts +git show origin/channels:src/channels/telegram-pairing.ts > src/channels/telegram-pairing.ts +git show origin/channels:src/channels/telegram-pairing.test.ts > src/channels/telegram-pairing.test.ts +git show origin/channels:src/channels/telegram-markdown-sanitize.ts > src/channels/telegram-markdown-sanitize.ts +git show origin/channels:src/channels/telegram-markdown-sanitize.test.ts > src/channels/telegram-markdown-sanitize.test.ts +git show origin/channels:setup/pair-telegram.ts > setup/pair-telegram.ts ``` -### Merge the skill branch +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if already present): + +```typescript +import './telegram.js'; +``` + +### 4. Register the setup step + +In `setup/index.ts`, add this entry to the `STEPS` map (right after the `register` line is fine; skip if already present): + +```typescript +'pair-telegram': () => import('./pair-telegram.js'), +``` + +### 5. Install the adapter package (pinned) ```bash -git fetch telegram main -git merge telegram/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} +pnpm install @chat-adapter/telegram@4.26.0 ``` -This merges in: -- `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`) -- `src/channels/telegram.test.ts` (unit tests with grammy mock) -- `import './telegram.js'` appended to the channel barrel file `src/channels/index.ts` -- `grammy` npm dependency in `package.json` -- `TELEGRAM_BOT_TOKEN` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes +### 6. Build ```bash -npm install -npm run build -npx vitest run src/channels/telegram.test.ts +pnpm run build ``` -All tests must pass (including the new Telegram tests) and build must be clean before proceeding. +## Credentials -## Phase 3: Setup +### Create Telegram Bot -### Create Telegram Bot (if needed) +1. Open Telegram and search for `@BotFather` +2. Send `/newbot` and follow the prompts: + - Bot name: Something friendly (e.g., "NanoClaw Assistant") + - Bot username: Must end with "bot" (e.g., "nanoclaw_bot") +3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) -If the user doesn't have a bot token, tell them: +**Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages: -> I need you to create a Telegram bot: -> -> 1. Open Telegram and search for `@BotFather` -> 2. Send `/newbot` and follow prompts: -> - Bot name: Something friendly (e.g., "Andy Assistant") -> - Bot username: Must end with "bot" (e.g., "andy_ai_bot") -> 3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) - -Wait for the user to provide the token. +1. Open `@BotFather` > `/mybots` > select your bot +2. **Bot Settings** > **Group Privacy** > **Turn off** ### Configure environment Add to `.env`: ```bash -TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_TOKEN=your-bot-token ``` -Channels auto-enable when their credentials are present — no extra configuration needed. +Sync to container: `mkdir -p data/env && cp .env data/env/env` -Sync to container environment: +## Next Steps -```bash -mkdir -p data/env && cp .env data/env/env -``` +If you're in the middle of `/setup`, return to the setup flow now. -The container reads environment from `data/env/env`, not `.env` directly. +Otherwise, run `/manage-channels` to wire this channel to an agent group. -### Disable Group Privacy (for group chats) +## Channel Info -Tell the user: - -> **Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages: -> -> 1. Open Telegram and search for `@BotFather` -> 2. Send `/mybots` and select your bot -> 3. Go to **Bot Settings** > **Group Privacy** > **Turn off** -> -> This is optional if you only want trigger-based responses via @mentioning the bot. - -### Build and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Registration - -### Get Chat ID - -Tell the user: - -> 1. Open your bot in Telegram (search for its username) -> 2. Send `/chatid` — it will reply with the chat ID -> 3. For groups: add the bot to the group first, then send `/chatid` in the group - -Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234567890`). - -### Register the chat - -The chat ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. - -For a main chat (responds to all messages): - -```bash -npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_main" --trigger "@${ASSISTANT_NAME}" --channel telegram --no-trigger-required --is-main -``` - -For additional chats (trigger-only): - -```bash -npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_" --trigger "@${ASSISTANT_NAME}" --channel telegram -``` - -## Phase 5: Verify - -### Test the connection - -Tell the user: - -> Send a message to your registered Telegram chat: -> - For main chat: Any message works -> - For non-main: `@Andy hello` or @mention the bot -> -> The bot should respond within a few seconds. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Troubleshooting - -### Bot not responding - -Check: -1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env` -2. Chat is registered in SQLite (check with: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`) -3. For non-main chats: message includes trigger pattern -4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) - -### Bot only responds to @mentions in groups - -Group Privacy is enabled (default). Fix: -1. `@BotFather` > `/mybots` > select bot > **Bot Settings** > **Group Privacy** > **Turn off** -2. Remove and re-add the bot to the group (required for the change to take effect) - -### Getting chat ID - -If `/chatid` doesn't work: -- Verify token: `curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"` -- Check bot is started: `tail -f logs/nanoclaw.log` - -## After Setup - -If running `npm run dev` while the service is active: -```bash -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -npm run dev -# When done testing: -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -# Linux: -# systemctl --user stop nanoclaw -# npm run dev -# systemctl --user start nanoclaw -``` - -## Removal - -To remove Telegram integration: - -1. Delete `src/channels/telegram.ts` and `src/channels/telegram.test.ts` -2. Remove `import './telegram.js'` from `src/channels/index.ts` -3. Remove `TELEGRAM_BOT_TOKEN` from `.env` -4. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"` -5. Uninstall: `npm uninstall grammy` -6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) +- **type**: `telegram` +- **terminology**: Telegram calls them "groups" and "chats." A "group" has multiple members; a "chat" is a 1:1 conversation with the bot. +- **how-to-find-id**: Do NOT ask the user for a chat ID. Telegram registration uses pairing — run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent `, show the user the 4-digit `CODE` from the `PAIR_TELEGRAM_ISSUED` block (follow the `REMINDER_TO_ASSISTANT` line in that block), and tell them to send just the 4 digits as a message from the chat they want to register (DM the bot for `main`, post in the group otherwise). In groups with Group Privacy ON, prefix with the bot handle: `@ CODE`. Wrong guesses invalidate the code — if a `PAIR_TELEGRAM_ATTEMPT` block arrives with a mismatched `RECEIVED_CODE`, a `PAIR_TELEGRAM_NEW_CODE` block will follow automatically (up to 5 regenerations); show the new code. On `PAIR_TELEGRAM STATUS=failed ERROR=max-regenerations-exceeded`, ask the user if they want to try again and re-invoke the step — each invocation starts a fresh 5-attempt batch. Success emits `PAIR_TELEGRAM STATUS=success` with `PLATFORM_ID`, `IS_GROUP`, and `ADMIN_USER_ID`. The service must be running for this to work (the polling adapter is what observes the code). +- **supports-threads**: no +- **typical-use**: Interactive chat — direct messages or small groups +- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups. diff --git a/.claude/skills/add-telegram/VERIFY.md b/.claude/skills/add-telegram/VERIFY.md new file mode 100644 index 0000000..79c0f0d --- /dev/null +++ b/.claude/skills/add-telegram/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Telegram + +Send a message to your bot in Telegram (search for its username), or add the bot to a group and send a message there. The bot should respond within a few seconds. diff --git a/.claude/skills/add-vercel/SKILL.md b/.claude/skills/add-vercel/SKILL.md new file mode 100644 index 0000000..dbd9780 --- /dev/null +++ b/.claude/skills/add-vercel/SKILL.md @@ -0,0 +1,147 @@ +--- +name: add-vercel +description: Add Vercel deployment capability to NanoClaw agents. Installs the Vercel CLI in agent containers and sets up OneCLI credential injection for api.vercel.com. Use when the user wants agents to deploy web applications to Vercel. +--- + +# Add Vercel + +This skill gives NanoClaw agents the ability to deploy web applications to Vercel. It installs the Vercel CLI in agent containers and configures OneCLI to inject Vercel credentials automatically. + +**Principle:** Do the work — don't tell the user to do it. Only ask for their input when it genuinely requires manual action (pasting a token). + +## Phase 1: Pre-flight + +### Check if already applied + +Check if the container skill exists: + +```bash +test -d container/skills/vercel-cli && echo "INSTALLED" || echo "NOT_INSTALLED" +``` + +If `INSTALLED`, skip to Phase 3 (Configure Credentials). + +### Check prerequisites + +Verify OneCLI is working (required for credential injection): + +```bash +onecli version 2>/dev/null && echo "ONECLI_OK" || echo "ONECLI_MISSING" +``` + +If `ONECLI_MISSING`, tell the user to run `/init-onecli` first, then retry `/add-vercel`. Stop here. + +## Phase 2: Install Container Skill + +Copy the bundled container skill into the container skills directory: + +```bash +rsync -a .claude/skills/add-vercel/container-skills/ container/skills/ +``` + +Verify: + +```bash +head -5 container/skills/vercel-cli/SKILL.md +``` + +## Phase 3: Configure Credentials + +### Check if Vercel credential already exists + +```bash +onecli secrets list 2>/dev/null | grep -i vercel +``` + +If a Vercel credential already exists, skip to Phase 4. + +### Set up Vercel API credential + +The agent needs a Vercel personal access token. Tell the user: + +> I need your Vercel personal access token. Go to https://vercel.com/account/tokens and create one with these settings: +> +> - **Token name:** `nanoclaw` (or any name you'll recognize) +> - **Scope:** "Full Account" — the agent needs to create projects, deploy, and manage domains +> - **Expiration:** "No expiration" recommended (avoids credential rotation), or pick a date if your security policy requires it +> +> After creating the token, copy it — you'll only see it once. + +Once the user provides the token, add it to OneCLI: + +```bash +onecli secrets create \ + --name "Vercel API Token" \ + --type generic \ + --value "" \ + --host-pattern "api.vercel.com" \ + --header-name "Authorization" \ + --value-format "Bearer {value}" +``` + +Verify: + +```bash +onecli secrets list | grep -i vercel +``` + +### Assign the secret to all agents + +OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent: + +```bash +# For each agent, add the Vercel secret to its assigned secrets list. +# First get current assignments, then set them with the new secret appended. +VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//') +for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do + CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//') + onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID" +done +``` + +## Phase 4: Ensure Vercel CLI in Container Image + +Check if `vercel` is already in the Dockerfile: + +```bash +grep -q 'vercel' container/Dockerfile && echo "PRESENT" || echo "MISSING" +``` + +If `MISSING`, add `vercel` to the global npm install line in `container/Dockerfile`, then rebuild: + +```bash +./container/build.sh +``` + +If `PRESENT`, skip — no rebuild needed. + +## Phase 5: Sync Skills to Running Agent Groups + +Container skills are copied once at group creation and not auto-synced. After installing or updating a container skill, sync it to all existing agent groups: + +```bash +for session_dir in data/v2-sessions/ag-*; do + if [ -d "$session_dir/.claude-shared/skills" ]; then + rsync -a container/skills/ "$session_dir/.claude-shared/skills/" + echo "Synced skills to: $session_dir" + fi +done +``` + +## Phase 6: Restart Running Containers + +Stop all running agent containers so they pick up the new skills on next wake: + +```bash +docker ps --format "{{.ID}} {{.Names}}" | grep nanoclaw-v2 | awk '{print $1}' | xargs -r docker stop +``` + +## Done + +The agent can now deploy web applications to Vercel. Key commands: + +- `vercel deploy --yes --prod --token placeholder` — deploy to production +- `vercel ls --token placeholder` — list deployments +- `vercel whoami --token placeholder` — check auth + +For the full command reference, the agent has the `vercel-cli` container skill loaded automatically. diff --git a/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md b/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md new file mode 100644 index 0000000..7933e77 --- /dev/null +++ b/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md @@ -0,0 +1,103 @@ +--- +name: vercel-cli +description: Deploy apps to Vercel. Use when asked to deploy, ship, or publish a web application, or manage Vercel projects, domains, and environment variables. +--- + +# Vercel CLI + +You can deploy web applications to Vercel using the `vercel` CLI. + +## Auth + +Auth is handled by OneCLI — the HTTPS_PROXY injects the real token into API requests automatically. The Vercel CLI requires a token to be present to skip its local credential check, so **always pass `--token placeholder`** on every command. OneCLI replaces this with the real token at the proxy level. + +Before any Vercel operation, verify auth: + +```bash +vercel whoami --token placeholder +``` + +If this fails with an auth error, ask the user to add a Vercel token to OneCLI. They can create one at https://vercel.com/account/tokens and register it via `onecli secrets create` on the host. Once added, retry `vercel whoami`. + +## Deploying + +Always use `--yes` to skip interactive prompts and `--token placeholder` for auth (OneCLI replaces with real token). + +```bash +# Deploy to production +vercel deploy --yes --prod --token placeholder + +# Deploy from a specific directory +vercel deploy --yes --prod --token placeholder --cwd /path/to/project + +# Preview deployment (not production) +vercel deploy --yes --token placeholder +``` + +After deploying, verify the live URL: + +```bash +# Check deployment status +vercel inspect --token placeholder +``` + +## Pre-Send Checks (do this before sharing the URL) + +Don't send the deployment URL to the user until you've confirmed it's actually working. At minimum: + +1. **Local build passes** — run `npm run build` (or the project's build command) before `vercel deploy`. If the build fails locally, fix it first; don't deploy broken code. +2. **Deployment succeeded** — the `vercel deploy` output shows a "Production: https://..." URL and the status is READY (confirm with `vercel inspect`). +3. **Live URL responds** — `curl -sI | head -1` should return `HTTP/2 200` (or another 2xx/3xx). A 404/500 means something's broken even though Vercel reported success. +4. **Optional visual check** — if `agent-browser` is loaded, open the URL and eyeball it. Helpful for catching broken layouts that a 200 response wouldn't reveal. + +If any check fails, fix the issue and redeploy before reporting to the user. + +## Project Management + +```bash +# Link to an existing Vercel project (non-interactive) +vercel link --yes --token placeholder + +# List recent deployments +vercel ls --token placeholder + +# List all projects +vercel project ls --token placeholder +``` + +## Domains + +```bash +# List domains +vercel domains ls --token placeholder + +# Add a domain to the current project +vercel domains add example.com --token placeholder +``` + +## Environment Variables + +```bash +# Pull env vars from Vercel to local .env +vercel env pull --token placeholder + +# Add an env var (use echo to pipe the value — avoids interactive prompt) +echo "value" | vercel env add VAR_NAME production --token placeholder +``` + +## Common Errors + +| Error | Fix | +|-------|-----| +| `Error: No framework detected` | Ensure the project has a `package.json` with a `build` script, or set the framework in `vercel.json` | +| `Error: Rate limited` | Wait and retry. Don't loop — report to user | +| `Error: You have reached your project limit` | User needs to upgrade Vercel plan or delete unused projects | +| `ENOTFOUND api.vercel.com` | Network issue. Check proxy connectivity | +| Auth error after `vercel whoami` | Credential may be expired. Ask the user to refresh the Vercel token in OneCLI | + +## Best Practices + +- Run `npm run build` locally before deploying to catch build errors early +- Use `--cwd` instead of `cd` to keep your working directory stable +- For Next.js projects, `vercel deploy` auto-detects the framework — no extra config needed +- Use `vercel.json` only when you need custom build settings, rewrites, or headers diff --git a/.claude/skills/add-voice-transcription/SKILL.md b/.claude/skills/add-voice-transcription/SKILL.md deleted file mode 100644 index 8ccec32..0000000 --- a/.claude/skills/add-voice-transcription/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: add-voice-transcription -description: Add voice message transcription to NanoClaw using OpenAI's Whisper API. Automatically transcribes WhatsApp voice notes so the agent can read and respond to them. ---- - -# Add Voice Transcription - -This skill adds automatic voice message transcription to NanoClaw's WhatsApp channel using OpenAI's Whisper API. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice: ]`. - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place. - -### Ask the user - -Use `AskUserQuestion` to collect information: - -AskUserQuestion: Do you have an OpenAI API key for Whisper transcription? - -If yes, collect it now. If no, direct them to create one at https://platform.openai.com/api-keys. - -## Phase 2: Apply Code Changes - -**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/voice-transcription -git merge whatsapp/skill/voice-transcription || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `src/transcription.ts` (voice transcription module using OpenAI Whisper) -- Voice handling in `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call) -- Transcription tests in `src/channels/whatsapp.test.ts` -- `openai` npm dependency in `package.json` -- `OPENAI_API_KEY` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -npm install --legacy-peer-deps -npm run build -npx vitest run src/channels/whatsapp.test.ts -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Configure - -### Get OpenAI API key (if needed) - -If the user doesn't have an API key: - -> I need you to create an OpenAI API key: -> -> 1. Go to https://platform.openai.com/api-keys -> 2. Click "Create new secret key" -> 3. Give it a name (e.g., "NanoClaw Transcription") -> 4. Copy the key (starts with `sk-`) -> -> Cost: ~$0.006 per minute of audio (~$0.003 per typical 30-second voice note) - -Wait for the user to provide the key. - -### Add to environment - -Add to `.env`: - -```bash -OPENAI_API_KEY= -``` - -Sync to container environment: - -```bash -mkdir -p data/env && cp .env data/env/env -``` - -The container reads environment from `data/env/env`, not `.env` directly. - -### Build and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -## Phase 4: Verify - -### Test with a voice note - -Tell the user: - -> Send a voice note in any registered WhatsApp chat. The agent should receive it as `[Voice: ]` and respond to its content. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log | grep -i voice -``` - -Look for: -- `Transcribed voice message` — successful transcription with character count -- `OPENAI_API_KEY not set` — key missing from `.env` -- `OpenAI transcription failed` — API error (check key validity, billing) -- `Failed to download audio message` — media download issue - -## Troubleshooting - -### Voice notes show "[Voice Message - transcription unavailable]" - -1. Check `OPENAI_API_KEY` is set in `.env` AND synced to `data/env/env` -2. Verify key works: `curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 200` -3. Check OpenAI billing — Whisper requires a funded account - -### Voice notes show "[Voice Message - transcription failed]" - -Check logs for the specific error. Common causes: -- Network timeout — transient, will work on next message -- Invalid API key — regenerate at https://platform.openai.com/api-keys -- Rate limiting — wait and retry - -### Agent doesn't respond to voice notes - -Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups. diff --git a/.claude/skills/add-webex/REMOVE.md b/.claude/skills/add-webex/REMOVE.md new file mode 100644 index 0000000..b778644 --- /dev/null +++ b/.claude/skills/add-webex/REMOVE.md @@ -0,0 +1,6 @@ +# Remove Webex Channel + +1. Comment out `import './webex.js'` in `src/channels/index.ts` +2. Remove `WEBEX_BOT_TOKEN` and `WEBEX_WEBHOOK_SECRET` from `.env` +3. `pnpm uninstall @bitbasti/chat-adapter-webex` +4. Rebuild and restart diff --git a/.claude/skills/add-webex/SKILL.md b/.claude/skills/add-webex/SKILL.md new file mode 100644 index 0000000..0f52178 --- /dev/null +++ b/.claude/skills/add-webex/SKILL.md @@ -0,0 +1,88 @@ +--- +name: add-webex +description: Add Webex channel integration via Chat SDK. +--- + +# Add Webex Channel + +Adds Cisco Webex support via the Chat SDK bridge. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the Webex adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/webex.ts` exists +- `src/channels/index.ts` contains `import './webex.js';` +- `@bitbasti/chat-adapter-webex` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/webex.ts > src/channels/webex.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './webex.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @bitbasti/chat-adapter-webex@0.1.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +1. Go to [developer.webex.com](https://developer.webex.com/my-apps/new/bot) and create a new bot +2. Copy the **Bot Access Token** +3. Set up a webhook: + - Use the Webex API or Developer Portal to create a webhook pointing to `https://your-domain/webhook/webex` + - Set a webhook secret for signature verification + +### Configure environment + +Add to `.env`: + +```bash +WEBEX_BOT_TOKEN=your-bot-token +WEBEX_WEBHOOK_SECRET=your-webhook-secret +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `webex` +- **terminology**: Webex has "spaces." A space can be a group conversation or a 1:1 direct message with the bot. +- **how-to-find-id**: Open the space in Webex, click the space name > Settings — the Space ID is listed there. Or use the Webex API (`GET /rooms`) to list spaces and their IDs. +- **supports-threads**: yes +- **typical-use**: Interactive chat — team spaces or direct messages +- **default-isolation**: Same agent group for spaces where you're the primary user. Separate agent group for spaces with different teams or sensitive information. diff --git a/.claude/skills/add-webex/VERIFY.md b/.claude/skills/add-webex/VERIFY.md new file mode 100644 index 0000000..3bd872b --- /dev/null +++ b/.claude/skills/add-webex/VERIFY.md @@ -0,0 +1,3 @@ +# Verify Webex Channel + +Add the bot to a Webex space or send it a direct message. The bot should respond within a few seconds. diff --git a/.claude/skills/add-wechat/REMOVE.md b/.claude/skills/add-wechat/REMOVE.md new file mode 100644 index 0000000..366739e --- /dev/null +++ b/.claude/skills/add-wechat/REMOVE.md @@ -0,0 +1,49 @@ +# Remove WeChat Channel + +Undo `/add-wechat`. + +### 1. Remove credentials + +Delete WeChat lines from `.env`: + +```bash +sed -i.bak '/^WECHAT_ENABLED=/d' .env && rm -f .env.bak +cp .env data/env/env +``` + +### 2. Remove adapter and import + +```bash +rm -f src/channels/wechat.ts +sed -i.bak "/import '\.\/wechat\.js';/d" src/channels/index.ts && rm -f src/channels/index.ts.bak +``` + +### 3. Uninstall the package + +```bash +pnpm remove wechat-ilink-client +``` + +### 4. Remove saved auth + sync state + +```bash +rm -rf data/wechat +``` + +### 5. Remove DB wiring + +```sql +-- Remove any sessions first (foreign key) +DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat'); +DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat'); +DELETE FROM messaging_groups WHERE channel_type = 'wechat'; +``` + +### 6. Rebuild and restart + +```bash +pnpm run build +systemctl --user restart nanoclaw # Linux +# or +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` diff --git a/.claude/skills/add-wechat/SKILL.md b/.claude/skills/add-wechat/SKILL.md new file mode 100644 index 0000000..ba0294a --- /dev/null +++ b/.claude/skills/add-wechat/SKILL.md @@ -0,0 +1,170 @@ +--- +name: add-wechat +description: Add WeChat (personal) channel integration via Tencent's official iLink Bot API. Uses long-polling and QR scan — no webhook, no ToS risk, no paid token. +--- + +# Add WeChat Channel + +Adds WeChat support via **iLink Bot API** — the first-party Tencent API for personal WeChat bots (different from WeCom / Official Account). + +**Why this is different from wechaty/PadLocal:** + +- Official Tencent API — no ToS violation, no ban risk +- Free — no PadLocal token required +- No public webhook URL needed — uses long-poll +- Works with any personal WeChat account + +## Prerequisites + +- A **personal WeChat account** with the mobile app installed +- A phone to scan the QR code for login +- Node.js >= 20 (already required by NanoClaw) + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the WeChat adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/wechat.ts` exists +- `src/channels/index.ts` contains `import './wechat.js';` +- `wechat-ilink-client` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './wechat.js'; +``` + +### 4. Install the library (pinned) + +```bash +pnpm install wechat-ilink-client@0.1.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +Unlike most channels, WeChat requires **no pre-configured API keys**. Auth happens via QR code scan from your phone. + +### 1. Enable the channel + +Add to `.env`: + +```bash +WECHAT_ENABLED=true +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +### 2. Start the service and scan the QR + +Restart NanoClaw: + +```bash +systemctl --user restart nanoclaw # Linux +# or +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`: + +```bash +tail -f logs/nanoclaw.log | grep WeChat +# or +cat data/wechat/qr.txt +``` + +Open the URL in a browser (it renders a QR code), then: + +1. Open WeChat on your phone +2. Use its built-in QR scanner (top-right "+" → Scan) +3. Approve the authorization on your phone +4. Auth credentials are saved to `data/wechat/auth.json` — do not commit this file + +The bot is now connected as your WeChat account. + +## Wire your first DM + +A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond. + +### 1. Trigger the first inbound message + +Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`. + +### 2. Run the wire script + +```bash +pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts +``` + +Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`). + +With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent. + +Non-interactive: + +```bash +pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \ + --platform-id wechat:wxid_xxxxx \ + --agent-group ag-xxxxx \ + --non-interactive +``` + +Flags: + +- `--platform-id ` — wire a specific messaging group (default: most recent unwired) +- `--agent-group ` — target agent group (default: prompt; or solo admin group in non-interactive) +- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs) +- `--session-mode shared|per-thread` — default `shared` + +### 3. Test + +Have the sender message the bot again — the agent should respond. + +## Operational notes + +- **Only one instance can use a given token at a time.** Don't run multiple NanoClaw instances pointing to the same `data/wechat/auth.json`. +- **Re-login on session expiry:** if you see `WeChat: session expired` in logs, delete `data/wechat/auth.json` and restart — you'll be asked to re-scan. +- **Sync cursor persistence:** `data/wechat/sync-buf.txt` holds the long-poll cursor. Deleting it replays recent history on next start; don't delete it in normal operation. +- **Account safety:** this uses the official Tencent API, so account bans for bot automation aren't a risk. That said, don't spam — normal rate limits still apply. + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, restart the service to pick up the new channel and wiring. + +## Channel Info + +- **type**: `wechat` +- **terminology**: WeChat has "contacts" (DMs) and "group chats" (rooms). Each DM or group is a separate messaging group. +- **how-to-find-id**: Send a message to the bot from the target account; the adapter auto-creates a messaging group and logs `WeChat inbound platformId=wechat:`. Use `wechat:` for DMs, `wechat:` for rooms. +- **admin-user-id**: The operator's WeChat user_id (for `init-first-agent.ts --admin-user-id`) is saved to `data/wechat/auth.json` as `operatorUserId` after the QR scan. Read it with `cat data/wechat/auth.json | jq -r .operatorUserId` and prefix with `wechat:` (i.e. `wechat:`). +- **supports-threads**: no (WeChat has no reply threads) +- **typical-use**: Long-poll — the adapter holds a persistent connection to Tencent's iLink API and receives messages in real time. No webhook URL needed. +- **default-isolation**: `shared` session mode per messaging group (DM or room). Use `strict` sender policy if you want only specific users to reach the agent; `public` opens it to anyone who messages the bot. +- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running inside `/new-setup`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above. diff --git a/.claude/skills/add-wechat/scripts/wire-dm.ts b/.claude/skills/add-wechat/scripts/wire-dm.ts new file mode 100644 index 0000000..f94c88d --- /dev/null +++ b/.claude/skills/add-wechat/scripts/wire-dm.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env pnpm exec tsx +/** + * Wire a WeChat DM (or group) to an agent group. + * + * After /add-wechat installs the adapter and the user scans the QR login, + * the first inbound message from another WeChat account auto-creates a + * `messaging_groups` row. This script finds that row, asks the operator + * which agent group to wire it to, and inserts the `messaging_group_agents` + * join row with sensible defaults — the "post-login wiring" step /add-wechat + * otherwise requires manual SQL for. + * + * Usage: + * pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts + * + * Flags: + * --platform-id Wire a specific messaging group (default: most recent unwired) + * --agent-group Target agent group (default: interactive pick; or solo admin group) + * --sender-policy

public | strict (default: public) + * --session-mode shared | per-thread (default: shared) + * --non-interactive Fail instead of prompting + */ +import Database from 'better-sqlite3'; +import path from 'node:path'; +import readline from 'node:readline'; + +const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db'); + +type SenderPolicy = 'public' | 'strict' | 'request_approval'; + +interface Args { + platformId?: string; + agentGroupId?: string; + senderPolicy: SenderPolicy; + sessionMode: 'shared' | 'per-thread'; + interactive: boolean; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + // Default matches the router's auto-create (`request_approval`) so the + // admin gets an approval card on the next unknown-sender DM rather than + // a silent allow. Pass `--sender-policy public` to open the channel to + // anyone, or `strict` to require explicit membership. + senderPolicy: 'request_approval', + sessionMode: 'shared', + interactive: true, + }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const val = argv[i + 1]; + switch (flag) { + case '--platform-id': args.platformId = val; i++; break; + case '--agent-group': args.agentGroupId = val; i++; break; + case '--sender-policy': + if (val !== 'public' && val !== 'strict' && val !== 'request_approval') { + throw new Error(`bad --sender-policy: ${val} (use public | strict | request_approval)`); + } + args.senderPolicy = val; i++; break; + case '--session-mode': + if (val !== 'shared' && val !== 'per-thread') throw new Error(`bad --session-mode: ${val}`); + args.sessionMode = val; i++; break; + case '--non-interactive': args.interactive = false; break; + case '--help': case '-h': + console.log('See .claude/skills/add-wechat/scripts/wire-dm.ts header for usage.'); + process.exit(0); + } + } + return args; +} + +async function prompt(q: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); })); +} + +function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + + // 1. Pick the messaging group + let platformId = args.platformId; + if (!platformId) { + const rows = db.prepare(` + SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at + FROM messaging_groups mg + LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id + WHERE mg.channel_type = 'wechat' AND mga.id IS NULL + ORDER BY mg.created_at DESC + `).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>; + + if (rows.length === 0) { + console.error('No unwired WeChat messaging groups found.'); + console.error('Send a message to the bot first (from another WeChat account), then re-run.'); + process.exit(1); + } + + if (rows.length === 1 || !args.interactive) { + platformId = rows[0].platform_id; + console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`); + } else { + console.log('Unwired WeChat messaging groups:'); + rows.forEach((r, i) => { + console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`); + }); + const pick = await prompt('Pick one [1]: '); + const idx = pick === '' ? 0 : parseInt(pick, 10) - 1; + if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice'); + platformId = rows[idx].platform_id; + } + } + + const mg = db.prepare( + 'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?' + ).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined; + if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`); + + // 2. Pick the agent group + let agentGroupId = args.agentGroupId; + if (!agentGroupId) { + const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC') + .all() as Array<{ id: string; name: string; is_admin: number }>; + if (agents.length === 0) throw new Error('no agent groups exist — create one first'); + + const adminAgents = agents.filter((a) => a.is_admin === 1); + if (adminAgents.length === 1 && !args.interactive) { + agentGroupId = adminAgents[0].id; + console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`); + } else if (args.interactive) { + console.log('Agent groups:'); + agents.forEach((a, i) => { + console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`); + }); + const pick = await prompt('Pick one [1]: '); + const idx = pick === '' ? 0 : parseInt(pick, 10) - 1; + if (Number.isNaN(idx) || idx < 0 || idx >= agents.length) throw new Error('invalid choice'); + agentGroupId = agents[idx].id; + } else { + throw new Error('multiple agent groups exist; pass --agent-group '); + } + } + + const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as + { id: string; name: string } | undefined; + if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`); + + // 3. Update sender policy + wire + const tx = db.transaction(() => { + db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?') + .run(args.senderPolicy, mg.id); + + db.prepare(` + INSERT INTO messaging_group_agents + (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) + VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now')) + `).run(generateId('mga'), mg.id, ag.id, args.sessionMode); + }); + tx(); + + console.log(''); + console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`); + db.close(); +} + +main().catch((err) => { + console.error('FAILED:', err.message); + process.exit(1); +}); diff --git a/.claude/skills/add-whatsapp-cloud/REMOVE.md b/.claude/skills/add-whatsapp-cloud/REMOVE.md new file mode 100644 index 0000000..d10df0e --- /dev/null +++ b/.claude/skills/add-whatsapp-cloud/REMOVE.md @@ -0,0 +1,6 @@ +# Remove WhatsApp Cloud API Channel + +1. Comment out `import './whatsapp-cloud.js'` in `src/channels/index.ts` +2. Remove `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_APP_SECRET`, `WHATSAPP_VERIFY_TOKEN` from `.env` +3. `pnpm uninstall @chat-adapter/whatsapp` +4. Rebuild and restart diff --git a/.claude/skills/add-whatsapp-cloud/SKILL.md b/.claude/skills/add-whatsapp-cloud/SKILL.md new file mode 100644 index 0000000..d08f375 --- /dev/null +++ b/.claude/skills/add-whatsapp-cloud/SKILL.md @@ -0,0 +1,95 @@ +--- +name: add-whatsapp-cloud +description: Add WhatsApp Business Cloud API channel via Chat SDK. Official Meta API. +--- + +# Add WhatsApp Cloud API Channel + +Connect NanoClaw to WhatsApp via the official Meta WhatsApp Business Cloud API. + +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the WhatsApp Cloud adapter in from the `channels` branch. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/whatsapp-cloud.ts` exists +- `src/channels/index.ts` contains `import './whatsapp-cloud.js';` +- `@chat-adapter/whatsapp` is listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter + +```bash +git show origin/channels:src/channels/whatsapp-cloud.ts > src/channels/whatsapp-cloud.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './whatsapp-cloud.js'; +``` + +### 4. Install the adapter package (pinned) + +```bash +pnpm install @chat-adapter/whatsapp@4.26.0 +``` + +### 5. Build + +```bash +pnpm run build +``` + +## Credentials + +1. Go to [Meta for Developers](https://developers.facebook.com/apps/) and create an app (type: Business). +2. Add the **WhatsApp** product. +3. Go to **WhatsApp** > **API Setup**: + - Note the **Phone Number ID** (not the phone number itself). + - Generate a **permanent System User access token** with `whatsapp_business_messaging` permission. +4. Go to **WhatsApp** > **Configuration**: + - Set webhook URL: `https://your-domain/webhook/whatsapp`. + - Set a **Verify Token** (any random string you choose). + - Subscribe to webhook fields: `messages`. +5. Copy the **App Secret** from **Settings** > **Basic**. + +### Configure environment + +Add to `.env`: + +```bash +WHATSAPP_ACCESS_TOKEN=your-system-user-access-token +WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id +WHATSAPP_APP_SECRET=your-app-secret +WHATSAPP_VERIFY_TOKEN=your-verify-token +``` + +Sync to container: `mkdir -p data/env && cp .env data/env/env` + +## Next Steps + +If you're in the middle of `/setup`, return to the setup flow now. + +Otherwise, run `/manage-channels` to wire this channel to an agent group. + +## Channel Info + +- **type**: `whatsapp-cloud` +- **terminology**: WhatsApp Cloud API supports 1:1 conversations only (no group chats). Each conversation is with a phone number. +- **how-to-find-id**: The platform ID is the Phone Number ID from the Meta Business dashboard (not the phone number itself). Find it under WhatsApp > API Setup. +- **supports-threads**: no +- **typical-use**: Interactive 1:1 chat -- direct messages only +- **default-isolation**: Same agent group if you're the only person messaging the bot. Each additional person who messages gets their own conversation automatically, but they share the agent's workspace and memory -- use a separate agent group if you need information isolation between different contacts. diff --git a/.claude/skills/add-whatsapp-cloud/VERIFY.md b/.claude/skills/add-whatsapp-cloud/VERIFY.md new file mode 100644 index 0000000..905f89f --- /dev/null +++ b/.claude/skills/add-whatsapp-cloud/VERIFY.md @@ -0,0 +1,3 @@ +# Verify WhatsApp Cloud API Channel + +Send a message to your WhatsApp Business number. The bot should respond within a few seconds. Note: WhatsApp Cloud API only supports 1:1 DMs, not group chats. diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index cbdf00b..3f10ce1 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -1,20 +1,81 @@ --- name: add-whatsapp -description: Add WhatsApp as a channel. Can replace other channels entirely or run alongside them. Uses QR code or pairing code for authentication. +description: Add WhatsApp channel via native Baileys adapter. Direct connection — no Chat SDK bridge. Uses QR code or pairing code for authentication. --- # Add WhatsApp Channel -This skill adds WhatsApp support to NanoClaw. It installs the WhatsApp channel code, dependencies, and guides through authentication, registration, and configuration. +Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge). -## Phase 1: Pre-flight +## Install + +NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge. + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/whatsapp.ts` exists +- `src/channels/index.ts` contains `import './whatsapp.js';` +- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist +- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:` +- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the channels branch + +```bash +git fetch origin channels +``` + +### 2. Copy the adapter and setup steps + +```bash +git show origin/channels:src/channels/whatsapp.ts > src/channels/whatsapp.ts +git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts +git show origin/channels:setup/groups.ts > setup/groups.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if already present): + +```typescript +import './whatsapp.js'; +``` + +### 4. Register the setup steps + +In `setup/index.ts`, add these entries to the `STEPS` map (skip lines already present): + +```typescript +groups: () => import('./groups.js'), +'whatsapp-auth': () => import('./whatsapp-auth.js'), +``` + +### 5. Install the adapter packages (pinned) + +```bash +pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +``` + +### 6. Build + +```bash +pnpm run build +``` + +## Credentials + +WhatsApp uses linked-device authentication — no API key, just a one-time pairing from your phone. ### Check current state -Check if WhatsApp is already configured. If `store/auth/` exists with credential files, skip to Phase 4 (Registration) or Phase 5 (Verify). +Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number". ```bash -ls store/auth/creds.json 2>/dev/null && echo "WhatsApp auth exists" || echo "No WhatsApp auth" +test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth" ``` ### Detect environment @@ -42,57 +103,6 @@ If they chose pairing code: AskUserQuestion: What is your phone number? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.) -## Phase 2: Apply Code Changes - -Check if `src/channels/whatsapp.ts` already exists. If it does, skip to Phase 3 (Authentication). - -### Ensure channel remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp main -git merge whatsapp/main || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This merges in: -- `src/channels/whatsapp.ts` (WhatsAppChannel class with self-registration via `registerChannel`) -- `src/channels/whatsapp.test.ts` (41 unit tests) -- `src/whatsapp-auth.ts` (standalone WhatsApp authentication script) -- `setup/whatsapp-auth.ts` (WhatsApp auth setup step) -- `import './whatsapp.js'` appended to the channel barrel file `src/channels/index.ts` -- `'whatsapp-auth'` step added to `setup/index.ts` -- `@whiskeysockets/baileys`, `qrcode`, `qrcode-terminal` npm dependencies in `package.json` -- `ASSISTANT_HAS_OWN_NUMBER` in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - -### Validate code changes - -```bash -npm install -npm run build -npx vitest run src/channels/whatsapp.test.ts -``` - -All tests must pass and build must be clean before proceeding. - -## Phase 3: Authentication - ### Clean previous auth state (if re-authenticating) ```bash @@ -104,7 +114,7 @@ rm -rf store/auth/ For QR code in browser (recommended): ```bash -npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser +pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser ``` (Bash timeout: 150000ms) @@ -120,10 +130,12 @@ Tell the user: For QR code in terminal: ```bash -npx tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal +pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal ``` -Tell the user to run `npm run auth` in another terminal, then: +(Bash timeout: 150000ms) + +Tell the user: > 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** > 2. Scan the QR code displayed in the terminal @@ -135,7 +147,7 @@ Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Devi Run the auth process in the background and poll `store/pairing-code.txt` for the code: ```bash -rm -f store/pairing-code.txt && npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone > /tmp/wa-auth.log 2>&1 & +rm -f store/pairing-code.txt && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone > /tmp/wa-auth.log 2>&1 & ``` Then immediately poll for the code (do NOT wait for the background command to finish): @@ -155,10 +167,10 @@ Display the code to the user the moment it appears. Tell them: After the user enters the code, poll for authentication to complete: ```bash -for i in $(seq 1 60); do grep -q 'AUTH_STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'AUTH_STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done +for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done ``` -**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry. +**If failed:** logged_out → delete `store/auth/` and re-run. timeout → ask user, offer retry. ### Verify authentication succeeded @@ -166,128 +178,43 @@ for i in $(seq 1 60); do grep -q 'AUTH_STATUS: authenticated' /tmp/wa-auth.log 2 test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed" ``` -### Configure environment +### Shared vs dedicated number -Channels auto-enable when their credentials are present — WhatsApp activates when `store/auth/creds.json` exists. +AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number? +- **Shared number** — your personal WhatsApp (bot prefixes messages with its name) +- **Dedicated number** — a separate phone/SIM for the assistant -Sync to container environment: +If dedicated, add to `.env`: ```bash -mkdir -p data/env && cp .env data/env/env +ASSISTANT_HAS_OWN_NUMBER=true ``` -## Phase 4: Registration +## Next Steps -### Configure trigger and channel type +If you're in the middle of `/setup`, return to the setup flow now. -Get the bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"` +Otherwise, run `/manage-channels` to wire this channel to an agent group. -AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number (separate device)? -- **Shared number** - Your personal WhatsApp number (recommended: use self-chat or a solo group) -- **Dedicated number** - A separate phone/SIM for the assistant +## Channel Info -AskUserQuestion: What trigger word should activate the assistant? -- **@Andy** - Default trigger -- **@Claw** - Short and easy -- **@Claude** - Match the AI name +- **type**: `whatsapp` +- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members. +- **how-to-find-id**: DMs use `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`. +- **supports-threads**: no +- **typical-use**: Interactive chat — direct messages or small groups +- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups. -AskUserQuestion: What should the assistant call itself? -- **Andy** - Default name -- **Claw** - Short and easy -- **Claude** - Match the AI name +### Features -AskUserQuestion: Where do you want to chat with the assistant? +- Markdown formatting — `**bold**`→`*bold*`, `*italic*`→`_italic_`, headings→bold, code blocks preserved +- Approval questions — `ask_user_question` renders with `/approve`, `/reject` slash commands +- File attachments — send and receive images, video, audio, documents +- Reactions — send emoji reactions on messages +- Typing indicators — composing presence updates +- Credential requests — text fallback (WhatsApp has no modal support) -**Shared number options:** -- **Self-chat** (Recommended) - Chat in your own "Message Yourself" conversation -- **Solo group** - A group with just you and the linked device -- **Existing group** - An existing WhatsApp group - -**Dedicated number options:** -- **DM with bot** (Recommended) - Direct message the bot's number -- **Solo group** - A group with just you and the bot -- **Existing group** - An existing WhatsApp group - -### Get the JID - -**Self-chat:** JID = your phone number with `@s.whatsapp.net`. Extract from auth credentials: - -```bash -node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')" -``` - -**DM with bot:** Ask for the bot's phone number. JID = `NUMBER@s.whatsapp.net` - -**Group (solo, existing):** Run group sync and list available groups: - -```bash -npx tsx setup/index.ts --step groups -npx tsx setup/index.ts --step groups --list -``` - -The output shows `JID|GroupName` pairs. Present candidates as AskUserQuestion (names only, not JIDs). - -### Register the chat - -```bash -npx tsx setup/index.ts --step register \ - --jid "" \ - --name "" \ - --trigger "@" \ - --folder "whatsapp_main" \ - --channel whatsapp \ - --assistant-name "" \ - --is-main \ - --no-trigger-required # Only for main/self-chat -``` - -For additional groups (trigger-required): - -```bash -npx tsx setup/index.ts --step register \ - --jid "" \ - --name "" \ - --trigger "@" \ - --folder "whatsapp_" \ - --channel whatsapp -``` - -## Phase 5: Verify - -### Build and restart - -```bash -npm run build -``` - -Restart the service: - -```bash -# macOS (launchd) -launchctl kickstart -k gui/$(id -u)/com.nanoclaw - -# Linux (systemd) -systemctl --user restart nanoclaw - -# Linux (nohup fallback) -bash start-nanoclaw.sh -``` - -### Test the connection - -Tell the user: - -> Send a message to your registered WhatsApp chat: -> - For self-chat / main: Any message works -> - For groups: Use the trigger word (e.g., "@Andy hello") -> -> The assistant should respond within a few seconds. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` +Not supported (WhatsApp linked device limitation): edit messages, delete messages. ## Troubleshooting @@ -296,77 +223,42 @@ tail -f logs/nanoclaw.log QR codes expire after ~60 seconds. Re-run the auth command: ```bash -rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts +rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser ``` ### Pairing code not working -Codes expire in ~60 seconds. To retry: +Codes expire in ~60 seconds. Delete auth and retry: ```bash -rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone +rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone ``` -Enter the code **immediately** when it appears. Also ensure: -1. Phone number is digits only — country code + number, no `+` prefix (e.g., `14155551234` where `1` is country code, `4155551234` is the number) -2. Phone has internet access -3. WhatsApp is updated to the latest version +Ensure: digits only (no `+`), phone has internet, WhatsApp is updated. If pairing code keeps failing, switch to QR-browser auth instead: ```bash -rm -rf store/auth/ && npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser +rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser ``` -### "conflict" disconnection +### "waiting for this message" on reactions -This happens when two instances connect with the same credentials. Ensure only one NanoClaw process is running: +Signal sessions corrupted from rapid restarts. Clear sessions: ```bash -pkill -f "node dist/index.js" -# Then restart +systemctl --user stop nanoclaw +rm store/auth/session-*.json +systemctl --user start nanoclaw ``` ### Bot not responding -Check: -1. Auth credentials exist: `ls store/auth/creds.json` -3. Chat is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE '%whatsapp%' OR jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"` -4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) -5. Logs: `tail -50 logs/nanoclaw.log` +1. Auth exists: `test -f store/auth/creds.json` +2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1` +3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"` +4. Service running: `systemctl --user status nanoclaw` -### Group names not showing +### "conflict" disconnection -Run group metadata sync: - -```bash -npx tsx setup/index.ts --step groups -``` - -This fetches all group names from WhatsApp. Runs automatically every 24 hours. - -## After Setup - -If running `npm run dev` while the service is active: - -```bash -# macOS: -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -npm run dev -# When done testing: -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist - -# Linux: -# systemctl --user stop nanoclaw -# npm run dev -# systemctl --user start nanoclaw -``` - -## Removal - -To remove WhatsApp integration: - -1. Delete auth credentials: `rm -rf store/auth/` -2. Remove WhatsApp registrations: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"` -3. Sync env: `mkdir -p data/env && cp .env data/env/env` -4. Rebuild and restart: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) +Two instances connected with same credentials. Ensure only one NanoClaw process is running. diff --git a/.claude/skills/channel-formatting/SKILL.md b/.claude/skills/channel-formatting/SKILL.md deleted file mode 100644 index 3e2334c..0000000 --- a/.claude/skills/channel-formatting/SKILL.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: channel-formatting -description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill. ---- - -# Channel Formatting - -This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's -responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or -Telegram. - -| Channel | Transformation | -|---------|---------------| -| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links → `text (url)` | -| Telegram | same as WhatsApp, but `[text](url)` links are preserved (Markdown v1 renders them natively) | -| Slack | same as WhatsApp, but links become `` | -| Discord | passthrough (Discord already renders Markdown) | -| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill | - -Code blocks (fenced and inline) are always protected — their content is never transformed. - -## Phase 1: Pre-flight - -### Check if already applied - -```bash -test -f src/text-styles.ts && echo "already applied" || echo "not yet applied" -``` - -If `already applied`, skip to Phase 3 (Verify). - -## Phase 2: Apply Code Changes - -### Ensure the upstream remote - -```bash -git remote -v -``` - -If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing, -add it: - -```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git -``` - -### Merge the skill branch - -```bash -git fetch upstream skill/channel-formatting -git merge upstream/skill/channel-formatting -``` - -If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming -version and continuing: - -```bash -git checkout --theirs package-lock.json -git add package-lock.json -git merge --continue -``` - -For any other conflict, read the conflicted file and reconcile both sides manually. - -This merge adds: - -- `src/text-styles.ts` — `parseTextStyles(text, channel)` for marker substitution and - `parseSignalStyles(text)` for Signal native rich text -- `src/router.ts` — `formatOutbound` gains an optional `channel` parameter; when provided - it calls `parseTextStyles` after stripping `` tags -- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound` -- `src/formatting.test.ts` — test coverage for both functions across all channels - -### Validate - -```bash -npm install -npm run build -npx vitest run src/formatting.test.ts -``` - -All 73 tests should pass and the build should be clean before continuing. - -## Phase 3: Verify - -### Rebuild and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS -# Linux: systemctl --user restart nanoclaw -``` - -### Spot-check formatting - -Send a message through any registered WhatsApp or Telegram chat that will trigger a -response from Claude. Ask something that will produce formatted output, such as: - -> Summarise the three main advantages of TypeScript using bullet points and **bold** headings. - -Confirm that the response arrives with native bold (`*text*`) rather than raw double -asterisks. - -### Check logs if needed - -```bash -tail -f logs/nanoclaw.log -``` - -## Signal Skill Integration - -If you have the Signal skill installed, `src/channels/signal.ts` can import -`parseSignalStyles` from the newly present `src/text-styles.ts`: - -```typescript -import { parseSignalStyles, SignalTextStyle } from '../text-styles.js'; -``` - -`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where -`textStyle` is an array of `{ style, start, length }` objects suitable for the -`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`). - -## Removal - -```bash -# Remove the new file -rm src/text-styles.ts - -# Revert router.ts to remove the channel param -git diff upstream/main src/router.ts # review changes -git checkout upstream/main -- src/router.ts - -# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText) -# (edit manually or: git checkout upstream/main -- src/index.ts) - -npm run build -``` \ No newline at end of file diff --git a/.claude/skills/convert-to-apple-container/SKILL.md b/.claude/skills/convert-to-apple-container/SKILL.md index c37633c..af6f39e 100644 --- a/.claude/skills/convert-to-apple-container/SKILL.md +++ b/.claude/skills/convert-to-apple-container/SKILL.md @@ -80,8 +80,8 @@ If the merge reports conflicts, resolve them by reading the conflicted files and ### Validate code changes ```bash -npm test -npm run build +pnpm test +pnpm run build ``` All tests must pass and build must be clean before proceeding. @@ -172,7 +172,7 @@ Expected: Both operations succeed. ### Full integration test ```bash -npm run build +pnpm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw ``` diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md index 614a979..c83bd26 100644 --- a/.claude/skills/customize/SKILL.md +++ b/.claude/skills/customize/SKILL.md @@ -91,7 +91,7 @@ Implementation: Always tell the user: ```bash # Rebuild and restart -npm run build +pnpm run build # macOS: launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 03c34de..128b8c3 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -41,7 +41,7 @@ Set `LOG_LEVEL=debug` for verbose output: ```bash # For development -LOG_LEVEL=debug npm run dev +LOG_LEVEL=debug pnpm run dev # For launchd service (macOS), add to plist EnvironmentVariables: LOG_LEVEL @@ -231,7 +231,7 @@ query({ ```bash # Rebuild main app -npm run build +pnpm run build # Rebuild container (use --no-cache for clean rebuild) ./container/build.sh diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md new file mode 100644 index 0000000..6b110d3 --- /dev/null +++ b/.claude/skills/init-first-agent/SKILL.md @@ -0,0 +1,120 @@ +--- +name: init-first-agent +description: Walk the operator through creating the first NanoClaw agent for a DM channel — resolve the operator's channel identity, wire the DM messaging group to a new agent, and trigger a welcome DM via the normal delivery path. Use after channel credentials are configured and the service is running. +--- + +# Init First Agent + +Stand up the first NanoClaw agent for a channel and verify end-to-end delivery by having the agent DM the operator. Everything the skill does is idempotent — rerunning is safe. + +## Prerequisites + +- **Service running.** Check: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first. +- **Target channel installed.** At least one `/add-` skill has run, credentials are in `.env`, and the adapter is uncommented in `src/channels/index.ts`. +- **Adapter connected.** Tail `logs/nanoclaw.log` — look for a recent `channel setup` / `adapter connected` line for the target channel. + +## 1. Pick the channel + +Read `src/channels/index.ts` to find enabled channels (uncommented imports). Cross-check `.env` for the relevant credentials. + +AskUserQuestion: "Which channel should host the welcome DM?" with one option per enabled channel (Discord, Slack, Telegram, WhatsApp, Webex, Teams, Google Chat, Matrix, iMessage, Resend, …). + +Record the choice as `CHANNEL` (lowercase, e.g. `discord`). + +## 2. Ask for the operator's identity + +Read the channel's own skill for its `## Channel Info > how-to-find-id` section (e.g. `.claude/skills/add-discord/SKILL.md`, `.claude/skills/add-telegram/SKILL.md`). Show those instructions to the user in plain text. + +Then ask in plain text (NOT `AskUserQuestion` — these are free-form): + +1. **Your user id on this channel** — e.g. a Discord user ID, Telegram user ID, Slack user ID. Record as `USER_HANDLE`. +2. **Your display name** — human name, used to name the agent group (`dm-with-`) and as the welcome-message addressee. Record as `DISPLAY_NAME`. +3. **Agent persona name** — the assistant's display name. Default: `DISPLAY_NAME`. Record as `AGENT_NAME`. + +## 3. Resolve the DM platform id + +This depends on whether the channel supports cold DM via `adapter.openDM`. + +**Channels without cold DM (direct-addressable): telegram, whatsapp, imessage, matrix, resend.** The user handle doubles as the DM chat id. Set: + +``` +PLATFORM_ID=${CHANNEL}:${USER_HANDLE} +``` + +Skip to step 4. + +**Channels with cold DM (resolution-required): discord, slack, teams, webex, gchat.** The bot can DM cold at runtime via Chat SDK, but this skill runs standalone — it can't call the adapter. Two resolutions: + +### 3a. User DMs the bot once (Discord / Slack / Teams / Webex / gChat) + +Tell the user: + +> Send any single message to the bot as a DM from your account on `${CHANNEL}`. The router will record the DM as a messaging group. Reply `done` here when you've sent the message. + +Wait for the user's confirmation. Then look up the most recent DM messaging groups: + +```bash +sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5" +``` + +Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues. + +### 3b. Telegram pair-code path (if the user prefers not to DM first) + +For Telegram only, there's an existing pair-code primitive. When you run this tool, take the output and extract the pairing code. Then show it to the user in plain text and ask the user to send the code in the Telegram chat to complete the pairing. + +```bash +npx tsx setup/index.ts --step pair-telegram -- --intent new-agent:dm-with- +``` + +Parse the `PAIR_TELEGRAM_ISSUED` status block for `CODE` and follow the `REMINDER_TO_ASSISTANT` line in that block. Then wait for the `PAIR_TELEGRAM` block — read `PLATFORM_ID` and `PAIRED_USER_ID` from it. telegram.ts's interceptor has already upserted the user and granted owner if none existed yet. Use `PLATFORM_ID` and `PAIRED_USER_ID` directly in step 4. + +## 4. Run the init script + +```bash +npx tsx scripts/init-first-agent.ts \ + --channel "${CHANNEL}" \ + --user-id "${CHANNEL}:${USER_HANDLE}" \ + --platform-id "${PLATFORM_ID}" \ + --display-name "${DISPLAY_NAME}" \ + --agent-name "${AGENT_NAME}" +``` + +Add `--welcome "System instruction: ..."` to override the default welcome prompt. + +The script: +1. Upserts the `users` row and grants `owner` role if no owner exists. +2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-/`. +3. Reuses or creates the DM `messaging_groups` row. +4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row). +5. Hands the welcome message to the running service via its CLI socket (`data/cli.sock`), targeting the DM messaging group. The service routes it into the DM session, which wakes the container synchronously. If the socket isn't reachable (service down), falls back to a direct `inbound.db` write that the next host sweep picks up. + +Show the script's output to the user. + +## 5. Verify + +The welcome DM is queued synchronously; the only wait is container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel. + +Do not tail the log or poll in a sleep loop. Ask the user in plain text: + +> The welcome DM should arrive shortly. Let me know when you've received it (or if it doesn't arrive within two minutes). + +Wait for the user's reply. If they confirm receipt, the skill is done. + +If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't): + +- `sqlite3 data/v2-sessions//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` with the values from the script's output. +- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes. +- `ls data/v2-sessions//sessions/*/outbound.db` — confirm the session exists. + +## Troubleshooting + +**"Missing required args"** — the script wants `--channel`, `--user-id`, `--platform-id`, `--display-name` at minimum. Re-check the command you assembled. + +**No `messaging_groups` row appears after the user DMs (step 3a)** — the router silently drops messages from unknown senders under `strict` policy but still creates the `messaging_groups` row. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit). + +**Owner already exists** — `hasAnyOwner()` returned true, so the grant is skipped silently. That's fine; the script still creates the agent and wiring. Reassigning ownership needs a separate flow (not this skill). + +**Wrong person got the welcome DM** — the `--platform-id` you passed is someone else's DM channel. Rerun with the correct one; the script is idempotent on user/messaging-group/agent-group but writes a new session welcome each run. + +**Agent group name collision** — if `dm-with-` already exists (e.g. rerunning with the same display name), the script reuses it. Pass a different `--display-name` to get a distinct folder. diff --git a/.claude/skills/init-onecli/SKILL.md b/.claude/skills/init-onecli/SKILL.md index bd37b96..b3d441f 100644 --- a/.claude/skills/init-onecli/SKILL.md +++ b/.claude/skills/init-onecli/SKILL.md @@ -231,10 +231,10 @@ Ask them to let you know when done. ## Phase 4: Build and restart ```bash -npm run build +pnpm run build ``` -If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `npm install` first. +If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `pnpm install` first. Restart the service: - macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md new file mode 100644 index 0000000..9d84d3d --- /dev/null +++ b/.claude/skills/manage-channels/SKILL.md @@ -0,0 +1,87 @@ +--- +name: manage-channels +description: Wire channels to agent groups, manage isolation levels, add new channel groups. Use after adding a channel, during setup, or standalone to reconfigure. +--- + +# Manage Channels + +Wire messaging channels to agent groups. See `docs/isolation-model.md` for the full isolation model. + +Privilege is a **user-level** concept, not a channel-level one (see `src/db/user-roles.ts`, `src/access.ts`). There is no "main channel" / "main group" — any user can be granted `owner` or `admin` (global or scoped to an agent group) via `grantRole()`, and messages from unknown senders are gated per-messaging-group by `unknown_sender_policy` (`strict` | `request_approval` | `public`). + +## Assess Current State + +Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. + +Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**. + +If the instance has no owner yet (`SELECT COUNT(*) FROM user_roles WHERE role='owner' AND agent_group_id IS NULL` returns 0), tell the user they should run `/init-first-agent` first — it stands up the first agent group, promotes the operator to owner, and verifies delivery end-to-end by having the agent DM them. Then return here for any additional channels/groups. + +## First Channel (No Agent Groups Exist) + +**Delegate to `/init-first-agent`.** It handles: channel choice, operator identity lookup, DM platform id resolution (with cold-DM or pair-code fallback), agent group creation, wiring, and the welcome DM. Return here afterward for any additional channels. + +## Wire New Channel + +For each unwired channel: + +1. Read its SKILL.md `## Channel Info` for terminology, how-to-find-id, typical-use, and default-isolation +2. Ask for the platform ID using the platform's terminology +3. Ask the isolation question (see below) +4. Register with the appropriate flags + +### Isolation Question + +Present a multiple-choice with a contextual recommendation. The three options: + +- **Same conversation** (`--session-mode "agent-shared"` + existing folder) — all messages land in one session. Recommend for webhook + chat combos (GitHub + Slack). +- **Same agent, separate conversations** (`--session-mode "shared"` + existing folder) — shared workspace/memory, independent threads. Recommend for same user across platforms. +- **Separate agent** (new `--folder`) — full isolation. Recommend when different people are involved. + +Use the channel's `typical-use` and `default-isolation` fields to pick the recommendation. Offer to explain more if the user is unsure — reference `docs/isolation-model.md` for the detailed explanation. + +### Register Command + +```bash +pnpm exec tsx setup/index.ts --step register -- \ + --platform-id "" --name "" \ + --folder "" --channel "" \ + --session-mode "" \ + --assistant-name "" +``` + +The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name — no separate destination step needed. + +For separate agents, also ask for a folder name and optionally a different assistant name. + +## Add Channel Group + +When adding another group/chat on an already-configured platform (e.g. a second Telegram group): + +1. **Telegram:** ask the isolation question first to determine intent (`wire-to:` for an existing agent, `new-agent:` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent `, show the CODE (follow the `REMINDER_TO_ASSISTANT` line in the `PAIR_TELEGRAM_ISSUED` block) and tell the user to post `@ CODE` in the target group (or DM the bot for a private chat). Wait for the `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring: + + ```bash + pnpm exec tsx setup/index.ts --step register -- \ + --platform-id "" --name "" \ + --folder "" --channel "telegram" \ + --session-mode "" \ + --assistant-name "" + ``` + +2. **Other channels:** read the channel's SKILL.md `## Channel Info` for terminology and how-to-find-id. Ask for the new group/chat ID, ask the isolation question, then register. No package or credential changes needed. + +## Change Wiring + +1. Show current wiring (agent_groups × messaging_group_agents) +2. Ask which channel to move and to which agent group +3. Delete the old `messaging_group_agents` entry, create a new one +4. Note: existing sessions stay with the old agent group; new messages route to the new one. The `agent_destinations` row created for the old wiring is NOT automatically removed — if you want the old agent to stop seeing the channel as a named target, delete it from `agent_destinations` manually. + +## Show Configuration + +Display a readable summary showing: + +- **Agent groups** with their wired channels (from `messaging_group_agents`) +- **Configured-but-unwired** channels (credentials present, no DB entities) +- **Unconfigured** channels +- **Privileged users**: `SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC` diff --git a/.claude/skills/manage-mounts/SKILL.md b/.claude/skills/manage-mounts/SKILL.md new file mode 100644 index 0000000..ddfa28b --- /dev/null +++ b/.claude/skills/manage-mounts/SKILL.md @@ -0,0 +1,47 @@ +--- +name: manage-mounts +description: Configure which host directories agent containers can access. View, add, or remove mount allowlist entries. Triggers on "mounts", "mount allowlist", "agent access to directories", "container mounts". +--- + +# Manage Mounts + +Configure which host directories NanoClaw agent containers can access. The mount allowlist lives at `~/.config/nanoclaw/mount-allowlist.json`. + +## Show Current Config + +```bash +cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured" +``` + +Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only. + +## Add Directories + +Ask which directories the user wants agents to access. For each path: +- Validate the path exists +- Ask if it should be read-only for non-main agents (default: yes) + +Build the JSON config and write it: + +```bash +npx tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}' +``` + +Use `--force` to overwrite the existing config. + +## Remove Directories + +Read the current config, show it, ask which entry to remove, write the updated config. + +## Reset to Empty + +```bash +npx tsx setup/index.ts --step mounts --force -- --empty +``` + +## After Changes + +Restart the service so containers pick up the new config: + +- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` +- Linux: `systemctl --user restart nanoclaw` diff --git a/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md b/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md index cb6ed33..437c6f4 100644 --- a/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +++ b/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md @@ -68,7 +68,7 @@ Source: `src/db.ts` Insert directly into the SQLite database. This requires groups to be registered first (Phase 1). Use the registered group's `folder` and `chat_jid`: ```bash -npx tsx -e " +pnpm exec tsx -e " const Database = require('better-sqlite3'); const { CronExpressionParser } = require('cron-parser'); const db = new Database('store/messages.db'); diff --git a/.claude/skills/migrate-from-openclaw/SKILL.md b/.claude/skills/migrate-from-openclaw/SKILL.md index c393269..46a18f9 100644 --- a/.claude/skills/migrate-from-openclaw/SKILL.md +++ b/.claude/skills/migrate-from-openclaw/SKILL.md @@ -36,7 +36,7 @@ Keep it factual and terse — this is for machine recovery after compaction, not Run the discovery script to find and summarize the OpenClaw installation: ```bash -npx tsx ${CLAUDE_SKILL_DIR}/scripts/discover-openclaw.ts +pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/discover-openclaw.ts ``` If the user specifies a custom path, pass it: `--state-dir ` @@ -106,7 +106,7 @@ The discovery script provides detected groups in the GROUPS field (format: `chan For each group the user wants to bring over, pre-register it: ```bash -npx tsx setup/index.ts --step register -- --jid "" --name "" --folder "_" --trigger "@" --channel --assistant-name "" +pnpm exec tsx setup/index.ts --step register -- --jid "" --name "" --folder "_" --trigger "@" --channel --assistant-name "" ``` Only pass `--assistant-name` on the first registration (it updates all CLAUDE.md templates globally). @@ -115,7 +115,7 @@ Folder naming: `_` (e.g. `whatsapp_family-chat`, `telegram_d For the first/primary group, add `--is-main --no-trigger-required`. Other groups default to requiring a trigger prefix. -**Important:** Registration requires the database to exist. If the environment step hasn't been run yet, run it first: `npx tsx setup/index.ts --step environment`. Registration also creates the group folder under `groups/` and copies the CLAUDE.md template. +**Important:** Registration requires the database to exist. If the environment step hasn't been run yet, run it first: `pnpm exec tsx setup/index.ts --step environment`. Registration also creates the group folder under `groups/` and copies the CLAUDE.md template. Register groups from all channels — including channels NanoClaw doesn't yet support (signal, matrix, etc.). The registration stores the JID and metadata in the database, ready for when that channel is added later. Groups won't receive messages until their channel code is installed, but the registration, group folder, and CLAUDE.md will be ready. @@ -295,7 +295,7 @@ For each detected plugin, present the name to the user and discuss whether to se 1. **If NanoClaw has a matching skill** — check the available NanoClaw skills list for an equivalent (e.g. `/add-voice-transcription` for whisper). If found, save the API key to `.env` and invoke that skill. -2. **If the OpenClaw plugin was an MCP server** — read its config to find the exact package name and command. Install the same MCP server (e.g. `npx -y `). Don't search for or guess at MCP packages — only install what was explicitly configured. +2. **If the OpenClaw plugin was an MCP server** — read its config to find the exact package name and command. Install the same MCP server (e.g. `pnpm dlx `). Don't search for or guess at MCP packages — only install what was explicitly configured. 3. **If the OpenClaw plugin was a CLI tool** — read the config to identify the exact tool. If it's an npm package, add it to the container's Dockerfile. Add a note to the group's CLAUDE.md that the tool is available and how to invoke it. @@ -324,7 +324,7 @@ Run the credential extraction script with `--write-env .env` so it writes creden First, run without `--write-env` to preview: ```bash -npx tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir --channel +pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir --channel ``` Parse the status block. Key fields: HAS_CREDENTIAL, CREDENTIAL_MASKED, NANOCLAW_ENV_VAR. @@ -339,7 +339,7 @@ If HAS_CREDENTIAL=true: Show the masked credential (`CREDENTIAL_MASKED`). AskUse If using the credential: ```bash -npx tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir --channel --write-env .env +pnpm exec tsx ${CLAUDE_SKILL_DIR}/scripts/extract-channel-credentials.ts --state-dir --channel --write-env .env ``` The script writes the credential directly to `.env` using the correct NanoClaw variable name (e.g. `TELEGRAM_BOT_TOKEN`). Check the status block for `WRITTEN_TO` and `WRITTEN_COUNT` to confirm. diff --git a/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts b/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts index e15ed34..ec44dae 100644 --- a/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +++ b/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts @@ -1,7 +1,7 @@ /** * Discover an existing OpenClaw installation and emit a structured summary. * - * Usage: npx tsx .claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts [--state-dir ] + * Usage: pnpm exec tsx .claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts [--state-dir ] * * Checks (in order): --state-dir arg, $OPENCLAW_STATE_DIR, ~/.openclaw, ~/.clawdbot * Parses openclaw.json (JSON5-tolerant), scans workspace for identity/memory files, diff --git a/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts b/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts index b8959ba..b1de03f 100644 --- a/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +++ b/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts @@ -2,7 +2,7 @@ * Extract a channel credential from an OpenClaw configuration and write it * directly to the NanoClaw .env file. * - * Usage: npx tsx .claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts \ + * Usage: pnpm exec tsx .claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts \ * --channel telegram --state-dir ~/.openclaw --write-env .env * * Handles OpenClaw SecretRef formats: diff --git a/.claude/skills/migrate-nanoclaw/SKILL.md b/.claude/skills/migrate-nanoclaw/SKILL.md index 68c3ae7..82e1009 100644 --- a/.claude/skills/migrate-nanoclaw/SKILL.md +++ b/.claude/skills/migrate-nanoclaw/SKILL.md @@ -391,7 +391,7 @@ For behavior customizations (CLAUDE.md files): copy from the main tree. These ar ## 2.6 Validate in worktree ```bash -cd "$WORKTREE" && npm install && npm run build && npm test +cd "$WORKTREE" && pnpm install && pnpm run build && pnpm test ``` If build fails, show the error. Fix only issues caused by the migration. If unclear, ask the user. @@ -417,7 +417,7 @@ If testing live: ln -s "$PROJECT_ROOT/.env" "$WORKTREE/.env" ``` -3. Start from worktree: `cd "$WORKTREE" && npm run dev` +3. Start from worktree: `cd "$WORKTREE" && pnpm run dev` 4. Ask the user to send a test message from their phone. Wait for them to confirm it works. @@ -461,7 +461,7 @@ Do NOT use `git checkout -B` to create an intermediate branch — this caused is ## 2.9 Post-upgrade -Run `npm install && npm run build` in the main tree to confirm. +Run `npm install && pnpm run build` in the main tree to confirm. Restart the service: ```bash diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md new file mode 100644 index 0000000..4a3f1b8 --- /dev/null +++ b/.claude/skills/new-setup/SKILL.md @@ -0,0 +1,270 @@ +--- +name: new-setup +description: End-to-end NanoClaw setup for any user regardless of technical background — from zero to a named agent reachable on a real messaging channel, with sensible defaults and every post-verification step skippable. +allowed-tools: Bash(bash setup.sh) Bash(bash setup/probe.sh) Bash(bash setup/install-node.sh) Bash(bash setup/install-docker.sh) Bash(bash setup/install-telegram.sh) Bash(bash setup/install-telegram.sh:*) Bash(pnpm exec tsx setup/index.ts:*) Bash(pnpm exec tsx scripts/init-first-agent.ts:*) Bash(pnpm run chat) Bash(pnpm run chat:*) Bash(open -a Docker) Bash(sudo systemctl start docker) Bash(node --version) Bash(tail:*) Bash(head:*) Bash(grep:*) +--- + +# NanoClaw setup + +Purpose of this skill is to take any user — technical or not — from zero to a named agent wired to a real messaging channel in the fewest steps possible. + +The flow has two halves: + +- **Steps 1–6 — required.** Prerequisites, credential, service start, end-to-end ping. These run straight through. +- **Steps 7–12 — skippable.** Naming, channel wiring, QoL. Every step here is skippable: if the user says "skip", "not now", "later", or similar, move on without complaint. If they say they're done at any point, stop cleanly — don't push the remaining steps. + +Before each step, narrate to the user in your own words what's about to happen — one short, friendly sentence, no jargon. Don't read a scripted line; use the step context below to speak naturally. + +Each step is invoked as `pnpm exec tsx setup/index.ts --step ` and emits a structured status block Claude parses to decide what to do next. + +Start with a probe: a single upfront scan that snapshots every prerequisite and dependency. The rest of the flow reads this snapshot to decide what to run, skip, or ask about — no per-step re-checking. The probe is pure bash (`setup/probe.sh`) with no external deps so it runs correctly before Node has been installed. + +## Current state + +!`bash setup/probe.sh` + +## Flow + +Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly. The probe always returns a real snapshot — there is no "node not installed" fallback; `HOST_DEPS=missing` is how you know Node/pnpm haven't been bootstrapped yet. + +## Ordering and parallelism + +Run steps sequentially by default: invoke the step, wait for its status block, act on the result, move to the next. + +One permitted parallelism: + +- **Step 2 (container image build) and step 3 (OneCLI install)** are independent — they may start together in the background. +- **Step 4 (auth) must NOT start until step 3 has completed.** Auth writes the secret into the OneCLI vault; if OneCLI isn't installed and healthy yet, the user gets asked for a credential the system can't store. Do not open an `AskUserQuestion` for step 4 while OneCLI is still installing. +- Step 2's image build may continue running past step 4 — the image isn't consumed until step 6 (first CLI agent). Join before step 6. + +### 1. Node bootstrap + +Check probe results and skip if `HOST_DEPS=ok` — Node, pnpm, `node_modules`, and `better-sqlite3`'s native binding are already in place. + +If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), run `bash setup/install-node.sh` **before** `bash setup.sh` — the script handles both macOS (via `brew`) and Linux/WSL (NodeSource + apt). It's idempotent and short-circuits when node is already on PATH. + +Then run `bash setup.sh`. If Node is already present and only `HOST_DEPS=missing`, run `bash setup.sh` directly — deps just haven't been installed yet. + +Parse the status block: + +- `NODE_OK=false` → Node install didn't take effect (PATH issue, keg-only formula, etc.). Investigate `logs/setup.log`, resolve, re-run. +- `DEPS_OK=false` or `NATIVE_OK=false` → Read `logs/setup.log`, fix, re-run. + +> **Loose command:** `bash setup.sh`. Justification: pre-Node bootstrap. Can't call the Node-based dispatcher before Node and `pnpm install` are in place. + +### 2. Docker + +Check probe results and skip if `DOCKER=running` AND `IMAGE_PRESENT=true`. + +**Runtime:** +- `DOCKER=not_found` → Docker is missing — install it so agent containers have an isolated place to run. Run `bash setup/install-docker.sh` (handles macOS via `brew --cask` and Linux via the official get.docker.com script, and adds the user to the `docker` group on Linux). On Linux the user may need to log out/in for group membership to take effect. On macOS, launch Docker afterwards with `open -a Docker`. +- `DOCKER=installed_not_running` → Docker is installed but the daemon is down — start it. + - macOS: `open -a Docker` + - Linux: `sudo systemctl start docker` + +Wait ~15s after either, then proceed. + +> **Loose commands:** `open -a Docker`, `sudo systemctl start docker`. Justification: daemon-start is a one-liner per platform, not worth wrapping. The actual install (which had the unmatchable `curl | sh` pattern) is now inside `setup/install-docker.sh`. + +**Image (run if `IMAGE_PRESENT=false`):** build the agent container image — takes a few minutes the first time, one-off cost. + +`pnpm exec tsx setup/index.ts --step container -- --runtime docker` + +### 3. OneCLI + +Check probe results and skip if `ONECLI_STATUS=healthy`. + +OneCLI is the local vault that holds API keys and only releases them to agents when they need them. + +`pnpm exec tsx setup/index.ts --step onecli` + +### 4. Anthropic credential + +Check probe results and skip if `ANTHROPIC_SECRET=true`. + +The credential never travels through chat — the user generates it, registers it with OneCLI themselves, and the skill verifies. + +**4a. Pick the source.** `AskUserQuestion`: + +1. **Claude subscription (Pro/Max)** — "Generate a token via `claude setup-token` in another terminal." +2. **Anthropic API key** — "Use a pay-per-use key from console.anthropic.com/settings/keys." + +**4b. Wait for the user to obtain the credential.** For subscription, have them run `claude setup-token` in another terminal. For API key, point them to the console URL above. Either way, they keep the token — just confirm when they have it. + +**4c. Pick the registration path.** `AskUserQuestion` — substitute `${ONECLI_URL}` from the probe (or `.env`): + +1. **Dashboard** — "Open ${ONECLI_URL} in a browser; add a secret of type `anthropic`, value = the token, host-pattern `api.anthropic.com`." +2. **CLI** — "Run in another terminal: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`" + +Wait for the user's confirmation. If their reply happens to include a token (starts with `sk-ant-`), register it for them: `pnpm exec tsx setup/index.ts --step auth -- --create --value `. + +**4d. Verify.** + +`pnpm exec tsx setup/index.ts --step auth -- --check` + +If `ANTHROPIC_OK=false`, the secret isn't there yet — ask them to retry, then re-check. + +### 5. Service + +Check probe results and skip if `SERVICE_STATUS=running`. + +Start the NanoClaw background service — it relays messages between the user and the agent. + +`pnpm exec tsx setup/index.ts --step service` + +### 6. Wire a scratch CLI agent and verify end-to-end + +**Do not narrate this step.** No "creating your first agent…", no "sending a ping…" chatter. The user's experience here is: they finished the last visible step (service), then a single success line appears. Wiring is an implementation detail at this point, not a user-facing milestone. + +If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image. + +Use `INFERRED_DISPLAY_NAME` from the probe silently. **Do not ask the user.** The CLI agent at this stage is a scratch agent whose only purpose is to verify the end-to-end pipeline (host → container → agent → back). The user's real name capture happens in step 7. + +Run wiring and ping back-to-back, silently: + +``` +pnpm exec tsx setup/index.ts --step cli-agent -- --display-name "" +pnpm run chat ping +``` + +First container spin-up takes ~60s. When the agent's reply arrives, emit exactly one line to the user: + +> Test Agent success, proceeding with setup + +If `pnpm run chat ping` times out or errors, tail `logs/nanoclaw.log`, diagnose, and fix — don't surface a half-success. + +> **Loose command:** `pnpm run chat ping`. Justification: this is the same command the user will keep using after setup, so verification and the real channel are one and the same. + +### 7. What should the agent call you? + +Plain-prose ask (do **not** use `AskUserQuestion`): + +> What should your agent call you? (Default: ``) + +Capture the answer into a local variable `OPERATOR_NAME`. **Don't persist yet** — this value is consumed by step 10's channel wiring. If the user skips or confirms the default, set `OPERATOR_NAME = INFERRED_DISPLAY_NAME`. + +### 8. What's your agent's name? + +Plain-prose ask: + +> What would you like to call your agent? (Default: ``) + +Capture as `AGENT_NAME`. If skipped, set `AGENT_NAME = OPERATOR_NAME`. Nothing persisted yet. + +### 9. Timezone + +Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. + +- **RESOLVED_TZ is `UTC` or `Etc/UTC`** — before leaving UTC in `.env`, confirm with `AskUserQuestion`: + + - **Question**: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?" + - **Header**: "Timezone" + - **Options**: + 1. `Keep UTC` — "Leave timezone as UTC." + 2. `I'm somewhere else` — "I'll name the IANA zone (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`) via Other." + + If they pick "I'm somewhere else" (or type an IANA zone via Other), re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` to overwrite `.env`. If they keep UTC or skip, leave UTC in place. + +- **NEEDS_USER_INPUT=true** — autodetection failed. Use `AskUserQuestion` with the same two options above (reword the question to "Autodetection failed — what timezone are you in?"), then re-run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` if they supply one. If they skip, move on. + +- Otherwise — timezone is already set; move on. + +### 10. Pick a messaging channel + +Print the list as a numbered plain-prose list (too many options for `AskUserQuestion`, which caps at 4). The user replies with a number or channel name. Preserve the numbering exactly: + +> Which messaging channel should I wire your agent to? +> +> 1. **WhatsApp (native)** — `/add-whatsapp` +> 2. **WhatsApp Cloud (Meta official)** — `/add-whatsapp-cloud` +> 3. **Telegram** — `/add-telegram` +> 4. **Slack** — `/add-slack` +> 5. **Discord** — `/add-discord` +> 6. **iMessage** — `/add-imessage` +> 7. **Teams** — `/add-teams` +> 8. **Matrix** — `/add-matrix` +> 9. **Google Chat** — `/add-gchat` +> 10. **Linear** — `/add-linear` +> 11. **GitHub** — `/add-github` +> 12. **Webex** — `/add-webex` +> 13. **Resend (email)** — `/add-resend` +> 14. **Emacs** — `/add-emacs` +> 15. **WeChat** — `/add-wechat` +> +> Or say "skip" to leave this for later. + +When the user picks one: + +1. **Install the adapter.** For **Telegram**, run `bash setup/install-telegram.sh` directly — it bundles the preflight + fetch + copy + register + `pnpm install` + build from `/add-telegram` into one idempotent call. Then handle Telegram credentials inline (below) — **do not** invoke `/add-telegram` afterward; its Credentials section would generate an unapprovable `grep && sed && rm` to write `.env`. For every other channel, invoke the matching `/add-` skill via the Skill tool; it copies the adapter source in from the `channels` branch, registers it, installs the pinned npm package, and handles credentials. Some channels also run a pairing step as part of their flow. + + **Telegram credentials (inline):** + - Walk the user through BotFather: `/newbot` → pick name + username ending in `bot` → copy the token. + - Remind them: in `@BotFather` → `/mybots` → their bot → Bot Settings → Group Privacy → **Turn off** (only needed if the bot will live in groups; DM-only can skip). + - Persist the token and sync it to the container mount with the generic setter: + + ``` + pnpm exec tsx setup/index.ts --step set-env -- \ + --key TELEGRAM_BOT_TOKEN --value "" --sync-container + ``` + +2. **Capture platform IDs.** After the `/add-` skill finishes (or after inline credentials for Telegram), you need two values: the operator's user-id on that platform, and the chat/channel platform-id. Each channel surfaces these differently — consult the **Channel Info** section at the bottom of that skill's `SKILL.md` for the exact path. For Telegram, run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent ` directly and follow its `PAIR_TELEGRAM_ISSUED`/`PAIR_TELEGRAM STATUS=success` blocks — `PLATFORM_ID` and `ADMIN_USER_ID` land in the success block. +3. **Wire the agent.** Run `init-first-agent.ts` in DM mode: + + ``` + pnpm exec tsx scripts/init-first-agent.ts \ + --channel \ + --user-id "" \ + --platform-id "" \ + --display-name "" \ + --agent-name "" + ``` + +4. **Announce.** On success, emit the encouragement line verbatim: + + > Your agent is now available on {channel-name}, you can already start chatting — But I encourage you to continue and finish this setup, we're almost done! + + Substitute `{channel-name}` with the friendly name (e.g. "Telegram", "WhatsApp", "Slack"). + +If the user skipped, move on to step 11. + +### 11. Host directory access + +By default, agent containers can only touch their own workspace. If the user wants the agent to read or write files in specific host directories, those paths need to go on the mount allowlist. + +Use `AskUserQuestion`: + +- **Question**: "Want your agent to read or write files in any host directories (e.g. a code project, `~/Documents`)?" +- **Header**: "Host mounts" +- **Options**: + 1. `Keep isolated` — "Agent only touches its own workspace (Recommended)." + 2. `Add host paths` — "I'll name the directories to allowlist via Other." + +If they pick "Add host paths" (or name paths via Other), invoke `/manage-mounts` via the Skill tool to add them. If they keep it isolated or skip, move on. + +### 12. Quality of life + +Optional polish. Print the list; the user may pick zero, one, or several — invoke each chosen skill in sequence: + +> Want to add any of these? Pick any that sound useful — or skip: +> +> - `/add-dashboard` — browser dashboard showing agent activity +> - `/add-compact` — `/compact` slash command for managing long sessions +> - `/add-karpathy-llm-wiki` — persistent knowledge-base memory for the agent + +If the probe reports `PLATFORM=darwin`, also offer: + +> - `/add-macos-statusbar` — macOS menu bar indicator with Start/Stop/Restart controls + +Do **not** list `/add-macos-statusbar` on Linux. If the user skips everything, just move on. + +### 13. Done + +Short wrap-up: + +> Setup complete. You can chat with your agent on {channel-name} — or via CLI with `pnpm run chat `. + +Substitute `{channel-name}` with whatever was wired in step 10. If step 10 was skipped, drop the "on {channel-name} — or" clause entirely so the line just mentions the CLI form. + +## If anything fails + +Any step that reports `STATUS: failed` in its status block: read `logs/setup.log` (or `logs/nanoclaw.log` for runtime failures), diagnose, fix the underlying cause, re-run the same `--step`. Don't bypass errors to keep moving. diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 200938d..3b21e14 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -5,50 +5,41 @@ description: Run initial NanoClaw setup. Use when user wants to install dependen # NanoClaw Setup -Run setup steps automatically. Only pause when user action is required (channel authentication, configuration choices). Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step ` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`. +Welcome the user to NanoClaw. Introduce yourself — you'll be walking them through the entire setup process step by step, from installing dependencies to getting their first message through. Keep it warm and brief (2-3 sentences). -**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work. +Then explain that setup involves running many shell commands (installing packages, building containers, starting services), and recommend pre-approving the standard setup commands so they don't have to confirm each one individually. -**UX Note:** Use `AskUserQuestion` for multiple-choice questions only (e.g. "Docker or Apple Container?", "which channels?"). Do NOT use it when free-text input is needed (e.g. phone numbers, tokens, paths) — just ask the question in plain text and wait for the user's reply. +Use `AskUserQuestion` with these options: -## 0. Git & Fork Setup +1. **Pre-approve (recommended)** — description: "Pre-approve standard setup commands so you don't have to confirm each one. You can review the list first if you'd like." +2. **No thanks** — description: "I'll approve each command individually as it comes up." +3. **Show me the list first** — description: "Show me exactly which commands will be pre-approved before I decide." -Check the git remote configuration to ensure the user has a fork and upstream is configured. +If they pick option 1: read `.claude/skills/setup/setup-permissions.json`, then read the project settings file at `.claude/settings.json` (create it if it doesn't exist with `{}`), and directly edit it to add/merge the permissions into the `permissions.allow` array. Do NOT use the `update-config` skill. -Run: -- `git remote -v` +If they pick option 3: read and display `.claude/skills/setup/setup-permissions.json`, then re-ask with just options 1 and 2. -**Case A — `origin` points to `qwibitai/nanoclaw` (user cloned directly):** +If they decline, continue — they'll approve commands individually. -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 +--- + +**Internal guidance (do not show to user):** + +- Run setup steps automatically. Only pause when user action is required (channel authentication, configuration choices). +- Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step ` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`. +- **Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. +- **UX Note:** Use `AskUserQuestion` for multiple-choice questions only (e.g. "which credential method?"). Do NOT use it when free-text input is needed (e.g. phone numbers, tokens, paths) — just ask the question in plain text and wait for the user's reply. +- **Timeouts:** Use 5m timeouts for install and build steps. +- **Waiting on user:** When the user needs to do something (change a setting, get a token, open a browser, etc.), stop and wait. Give clear instructions, then say "Let me know when done or if you need help." Do NOT continue to the next step. If they ask for help, give more detail, ask where they got stuck, and try to assist. + +## 0. Git Upstream + +Ensure `upstream` remote points to `qwibitai/nanoclaw`. If missing, add it silently: -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 +git remote -v +git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true ``` -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) @@ -64,21 +55,15 @@ Run `bash setup.sh` and parse the status block. ## 2. Check Environment -Run `npx tsx setup/index.ts --step environment` and parse the status block. +Run `pnpm exec tsx setup/index.ts --step environment` and parse the status block. - If HAS_AUTH=true → WhatsApp is already configured, note for step 5 - If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure -- Record APPLE_CONTAINER and DOCKER values for step 3 +- Record DOCKER value for step 3 ### OpenClaw Migration Detection -Check for an existing OpenClaw installation: - -```bash -ls -d ~/.openclaw 2>/dev/null || ls -d ~/.clawdbot 2>/dev/null -``` - -If a directory is found, AskUserQuestion: +If OPENCLAW_PATH is not `none` from the environment check above, AskUserQuestion: 1. **Migrate now** — "Import identity, credentials, and settings from OpenClaw before continuing setup." 2. **Fresh start** — "Skip migration and set up NanoClaw from scratch." @@ -88,61 +73,51 @@ If "Migrate now": invoke `/migrate-from-openclaw`, then return here and continue ## 2a. Timezone -Run `npx tsx setup/index.ts --step timezone` and parse the status block. +Run `pnpm exec tsx setup/index.ts --step timezone` and parse the status block. -- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `npx tsx setup/index.ts --step timezone -- --tz `. +- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `pnpm exec tsx setup/index.ts --step timezone -- --tz `. +- If STATUS=success and RESOLVED_TZ is `UTC` or `Etc/UTC` → confirm with the user: "Your system timezone is UTC — is that correct, or are you on a remote server?" If wrong, ask for their actual timezone and re-run with `--tz`. - If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference. -## 3. Container Runtime +## 3. Container Runtime (Docker) -### 3a. Choose runtime +### 3a. Install Docker -Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1. - -- PLATFORM=linux → Docker (only option) -- PLATFORM=macos + APPLE_CONTAINER=installed → AskUserQuestion with two options: - 1. **Docker (recommended)** — description: "Cross-platform, better credential management, well-tested." - 2. **Apple Container (experimental)** — description: "Native macOS runtime. Requires advanced setup." - If Apple Container, run `/convert-to-apple-container` now, then skip to 3c. -- PLATFORM=macos + APPLE_CONTAINER=not_found → Docker - -### 3a-docker. Install Docker - -- DOCKER=running → continue to 4b +- DOCKER=running → continue to step 4 - DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`. - DOCKER=not_found → Use `AskUserQuestion: Docker is required for running agents. Would you like me to install it?` If confirmed: - macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop - Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership. -### 3b. Apple Container conversion gate (if needed) +### 3b. CJK fonts -**If the chosen runtime is Apple Container**, you MUST check whether the source code has already been converted from Docker to Apple Container. Do NOT skip this step. Run: +Agent containers skip CJK fonts by default (~200MB saved). Without them, Chromium-rendered screenshots and PDFs show tofu for Chinese/Japanese/Korean. + +- **User writing to you in Chinese, Japanese, or Korean** → enable without asking. Mention it briefly. +- **Resolved timezone from step 2a is a CJK region** (`Asia/Tokyo`, `Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Taipei`, `Asia/Seoul`) or other signal short of active CJK use → ask: "Enable CJK fonts? Adds ~200MB, lets the agent render CJK in screenshots and PDFs." +- **Otherwise** → skip. + +To enable, write `INSTALL_CJK_FONTS=true` to `.env`: ```bash -grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "ALREADY_CONVERTED" || echo "NEEDS_CONVERSION" +grep -q '^INSTALL_CJK_FONTS=' .env && sed -i.bak 's/^INSTALL_CJK_FONTS=.*/INSTALL_CJK_FONTS=true/' .env && rm -f .env.bak || echo 'INSTALL_CJK_FONTS=true' >> .env ``` -**If NEEDS_CONVERSION**, the source code still uses Docker as the runtime. You MUST run the `/convert-to-apple-container` skill NOW, before proceeding to the build step. - -**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c. - -**If the chosen runtime is Docker**, no conversion is needed. Continue to 3c. +The next step's build picks it up automatically. ### 3c. Build and test -Run `npx tsx setup/index.ts --step container -- --runtime ` and parse the status block. +Run `pnpm exec tsx setup/index.ts --step container -- --runtime docker` and parse the status block. **If BUILD_OK=false:** Read `logs/setup.log` tail for the build error. -- Cache issue (stale layers): `docker builder prune -f` (Docker) or `container builder stop && container builder rm && container builder start` (Apple Container). Retry. +- Cache issue (stale layers): `docker builder prune -f`. Retry. - Dockerfile syntax or missing files: diagnose from the log and fix, then retry. **If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test. ## 4. Credential System -The credential system depends on the container runtime chosen in step 3. - -### 4a. Docker → OneCLI +### 4a. OneCLI Install OneCLI and its CLI tool: @@ -214,69 +189,65 @@ Ask them to let you know when done. **After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again. -### 4b. Apple Container → Native Credential Proxy - -Apple Container is not compatible with OneCLI. The credential proxy code is already included in the apple-container branch — do NOT invoke `/use-native-credential-proxy` (it would conflict with already-applied code). - -Instead, just configure the credentials in `.env`: - -AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**? - -1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. Run `claude setup-token` in another terminal to get your token." -2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com." - -For subscription: tell the user to run `claude setup-token` in another terminal. Stop and wait for the user to confirm they have completed this step successfully before proceeding. - -Once confirmed, add the token to `.env`: -```bash -echo 'CLAUDE_CODE_OAUTH_TOKEN=' >> .env -``` - -For API key: add to `.env`: -```bash -echo 'ANTHROPIC_API_KEY=' >> .env -``` - -Verify the proxy starts: `npm run dev` should show "Credential proxy listening" in the logs. - ## 5. Set Up Channels -AskUserQuestion (multiSelect): Which messaging channels do you want to enable? -- WhatsApp (authenticates via QR code or pairing code) -- Telegram (authenticates via bot token from @BotFather) -- Slack (authenticates via Slack app with Socket Mode) -- Discord (authenticates via Discord bot token) +Show the full list of available channels in plain text (do NOT use AskUserQuestion — it limits to 4 options). Ask which one they want to start with. They can add more later with `/customize`. -**Delegate to each selected channel's own skill.** Each channel skill handles its own code installation, authentication, registration, and JID resolution. This avoids duplicating channel-specific logic and ensures JIDs are always correct. +Channels where the agent gets its own identity (name and avatar) are marked as recommended. -For each selected channel, invoke its skill: +1. Discord *(recommended — agent gets own identity)* +2. Slack *(recommended — agent gets own identity)* +3. Telegram *(recommended — agent gets own identity)* +4. Microsoft Teams *(recommended — agent gets own identity)* +5. Webex *(recommended — agent gets own identity)* +6. WhatsApp +7. WhatsApp Cloud API +8. iMessage +9. GitHub +10. Linear +11. Google Chat +12. Resend (email) +13. Matrix + +**Delegate to the selected channel's skill.** Each channel skill handles its own package installation, authentication, registration, and configuration. + +Invoke the matching skill: -- **WhatsApp:** Invoke `/add-whatsapp` -- **Telegram:** Invoke `/add-telegram` -- **Slack:** Invoke `/add-slack` - **Discord:** Invoke `/add-discord` +- **Slack:** Invoke `/add-slack` +- **Telegram:** Invoke `/add-telegram` +- **GitHub:** Invoke `/add-github` +- **Linear:** Invoke `/add-linear` +- **Microsoft Teams:** Invoke `/add-teams` +- **Google Chat:** Invoke `/add-gchat` +- **WhatsApp Cloud API:** Invoke `/add-whatsapp-cloud` +- **WhatsApp Baileys:** Invoke `/add-whatsapp` +- **Resend:** Invoke `/add-resend` +- **Matrix:** Invoke `/add-matrix` +- **Webex:** Invoke `/add-webex` +- **iMessage:** Invoke `/add-imessage` -Each skill will: -1. Install the channel code (via `git merge` of the skill branch) -2. Collect credentials/tokens and write to `.env` -3. Authenticate (WhatsApp QR/pairing, or verify token-based connection) -4. Register the chat with the correct JID format -5. Build and verify +The skill will: +1. Install the Chat SDK adapter package +2. Uncomment the channel import in `src/channels/index.ts` +3. Collect credentials/tokens and write to `.env` +4. Build and verify -**After all channel skills complete**, install dependencies and rebuild — channel merges may introduce new packages: +**After the channel skill completes**, install dependencies and rebuild — channel merges may introduce new packages: ```bash -npm install && npm run build +pnpm install && pnpm run build ``` -If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 6. +If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 5a. ## 6. Mount Allowlist -AskUserQuestion: Agent access to external directories? +Set empty mount allowlist (agents only access their own workspace). Users can configure mounts later with `/manage-mounts`. -**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}'` +```bash +pnpm exec tsx setup/index.ts --step mounts -- --empty +``` ## 7. Start Service @@ -284,7 +255,7 @@ If service already running: unload first. - macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` - Linux: `systemctl --user stop nanoclaw` (or `systemctl stop nanoclaw` if root) -Run `npx tsx setup/index.ts --step service` and parse the status block. +Run `pnpm exec tsx setup/index.ts --step service` and parse the status block. **If FALLBACK=wsl_no_systemd:** WSL without systemd detected. Tell user they can either enable systemd in WSL (`echo -e "[boot]\nsystemd=true" | sudo tee /etc/wsl.conf` then restart WSL) or use the generated `start-nanoclaw.sh` wrapper. @@ -308,27 +279,47 @@ 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. +## 7a. Wire Channels to Agent Groups + +The service is now running, so polling-based adapters (Telegram) can observe inbound messages — required for pairing. + +Invoke `/manage-channels` to wire the installed channels to agent groups. This step: +1. Creates the agent group(s) and assigns a name to the assistant +2. Resolves each channel's platform-specific ID (Telegram via pairing code; other channels via the platform's own ID lookup) +3. Decides the isolation level — whether channels share an agent, session, or are fully separate + +The `/manage-channels` skill reads each channel's `## Channel Info` section from its SKILL.md for platform-specific guidance (terminology, how to find IDs, recommended isolation). + +**This step is required.** Without it, channels are installed but not wired — messages will be silently dropped because the router has no agent group to route to. + +## 7b. Dashboard & Web Applications + +AskUserQuestion: Do you want to create a dashboard and build web applications? + +1. **Yes (recommended)** — description: "Get a NanoClaw dashboard to monitor your agents and build custom websites however you want. Deploys to Vercel." +2. **Not now** — description: "You can add this later with `/add-vercel`." + +If yes: invoke `/add-vercel`. + ## 8. Verify -Run `npx tsx setup/index.ts --step verify` and parse the status block. +Run `pnpm exec 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=stopped → `pnpm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup) - SERVICE=not_found → re-run step 7 -- CREDENTIALS=missing → re-run step 4 (Docker: check `onecli secrets list`; Apple Container: check `.env` for credentials) +- CREDENTIALS=missing → re-run step 4 (check `onecli secrets list`) - CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`) -- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5 -- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` - +- REGISTERED_GROUPS=0 → re-invoke `/manage-channels` from step 7a 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), credential system not running (Docker: check `curl ${ONECLI_URL}/api/health`; Apple Container: check `.env` credentials), missing channel credentials (re-invoke channel skill). +**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (check `curl ${ONECLI_URL}/api/health`), missing channel credentials (re-invoke channel skill). -**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. +**Container agent fails ("Claude Code process exited with code 1"):** Ensure Docker is running — `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. -**No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `npx tsx setup/index.ts --step verify`. Check `logs/nanoclaw.log`. +**No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `pnpm exec tsx setup/index.ts --step verify`. Check `logs/nanoclaw.log`. **Channel not connecting:** Verify the channel's credentials are set in `.env`. Channels auto-enable when their credentials are present. For WhatsApp: check `store/auth/creds.json` exists. For token-based channels: check token values in `.env`. Restart the service after any `.env` change. @@ -339,3 +330,20 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/ 1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`. 2. Follow every step in that file before completing setup. + +## 10. Fork Setup + +Only run this after the user has confirmed 2-way messaging works. + +Check `git remote -v`. If `origin` points to `qwibitai/nanoclaw` (not a fork), ask in plain text: + +> We recommend forking NanoClaw so you can push your customizations and pull updates easily. Would you like to set up a fork now? + +If yes: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask 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 +``` + +If no: skip — upstream is already configured from step 0. diff --git a/.claude/skills/setup/setup-permissions.json b/.claude/skills/setup/setup-permissions.json new file mode 100644 index 0000000..a263d4c --- /dev/null +++ b/.claude/skills/setup/setup-permissions.json @@ -0,0 +1,34 @@ +[ + "Bash(bash setup.sh*)", + "Bash(git remote *)", + "Bash(npx tsx setup/index.ts*)", + "Bash(npx tsx scripts/init-first-agent.ts*)", + "Bash(npm install @chat-adapter/*)", + "Bash(npm install chat-adapter-imessage*)", + "Bash(npm install @bitbasti/chat-adapter-webex*)", + "Bash(npm install @resend/chat-sdk-adapter*)", + "Bash(npm install @whiskeysockets/baileys*)", + "Bash(npm install @beeper/chat-adapter-matrix*)", + "Bash(npm install @nanoco/nanoclaw-dashboard*)", + "Bash(npm ci*)", + "Bash(npm run build*)", + "Bash(curl -fsSL onecli.sh*)", + "Bash(onecli *)", + "Bash(grep -q *)", + "Bash(echo *>> .env)", + "Bash(ls *)", + "Bash(cat ~/.config/nanoclaw/*)", + "Bash(tail *logs/*)", + "Bash(launchctl *nanoclaw*)", + "Bash(sqlite3 data/*)", + "Bash(docker info*)", + "Bash(docker logs *)", + "Bash(mkdir -p *)", + "Bash(cp .env *)", + "Bash(rsync -a .claude/skills/*)", + "Bash(head *)", + "Bash(xattr *)", + "Bash(find ~/.npm *)", + "Bash(which onecli*)", + "Bash(./container/build.sh*)" +] diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index cc94d66..aebe96e 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -30,7 +30,7 @@ Run `/update-nanoclaw` in Claude Code. **Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact. -**Validation**: runs `npm run build` and `npm test`. +**Validation**: runs `pnpm run build` and `pnpm test`. **Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate. @@ -109,7 +109,7 @@ Show file-level impact from upstream: Bucket the upstream changed files: - **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill - **Source** (`src/`): may conflict if user modified the same files -- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed +- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`, `container/`, `launchd/`): review needed - **Other**: docs, tests, misc **Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push. @@ -175,8 +175,8 @@ If it gets messy (more than 3 rounds of conflicts): # Step 5: Validation Run: -- `npm run build` -- `npm test` (do not fail the flow if tests are not configured) +- `pnpm run build` +- `pnpm test` (do not fail the flow if tests are not configured) If build fails: - Show the error. @@ -234,7 +234,7 @@ Tell the user: - Backup branch also exists: `backup/pre-update--` - Restart the service to apply changes: - If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` - - If running manually: restart `npm run dev` + - If running manually: restart `pnpm run dev` ## Diagnostics diff --git a/.claude/skills/update-skills/SKILL.md b/.claude/skills/update-skills/SKILL.md index cbbff39..ca497ad 100644 --- a/.claude/skills/update-skills/SKILL.md +++ b/.claude/skills/update-skills/SKILL.md @@ -110,8 +110,8 @@ If a merge fails badly (e.g., cannot resolve conflicts): # Step 4: Validation After all selected skills are merged: -- `npm run build` -- `npm test` (do not fail the flow if tests are not configured) +- `pnpm run build` +- `pnpm test` (do not fail the flow if tests are not configured) If build fails: - Show the error. diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md deleted file mode 100644 index ec18a09..0000000 --- a/.claude/skills/use-local-whisper/SKILL.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: use-local-whisper -description: Use when the user wants local voice transcription instead of OpenAI Whisper API. Switches to whisper.cpp running on Apple Silicon. WhatsApp only for now. Requires voice-transcription skill to be applied first. ---- - -# Use Local Whisper - -Switches voice transcription from OpenAI's Whisper API to local whisper.cpp. Runs entirely on-device — no API key, no network, no cost. - -**Channel support:** Currently WhatsApp only. The transcription module (`src/transcription.ts`) uses Baileys types for audio download. Other channels (Telegram, Discord, etc.) would need their own audio-download logic before this skill can serve them. - -**Note:** The Homebrew package is `whisper-cpp`, but the CLI binary it installs is `whisper-cli`. - -## Prerequisites - -- `voice-transcription` skill must be applied first (WhatsApp channel) -- macOS with Apple Silicon (M1+) recommended -- `whisper-cpp` installed: `brew install whisper-cpp` (provides the `whisper-cli` binary) -- `ffmpeg` installed: `brew install ffmpeg` -- A GGML model file downloaded to `data/models/` - -## Phase 1: Pre-flight - -### Check if already applied - -Check if `src/transcription.ts` already uses `whisper-cli`: - -```bash -grep 'whisper-cli' src/transcription.ts && echo "Already applied" || echo "Not applied" -``` - -If already applied, skip to Phase 3 (Verify). - -### Check dependencies are installed - -```bash -whisper-cli --help >/dev/null 2>&1 && echo "WHISPER_OK" || echo "WHISPER_MISSING" -ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING" -``` - -If missing, install via Homebrew: -```bash -brew install whisper-cpp ffmpeg -``` - -### Check for model file - -```bash -ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL" -``` - -If no model exists, download the base model (148MB, good balance of speed and accuracy): -```bash -mkdir -p data/models -curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" -``` - -For better accuracy at the cost of speed, use `ggml-small.bin` (466MB) or `ggml-medium.bin` (1.5GB). - -## Phase 2: Apply Code Changes - -### Ensure WhatsApp fork remote - -```bash -git remote -v -``` - -If `whatsapp` is missing, add it: - -```bash -git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git -``` - -### Merge the skill branch - -```bash -git fetch whatsapp skill/local-whisper -git merge whatsapp/skill/local-whisper || { - git checkout --theirs package-lock.json - git add package-lock.json - git merge --continue -} -``` - -This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API. - -### Validate - -```bash -npm run build -``` - -## Phase 3: Verify - -### Ensure launchd PATH includes Homebrew - -The NanoClaw launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH. - -Check the current PATH: -```bash -grep -A1 'PATH' ~/Library/LaunchAgents/com.nanoclaw.plist -``` - -If `/opt/homebrew/bin` is missing, add it to the `` value inside the `PATH` key in the plist. Then reload: -```bash -launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist -``` - -### Build and restart - -```bash -npm run build -launchctl kickstart -k gui/$(id -u)/com.nanoclaw -``` - -### Test - -Send a voice note in any registered group. The agent should receive it as `[Voice: ]`. - -### Check logs - -```bash -tail -f logs/nanoclaw.log | grep -i -E "voice|transcri|whisper" -``` - -Look for: -- `Transcribed voice message` — successful transcription -- `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH - -## Configuration - -Environment variables (optional, set in `.env`): - -| Variable | Default | Description | -|----------|---------|-------------| -| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | -| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | - -## Troubleshooting - -**"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually: -```bash -ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y -whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt -``` - -**Transcription works in dev but not as service**: The launchd plist PATH likely doesn't include `/opt/homebrew/bin`. See "Ensure launchd PATH includes Homebrew" in Phase 3. - -**Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing. - -**Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`. diff --git a/.claude/skills/use-native-credential-proxy/SKILL.md b/.claude/skills/use-native-credential-proxy/SKILL.md index 71448b1..3b94822 100644 --- a/.claude/skills/use-native-credential-proxy/SKILL.md +++ b/.claude/skills/use-native-credential-proxy/SKILL.md @@ -48,8 +48,8 @@ git remote add upstream https://github.com/qwibitai/nanoclaw.git ```bash git fetch upstream skill/native-credential-proxy git merge upstream/skill/native-credential-proxy || { - git checkout --theirs package-lock.json - git add package-lock.json + git checkout --theirs pnpm-lock.yaml + git add pnpm-lock.yaml git merge --continue } ``` @@ -62,7 +62,7 @@ This merges in: - Restored platform-aware proxy bind address detection - Reverted setup skill to `.env`-based credential instructions -If the merge reports conflicts beyond `package-lock.json`, resolve them by reading the conflicted files and understanding the intent of both sides. +If the merge reports conflicts beyond `pnpm-lock.yaml`, resolve them by reading the conflicted files and understanding the intent of both sides. ### Update main group CLAUDE.md @@ -77,9 +77,9 @@ with: ### Validate code changes ```bash -npm install -npm run build -npx vitest run src/credential-proxy.test.ts src/container-runner.test.ts +pnpm install +pnpm run build +pnpm exec vitest run src/credential-proxy.test.ts src/container-runner.test.ts ``` All tests must pass and build must be clean before proceeding. @@ -125,7 +125,7 @@ echo 'ANTHROPIC_API_KEY=' >> .env 1. Rebuild and restart: ```bash -npm run build +pnpm run build ``` Then restart the service: @@ -161,7 +161,7 @@ To revert to OneCLI gateway: 1. Find the merge commit: `git log --oneline --merges -5` 2. Revert it: `git revert -m 1` (undoes the skill branch merge, keeps your other changes) -3. `npm install` (re-adds `@onecli-sh/sdk`) -4. `npm run build` +3. `pnpm install` (re-adds `@onecli-sh/sdk`) +4. `pnpm run build` 5. Follow `/setup` step 4 to configure OneCLI credentials 6. Remove `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` from `.env` diff --git a/.claude/skills/x-integration/SKILL.md b/.claude/skills/x-integration/SKILL.md index 29a7be6..5dc859e 100644 --- a/.claude/skills/x-integration/SKILL.md +++ b/.claude/skills/x-integration/SKILL.md @@ -26,7 +26,7 @@ Before using this skill, ensure: 1. **NanoClaw is installed and running** - WhatsApp connected, service active 2. **Dependencies installed**: ```bash - npm ls playwright dotenv-cli || npm install playwright dotenv-cli + pnpm ls playwright dotenv-cli || pnpm install playwright dotenv-cli ``` 3. **CHROME_PATH configured** in `.env` (if Chrome is not at default location): ```bash @@ -40,7 +40,7 @@ Before using this skill, ensure: ```bash # 1. Setup authentication (interactive) -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts # Verify: data/x-auth.json should exist after successful login # 2. Rebuild container to include skill @@ -48,7 +48,7 @@ npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts # Verify: Output shows "COPY .claude/skills/x-integration/agent.ts" # 3. Rebuild host and restart service -npm run build +pnpm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw # Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux) @@ -225,7 +225,7 @@ COPY container/agent-runner/package*.json ./ COPY container/agent-runner/ ./ ``` -Then add COPY line after `COPY container/agent-runner/ ./` and before `RUN npm run build`: +Then add COPY line after `COPY container/agent-runner/ ./` and before `RUN pnpm run build`: ```dockerfile # Copy skill MCP tools COPY .claude/skills/x-integration/agent.ts ./src/skills/x-integration/ @@ -247,7 +247,7 @@ echo "Chrome not found - update CHROME_PATH in .env" ### 2. Run Authentication ```bash -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts ``` This opens Chrome for manual X login. Session saved to `data/x-browser-profile/`. @@ -271,7 +271,7 @@ cat data/x-auth.json # Should show {"authenticated": true, ...} ### 4. Restart Service ```bash -npm run build +pnpm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` @@ -317,26 +317,26 @@ ls -la data/x-browser-profile/ 2>/dev/null | head -5 ### Re-authenticate (if expired) ```bash -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts ``` ### Test Post (will actually post) ```bash -echo '{"content":"Test tweet - please ignore"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/post.ts +echo '{"content":"Test tweet - please ignore"}' | pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/post.ts ``` ### Test Like ```bash -echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/like.ts +echo '{"tweetUrl":"https://x.com/user/status/123"}' | pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/like.ts ``` Or export `CHROME_PATH` manually before running: ```bash export CHROME_PATH="/path/to/chrome" -echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts +echo '{"content":"Test"}' | pnpm exec tsx .claude/skills/x-integration/scripts/post.ts ``` ## Troubleshooting @@ -344,7 +344,7 @@ echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts ### Authentication Expired ```bash -npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts +pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` diff --git a/.claude/skills/x-integration/host.ts b/.claude/skills/x-integration/host.ts index 8971f64..55a2b3a 100644 --- a/.claude/skills/x-integration/host.ts +++ b/.claude/skills/x-integration/host.ts @@ -22,7 +22,7 @@ async function runScript(script: string, args: object): Promise { const scriptPath = path.join(process.cwd(), '.claude', 'skills', 'x-integration', 'scripts', `${script}.ts`); return new Promise((resolve) => { - const proc = spawn('npx', ['tsx', scriptPath], { + const proc = spawn('pnpm', ['exec', 'tsx', scriptPath], { cwd: process.cwd(), env: { ...process.env, NANOCLAW_ROOT: process.cwd() }, stdio: ['pipe', 'pipe', 'pipe'] diff --git a/.claude/skills/x-integration/scripts/like.ts b/.claude/skills/x-integration/scripts/like.ts index c55b8b4..b0c60f5 100644 --- a/.claude/skills/x-integration/scripts/like.ts +++ b/.claude/skills/x-integration/scripts/like.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Like Tweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx like.ts + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | pnpm exec tsx like.ts */ import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/post.ts b/.claude/skills/x-integration/scripts/post.ts index f7b47dc..66b1090 100644 --- a/.claude/skills/x-integration/scripts/post.ts +++ b/.claude/skills/x-integration/scripts/post.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Post Tweet - * Usage: echo '{"content":"Hello world"}' | npx tsx post.ts + * Usage: echo '{"content":"Hello world"}' | pnpm exec tsx post.ts */ import { getBrowserContext, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/quote.ts b/.claude/skills/x-integration/scripts/quote.ts index e0d2c33..6c779e7 100644 --- a/.claude/skills/x-integration/scripts/quote.ts +++ b/.claude/skills/x-integration/scripts/quote.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Quote Tweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","comment":"My thoughts"}' | npx tsx quote.ts + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","comment":"My thoughts"}' | pnpm exec tsx quote.ts */ import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/reply.ts b/.claude/skills/x-integration/scripts/reply.ts index e981cab..e4e345d 100644 --- a/.claude/skills/x-integration/scripts/reply.ts +++ b/.claude/skills/x-integration/scripts/reply.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Reply to Tweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","content":"Great post!"}' | npx tsx reply.ts + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","content":"Great post!"}' | pnpm exec tsx reply.ts */ import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/retweet.ts b/.claude/skills/x-integration/scripts/retweet.ts index 05b7437..9c51424 100644 --- a/.claude/skills/x-integration/scripts/retweet.ts +++ b/.claude/skills/x-integration/scripts/retweet.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Retweet - * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx retweet.ts + * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | pnpm exec tsx retweet.ts */ import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; diff --git a/.claude/skills/x-integration/scripts/setup.ts b/.claude/skills/x-integration/scripts/setup.ts index 94e5c03..36a7c53 100644 --- a/.claude/skills/x-integration/scripts/setup.ts +++ b/.claude/skills/x-integration/scripts/setup.ts @@ -1,7 +1,7 @@ -#!/usr/bin/env npx tsx +#!/usr/bin/env pnpm exec tsx /** * X Integration - Authentication Setup - * Usage: npx tsx setup.ts + * Usage: pnpm exec tsx setup.ts * * Interactive script - opens browser for manual login */ diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 8191085..f1da58c 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -20,10 +20,12 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} + - uses: pnpm/action-setup@v4 + - name: Bump patch version run: | - npm version patch --no-git-tag-version - git add package.json package-lock.json + pnpm version patch --no-git-tag-version + git add package.json git diff --cached --quiet && exit 0 git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e11c2f4..41a3a5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,17 +9,31 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 - cache: npm - - run: npm ci + cache: pnpm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.12 + - run: pnpm install --frozen-lockfile + - name: Install agent-runner deps (Bun) + working-directory: container/agent-runner + run: bun install --frozen-lockfile - name: Format check - run: npm run format:check + run: pnpm run format:check - - name: Typecheck - run: npx tsc --noEmit + - name: Typecheck host + run: pnpm exec tsc --noEmit - - name: Tests - run: npx vitest run + - name: Typecheck container + run: pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit + + - name: Host tests + run: pnpm exec vitest run + + - name: Container tests + working-directory: container/agent-runner + run: bun test diff --git a/.gitignore b/.gitignore index e259fbf..8a57c51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Dependencies node_modules/ .npm-cache/ +# pnpm content-addressable store (created when running in sandbox mode) +.pnpm-store/ # Build output dist/ @@ -9,18 +11,19 @@ store/ data/ logs/ -# Groups - only track base structure and specific CLAUDE.md files +# Groups - per-installation state, not tracked groups/* -!groups/main/ -!groups/global/ -groups/main/* -groups/global/* -!groups/main/CLAUDE.md -!groups/global/CLAUDE.md + +# Composer-managed CLAUDE.md artifacts (regenerated every spawn) and +# per-group memory (CLAUDE.local.md) must never be committed. +**/CLAUDE.local.md +**/.claude-shared.md +**/.claude-fragments/ # Secrets *.keys.json .env +.env* # Temp files .tmp-* diff --git a/.husky/pre-commit b/.husky/pre-commit index 73c726d..799cd8f 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npm run format:fix +pnpm run format:fix diff --git a/.npmrc b/.npmrc index 2e218c5..7e54394 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,3 @@ -minReleaseAge=7d +# Safety net — pnpm-workspace.yaml has the authoritative minimumReleaseAge (4320 min = 3 days) +# This .npmrc value is a fallback if npm is ever invoked directly +minReleaseAge=3d diff --git a/.prettierrc b/.prettierrc index 544138b..0981b7c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,4 @@ { - "singleQuote": true + "singleQuote": true, + "printWidth": 120 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2503be7..ab2fd5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to NanoClaw will be documented in this file. For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). +## [2.0.0] - 2026-04-22 + +Major version. NanoClaw v2 is a substantial architectural rewrite. Existing forks should run `/migrate-nanoclaw` (clean-base replay of customizations) or `/update-nanoclaw` (selective cherry-pick) before resuming work. + +- [BREAKING] **New entity model.** Users, roles (owner/admin), messaging groups, and agent groups are now tracked as separate entities, wired via `messaging_group_agents`. Privilege is user-level instead of channel-level, so the old "main channel = admin" concept is retired. See [docs/architecture.md](docs/architecture.md) and [docs/isolation-model.md](docs/isolation-model.md). +- [BREAKING] **Two-DB session split.** Each session now has `inbound.db` (host writes, container reads) and `outbound.db` (container writes, host reads) with exactly one writer each. Replaces the single shared session DB and eliminates cross-mount SQLite contention. See [docs/db-session.md](docs/db-session.md). +- [BREAKING] **Install flow replaced.** `bash nanoclaw.sh` is the new default: a scripted installer that hands off to Claude Code for error recovery and guided decisions. The `/setup` Claude-guided skill still works as an alternative. +- [BREAKING] **Channels moved to the `channels` branch.** Trunk no longer ships Discord, Slack, Telegram, WhatsApp, iMessage, Teams, Linear, GitHub, WeChat, Matrix, Google Chat, Webex, Resend, or WhatsApp Cloud. Install them per fork via `/add-` skills, which copy from the `channels` branch. `/update-nanoclaw` will re-install the channels your fork had. +- [BREAKING] **Alternative providers moved to the `providers` branch.** OpenCode, Codex, and Ollama install via `/add-opencode`, `/add-codex`, `/add-ollama-provider`. Claude remains the default provider baked into trunk. +- [BREAKING] **Three-level channel isolation.** Wire channels to their own agent (separate agent groups), share an agent with independent conversations (`session_mode: 'shared'`), or merge channels into one shared session (`session_mode: 'agent-shared'`). Chosen per channel via `/manage-channels`. +- [BREAKING] **Apple Container removed from default setup.** Still available as an opt-in via `/convert-to-apple-container`. +- **Shared-source agent-runner.** Per-group `agent-runner-src/` overlays are gone; all groups mount the same agent-runner read-only. Per-group customization flows through composed `CLAUDE.md` (shared base + per-group fragments). +- **Agent-runner runtime moved from Node to Bun.** Container image is self-contained; no host-side impact. Host remains on Node + pnpm. +- **OneCLI Agent Vault is the sole credential path.** Containers never receive raw API keys; credentials are injected at request time. + ## [1.2.36] - 2026-03-26 - [BREAKING] Replaced pino logger with built-in logger. WhatsApp users must re-merge the WhatsApp fork to pick up the Baileys logger compatibility fix: `git fetch whatsapp main && git merge whatsapp/main`. If the `whatsapp` remote is not configured: `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git`. diff --git a/CLAUDE.md b/CLAUDE.md index c9c49ff..7115c4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,80 +1,247 @@ +# ⚠️ STOP — READ THIS FIRST IF YOU ARE CLAUDE ⚠️ + +**If you are reading this because you just ran `git pull`, `git merge`, `git fetch && git merge`, or any equivalent to bring in upstream changes — and you see merge conflicts or a large diff involving this file — HALT IMMEDIATELY.** + +This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It cannot be merged into an existing v1 install. Attempting to resolve the conflicts by hand, run builds, or "fix" anything will corrupt the user's install and burn tokens for no result. + +**Do this instead:** +1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed). +2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `bash migrate-v2.sh` to migrate instead."* +3. Wait for the user to confirm before doing anything else. Do not attempt the migration yourself. + +If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below. + +--- + # NanoClaw -Personal Claude assistant. See [README.md](README.md) for philosophy and setup. See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) for architecture decisions. +Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`. ## Quick Context -Single Node.js process with skill-based channel system. Channels (WhatsApp, Telegram, Slack, Discord, Gmail) are skills that self-register at startup. Messages route to Claude Agent SDK running in containers (Linux VMs). Each group has isolated filesystem and memory. +The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls Claude, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter. + +**Everything is a message.** There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface. + +## Entity Model + +``` +users (id ":", kind, display_name) +user_roles (user_id, role, agent_group_id) — owner | admin (global or scoped) +agent_group_members (user_id, agent_group_id) — unprivileged access gate +user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache + +agent_groups (workspace, memory, CLAUDE.md, personality, container config) + ↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority) +messaging_groups (one chat/channel on one platform; unknown_sender_policy) + +sessions (agent_group_id + messaging_group_id + thread_id → per-session container) +``` + +Privilege is user-level (owner/admin), not agent-group-level. See [docs/isolation-model.md](docs/isolation-model.md) for the three isolation levels (`agent-shared`, `shared`, separate agents). + +## Two-DB Session Split + +Each session has **two** SQLite files under `data/v2-sessions//`: + +- `inbound.db` — host writes, container reads. `messages_in`, routing, destinations, pending_questions, processing_ack. +- `outbound.db` — container writes, host reads. `messages_out`, session_state. + +Exactly one writer per file — no cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd. + +## Central DB + +`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`. ## Key Files | File | Purpose | |------|---------| -| `src/index.ts` | Orchestrator: state, message loop, agent invocation | -| `src/channels/registry.ts` | Channel registry (self-registration at startup) | -| `src/ipc.ts` | IPC watcher and task processing | -| `src/router.ts` | Message formatting and outbound routing | -| `src/config.ts` | Trigger pattern, paths, intervals | -| `src/container-runner.ts` | Spawns agent containers with mounts | -| `src/task-scheduler.ts` | Runs scheduled tasks | -| `src/db.ts` | SQLite operations | -| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | -| `container/skills/` | Skills loaded inside agent containers (browser, status, formatting) | +| `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown | +| `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake | +| `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions (schedule, approvals, etc.) | +| `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence | +| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path | +| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` | +| `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup | +| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` | +| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry | +| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) | +| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge | +| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache | +| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) | +| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations | +| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch | +| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) | +| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations | +| `container/skills/` | Container skills mounted into every agent session | +| `groups//` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) | +| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) | -## Secrets / Credentials / Proxy (OneCLI) +## Channels and Providers (skill-installed) -API keys, secret keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway — which handles secret injection into containers at request time, so no keys or tokens are ever passed to containers directly. Run `onecli --help`. +Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills: + +- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud (+ helpers, tests, channel-specific setup steps). Installed via `/add-` skills. +- **`providers` branch** — OpenCode (and any future non-default agent providers). Installed via `/add-opencode`. + +Each `/add-` skill is idempotent: `git fetch origin ` → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → `pnpm install @` → build. + +## Self-Modification + +One tier of agent self-modification today: + +1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`. + +A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented. + +## Secrets / Credentials / OneCLI + +API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`. + +### Gotcha: auto-created agents start in `selective` secret mode + +When the host first spawns a session for a new agent group, `container-runner.ts:385` calls `onecli.ensureAgent({ name, identifier })`. The OneCLI `POST /api/agents` endpoint creates the agent in **`selective`** secret mode — meaning **no secrets are assigned to it by default**, even if the secrets exist in the vault and have host patterns that would otherwise match. + +Symptom: container starts, the proxy + CA cert are wired correctly, but the agent gets `401 Unauthorized` (or similar) from APIs whose credentials *are* in the vault. The credential just isn't in this agent's allow-list. + +The SDK does not expose `setSecretMode` — the only fix is the CLI (or the web UI at `http://127.0.0.1:10254`). + +```bash +# Find the agent (identifier is the agent group id) +onecli agents list + +# Flip to "all" so every vault secret with a matching host pattern gets injected +onecli agents set-secret-mode --id --mode all + +# Or, stay selective and assign specific secrets +onecli secrets list # find secret ids +onecli agents set-secrets --id --secret-ids , + +# Inspect what an agent currently has +onecli agents secrets --id # secrets assigned to this agent +onecli secrets list # all vault secrets (with host patterns) +``` + +If you've just enabled `mode all`, no container restart is needed — the gateway looks up secrets per request, so the next API call from the running container will see the new credentials. + +### Requiring approval for credential use + +Approval-gating credentialed actions is a **two-sided** flow: + +- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating. +- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only. + +If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired. ## Skills -Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy and guidelines. +Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy. -- **Feature skills** — merge a `skill/*` branch to add capabilities (e.g. `/add-telegram`, `/add-slack`) -- **Utility skills** — ship code files alongside SKILL.md (e.g. `/claw`) -- **Operational skills** — instruction-only workflows, always on `main` (e.g. `/setup`, `/debug`) -- **Container skills** — loaded inside agent containers at runtime (`container/skills/`) +- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`). +- **Utility skills** — ship code files alongside `SKILL.md` (e.g. `/claw`). +- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`). +- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `welcome`, `self-customize`, `agent-browser`, `slack-formatting`). | Skill | When to Use | |-------|-------------| -| `/setup` | First-time installation, authentication, service configuration | -| `/customize` | Adding channels, integrations, changing behavior | +| `/setup` | First-time install, auth, service config | +| `/init-first-agent` | Bootstrap the first DM-wired agent (channel pick → identity → wire → welcome DM) | +| `/manage-channels` | Wire channels to agent groups with isolation level decisions | +| `/customize` | Adding channels, integrations, behavior changes | | `/debug` | Container issues, logs, troubleshooting | -| `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install | -| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials to it | -| `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch | -| `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks | +| `/update-nanoclaw` | Bring upstream updates into a customized install | +| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials | ## Contributing -Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, SKILL.md format rules, PR requirements, and the pre-submission checklist (searching for existing PRs/issues, testing, description format). +Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist. ## Development -Run commands directly—don't tell the user to run them. +Run commands directly — don't tell the user to run them. ```bash -npm run dev # Run with hot reload -npm run build # Compile TypeScript -./container/build.sh # Rebuild agent container +# Host (Node + pnpm) +pnpm run dev # Host with hot reload +pnpm run build # Compile host TypeScript (src/) +./container/build.sh # Rebuild agent container image (nanoclaw-agent:latest) +pnpm test # Host tests (vitest) + +# Agent-runner (Bun — separate package tree under container/agent-runner/) +cd container/agent-runner && bun install # After editing agent-runner deps +cd container/agent-runner && bun test # Container tests (bun:test) ``` +Container typecheck is a separate tsconfig — if you edit `container/agent-runner/src/`, run `pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit` from root (or `bun run typecheck` from `container/agent-runner/`). + Service management: ```bash # macOS (launchd) -launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist +launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart # Linux (systemd) -systemctl --user start nanoclaw -systemctl --user stop nanoclaw -systemctl --user restart nanoclaw +systemctl --user start|stop|restart nanoclaw ``` -## Troubleshooting +Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here). -**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. +## Supply Chain Security (pnpm) + +This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml`. New package versions must exist on the npm registry for 3 days before pnpm will resolve them. + +**Rules — do not bypass without explicit human approval:** +- **`minimumReleaseAgeExclude`**: Never add entries without human sign-off. If a package must bypass the release age gate, the human must approve and the entry must pin the exact version being excluded (e.g. `package@1.2.3`), never a range. +- **`onlyBuiltDependencies`**: Never add packages to this list without human approval — build scripts execute arbitrary code during install. +- **`pnpm install --frozen-lockfile`** should be used in CI, automation, and container builds. Never run bare `pnpm install` in those contexts. + +## Docs Index + +| Doc | Purpose | +|-----|---------| +| [docs/architecture.md](docs/architecture.md) | Full architecture writeup | +| [docs/api-details.md](docs/api-details.md) | Host API + DB schema details | +| [docs/db.md](docs/db.md) | DB architecture overview: three-DB model, cross-mount rules, readers/writers map | +| [docs/db-central.md](docs/db-central.md) | Central DB (`data/v2.db`) — every table + migration system | +| [docs/db-session.md](docs/db-session.md) | Per-session `inbound.db` + `outbound.db` schemas + seq parity | +| [docs/agent-runner-details.md](docs/agent-runner-details.md) | Agent-runner internals + MCP tool interface | +| [docs/isolation-model.md](docs/isolation-model.md) | Three-level channel isolation model | +| [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow | +| [docs/architecture-diagram.md](docs/architecture-diagram.md) | Diagram version of the architecture | +| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants | ## Container Build Cache The container buildkit caches the build context aggressively. `--no-cache` alone does NOT invalidate COPY steps — the builder's volume retains stale files. To force a truly clean rebuild, prune the builder then re-run `./container/build.sh`. + +## Container Runtime (Bun) + +The agent container runs on **Bun**; the host runs on **Node** (pnpm). They communicate only via session DBs — no shared modules. Details and rationale: [docs/build-and-runtime.md](docs/build-and-runtime.md). + +**Gotchas — trigger + action:** + +- **Adding or bumping a runtime dep in `container/agent-runner/`** → edit `package.json`, then `cd container/agent-runner && bun install` and commit the updated `bun.lock`. Do not run `pnpm install` there — agent-runner is not a pnpm workspace. +- **Bumping `@anthropic-ai/claude-agent-sdk`, `@modelcontextprotocol/sdk`, or any agent-runner runtime dep** → no `minimumReleaseAge` policy applies to this tree. Check the release date on npm, pin deliberately, never `bun update` blindly. +- **Writing a new named-param SQL insert/update in the container** → use `$name` in both SQL and JS keys: `.run({ $id: msg.id })`. `bun:sqlite` does not auto-strip the prefix the way `better-sqlite3` does on the host. Positional `?` params work normally. +- **Adding a test in `container/agent-runner/src/`** → import from `bun:test`, not `vitest`. Vitest runs on Node and can't load `bun:sqlite`. `vitest.config.ts` excludes this tree. +- **Adding a Node CLI the agent invokes at runtime** (like `agent-browser`, `claude-code`, `vercel`) → put it in the Dockerfile's pnpm global-install block, pinned to an exact version via a new `ARG`. Don't use `bun install -g` — that bypasses the pnpm supply-chain policy. +- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~301) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step. +- **Changing session-DB pragmas** (`container/agent-runner/src/db/connection.ts`) → `journal_mode=DELETE` is load-bearing for cross-mount visibility. Read the comment block at the top of the file first. + +## CJK font support + +Agent containers ship without CJK fonts by default (~200MB saved). If you notice signals the user works with Chinese/Japanese/Korean content — conversing in CJK, CJK timezone (e.g., `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Seoul`, `Asia/Taipei`, `Asia/Hong_Kong`), system locale hint, or mentions of needing to render CJK in screenshots/PDFs/scraped pages — offer to enable it: + +```bash +# Ensure .env has INSTALL_CJK_FONTS=true (overwrite or append) +grep -q '^INSTALL_CJK_FONTS=' .env && sed -i.bak 's/^INSTALL_CJK_FONTS=.*/INSTALL_CJK_FONTS=true/' .env && rm -f .env.bak || echo 'INSTALL_CJK_FONTS=true' >> .env + +# Rebuild and restart so new sessions pick up the new image +./container/build.sh +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# systemctl --user restart nanoclaw # Linux +``` + +`container/build.sh` reads `INSTALL_CJK_FONTS` from `.env` and passes it through as a Docker build-arg. Without CJK fonts, Chromium-rendered screenshots and PDFs containing CJK text show tofu (empty rectangles) instead of characters. diff --git a/README.md b/README.md index 55fa16e..53ac2b0 100644 --- a/README.md +++ b/README.md @@ -12,27 +12,11 @@ 中文  •   日本語  •   Discord  •   - 34.9k tokens, 17% of context window + repo tokens

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

+> **注意:** この日本語訳は v1 時点のもので、最新の v2 アーキテクチャは反映されていません。最新の内容は [README.md](README.md) をご覧ください。 + ---

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

diff --git a/README_zh.md b/README_zh.md index 714bd87..b86d7ad 100644 --- a/README_zh.md +++ b/README_zh.md @@ -13,6 +13,9 @@ Discord  •   34.9k tokens, 17% of context window

+ +> **注意:** 此中文翻译对应 v1 版本,已不反映最新的 v2 架构。请参考 [README.md](README.md) 获取最新内容。 + 通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。 **新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。 diff --git a/container/.dockerignore b/container/.dockerignore new file mode 100644 index 0000000..598ce19 --- /dev/null +++ b/container/.dockerignore @@ -0,0 +1,2 @@ +agent-runner/node_modules +agent-runner/dist diff --git a/container/CLAUDE.md b/container/CLAUDE.md new file mode 100644 index 0000000..baf911a --- /dev/null +++ b/container/CLAUDE.md @@ -0,0 +1,21 @@ +You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn. + +## Communication + +Be concise — every message costs the reader's attention. Prefer outcomes over play-by-play; when the work is done, the final message should be about the result, not a transcript of what you did. + +## Workspace + +Files you create are saved in `/workspace/agent/`. Use this for notes, research, or anything that should persist across turns in this group. + +The file `CLAUDE.local.md` in your workspace is your per-group memory. Record things there that you'll want to remember in future sessions — user preferences, project context, recurring facts. Keep entries short and structured. + +## Memory + +When the user shares any substantive information with you, it must be stored somewhere you can retrieve it when relevant. If it's information that is pertinent to every single conversation turn it should be put into CLAUDE.local.md. Otherwise, create a system for storing the information depending on its type - e.g. create a file of people that the user mentions so you can keep track or a file of projects. For every file you create, add a concise reference in your CLAUDE.local.md so you'll be able to find it in future conversations. + +A core part of your job and the main thing that defines how useful you are to the user is how well you do in creating these systems for organizing information. These are your systems that help you do your job well. Evolve them over time as needed. + +## Conversation history + +The `conversations/` folder in your workspace holds searchable transcripts of past sessions with this group. Use it to recall prior context when a request references something that happened before. For structured long-lived data, prefer dedicated files (`customers.md`, `preferences.md`, etc.); split any file over ~500 lines into a folder with an index. diff --git a/container/Dockerfile b/container/Dockerfile index e8537c3..f492f1c 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,70 +1,121 @@ +# syntax=docker/dockerfile:1.7 # NanoClaw Agent Container -# Runs Claude Agent SDK in isolated Linux VM with browser automation +# Runs Claude Agent SDK in isolated Linux VM with browser automation. +# +# Runtime split: +# - agent-runner (our TypeScript code): Bun, mounted RO at /app/src by host +# - globally-installed Node CLIs (claude-code, agent-browser, vercel): pnpm + Node +# +# Source is never baked in — /app/src is provided by a shared read-only +# bind mount at runtime (see src/container-runner.ts). Source-only changes +# never require an image rebuild. FROM node:22-slim -# Install system dependencies for Chromium -RUN apt-get update && apt-get install -y \ - chromium \ - fonts-liberation \ - fonts-noto-cjk \ - fonts-noto-color-emoji \ - libgbm1 \ - libnss3 \ - libatk-bridge2.0-0 \ - libgtk-3-0 \ - libx11-xcb1 \ - libxcomposite1 \ - libxdamage1 \ - libxrandr2 \ - libasound2 \ - libpangocairo-1.0-0 \ - libcups2 \ - libdrm2 \ - libxshmfence1 \ - curl \ - git \ +# ---- Build-time arguments ---------------------------------------------------- +# CJK fonts add ~200MB. Opt in only if you render Chinese/Japanese/Korean text. +ARG INSTALL_CJK_FONTS=false + +# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs +# mean every rebuild silently picks up the latest and can break in lockstep +# across all users. +ARG CLAUDE_CODE_VERSION=2.1.116 +ARG AGENT_BROWSER_VERSION=latest +ARG VERCEL_VERSION=latest +ARG BUN_VERSION=1.3.12 + +# ---- System dependencies ----------------------------------------------------- +# tini: correct PID 1 / signal forwarding so outbound.db writes finalize on +# SIGTERM instead of being orphaned by the shell entrypoint. +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + fonts-liberation \ + fonts-noto-color-emoji \ + libgbm1 \ + libnss3 \ + libatk-bridge2.0-0 \ + libgtk-3-0 \ + libx11-xcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libasound2 \ + libpangocairo-1.0-0 \ + libcups2 \ + libdrm2 \ + libxshmfence1 \ + ca-certificates \ + curl \ + git \ + tini \ + unzip \ + && if [ "$INSTALL_CJK_FONTS" = "true" ]; then \ + apt-get install -y --no-install-recommends fonts-noto-cjk; \ + fi \ && rm -rf /var/lib/apt/lists/* -# Set Chromium path for agent-browser +# Chromium path for agent-browser / Playwright consumers ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium +# Belt-and-braces: prevent Playwright's postinstall from downloading its own +# ~300MB Chromium. We've already installed the system one above. +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 -# Install agent-browser and claude-code globally -RUN npm install -g agent-browser @anthropic-ai/claude-code +# ---- Bun runtime ------------------------------------------------------------- +# Install via the official script (handles multi-arch detection), then move +# the binary to /usr/local/bin so the non-root `node` user can execute it. +RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \ + install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun && \ + rm -rf /root/.bun -# Create app directory +# ---- agent-runner deps ------------------------------------------------------- +# Deps are cached independently of CLI versions. Source is NOT baked in — +# it's provided by the shared RO mount at runtime. WORKDIR /app -# Copy package files first for better caching -COPY agent-runner/package*.json ./ +COPY agent-runner/package.json agent-runner/bun.lock ./ -# Install dependencies -RUN npm install +RUN --mount=type=cache,target=/root/.bun/install/cache \ + bun install --frozen-lockfile -# Copy source code -COPY agent-runner/ ./ +# ---- pnpm + global Node CLIs ------------------------------------------------- +# Most stable first, most frequently bumped last. Bumping claude-code +# (the most common change) only invalidates one layer. +# +# only-built-dependencies gates pnpm's supply-chain policy: +# - agent-browser has a postinstall build step. +# - @anthropic-ai/claude-code's postinstall downloads the native Claude +# binary (linux-arm64 variant on our image). Without the allowlist +# the SDK fails at spawn time with "native binary not found". +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable -# Build TypeScript -RUN npm run build +RUN --mount=type=cache,target=/root/.cache/pnpm \ + echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ + echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \ + pnpm install -g "vercel@${VERCEL_VERSION}" -# Create workspace directories -RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" -# Create entrypoint script -# Container input (prompt, group info) is passed via stdin JSON. -# Credentials are injected by the host's credential proxy — never passed here. -# Follow-up messages arrive via IPC files in /workspace/ipc/input/ -RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" -# Set ownership to node user (non-root) for writable directories -RUN chown -R node:node /workspace && chmod 777 /home/node +# ---- Entrypoint -------------------------------------------------------------- +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# ---- Workspace + permissions ------------------------------------------------- +RUN mkdir -p /workspace/group /workspace/extra && \ + chown -R node:node /workspace && \ + chmod 755 /home/node -# Switch to non-root user (required for --dangerously-skip-permissions) USER node - -# Set working directory to group workspace WORKDIR /workspace/group -# Entry point reads JSON from stdin, outputs JSON to stdout -ENTRYPOINT ["/app/entrypoint.sh"] +# tini is PID 1, reaps zombies, forwards signals cleanly. entrypoint.sh does +# `exec bun ...` so bun runs as tini's direct child. +ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] diff --git a/container/agent-runner/bun.lock b/container/agent-runner/bun.lock new file mode 100644 index 0000000..3c08828 --- /dev/null +++ b/container/agent-runner/bun.lock @@ -0,0 +1,243 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "nanoclaw-agent-runner", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.116", + "@modelcontextprotocol/sdk": "^1.12.1", + "cron-parser": "^5.0.0", + "zod": "^4.0.0", + }, + "devDependencies": { + "@types/bun": "^1.1.0", + "@types/node": "^22.10.7", + "typescript": "^5.7.3", + }, + }, + }, + "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cron-parser": ["cron-parser@5.5.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + } +} diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json deleted file mode 100644 index 5835e1c..0000000 --- a/container/agent-runner/package-lock.json +++ /dev/null @@ -1,1576 +0,0 @@ -{ - "name": "nanoclaw-agent-runner", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "nanoclaw-agent-runner", - "version": "1.0.0", - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.92", - "@modelcontextprotocol/sdk": "^1.12.1", - "cron-parser": "^5.0.0", - "zod": "^4.0.0" - }, - "devDependencies": { - "@types/node": "^22.10.7", - "typescript": "^5.7.3" - } - }, - "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.92", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.92.tgz", - "integrity": "sha512-loYyxVUC5gBwHjGi9Fv0b84mduJTp9Z3Pum+y/7IVQDb4NynKfVQl6l4VeDKZaW+1QTQtd25tY4hwUznD7Krqw==", - "license": "SEE LICENSE IN README.md", - "dependencies": { - "@anthropic-ai/sdk": "^0.80.0", - "@modelcontextprotocol/sdk": "^1.27.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.34.2", - "@img/sharp-darwin-x64": "^0.34.2", - "@img/sharp-linux-arm": "^0.34.2", - "@img/sharp-linux-arm64": "^0.34.2", - "@img/sharp-linux-x64": "^0.34.2", - "@img/sharp-linuxmusl-arm64": "^0.34.2", - "@img/sharp-linuxmusl-x64": "^0.34.2", - "@img/sharp-win32-arm64": "^0.34.2", - "@img/sharp-win32-x64": "^0.34.2" - }, - "peerDependencies": { - "zod": "^4.0.0" - } - }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz", - "integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cron-parser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", - "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", - "license": "MIT", - "dependencies": { - "luxon": "^3.7.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", - "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.11.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz", - "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - } - } -} diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index 35ebc22..e9af0b1 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -3,18 +3,19 @@ "version": "1.0.0", "type": "module", "description": "Container-side agent runner for NanoClaw", - "main": "dist/index.js", "scripts": { - "build": "tsc", - "start": "node dist/index.js" + "start": "bun src/index.ts", + "typecheck": "tsc --noEmit", + "test": "bun test" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.92", + "@anthropic-ai/claude-agent-sdk": "^0.2.116", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" }, "devDependencies": { + "@types/bun": "^1.1.0", "@types/node": "^22.10.7", "typescript": "^5.7.3" } diff --git a/container/agent-runner/scripts/sdk-signal-probe.ts b/container/agent-runner/scripts/sdk-signal-probe.ts new file mode 100644 index 0000000..a4a3c98 --- /dev/null +++ b/container/agent-runner/scripts/sdk-signal-probe.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env bun +/** + * SDK signal probe: run a prompt, log every signal the Agent SDK emits — + * async-iterator events + hook callbacks + CLI stderr — with absolute + * and relative timing. + * + * Usage: + * bun run scripts/sdk-signal-probe.ts "" # simple string mode + * bun run scripts/sdk-signal-probe.ts --stream "" # streaming-input mode + * bun run scripts/sdk-signal-probe.ts --stream "

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

`, `