From a65902edf9e926231d2802ea2617098b998bc5ea Mon Sep 17 00:00:00 2001 From: Manohar Date: Sat, 2 May 2026 20:09:43 +0000 Subject: [PATCH] feat(bridge): Telegram polling, file-based tasks/projects, cron/keys/deploy routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routes/notify.ts: Telegram delivery endpoint POST /tiger/notify - routes/tasks-file.ts: TASKS.md/PROJECTS.md reader (JSON block parser — P0 fix) Parser reads only from TASKS_JSON fenced block; returns 502 if block absent - routes/cron.ts: GET /tiger/cron + POST /tiger/cron/:id/run - routes/keys.ts: GET /tiger/keys (key presence map, no values exposed) - routes/agents-activity.ts: GET /tiger/agents/:id/files - routes/route-task.ts: POST /tiger/route-task (LLM router) - routes/deploy.ts: POST /tiger/deploy-dashboard (path: OpenClawDashboard) - db.ts: comment Reserved telegram columns (future task-from-Telegram) --- bridge/src/db.ts | 24 ++- bridge/src/routes/agents-activity.ts | 108 ++++++++++++ bridge/src/routes/cron.ts | 77 +++++++++ bridge/src/routes/deploy.ts | 40 +++++ bridge/src/routes/keys.ts | 243 +++++++++++++++++++++++++++ bridge/src/routes/notify.ts | 86 ++++++++++ bridge/src/routes/route-task.ts | 44 +++++ bridge/src/routes/tasks-file.ts | 130 ++++++++++++++ 8 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 bridge/src/routes/agents-activity.ts create mode 100644 bridge/src/routes/cron.ts create mode 100644 bridge/src/routes/deploy.ts create mode 100644 bridge/src/routes/keys.ts create mode 100644 bridge/src/routes/notify.ts create mode 100644 bridge/src/routes/route-task.ts create mode 100644 bridge/src/routes/tasks-file.ts diff --git a/bridge/src/db.ts b/bridge/src/db.ts index 7056888..6038b87 100644 --- a/bridge/src/db.ts +++ b/bridge/src/db.ts @@ -48,6 +48,9 @@ db.exec(` status TEXT DEFAULT 'backlog', priority TEXT DEFAULT 'medium', assigned_agent TEXT, + agent_reason TEXT, + telegram_chat_id TEXT, -- Reserved: future task-from-Telegram + telegram_message_id TEXT, -- Reserved: future task-from-Telegram progress INTEGER DEFAULT 0, tags TEXT DEFAULT '[]', notes TEXT DEFAULT '', @@ -99,6 +102,14 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_outputs_task ON outputs(task_id); `); +// ─── Migrations ────────────────────────────────────────────────────────────── +// Columns added after initial schema creation. ALTER TABLE is idempotent via +// try/catch — SQLite raises "duplicate column" on repeated runs, which we ignore. +try { db.exec("ALTER TABLE tasks ADD COLUMN agent_reason TEXT"); } catch { /* already exists */ } +// Reserved columns — future task-from-Telegram feature +try { db.exec("ALTER TABLE tasks ADD COLUMN telegram_chat_id TEXT"); } catch { /* already exists */ } +try { db.exec("ALTER TABLE tasks ADD COLUMN telegram_message_id TEXT"); } catch { /* already exists */ } + // ─── Helper to generate IDs ───────────────────────────────────────────────── export function generateId(prefix: string): string { @@ -176,17 +187,18 @@ export const tasks = { }, create(data: { - project_id: string; + project_id: string | null; title: string; description?: string; priority?: string; assigned_agent?: string; + agent_reason?: string; parent_task_id?: string; }): unknown { const id = generateId("task"); db.prepare(` - INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent, agent_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( id, data.project_id, @@ -194,7 +206,8 @@ export const tasks = { data.title, data.description || "", data.priority || "medium", - data.assigned_agent || null + data.assigned_agent || null, + data.agent_reason || null ); return tasks.findById(id); }, @@ -209,6 +222,7 @@ export const tasks = { tags: string; notes: string; due_date: string; + agent_reason: string; }>): unknown | undefined { const updates: string[] = []; const values: unknown[] = []; @@ -222,6 +236,8 @@ export const tasks = { if (data.tags !== undefined) { updates.push("tags = ?"); values.push(data.tags); } if (data.notes !== undefined) { updates.push("notes = ?"); values.push(data.notes); } if (data.due_date !== undefined) { updates.push("due_date = ?"); values.push(data.due_date); } + if (data.agent_reason !== undefined) { updates.push("agent_reason = ?"); values.push(data.agent_reason); } + if (data.status !== undefined) { updates.push("status = ?"); values.push(data.status); } if (updates.length === 0) return tasks.findById(id); diff --git a/bridge/src/routes/agents-activity.ts b/bridge/src/routes/agents-activity.ts new file mode 100644 index 0000000..0855577 --- /dev/null +++ b/bridge/src/routes/agents-activity.ts @@ -0,0 +1,108 @@ +/** + * routes/agents-activity.ts — Transform agents data into per-agent activity view + * + * GET /tiger/agents/activity + * Uses execInSandbox to call /tiger/agents from inside OpenClaw container, + * then transforms to per-agent activity cards. + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +function agentName(subAgentId: string | null | undefined): string { + if (!subAgentId) return "Tiger"; + const map: Record = { + "main": "Tiger", + "coder": "Cody", + "researcher": "Ethan", + "writer": "Cathy", + "pm": "Elon", + }; + return map[subAgentId] || subAgentId; +} + +function timeAgo(timestamp: number | null): string { + if (!timestamp) return "never"; + const seconds = Math.floor((Date.now() / 1000) - timestamp); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +router.get("/", async (_req: Request, res: Response) => { + try { + // Use execInSandbox to call /tiger/agents from inside OpenClaw container + const { stdout } = await execInSandbox( + `curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer 14fb879429386b69beac339bbd98e43011ec29485da17592410da34ed97e0236"` + ); + + let rawData: any; + try { + rawData = JSON.parse(stdout); + } catch { + return res.status(500).json({ ok: false, error: "Could not parse agents response" }); + } + + const rawSessions: any[] = rawData.sessions || []; + + // Build per-agent status + const agentMap: Record = { + "Tiger": { id: "main", name: "Tiger", subAgentId: "main", sessions: [], lastActive: null, isRunning: false, currentTask: "" }, + "Cody": { id: "coder", name: "Cody", subAgentId: "coder", sessions: [], lastActive: null, isRunning: false, currentTask: "" }, + "Ethan": { id: "researcher", name: "Ethan", subAgentId: "researcher", sessions: [], lastActive: null, isRunning: false, currentTask: "" }, + "Cathy": { id: "writer", name: "Cathy", subAgentId: "writer", sessions: [], lastActive: null, isRunning: false, currentTask: "" }, + "Elon": { id: "pm", name: "Elon", subAgentId: "pm", sessions: [], lastActive: null, isRunning: false, currentTask: "" }, + }; + + for (const session of rawSessions) { + const name = agentName(session.subAgentId); + if (!agentMap[name]) continue; + + agentMap[name].sessions.push({ + sessionKey: session.sessionKey, + label: session.label, + lastMessage: session.lastMessage || "", + model: session.model, + running: session.running || false, + }); + + if (session.running) { + agentMap[name].isRunning = true; + agentMap[name].currentTask = session.label || "Running"; + agentMap[name].lastActive = timeAgo(session.lastMessageTime); + } + } + + // Set lastActive for non-running agents based on most recent message + for (const name of Object.keys(agentMap)) { + const a = agentMap[name]; + if (!a.isRunning && a.sessions.length > 0) { + a.sessions.sort((x: any, y: any) => (y.lastMessageTime || 0) - (x.lastMessageTime || 0)); + a.lastActive = timeAgo(a.sessions[0].lastMessageTime); + } + } + + const agents = Object.values(agentMap).map((a: any) => ({ + id: a.id, + name: a.name, + subAgentId: a.subAgentId, + status: a.isRunning ? "active" : (a.sessions.length > 0 ? "idle" : "offline"), + currentTask: a.currentTask, + lastActive: a.lastActive, + sessionCount: a.sessions.length, + isRunning: a.isRunning, + })); + + res.json({ ok: true, count: agents.length, agents, updated: new Date().toISOString() }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; diff --git a/bridge/src/routes/cron.ts b/bridge/src/routes/cron.ts new file mode 100644 index 0000000..94962dd --- /dev/null +++ b/bridge/src/routes/cron.ts @@ -0,0 +1,77 @@ +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +// cron/jobs.json lives inside the container at a known path. +// We read it directly — openclaw cron list --json requires an active gateway +// WebSocket which may not always be up. +const CRON_JOBS_PATH = "/home/node/.openclaw/cron/jobs.json"; + +function formatCronJobs(raw: any): any[] { + const jobs = raw?.jobs ?? []; + return jobs.map((j: any) => ({ + id: j.id, + name: j.name ?? j.id, + schedule: j.schedule?.expr ?? "", + tz: j.schedule?.tz ?? "UTC", + enabled: j.enabled ?? true, + agentId: j.agentId ?? "main", + lastRun: j.state?.lastRunAtMs + ? { + at: new Date(j.state.lastRunAtMs).toISOString(), + status: j.state.lastRunStatus ?? j.state.lastStatus ?? "unknown", + durationMs: j.state.lastDurationMs, + errors: j.state.consecutiveErrors ?? 0, + lastError: j.state.lastError ?? null, + } + : null, + nextRun: j.state?.nextRunAtMs + ? new Date(j.state.nextRunAtMs).toISOString() + : null, + message: j.payload?.message?.slice(0, 120) ?? "", + })); +} + +// GET /tiger/cron — list cron jobs directly from jobs.json +router.get("/", async (_req: Request, res: Response) => { + try { + const { stdout } = await execInSandbox(`cat ${CRON_JOBS_PATH} 2>/dev/null || echo '{}'`); + let raw: any = {}; + try { raw = JSON.parse(stdout.trim() || "{}"); } catch { raw = {}; } + + const jobs = formatCronJobs(raw); + + // Scheduler meta: enabled + nextWakeAt (from first job with nextRunAtMs) + const nextWake = jobs.find((j) => j.nextRun)?.nextRun ?? null; + const hasErrors = jobs.some((j) => (j.lastRun?.errors ?? 0) > 0); + + res.json({ + ok: true, + jobs, + status: { + enabled: true, + jobCount: jobs.length, + nextWake, + hasErrors, + }, + }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// POST /tiger/cron/:id/run — trigger a cron job immediately via gateway +router.post("/:id/run", async (req: Request, res: Response) => { + const { id } = req.params; + try { + const { stdout, stderr } = await execInSandbox( + `openclaw cron run ${id} 2>&1 || true` + ); + res.json({ ok: true, output: (stdout || stderr).slice(0, 500) }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; diff --git a/bridge/src/routes/deploy.ts b/bridge/src/routes/deploy.ts new file mode 100644 index 0000000..2a62154 --- /dev/null +++ b/bridge/src/routes/deploy.ts @@ -0,0 +1,40 @@ +/** + * deploy.ts — POST /tiger/deploy-dashboard + * + * Triggers a full dashboard rebuild + service restart on the host. + * Called by Tiger (from inside container) after editing dashboard source files. + * + * Flow: + * Tiger edits /home/node/dashboard/... (bind-mounted from /root/OpenClawDashboard) + * Tiger calls POST /tiger/deploy-dashboard + * Bridge runs /root/scripts/rebuild-dashboard.sh on HOST + * Returns build output so Tiger can confirm success or report errors + */ + +import { Router } from 'express'; +import { execOnHost } from '../tiger.js'; + +const router = Router(); + +router.post('/', async (_req, res) => { + try { + console.log('[deploy] Dashboard deploy triggered by Tiger'); + const result = await execOnHost( + '/root/scripts/rebuild-dashboard.sh', + 120_000 // 2 min timeout — Next.js builds can be slow + ); + const success = result.exitCode === 0; + res.json({ + ok: success, + message: success ? 'Dashboard deployed successfully' : 'Deploy failed', + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } catch (err: any) { + console.error('[deploy] Error:', err.message); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; diff --git a/bridge/src/routes/keys.ts b/bridge/src/routes/keys.ts new file mode 100644 index 0000000..43409da --- /dev/null +++ b/bridge/src/routes/keys.ts @@ -0,0 +1,243 @@ +/** + * routes/keys.ts — API key management for the bridge + * + * Persists API keys (Anthropic, OpenRouter, Telegram) to the bridge's .env + * file so they survive restarts. The settings page in the dashboard calls + * these endpoints. + * + * Endpoints: + * GET /tiger/keys — return key presence (NEVER the values themselves) + * PATCH /tiger/keys — set one or more keys; { ANTHROPIC_API_KEY?, ... } + * DELETE /tiger/keys/:name — clear a single key + * + * Security model: + * - GETs return only { isSet: true|false } per key. The actual value + * never leaves the server. This means the UI shows "Anthropic key + * configured ✓" rather than echoing the key back. + * - PATCH writes the .env file with the new values. systemd then needs + * a restart to pick them up — we do NOT auto-restart from this route + * because that would kill the very HTTP request that triggered the + * change. The UI tells the user to click "Restart bridge" after saving. + * - .env is owned by uid 1000 (the bridge user) and chmod 600. + * + * We do NOT support env vars other than the documented allowlist below. + * That keeps an attacker who finds a way past auth from injecting + * arbitrary env into a service restart. + */ + +import { Router, Request, Response } from "express"; +import { readFile, writeFile } from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// .env lives at bridge/.env — i.e. two directories up from src/routes. +// __dirname when running compiled code is dist/routes, when running via tsx +// it's src/routes. Both resolve to the same target via "../../". +const ENV_PATH = path.resolve(__dirname, "../../.env"); + +// Allowlist — only these keys may be set/cleared via this endpoint. +const ALLOWED_KEYS = [ + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_CHAT_ID", + "TIGER_ROUTER_MODEL", +] as const; +type AllowedKey = (typeof ALLOWED_KEYS)[number]; + +const router = Router(); + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +/** + * Read the .env file as a Map. Lines that aren't KEY=VALUE + * (comments, blanks) are kept verbatim so we can preserve them on write. + * + * Returns { entries, raw } where: + * entries is the parsed map of KEY → VALUE + * raw is the original line array (so we can reconstruct on write) + */ +async function readEnvFile(): Promise<{ + entries: Map; + raw: string[]; +}> { + let content = ""; + try { + content = await readFile(ENV_PATH, "utf-8"); + } catch { + // Missing .env is fine — treat as empty + return { entries: new Map(), raw: [] }; + } + const raw = content.split("\n"); + const entries = new Map(); + for (const line of raw) { + // Skip comments and blanks + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + const k = line.slice(0, eq).trim(); + const v = line.slice(eq + 1).trim(); + entries.set(k, v); + } + return { entries, raw }; +} + +/** + * Reconstruct the .env file with `updates` applied. Existing lines are kept + * (including comments and order); updated keys are replaced in place; + * new keys are appended at the bottom. + */ +function applyUpdates( + raw: string[], + updates: Map, +): string { + const seen = new Set(); + const out: string[] = []; + + for (const line of raw) { + const trimmed = line.trim(); + // Pass through comments and blanks unchanged + if (!trimmed || trimmed.startsWith("#")) { + out.push(line); + continue; + } + const eq = line.indexOf("="); + if (eq < 0) { + out.push(line); + continue; + } + const k = line.slice(0, eq).trim(); + + if (updates.has(k)) { + const v = updates.get(k); + if (v === null) { + // Cleared: replace with empty value but keep the key so the + // structure of .env is preserved across restarts. + out.push(`${k}=`); + } else { + out.push(`${k}=${v}`); + } + seen.add(k); + } else { + // Not being updated — keep as-is + out.push(line); + } + } + + // Append any new keys not already in the file + for (const [k, v] of updates) { + if (seen.has(k)) continue; + if (v === null) continue; // don't bother adding cleared keys + out.push(`${k}=${v}`); + } + + return out.join("\n"); +} + +// ─── GET /tiger/keys ─────────────────────────────────────────────────────── +// Returns presence-only: { ANTHROPIC_API_KEY: { isSet: true }, ... } +router.get("/", async (_req: Request, res: Response) => { + try { + const { entries } = await readEnvFile(); + const result: Record = {}; + + for (const k of ALLOWED_KEYS) { + const v = entries.get(k) ?? ""; + // For TIGER_ROUTER_MODEL, the value is non-secret — return it directly. + // For everything else, only return presence. + if (k === "TIGER_ROUTER_MODEL") { + result[k] = { isSet: v.length > 0, preview: v }; + } else { + result[k] = { isSet: v.length > 0 }; + } + } + res.json({ ok: true, keys: result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ─── PATCH /tiger/keys ───────────────────────────────────────────────────── +// Body: partial object of allowed keys → string (set) or null (clear). +// Example: { ANTHROPIC_API_KEY: "sk-ant-...", TELEGRAM_CHAT_ID: null } +router.patch("/", async (req: Request, res: Response) => { + const body = req.body as Record; + if (!body || typeof body !== "object") { + return res.status(400).json({ ok: false, error: "Body must be an object" }); + } + + const updates = new Map(); + for (const [k, v] of Object.entries(body)) { + if (!(ALLOWED_KEYS as readonly string[]).includes(k)) { + return res.status(400).json({ + ok: false, + error: `Key not allowed: ${k}. Allowed: ${ALLOWED_KEYS.join(", ")}`, + }); + } + if (v === null) { + updates.set(k, null); + } else if (typeof v === "string") { + // Reject control chars and newlines that would break .env format + if (/[\r\n]/.test(v)) { + return res.status(400).json({ + ok: false, + error: `Value for ${k} contains newline characters`, + }); + } + updates.set(k, v); + } else { + return res.status(400).json({ + ok: false, + error: `Value for ${k} must be a string or null`, + }); + } + } + + try { + const { raw } = await readEnvFile(); + const newContent = applyUpdates(raw, updates); + await writeFile(ENV_PATH, newContent, { encoding: "utf-8", mode: 0o600 }); + + res.json({ + ok: true, + updated: Array.from(updates.keys()), + message: + "Keys saved to .env. Restart tiger-bridge for changes to take effect.", + }); + } catch (err: any) { + console.error("[keys] PATCH failed:", err); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ─── DELETE /tiger/keys/:name ────────────────────────────────────────────── +// Clear a single key (sets it to empty in .env). +router.delete("/:name", async (req: Request, res: Response) => { + const name = req.params.name; + if (!(ALLOWED_KEYS as readonly string[]).includes(name)) { + return res.status(400).json({ + ok: false, + error: `Key not allowed: ${name}`, + }); + } + try { + const { raw } = await readEnvFile(); + const updates = new Map([[name, null]]); + const newContent = applyUpdates(raw, updates); + await writeFile(ENV_PATH, newContent, { encoding: "utf-8", mode: 0o600 }); + res.json({ + ok: true, + cleared: name, + message: "Key cleared. Restart tiger-bridge for changes to take effect.", + }); + } catch (err: any) { + console.error("[keys] DELETE failed:", err); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; diff --git a/bridge/src/routes/notify.ts b/bridge/src/routes/notify.ts new file mode 100644 index 0000000..9302829 --- /dev/null +++ b/bridge/src/routes/notify.ts @@ -0,0 +1,86 @@ +import { Router, Request, Response } from "express"; +import { readFileSync } from "fs"; + +const router = Router(); + +const OPENCLAW_CONFIG_PATH = + process.env.OPENCLAW_CONFIG_PATH || + "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json"; + +function getBotToken(): string { + if (process.env.TELEGRAM_BOT_TOKEN?.trim()) return process.env.TELEGRAM_BOT_TOKEN.trim(); + try { + const cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); + return cfg?.channels?.telegram?.botToken ?? ""; + } catch { return ""; } +} + +function getChatId(): string { + if (process.env.TELEGRAM_CHAT_ID?.trim()) return process.env.TELEGRAM_CHAT_ID.trim(); + return ""; +} + +/** + * POST /tiger/notify + * Body: { message: string, chatId?: string } + * + * Sends a Telegram message via the bridge's bot token. + * Called by Tiger's cron jobs (via curl from inside the container) + * since OpenClaw's native Telegram channel is disabled (bridge owns polling). + * + * curl example from inside container: + * curl -s -X POST http://172.17.0.1:3456/tiger/notify \ + * -H "Content-Type: application/json" \ + * -H "Authorization: Bearer $BRIDGE_TOKEN" \ + * -d '{"message":"HEARTBEAT_OK"}' + */ +router.post("/", async (req: Request, res: Response) => { + const { message, chatId: overrideChatId } = req.body as { + message?: string; + chatId?: string; + }; + + if (!message?.trim()) { + return res.status(400).json({ ok: false, error: "message is required" }); + } + + const token = getBotToken(); + const chatId = overrideChatId || getChatId(); + + if (!token) { + return res.status(503).json({ ok: false, error: "No bot token configured" }); + } + if (!chatId) { + return res.status(503).json({ + ok: false, + error: "No TELEGRAM_CHAT_ID in bridge .env — set it so Tiger knows where to send notifications", + }); + } + + try { + const tgRes = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: chatId, + text: message.slice(0, 4096), + parse_mode: "Markdown", + }), + signal: AbortSignal.timeout(10_000), + }); + + const data = await tgRes.json() as any; + if (!data.ok) { + console.error("[notify] Telegram error:", data.description); + return res.status(502).json({ ok: false, error: data.description }); + } + + console.log(`[notify] Sent to chat ${chatId}: ${message.slice(0, 60)}…`); + res.json({ ok: true, messageId: data.result?.message_id }); + } catch (err: any) { + console.error("[notify] fetch failed:", err.message); + res.status(502).json({ ok: false, error: err.message }); + } +}); + +export default router; diff --git a/bridge/src/routes/route-task.ts b/bridge/src/routes/route-task.ts new file mode 100644 index 0000000..e3aa6bf --- /dev/null +++ b/bridge/src/routes/route-task.ts @@ -0,0 +1,44 @@ +/** + * routes/route-task.ts — Standalone routing endpoint + * + * POST /tiger/route-task + * Body: { text: string } + * Returns: { ok: true, agent: AgentId, reason: string } + * + * This is a thin HTTP wrapper around classifyAgent() from lib/llm.ts. + * It exists so the dashboard (or external tools / Telegram) can ask + * "where would you route this?" without creating a task. + * + * The actual task-creation flow in projects.ts and dispatch.ts will + * import classifyAgent directly — they don't need to round-trip through + * this endpoint. So this route is for the UI's "preview routing" affordance. + * + * Failure mode: classifyAgent never throws. The HTTP response will + * always be 200 with { agent, reason }. If reason starts with + * "router_unavailable:" the UI can show a warning banner. + */ + +import { Router, Request, Response } from "express"; +import { classifyAgent } from "../lib/llm.js"; + +const router = Router(); + +router.post("/", async (req: Request, res: Response) => { + const { text } = req.body as { text?: string }; + + if (typeof text !== "string" || !text.trim()) { + return res.status(400).json({ + ok: false, + error: "Body.text is required and must be a non-empty string", + }); + } + + // classifyAgent has its own try/catch — we never get an exception here, + // we just get a result with a "router_unavailable:" reason if the LLM + // call failed. That's the contract. + const result = await classifyAgent(text); + + res.json({ ok: true, ...result }); +}); + +export default router; diff --git a/bridge/src/routes/tasks-file.ts b/bridge/src/routes/tasks-file.ts new file mode 100644 index 0000000..e31088a --- /dev/null +++ b/bridge/src/routes/tasks-file.ts @@ -0,0 +1,130 @@ +/** + * routes/tasks-file.ts — Read tasks and projects from Tiger's markdown files + * + * Endpoints: + * GET /tiger/file-tasks — all tasks from TASKS.md + * GET /tiger/file-tasks/active — only active/pending-action tasks + * GET /tiger/file-tasks/completed — only completed tasks + * GET /tiger/file-tasks/projects — all projects from PROJECTS.md + * + * Parser contract (TASKS.md): + * Tiger maintains a fenced ```json TASKS ... ``` block at the bottom of + * TASKS.md. The parser reads ONLY from that block — no regex over markdown. + * If the block is absent, the endpoint returns HTTP 502 with a clear error. + * Tiger's MEMORY.md contains the rule: always emit the TASKS_JSON block + * on every TASKS.md write. + * + * Task JSON schema per entry: + * { id, title, agent, status, section, note?, age?, project? } + * section values: "pending-action" | "in-progress" | "completed" + * status values: "pending-action" | "in-progress" | "blocked" | "done" + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +// ── Parser: read TASKS_JSON fenced block from TASKS.md ─────────────────────── +function parseTasksJsonBlock(stdout: string): any[] { + // Match: ```json\nTASKS\n\n``` + const match = stdout.match(/```json\s+TASKS\s*\n([\s\S]+?)\n```/); + if (!match) { + throw new Error( + "TASKS.md missing TASKS_JSON block. Tiger must emit:\n" + + "```json\nTASKS\n[...]\n```\n" + + "at the bottom of every TASKS.md write." + ); + } + try { + return JSON.parse(match[1]); + } catch (e: any) { + throw new Error(`TASKS_JSON block is not valid JSON: ${e.message}`); + } +} + +// ── Parser: projects from PROJECTS.md ──────────────────────────────────────── +function parseProjectsMarkdown(stdout: string) { + const projects: any[] = []; + const lines = stdout.split("\n"); + let inActive = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith("## Active Projects")) { inActive = true; continue; } + if (trimmed.startsWith("## Completed Projects")) break; + + if (inActive && trimmed.match(/^\| \d/)) { + const cols = trimmed.split("|").map((c: string) => c.trim()).filter(Boolean); + if (cols.length >= 5) { + projects.push({ + id: cols[0], + name: cols[1], + description: cols[2], + created: cols[3], + tasks_count: cols[4], + status: cols[5] || "active", + }); + } + } + } + + return projects; +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +// GET /tiger/file-tasks — all tasks +router.get("/", async (req: Request, res: Response) => { + try { + const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md"); + const allTasks = parseTasksJsonBlock(stdout); + const projectFilter = (req.query.project as string || "").trim().toLowerCase(); + const filtered = projectFilter + ? allTasks.filter((t: any) => t.project?.toLowerCase().includes(projectFilter)) + : allTasks; + res.json({ ok: true, source: "TASKS.md", count: filtered.length, tasks: filtered }); + } catch (err: any) { + const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500; + res.status(status).json({ ok: false, error: err.message }); + } +}); + +// GET /tiger/file-tasks/active — active + pending-action tasks only +router.get("/active", async (_req: Request, res: Response) => { + try { + const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md"); + const tasks = parseTasksJsonBlock(stdout).filter( + (t: any) => t.section === "in-progress" || t.section === "pending-action" + ); + res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks }); + } catch (err: any) { + const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500; + res.status(status).json({ ok: false, error: err.message }); + } +}); + +// GET /tiger/file-tasks/completed — completed tasks only +router.get("/completed", async (_req: Request, res: Response) => { + try { + const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md"); + const tasks = parseTasksJsonBlock(stdout).filter((t: any) => t.section === "completed"); + res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks }); + } catch (err: any) { + const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500; + res.status(status).json({ ok: false, error: err.message }); + } +}); + +// GET /tiger/file-tasks/projects — all projects +router.get("/projects", async (_req: Request, res: Response) => { + try { + const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/PROJECTS.md"); + const projects = parseProjectsMarkdown(stdout); + res.json({ ok: true, source: "PROJECTS.md", count: projects.length, projects }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router;