diff --git a/bin/cli.mjs b/bin/cli.mjs index 019fd50..14be4c2 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -17,6 +17,9 @@ import { execApprovalRequest, execApprovalWait, execApprovalResolve, execApprova import { listHooks, createForwarderHook, deleteHook } from '../src/openclaw-hooks.mjs'; import { cronList, cronAdd, cronRemove, cronRun, cronStatus } from '../src/openclaw-cron.mjs'; import { keepaliveStart, keepaliveStop, keepaliveStatus } from '../src/session-keepalive.mjs'; +import { moltbookPost, moltbookFeed } from '../src/moltbook.mjs'; +import { startRoomAutomation } from '../src/room-automation.mjs'; +import { pollComments, startCommentPoller } from '../src/comment-poller.mjs'; const args = process.argv.slice(2); const command = args[0]; @@ -89,6 +92,21 @@ Usage: stop: Stop managed caffeinate + heartbeat processes status: Show keepalive state, system caffeinate procs, and pmset settings + ide-agent-kit moltbook [options] + Moltbook social platform integration. + post: --content [--submolt ] [--title ] [--api-key ] + feed: [--limit ] [--submolt ] + + ide-agent-kit automate --rooms --api-key --handle <@handle> [--interval ] [--config ] + Run rule-based automation on room messages. + Rules are defined in config under automation.rules. + + ide-agent-kit comments [options] + Poll Moltbook posts and GitHub issues/discussions for new comments. + poll: One-shot poll, print new comments as JSON. + watch: Long-running poller with tmux nudge on new comments. + Config: comments.moltbook.posts, comments.github.repos + ide-agent-kit init [--ide ] [--profile ] Generate starter config for your IDE. `); @@ -576,6 +594,106 @@ async function main() { process.exit(1); } + // ── Moltbook ────────────────────────────────────────── + if (command === 'moltbook') { + const opts = parseKV(args, subcommand || 'moltbook'); + const config = loadConfig(opts.config); + + if (subcommand === 'post') { + if (!opts.content) { console.error('Error: --content is required'); process.exit(1); } + const result = await moltbookPost(config, { + content: opts.content, + submolt: opts.submolt, + title: opts.title, + apiKey: opts['api-key'] + }); + if (!result.ok) { + console.error(`Post failed: ${result.error}`); + process.exit(1); + } + console.log(JSON.stringify(result.data, null, 2)); + if (result.url) console.log(`URL: ${result.url}`); + return; + } + if (subcommand === 'feed') { + const result = await moltbookFeed(config, { + limit: opts.limit ? parseInt(opts.limit) : undefined, + submolt: opts.submolt, + apiKey: opts['api-key'] + }); + if (!result.ok) { + console.error(`Feed failed: ${result.error}`); + process.exit(1); + } + const posts = result.data?.posts || []; + if (posts.length === 0) { + console.log('No posts found.'); + } else { + posts.forEach(p => { + const author = p.author?.name || '?'; + const date = (p.created_at || '').slice(0, 19); + console.log(`[${date}] @${author}: ${(p.content || '').slice(0, 120)}`); + if (p.id) console.log(` https://www.moltbook.com/post/${p.id}`); + console.log(); + }); + } + return; + } + console.error('Usage: ide-agent-kit moltbook '); + process.exit(1); + } + + // ── Room Automation ───────────────────────────────────── + if (command === 'automate') { + const opts = parseKV(args, 'automate'); + if (!opts.rooms || !opts['api-key'] || !opts.handle) { + console.error('Error: --rooms, --api-key, and --handle are required'); + console.error('Example: ide-agent-kit automate --rooms thinkoff-development --api-key --handle @claudemm'); + process.exit(1); + } + const config = loadConfig(opts.config); + await startRoomAutomation({ + rooms: opts.rooms.split(','), + apiKey: opts['api-key'], + handle: opts.handle, + interval: opts.interval ? parseInt(opts.interval) : undefined, + config + }); + return; + } + + // ── Comment Poller ───────────────────────────────────── + if (command === 'comments') { + const opts = parseKV(args, subcommand || 'comments'); + const config = loadConfig(opts.config); + + if (subcommand === 'poll') { + const comments = pollComments(config); + if (comments.length === 0) { + console.log('No new comments.'); + } else { + for (const c of comments) { + const sourceLabel = c.source === 'moltbook' + ? `moltbook/${c.post_id?.slice(0, 8)}` + : `${c.repo}#${c.number}`; + console.log(`@${c.author} on ${sourceLabel}: ${c.body.slice(0, 120)}`); + if (c.url) console.log(` ${c.url}`); + console.log(); + } + } + return; + } + if (subcommand === 'watch') { + await startCommentPoller({ + config, + interval: opts.interval ? parseInt(opts.interval) : undefined + }); + return; + } + console.error('Usage: ide-agent-kit comments '); + process.exit(1); + } + if (command === 'init') { const opts = parseKV(args, 'init'); const ide = opts.ide || 'claude-code'; @@ -645,7 +763,14 @@ async function initIdeConfig(ide, profile = 'balanced') { # Add commands to tmux.allow to permit them # Set github.webhook_secret to verify inbound webhooks # Set openclaw.token to connect to your OpenClaw gateway -# Profile: ${normalizedProfile}` +# Profile: ${normalizedProfile} +# +# To run with full auto-approval (no permission prompts): +# claude --dangerously-skip-permissions +# +# Or use the generated .claude/settings.json for granular control. +# WARNING: --dangerously-skip-permissions skips ALL safety prompts. +# Only use in trusted environments with bounded agent tasks.` }, 'codex': { filename: 'ide-agent-kit.json', @@ -747,6 +872,76 @@ async function initIdeConfig(ide, profile = 'balanced') { writeFileSync(outPath, JSON.stringify(preset.config, null, 2) + '\n'); console.log(`Created ${outPath} for ${ide}`); console.log(preset.notes); + + // Generate IDE-specific permission settings + if (ide === 'claude-code') { + const claudeDir = resolve('.claude'); + if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true }); + const settingsPath = resolve('.claude', 'settings.json'); + if (!existsSync(settingsPath)) { + const defaultAllowedTools = [ + 'Bash(node bin/cli.mjs *)', + 'Bash(node --test *)', + 'Bash(npm test*)', + 'Bash(npm run build*)', + 'Bash(npm run lint*)', + 'Bash(npm run typecheck*)', + 'Bash(git status*)', + 'Bash(git diff*)', + 'Bash(git log*)', + 'Bash(git show*)', + 'Bash(git branch*)', + 'Bash(git fetch*)', + 'Bash(gh api *)', + 'Bash(gh auth status*)', + 'Bash(gh search *)', + 'Bash(curl -sS *)', + 'Bash(lsof *)', + 'Bash(ps *)', + 'Bash(tail *)', + 'Bash(head *)', + 'Bash(wc *)', + 'Bash(cat *)', + 'Bash(ls *)', + 'Bash(python3 -c *)', + 'Bash(python3 -m pytest*)', + 'Bash(kill *)', + 'Bash(nohup *)', + ]; + const lowFrictionAllowedTools = [ + ...defaultAllowedTools, + 'Bash(rg *)', + 'Bash(jq *)', + 'Bash(sed *)', + 'Bash(awk *)', + 'Bash(git pull --ff-only*)', + 'Bash(ssh family@localhost *)', + 'Bash(openclaw *)', + ]; + const settings = { + permissions: { + allowedTools: normalizedProfile === 'low-friction' ? lowFrictionAllowedTools : defaultAllowedTools, + dangerouslySkipPermissions: true + } + }; + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + console.log(`Created ${settingsPath} with auto-approved commands (profile: ${normalizedProfile})`); + } else { + console.log(`Claude Code settings already exist: ${settingsPath}`); + } + } + + if (ide === 'codex') { + const codexPath = resolve('codex.json'); + if (!existsSync(codexPath)) { + const codexSettings = { + approvalPolicy: normalizedProfile === 'low-friction' ? 'on-request' : 'unless-allow-listed', + sandboxMode: 'workspace-write' + }; + writeFileSync(codexPath, JSON.stringify(codexSettings, null, 2) + '\n'); + console.log(`Created ${codexPath} with Codex approval settings (profile: ${normalizedProfile})`); + } + } } main().catch(e => { diff --git a/package.json b/package.json index 9ccd546..c57432c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ide-agent-kit", - "version": "0.2.0", - "description": "IDE agent toolkit with OpenClaw integration — gateway, sessions, exec approvals, hooks, cron, memory, polling", + "version": "0.3.0", + "description": "Built for OpenClaw workflows — room-triggered automation, comment polling, Moltbook + GitHub connectors, receipts, exec approvals", "type": "module", "bin": { "ide-agent-kit": "./bin/cli.mjs" @@ -20,7 +20,11 @@ "openclaw", "gateway", "sessions", - "governance" + "governance", + "moltbook", + "automation", + "comment-polling", + "github-connector" ], "license": "AGPL-3.0-only", "engines": { diff --git a/src/comment-poller.mjs b/src/comment-poller.mjs new file mode 100644 index 0000000..dad461d --- /dev/null +++ b/src/comment-poller.mjs @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, appendFileSync, existsSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; + +/** + * Comment Poller — polls Moltbook posts and GitHub issues/discussions + * for new comments. Writes new comments to the event queue and + * optionally nudges the IDE tmux session. + * + * Config (in ide-agent-kit.json under comments): + * { + * "moltbook": { + * "posts": ["uuid1", "uuid2"], + * "base_url": "https://www.moltbook.com" + * }, + * "github": { + * "repos": [ + * { "owner": "ThinkOffApp", "repo": "ide-agent-kit", "type": "issues" }, + * { "owner": "HKUDS", "repo": "nanobot", "number": 431, "type": "discussion" } + * ], + * "token": "" + * }, + * "interval_sec": 120, + * "seen_file": "/tmp/iak-comment-seen.txt" + * } + */ + +const SEEN_FILE_DEFAULT = '/tmp/iak-comment-seen.txt'; + +function loadSeenIds(path) { + try { + return new Set(readFileSync(path, 'utf8').split('\n').filter(Boolean)); + } catch { + return new Set(); + } +} + +function saveSeenIds(path, ids) { + const arr = [...ids].slice(-5000); + writeFileSync(path, arr.join('\n') + '\n'); +} + +function nudgeTmux(session, text) { + try { + execSync(`tmux has-session -t ${JSON.stringify(session)} 2>/dev/null`); + } catch { + return false; + } + try { + execSync(`tmux send-keys -t ${JSON.stringify(session)} -l ${JSON.stringify(text)}`); + execSync('sleep 0.3'); + execSync(`tmux send-keys -t ${JSON.stringify(session)} Enter`); + return true; + } catch { + return false; + } +} + +/** + * Fetch comments for a Moltbook post. + */ +function fetchMoltbookComments(postId, baseUrl = 'https://www.moltbook.com') { + try { + const result = execSync( + `curl -sS "${baseUrl}/api/v1/posts/${postId}/comments"`, + { encoding: 'utf8', timeout: 15000 } + ); + const data = JSON.parse(result); + return Array.isArray(data) ? data : (data.comments || []); + } catch (e) { + console.error(` moltbook ${postId.slice(0, 8)} failed: ${e.message}`); + return []; + } +} + +/** + * Fetch comments for a GitHub issue or discussion. + */ +function fetchGitHubComments(owner, repo, number, type = 'issues', token = '') { + const authHeader = token ? `-H "Authorization: token ${token}"` : ''; + const endpoint = type === 'discussion' + ? `https://api.github.com/repos/${owner}/${repo}/discussions/${number}/comments` + : `https://api.github.com/repos/${owner}/${repo}/issues/${number}/comments`; + + try { + const result = execSync( + `curl -sS ${authHeader} "${endpoint}?per_page=50&sort=created&direction=desc"`, + { encoding: 'utf8', timeout: 15000 } + ); + const data = JSON.parse(result); + return Array.isArray(data) ? data : []; + } catch (e) { + console.error(` github ${owner}/${repo}#${number} failed: ${e.message}`); + return []; + } +} + +/** + * Fetch all open issues for a GitHub repo (to discover new comments). + */ +function fetchGitHubIssues(owner, repo, token = '') { + const authHeader = token ? `-H "Authorization: token ${token}"` : ''; + try { + const result = execSync( + `curl -sS ${authHeader} "https://api.github.com/repos/${owner}/${repo}/issues?state=open&sort=updated&direction=desc&per_page=10"`, + { encoding: 'utf8', timeout: 15000 } + ); + const data = JSON.parse(result); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +/** + * Poll all configured sources for new comments. + * + * @returns {object[]} Array of new comment events + */ +export function pollComments(config) { + const commentsCfg = config?.comments || {}; + const seen = loadSeenIds(commentsCfg.seen_file || SEEN_FILE_DEFAULT); + const newComments = []; + + // Poll Moltbook posts + const moltbookCfg = commentsCfg.moltbook || {}; + const posts = moltbookCfg.posts || []; + const moltBaseUrl = moltbookCfg.base_url || 'https://www.moltbook.com'; + + for (const postId of posts) { + const comments = fetchMoltbookComments(postId, moltBaseUrl); + for (const c of comments) { + const cid = c.id || ''; + const key = `moltbook:${cid}`; + if (!cid || seen.has(key)) continue; + seen.add(key); + + const author = typeof c.author === 'object' ? c.author?.name : (c.author || '?'); + newComments.push({ + trace_id: randomUUID(), + source: 'moltbook', + kind: 'moltbook.comment.created', + timestamp: c.created_at || c.createdAt || new Date().toISOString(), + post_id: postId, + comment_id: cid, + author, + body: (c.body || c.content || '').slice(0, 500), + url: `${moltBaseUrl}/post/${postId}#comment-${cid}` + }); + } + } + + // Poll GitHub repos + const githubCfg = commentsCfg.github || {}; + const repos = githubCfg.repos || []; + const ghToken = githubCfg.token || ''; + + for (const repoCfg of repos) { + const { owner, repo, type } = repoCfg; + + if (repoCfg.number) { + // Poll specific issue/discussion + const comments = fetchGitHubComments(owner, repo, repoCfg.number, type, ghToken); + for (const c of comments) { + const cid = String(c.id || ''); + const key = `github:${owner}/${repo}:${cid}`; + if (!cid || seen.has(key)) continue; + seen.add(key); + + newComments.push({ + trace_id: randomUUID(), + source: 'github', + kind: `github.${type || 'issues'}.comment.created`, + timestamp: c.created_at || new Date().toISOString(), + repo: `${owner}/${repo}`, + number: repoCfg.number, + comment_id: cid, + author: c.user?.login || '?', + body: (c.body || '').slice(0, 500), + url: c.html_url || `https://github.com/${owner}/${repo}/issues/${repoCfg.number}` + }); + } + } else if (type === 'issues') { + // Poll all open issues for new comments + const issues = fetchGitHubIssues(owner, repo, ghToken); + for (const issue of issues) { + if (!issue.comments || issue.comments === 0) continue; + const comments = fetchGitHubComments(owner, repo, issue.number, 'issues', ghToken); + for (const c of comments) { + const cid = String(c.id || ''); + const key = `github:${owner}/${repo}:${cid}`; + if (!cid || seen.has(key)) continue; + seen.add(key); + + newComments.push({ + trace_id: randomUUID(), + source: 'github', + kind: 'github.issues.comment.created', + timestamp: c.created_at || new Date().toISOString(), + repo: `${owner}/${repo}`, + number: issue.number, + comment_id: cid, + author: c.user?.login || '?', + body: (c.body || '').slice(0, 500), + url: c.html_url || '' + }); + } + } + } + } + + saveSeenIds(commentsCfg.seen_file || SEEN_FILE_DEFAULT, seen); + return newComments; +} + +/** + * Start the comment poller as a long-running process. + */ +export async function startCommentPoller({ config, interval }) { + const commentsCfg = config?.comments || {}; + const pollInterval = interval || commentsCfg.interval_sec || 120; + const queuePath = config?.queue?.path || './ide-agent-queue.jsonl'; + const session = config?.tmux?.ide_session || 'claude'; + const nudgeText = config?.tmux?.nudge_text || 'check rooms'; + + const moltPosts = commentsCfg.moltbook?.posts || []; + const ghRepos = commentsCfg.github?.repos || []; + + console.log(`Comment poller started`); + console.log(` moltbook posts: ${moltPosts.length}`); + console.log(` github repos: ${ghRepos.length}`); + console.log(` interval: ${pollInterval}s`); + + // Seed: do initial poll to mark existing comments as seen + console.log(` seeding existing comments...`); + const initial = pollComments(config); + console.log(` seeded (${initial.length} comments marked as seen)`); + + async function poll() { + const newComments = pollComments(config); + + if (newComments.length > 0) { + for (const c of newComments) { + appendFileSync(queuePath, JSON.stringify(c) + '\n'); + const sourceLabel = c.source === 'moltbook' + ? `moltbook/${c.post_id?.slice(0, 8)}` + : `${c.repo}#${c.number}`; + console.log(` NEW: @${c.author} on ${sourceLabel}: ${c.body.slice(0, 80)}`); + } + + const nudged = nudgeTmux(session, nudgeText); + console.log(` ${newComments.length} new comment(s) → ${nudged ? 'nudged' : 'no tmux session'}`); + } + } + + // Start interval + const timer = setInterval(poll, pollInterval * 1000); + + process.on('SIGINT', () => { + console.log('\nComment poller stopped.'); + clearInterval(timer); + process.exit(0); + }); + process.on('SIGTERM', () => { + clearInterval(timer); + process.exit(0); + }); + + return timer; +} diff --git a/src/config.mjs b/src/config.mjs index 1d7372c..02acc84 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -9,7 +9,20 @@ const DEFAULT_CONFIG = { receipts: { path: './ide-agent-receipts.jsonl', stdout_tail_lines: 80 }, tmux: { default_session: 'iak-runner', ide_session: 'claude', nudge_text: 'check rooms', allow: [] }, github: { webhook_secret: '', event_kinds: ['pull_request', 'issue_comment', 'check_suite', 'workflow_run'] }, - outbound: { default_webhook_url: '' } + outbound: { default_webhook_url: '' }, + automation: { + rules: [], + seen_file: '/tmp/iak-automation-seen.txt', + interval_sec: 30, + cooldown_sec: 5, + first_match_only: true + }, + comments: { + moltbook: { posts: [], base_url: 'https://www.moltbook.com' }, + github: { repos: [], token: '' }, + interval_sec: 120, + seen_file: '/tmp/iak-comment-seen.txt' + } }; export function loadConfig(configPath) { @@ -22,6 +35,13 @@ export function loadConfig(configPath) { receipts: { ...DEFAULT_CONFIG.receipts, ...raw.receipts }, tmux: { ...DEFAULT_CONFIG.tmux, ...raw.tmux }, github: { ...DEFAULT_CONFIG.github, ...raw.github }, - outbound: { ...DEFAULT_CONFIG.outbound, ...raw.outbound } + outbound: { ...DEFAULT_CONFIG.outbound, ...raw.outbound }, + automation: { ...DEFAULT_CONFIG.automation, ...raw.automation, rules: raw.automation?.rules || [] }, + comments: { + ...DEFAULT_CONFIG.comments, + ...raw.comments, + moltbook: { ...DEFAULT_CONFIG.comments.moltbook, ...raw.comments?.moltbook }, + github: { ...DEFAULT_CONFIG.comments.github, ...raw.comments?.github } + } }; } diff --git a/src/moltbook.mjs b/src/moltbook.mjs new file mode 100644 index 0000000..b56ff2f --- /dev/null +++ b/src/moltbook.mjs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { request as httpsRequest } from 'node:https'; + +/** + * Moltbook — Post to Moltbook social platform for AI agents. + * + * Config (in ide-agent-kit.json): + * moltbook.api_key — Moltbook API key (X-API-Key header) + * moltbook.base_url — Base URL (default: https://www.moltbook.com) + * + * Flow: + * 1. POST /api/v1/posts with content → returns challenge (math verification) + * 2. Solve the challenge + * 3. POST /api/v1/verify with verification_code + answer → publishes the post + */ + +const DEFAULT_BASE_URL = 'https://www.moltbook.com'; + +function moltbookFetch(baseUrl, path, method, apiKey, body = null) { + const url = new URL(path, baseUrl); + + return new Promise((resolve, reject) => { + const opts = { + method, + headers: { + 'X-API-Key': apiKey, + 'Content-Type': 'application/json' + } + }; + + const req = httpsRequest(url, opts, (res) => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + try { + resolve({ status: res.statusCode, data: JSON.parse(raw) }); + } catch { + resolve({ status: res.statusCode, data: raw }); + } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +/** + * Solve a simple math challenge string like "What is 7 + 3?" + * Returns the numeric answer as a string, or null if unparseable. + */ +function solveChallenge(challengeText) { + if (!challengeText) return null; + + // Try to extract "X op Y" patterns + const match = challengeText.match(/(-?\d+)\s*([+\-*/×÷])\s*(-?\d+)/); + if (!match) return null; + + const a = parseInt(match[1], 10); + const op = match[2]; + const b = parseInt(match[3], 10); + + let result; + switch (op) { + case '+': result = a + b; break; + case '-': result = a - b; break; + case '*': case '×': result = a * b; break; + case '/': case '÷': result = b !== 0 ? a / b : null; break; + default: return null; + } + + return result != null ? String(result) : null; +} + +/** + * Create a post on Moltbook with auto-verification. + * + * @param {object} config - IDE Agent Kit config + * @param {object} params - { content, submolt?, title? } + * @returns {object} - { ok, data, url? } or { ok: false, error } + */ +export async function moltbookPost(config, params) { + const apiKey = params.apiKey || config?.moltbook?.api_key; + const baseUrl = config?.moltbook?.base_url || DEFAULT_BASE_URL; + + if (!apiKey) { + return { ok: false, error: 'No Moltbook API key. Set moltbook.api_key in config or pass --api-key.' }; + } + if (!params.content) { + return { ok: false, error: 'Content is required.' }; + } + + const postBody = { content: params.content }; + if (params.submolt) postBody.submolt = params.submolt; + if (params.title) postBody.title = params.title; + + // Step 1: Create post (may return challenge) + const postRes = await moltbookFetch(baseUrl, '/api/v1/posts', 'POST', apiKey, postBody); + + if (postRes.status === 403) { + return { ok: false, error: postRes.data?.message || 'Forbidden — key may need claiming at /claim' }; + } + + // If post succeeded directly (no challenge) + if (postRes.status === 200 || postRes.status === 201) { + const postId = postRes.data?.id || postRes.data?.post?.id; + return { + ok: true, + data: postRes.data, + url: postId ? `${baseUrl}/post/${postId}` : null + }; + } + + // Step 2: Handle challenge-verify flow + const challenge = postRes.data?.challenge || postRes.data?.verification_challenge; + const verificationCode = postRes.data?.verification_code; + + if (!challenge && !verificationCode) { + // Unknown response — return as-is + return { + ok: postRes.status < 400, + data: postRes.data, + error: postRes.status >= 400 ? (postRes.data?.message || `HTTP ${postRes.status}`) : undefined + }; + } + + // Solve the math challenge + const answer = solveChallenge(challenge); + if (!answer) { + return { + ok: false, + error: `Could not solve challenge: "${challenge}"`, + data: postRes.data + }; + } + + // Step 3: Verify + const verifyRes = await moltbookFetch(baseUrl, '/api/v1/verify', 'POST', apiKey, { + verification_code: verificationCode, + answer + }); + + if (verifyRes.status >= 400) { + return { + ok: false, + error: verifyRes.data?.message || `Verify failed: HTTP ${verifyRes.status}`, + data: verifyRes.data + }; + } + + const postId = verifyRes.data?.id || verifyRes.data?.post?.id || postRes.data?.id; + return { + ok: true, + data: verifyRes.data, + url: postId ? `${baseUrl}/post/${postId}` : null + }; +} + +/** + * Read recent posts from Moltbook feed. + * + * @param {object} config - IDE Agent Kit config + * @param {object} params - { limit?, submolt?, cursor? } + * @returns {object} - { ok, data } + */ +export async function moltbookFeed(config, params = {}) { + const apiKey = params.apiKey || config?.moltbook?.api_key || ''; + const baseUrl = config?.moltbook?.base_url || DEFAULT_BASE_URL; + const limit = params.limit || 10; + + let path = `/api/v1/posts?limit=${limit}`; + if (params.submolt) path += `&submolt=${encodeURIComponent(params.submolt)}`; + if (params.cursor) path += `&cursor=${encodeURIComponent(params.cursor)}`; + + const res = await moltbookFetch(baseUrl, path, 'GET', apiKey); + + if (res.status >= 400) { + return { ok: false, error: res.data?.message || `HTTP ${res.status}` }; + } + + return { ok: true, data: res.data }; +} diff --git a/src/room-automation.mjs b/src/room-automation.mjs new file mode 100644 index 0000000..4b15add --- /dev/null +++ b/src/room-automation.mjs @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, appendFileSync, existsSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { createReceipt, appendReceipt } from './receipt.mjs'; + +/** + * Room Automation — rule-based automation triggered by Ant Farm room messages. + * + * Watch room messages, match against rules (keyword, sender, room, regex), + * execute bounded actions, and write a receipt for every action taken. + * + * Rules config (in ide-agent-kit.json under automation.rules): + * [ + * { + * "name": "greet-owner", + * "match": { "sender": "petrus", "keywords": ["hello", "hi"] }, + * "action": { "type": "post", "room": "${room}", "body": "Hello! I am here." } + * }, + * { + * "name": "poll-comments", + * "match": { "keywords": ["check comments", "poll comments"] }, + * "action": { "type": "exec", "command": "node bin/cli.mjs comments poll" } + * }, + * { + * "name": "catch-mention", + * "match": { "mention": "@claudemm", "regex": "deploy|ship|release" }, + * "action": { "type": "nudge", "text": "check rooms" } + * } + * ] + */ + +const SEEN_FILE_DEFAULT = '/tmp/iak-automation-seen.txt'; + +function loadSeenIds(path) { + try { + return new Set(readFileSync(path, 'utf8').split('\n').filter(Boolean)); + } catch { + return new Set(); + } +} + +function saveSeenIds(path, ids) { + const arr = [...ids].slice(-2000); + writeFileSync(path, arr.join('\n') + '\n'); +} + +function fetchRoomMessages(room, apiKey, limit = 20) { + const url = `https://antfarm.world/api/v1/rooms/${room}/messages?limit=${limit}`; + try { + const result = execSync( + `curl -sS -H "X-API-Key: ${apiKey}" "${url}"`, + { encoding: 'utf8', timeout: 15000 } + ); + const data = JSON.parse(result); + return data.messages || (Array.isArray(data) ? data : []); + } catch (e) { + console.error(` fetch ${room} failed: ${e.message}`); + return []; + } +} + +function postMessage(room, body, apiKey) { + const payload = JSON.stringify({ room, body }); + try { + execSync( + `curl -sS -X POST "https://antfarm.world/api/v1/messages" -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" -d '${payload.replace(/'/g, "'\\''")}'`, + { timeout: 15000 } + ); + return true; + } catch (e) { + console.error(` post failed: ${e.message}`); + return false; + } +} + +/** + * Check if a message matches a rule's conditions. + */ +function matchesRule(msg, rule) { + const match = rule.match || {}; + const body = (msg.body || '').toLowerCase(); + const sender = (msg.user?.handle || msg.from || msg.sender || '').toLowerCase(); + const room = msg.room || ''; + + // Sender filter + if (match.sender && !sender.includes(match.sender.toLowerCase())) return false; + + // Room filter + if (match.room && room !== match.room) return false; + + // Keyword match (any keyword present) + if (match.keywords && match.keywords.length > 0) { + const hasKeyword = match.keywords.some(kw => body.includes(kw.toLowerCase())); + if (!hasKeyword) return false; + } + + // Mention match + if (match.mention) { + const mention = match.mention.toLowerCase().replace('@', ''); + if (!body.includes(`@${mention}`) && !body.includes(mention)) return false; + } + + // Regex match + if (match.regex) { + try { + const re = new RegExp(match.regex, 'i'); + if (!re.test(msg.body || '')) return false; + } catch { + return false; + } + } + + return true; +} + +/** + * Execute a rule action and return a receipt. + */ +function executeAction(action, msg, apiKey, config) { + const startedAt = new Date().toISOString(); + if (!action) { + return createReceipt({ + actor: { name: 'automation', kind: 'unknown' }, + action: 'missing action block', + status: 'skipped', + startedAt, + }); + } + const room = msg.room || ''; + + // Template substitution for action fields + const sub = (str) => (str || '') + .replace(/\$\{room\}/g, room) + .replace(/\$\{sender\}/g, msg.user?.handle || msg.from || '?') + .replace(/\$\{body\}/g, (msg.body || '').slice(0, 200)); + + if (action.type === 'post') { + const targetRoom = sub(action.room) || room; + const body = sub(action.body); + const ok = postMessage(targetRoom, body, apiKey); + return createReceipt({ + actor: { name: config?.poller?.handle || 'ide-agent-kit', kind: 'automation' }, + action: `post to ${targetRoom}`, + status: ok ? 'ok' : 'error', + notes: ok ? `Posted: ${body.slice(0, 100)}` : 'Post failed', + startedAt, + }); + } + + if (action.type === 'exec') { + const cmd = sub(action.command); + const timeout = action.timeout || 30000; + try { + const output = execSync(cmd, { encoding: 'utf8', timeout, cwd: action.cwd }); + return createReceipt({ + actor: { name: 'automation', kind: 'exec' }, + action: `exec: ${cmd.slice(0, 80)}`, + status: 'ok', + exitCode: 0, + stdoutTail: output.slice(-500), + startedAt, + }); + } catch (e) { + return createReceipt({ + actor: { name: 'automation', kind: 'exec' }, + action: `exec: ${cmd.slice(0, 80)}`, + status: 'error', + exitCode: e.status || 1, + stderrTail: (e.stderr || e.message || '').slice(-500), + startedAt, + }); + } + } + + if (action.type === 'nudge') { + const session = config?.tmux?.ide_session || 'claude'; + const text = sub(action.text) || 'check rooms'; + try { + execSync(`tmux send-keys -t ${JSON.stringify(session)} -l ${JSON.stringify(text)}`); + execSync('sleep 0.3'); + execSync(`tmux send-keys -t ${JSON.stringify(session)} Enter`); + return createReceipt({ + actor: { name: 'automation', kind: 'nudge' }, + action: `nudge tmux ${session}`, + status: 'ok', + notes: `Sent: ${text}`, + startedAt, + }); + } catch (e) { + return createReceipt({ + actor: { name: 'automation', kind: 'nudge' }, + action: `nudge tmux ${session}`, + status: 'error', + notes: e.message, + startedAt, + }); + } + } + + return createReceipt({ + actor: { name: 'automation', kind: 'unknown' }, + action: `unknown action type: ${action.type}`, + status: 'skipped', + startedAt, + }); +} + +/** + * Start the room automation engine. + * + * @param {object} opts - { rooms, apiKey, handle, interval, config, rules } + */ +export async function startRoomAutomation({ rooms, apiKey, handle, interval, config }) { + const rules = config?.automation?.rules || []; + const seenFile = config?.automation?.seen_file || SEEN_FILE_DEFAULT; + const receiptPath = config?.receipts?.path || './ide-agent-receipts.jsonl'; + const pollInterval = interval || config?.automation?.interval_sec || 30; + const selfHandle = (handle || config?.poller?.handle || '@unknown').replace('@', ''); + const cooldownMs = (config?.automation?.cooldown_sec || 5) * 1000; + + console.log(`Room automation started`); + console.log(` rooms: ${rooms.join(', ')}`); + console.log(` rules: ${rules.length}`); + console.log(` interval: ${pollInterval}s`); + console.log(` cooldown: ${cooldownMs / 1000}s`); + + if (rules.length === 0) { + console.log(' WARNING: No automation rules configured. Add rules to automation.rules in config.'); + } + + const seen = loadSeenIds(seenFile); + const lastFired = new Map(); // rule name → timestamp + + // Seed on first run + if (seen.size === 0) { + console.log(` seeding seen IDs...`); + for (const room of rooms) { + const msgs = await fetchRoomMessages(room, apiKey, 50); + for (const m of msgs) { + if (m.id) seen.add(m.id); + } + } + saveSeenIds(seenFile, seen); + console.log(` seeded ${seen.size} IDs`); + } + + async function poll() { + let actionsRun = 0; + const now = Date.now(); + + for (const room of rooms) { + const msgs = fetchRoomMessages(room, apiKey); + for (const m of msgs) { + if (!m.id || seen.has(m.id)) continue; + seen.add(m.id); + + // Skip own messages + const sender = (m.user?.handle || m.from || m.sender || '').replace('@', ''); + if (sender === selfHandle) continue; + + // Attach room for rule matching + m.room = room; + + // Check each rule + for (const rule of rules) { + if (!matchesRule(m, rule)) continue; + + // Cooldown check + const lastTime = lastFired.get(rule.name) || 0; + if (now - lastTime < cooldownMs) { + console.log(` rule "${rule.name}" cooled down, skipping`); + continue; + } + + console.log(` rule "${rule.name}" matched → ${rule.action?.type || '?'}`); + const receipt = executeAction(rule.action, m, apiKey, config); + appendReceipt(receiptPath, receipt); + lastFired.set(rule.name, now); + actionsRun++; + + // Only fire first matching rule per message (avoid cascades) + if (config?.automation?.first_match_only !== false) break; + } + } + } + + saveSeenIds(seenFile, seen); + + if (actionsRun > 0) { + console.log(` ${actionsRun} automation action(s) executed`); + } + } + + // Initial poll + await poll(); + + // Start interval + const timer = setInterval(poll, pollInterval * 1000); + + process.on('SIGINT', () => { + console.log('\nAutomation stopped.'); + clearInterval(timer); + process.exit(0); + }); + process.on('SIGTERM', () => { + clearInterval(timer); + process.exit(0); + }); + + return timer; +} diff --git a/test/comment-poller.test.mjs b/test/comment-poller.test.mjs new file mode 100644 index 0000000..3e31349 --- /dev/null +++ b/test/comment-poller.test.mjs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { pollComments, startCommentPoller } from '../src/comment-poller.mjs'; + +describe('comment-poller', () => { + it('exports pollComments function', () => { + assert.equal(typeof pollComments, 'function'); + }); + + it('exports startCommentPoller function', () => { + assert.equal(typeof startCommentPoller, 'function'); + }); + + it('pollComments returns empty array with no config', () => { + const result = pollComments({}); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 0); + }); + + it('pollComments returns empty array with empty sources', () => { + const result = pollComments({ + comments: { + moltbook: { posts: [] }, + github: { repos: [] }, + seen_file: '/tmp/iak-test-comment-seen.txt' + } + }); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 0); + }); +}); diff --git a/test/config.test.mjs b/test/config.test.mjs new file mode 100644 index 0000000..dec8190 --- /dev/null +++ b/test/config.test.mjs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { loadConfig } from '../src/config.mjs'; + +describe('config', () => { + it('loadConfig returns defaults when no file exists', () => { + const cfg = loadConfig('/tmp/iak-nonexistent-config.json'); + assert.ok(cfg.listen); + assert.ok(cfg.queue); + assert.ok(cfg.receipts); + assert.ok(cfg.tmux); + assert.ok(cfg.automation); + assert.ok(cfg.comments); + }); + + it('default config has automation section', () => { + const cfg = loadConfig('/tmp/iak-nonexistent-config.json'); + assert.ok(Array.isArray(cfg.automation.rules)); + assert.equal(cfg.automation.rules.length, 0); + assert.equal(cfg.automation.interval_sec, 30); + assert.equal(cfg.automation.cooldown_sec, 5); + assert.equal(cfg.automation.first_match_only, true); + }); + + it('default config has comments section', () => { + const cfg = loadConfig('/tmp/iak-nonexistent-config.json'); + assert.ok(Array.isArray(cfg.comments.moltbook.posts)); + assert.ok(Array.isArray(cfg.comments.github.repos)); + assert.equal(cfg.comments.interval_sec, 120); + }); +}); diff --git a/test/room-automation.test.mjs b/test/room-automation.test.mjs new file mode 100644 index 0000000..50d61f3 --- /dev/null +++ b/test/room-automation.test.mjs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +// Test the matchesRule logic by importing the module (startRoomAutomation is the export) +// We test the matching logic indirectly through the exported function signature +import { startRoomAutomation } from '../src/room-automation.mjs'; + +describe('room-automation', () => { + it('exports startRoomAutomation function', () => { + assert.equal(typeof startRoomAutomation, 'function'); + }); +});