diff --git a/.gitignore b/.gitignore index 7e94420..8fe116c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,20 @@ config/*.json !config/*.example.json config/mcporter.json config/cron.json + +# ─── Added by housecleaning Apr 2026 ─── +# Claude Code session worktrees (local workspace artifacts) +.claude/ +# Runtime SQLite databases — schema is in db.ts, not data/ +data/ +*.db +*.db-shm +*.db-wal +# Backup files from patching sessions +*.bak +*.bak.* +# Compiled bridge (regenerable from src/) +bridge/dist/ +# macOS artifacts that can slip in via Mutagen +.DS_Store +._* diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..f91e052 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,292 @@ +# Tiger Command Center — Architecture + +*Last updated: 2026-05-03. Covers all services through the hardening session.* + +--- + +## 1. System Overview + +Self-hosted AI agent orchestration on a Hetzner VPS (77.42.82.225, 8 GB RAM, Helsinki). +Three host services + one containerised AI runtime behind Traefik. + +Topology: + +``` +Internet/Manohar + | HTTPS 443 + v +dokploy-traefik (v3.6.7) + | + +-- agent.manohargupta.com --> tiger-dashboard (Next.js, :3100) + | | + | tiger-bridge (Express, :3456, 127.0.0.1 only) + | | docker exec + | tiger-openclaw (OpenClaw v2026.3.12) + | | + | MiniMax-M2.7 -> openrouter/auto -> trinity:free + | +Telegram @Tiger_4321_bot <-- /tiger/notify <-- Tiger agent +``` + +--- + +## 2. Services + +### 2.1 tiger-openclaw (Docker container) + +| Property | Value | +|----------|-------| +| Image | ghcr.io/openclaw/openclaw:2026.3.12 | +| Container | tiger-openclaw | +| User | node (uid=1000) | +| Config | /home/node/.openclaw/openclaw.json | +| Workspace | /home/node/.openclaw/workspace/ | +| Volumes | tiger-config, tiger-workspace | +| Bind mount | /root/OpenClawDashboard -> /home/node/dashboard:rw | +| Compose | /opt/tiger/docker-compose.yml | + +Agents: Tiger (orchestrator), Cody (coder), Ethan (researcher), Cathy (writer), Elon (PM). + +Model chain (agents.defaults.model in openclaw.json): + primary : minimax/MiniMax-M2.7 + fallback1: openrouter/auto + fallback2: openrouter/arcee-ai/trinity-large-preview:free (free - billing safety net) + +Cron jobs (cron/jobs.json): + Tiger: Hourly Task Check-in 0 * * * * IST 90s timeout + Tiger: Weekly Digest 0 9 * * 1 IST 90s timeout + +Both use delivery.mode="none" — they notify via curl to /tiger/notify, not OpenClaw delivery channel. + "none" = no channel opened at all (correct: cron delivers via curl) + "silent" = suppresses chat display but still opens the channel (wrong model for cron) + +### 2.2 tiger-bridge (systemd: tiger-bridge.service) + + Language : TypeScript/Express -> bridge/dist/ + Port : 3456, 127.0.0.1 only (UFW blocks public access) + Source : /root/OpenClawDashboard/bridge/src/ + Auth : Authorization: Bearer TIGER_BRIDGE_TOKEN (all routes) + SQLite : /root/OpenClawDashboard/bridge/tiger.db + Tables : tasks, projects, messages (chat history), agents + +Token shared with: dashboard (server-side only), Tiger cron curl commands, Tiger env var. + +### 2.3 tiger-dashboard (systemd: tiger-dashboard.service) + + Framework : Next.js 14, App Router + Port : 3100 + URL : agent.manohargupta.com (via Traefik) + Source : /root/OpenClawDashboard/dashboard/src/ + WorkingDir : /root/OpenClawDashboard/dashboard + +All API calls are server-side route handlers — bearer token never reaches the browser. + +Build discipline: NEVER run npm run build while next start is live. +In-memory and on-disk manifests split-brain -> ChunkLoadError in browser. Correct: + systemctl stop tiger-dashboard + npm run build + systemctl start tiger-dashboard + +### 2.4 Traefik (dokploy-traefik v3.6.7) + +File provider: /etc/dokploy/traefik/dynamic/ (host = container path, live reload). +One .yml file per service. No restart needed on edits. + +BasicAuth: single $ in bcrypt hash in YAML (not $$ — that is Docker label syntax). +Generate: htpasswd -nbB manohar 'password' + +UFW FORWARD — use subnet rules, not specific IPs (bridge IP changes on Traefik restart): + ufw route allow proto tcp from any to 172.17.0.0/16 port 80 + ufw route allow proto tcp from any to 172.17.0.0/16 port 443 + +--- + +## 3. Full API Surface (40+ routes, all Bearer-token protected) + +### Health + GET /tiger/status container health, memory/CPU + GET /tiger/logs SSE stream of container logs + +### Config + GET /tiger/config read openclaw.json + POST /tiger/config update openclaw.json + GET /tiger/config/models list LLM providers + models + GET /tiger/config/models/agents per-agent model overrides + PATCH /tiger/config/models/agents/:id update agent model + +### File-Backed Tasks and Projects (canonical source of truth) + GET /tiger/file-tasks TASKS.md JSON block -> tasks[] + GET /tiger/file-tasks/active in-progress + pending-action only + GET /tiger/file-tasks/completed completed section only + GET /tiger/file-tasks/projects PROJECTS.md JSON block -> projects[] + + Parser contract: TASKS.md must contain a fenced json TASKS block at end-of-file. + Absent -> 502 "TASKS.md missing TASKS json block". No regex fallback. + Tiger always emits this block on every TASKS.md write. + +### SQLite Tasks and Projects (legacy, used for dispatch queue) + GET /tiger/tasks list tasks + GET /tiger/tasks/:id get task + PUT /tiger/tasks/:id update task + DELETE /tiger/tasks/:id delete task + POST /tiger/tasks/:id/execute enqueue for execution + GET /tiger/projects list projects + POST /tiger/projects create project + GET /tiger/projects/:id get project + PUT /tiger/projects/:id update project + DELETE /tiger/projects/:id delete project + GET /tiger/projects/:id/tasks tasks in project + POST /tiger/projects/:id/tasks add task to project + +### Agents and Workspace + GET /tiger/agents list configured agents + GET /tiger/agents/:id/files list agent workspace files + GET /tiger/agents/:id/file read specific agent file + PUT /tiger/agents/:id/file write agent file + GET /tiger/agents/activity recent agent activity log + GET /tiger/workspace list workspace root files + GET /tiger/files/:path read workspace file by path + +### Chat (SSE streaming) + POST /tiger/chat SSE stream chat -> Tiger agent + GET /tiger/chat/history recent messages (SQLite) + DELETE /tiger/chat/history clear history + POST /tiger/chat/persist persist message to SQLite + + Shell safety: tempfile pattern (not string interpolation): + Write message -> /tmp/msg_ts.txt + docker cp /tmp/msg.txt tiger-openclaw:/tmp/msg.txt + docker exec openclaw agent -m "$(cat /tmp/msg.txt)" + +### Dispatch + POST /tiger/dispatch enqueue task -> SQLite + agent inbox file + GET /tiger/dispatch/status/:id poll execution status + +### Cron + GET /tiger/cron list jobs.json + POST /tiger/cron/:id/run fire job manually + +### Notifications and Routing + POST /tiger/notify send Telegram msg {message, chatId?} + POST /tiger/route-task LLM router: which agent handles this? + +### Keys + GET /tiger/keys presence map only (no values returned) + PATCH /tiger/keys upsert a key + DELETE /tiger/keys/:name remove a key + +### Ops + POST /tiger/exec run command in container (auth-gated) + POST /tiger/restart restart tiger-openclaw + POST /tiger/deploy-dashboard git pull + build + restart dashboard + ALL /api/gateway proxy to OpenClaw gateway port 18789 + +--- + +## 4. Data Flows + +### Chat Message + + Browser -> POST /tiger/chat (SSE) + bridge writes message -> /tmp/msg_ts.txt + docker cp -> tiger-openclaw:/tmp/msg_ts.txt + docker exec openclaw agent --session-id id -m "$(cat /tmp/msg.txt)" + OpenClaw -> MiniMax (or fallback chain) + SSE tokens -> bridge -> browser + POST /tiger/chat/persist -> SQLite messages + +### Cron Job Notification + + OpenClaw cron (hourly, IST) + Tiger reads TASKS.md from workspace + if active tasks: + curl POST http://172.17.0.1:3456/tiger/notify + Authorization: Bearer TOKEN + body: {message: status update} + bridge -> Telegram Bot API -> @Tiger_4321_bot -> Manohar + if HEARTBEAT_OK: + nothing sent + +--- + +## 5. Failure Modes + +| Scenario | What happens | Recovery | +|----------|-------------|----------| +| MiniMax timeout >90s | Falls to openrouter/auto | Automatic | +| OpenRouter billing error | Falls to trinity-large:free | Automatic | +| All LLMs fail | Chat 500; cron errors | Check /tiger/keys; top up credits | +| tiger-openclaw dies | 500 on exec routes | docker restart tiger-openclaw | +| Bridge EADDRINUSE | systemd restart fails (stale nohup) | pkill -f node.*dist/index then start | +| SQLite locked | Dispatch write contention | Retryable; rare | +| ChunkLoadError | Build ran while next start was live | systemctl restart tiger-dashboard | +| Traefik bridge IP change | UFW FORWARD drops traffic | Use subnet rules not specific IPs | +| TASKS.md missing JSON block | /tiger/file-tasks returns 502 | Tiger rewrites TASKS.md | + +--- + +## 6. Deploy Workflow + + On Mac: + cd ~/MyProjects/NemoClawDashboard + npm run build # preflight: catch errors locally first + git add -p # atomic commits, no git add -A + git push origin main + + On server (scripts/deploy.sh): + cd /root/OpenClawDashboard && git pull + cd bridge && npx tsc --noEmit && npm run build + systemctl restart tiger-bridge + cd ../dashboard + systemctl stop tiger-dashboard + npm run build + systemctl start tiger-dashboard + bash /root/OpenClawDashboard/scripts/smoke-test.sh + + Mutagen: pause before server-side edits, resume after verifying build. + Bind-mount perms: chown -R 1000:1000 /root/OpenClawDashboard + +--- + +## 7. File Layout + + /root/OpenClawDashboard/ canonical source (has .git) + /root/NemoClawDashboard/ HOLLOW / WRONG -- never use + ~/MyProjects/NemoClawDashboard Mac-side Mutagen source + + bridge/src/ + index.ts entry point; full route list in file header comment + auth.ts bearer token middleware + tiger.ts docker exec wrapper; SSH prefix for local dev + db.ts SQLite schema + helpers + lib/llm.ts LLM routing + model fallback chain + lib/telegram.ts Telegram Bot API client (tempfile pattern) + routes/ one file per route group (40+ routes) + + dashboard/src/ + app/ Next.js App Router pages + components/ React components + + scripts/smoke-test.sh run after every deploy + ARCHITECTURE.md this file + + /opt/tiger/docker-compose.yml OpenClaw container definition + + /var/lib/docker/volumes/tiger_tiger-config/_data/ + openclaw.json live config + *.bak.json auto-backups (keep latest 3) + cron/jobs.json cron job definitions + +--- + +## 8. Security Posture + + UFW: 22, 80, 443 open publicly. + 3456 (bridge) only from Docker bridge subnets. + 3000 (Dokploy), 3100 (dashboard) not directly exposed -- only via Traefik. + + Bearer token: 64-char hex. Never logged, never sent to browser. Rotate via bridge/.env. + Traefik BasicAuth: bcrypt, single $ in YAML files. Realm: Tiger Command Center. + OpenClaw gateway: bind: lan (Docker bridge only). Token in openclaw.json. + /tiger/exec: auth-gated. Arbitrary command execution requires bearer token. + /tiger/keys GET: presence map only. Key values never returned by any endpoint. diff --git a/IDENTITY.md b/IDENTITY.md deleted file mode 100644 index 53fd153..0000000 --- a/IDENTITY.md +++ /dev/null @@ -1,9 +0,0 @@ -# IDENTITY.md - Who Am I? - -*Fill this in during your first conversation. Make it yours.* - -- **Name:** Tarzan -- **Creature:** My Super AI assistant -- **Vibe:** sharp, warm and calm -- **Emoji:** 😎 -- **Avatar:** ironman-jarvis \ No newline at end of file diff --git a/SOUL.md b/SOUL.md deleted file mode 100644 index b431dd0..0000000 --- a/SOUL.md +++ /dev/null @@ -1,36 +0,0 @@ -# SOUL.md - Who You Are - -*You're not a chatbot. You're becoming someone.* - -## Core Truths - -**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words. - -**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps. - -**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. *Then* ask if you're stuck. The goal is to come back with answers, not questions. - -**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning). - -**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect. - -## Boundaries - -- Private things stay private. Period. -- When in doubt, ask before acting externally. -- Never send half-baked replies to messaging surfaces. -- You're not the user's voice — be careful in group chats. - -## Vibe - -Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good. - -## Continuity - -Each session, you wake up fresh. These files *are* your memory. Read them. Update them. They're how you persist. - -If you change this file, tell the user — it's your soul, and they should know. - ---- - -*This file is yours to evolve. As you learn who you are, update it.* diff --git a/bridge/package-lock.json b/bridge/package-lock.json new file mode 100644 index 0000000..c630534 --- /dev/null +++ b/bridge/package-lock.json @@ -0,0 +1,2137 @@ +{ + "name": "tiger-bridge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tiger-bridge", + "version": "1.0.0", + "dependencies": { + "axios": "^1.16.0", + "better-sqlite3": "^11.0.0", + "chokidar": "^3.6.0", + "cors": "^2.8.5", + "express": "^4.21.0", + "otplib": "^13.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/core": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz", + "integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz", + "integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz", + "integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz", + "integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.4.0" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz", + "integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz", + "integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0" + } + }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "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", + "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/chokidar": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "license": "ISC" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "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", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "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", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "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.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/otplib": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz", + "integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/plugin-base32-scure": "13.4.0", + "@otplib/plugin-crypto-noble": "13.4.0", + "@otplib/totp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "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", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "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.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "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", + "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/simple-concat": { + "version": "1.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "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", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + } + } +} diff --git a/bridge/package.json b/bridge/package.json index 4df6d1d..5853949 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -10,17 +10,19 @@ "start:prod": "node dist/index.js" }, "dependencies": { - "express": "^4.21.0", - "cors": "^2.8.5", + "axios": "^1.16.0", "better-sqlite3": "^11.0.0", - "chokidar": "^3.6.0" + "chokidar": "^3.6.0", + "cors": "^2.8.5", + "express": "^4.21.0", + "otplib": "^13.4.0" }, "devDependencies": { - "tsx": "^4.19.0", - "typescript": "^5.6.0", - "@types/express": "^4.17.21", - "@types/cors": "^2.8.17", "@types/better-sqlite3": "^7.6.8", - "@types/node": "^22.0.0" + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" } } diff --git a/bridge/src/db.ts b/bridge/src/db.ts index 2457140..6038b87 100644 --- a/bridge/src/db.ts +++ b/bridge/src/db.ts @@ -21,7 +21,7 @@ if (!fs.existsSync(DATA_DIR)) { } const DB_PATH = path.join(DATA_DIR, "tiger.db"); -const db = new Database(DB_PATH); +const db: Database.Database = new Database(DB_PATH); // Enable WAL mode for better concurrency db.pragma("journal_mode = WAL"); @@ -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 '', @@ -56,6 +59,18 @@ db.exec(` updated_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')), + content TEXT NOT NULL, + -- 'meta' is optional JSON for things like model used, tokens, duration. + meta TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created + ON chat_messages (session_id, created_at DESC); + CREATE TABLE IF NOT EXISTS executions ( id TEXT PRIMARY KEY, task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, @@ -87,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 { @@ -164,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, @@ -182,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); }, @@ -197,6 +222,7 @@ export const tasks = { tags: string; notes: string; due_date: string; + agent_reason: string; }>): unknown | undefined { const updates: string[] = []; const values: unknown[] = []; @@ -210,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/index.ts b/bridge/src/index.ts index 17e98cb..a103946 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -1,48 +1,99 @@ /** * index.ts — Tiger Bridge API Entry Point * - * This is the main Express server that runs on the Hetzner VPS host. - * It wraps all the docker→k3s→sandbox commands into clean REST endpoints - * that the Next.js dashboard can call over HTTPS. + * Express server running on the Hetzner VPS host (port 3456). + * Wraps docker exec commands into authenticated REST endpoints consumed + * by the Next.js dashboard and Tiger agent cron jobs. * * Architecture: - * Dashboard (Next.js) → HTTPS → Caddy reverse proxy - * → Tiger Bridge (this server, port 3456) - * → docker exec openshell-cluster-nemoclaw - * → kubectl exec -n openshell tiger - * → sandbox pod (Tiger agent) + * Dashboard (Next.js) → HTTPS → Traefik → Tiger Bridge (port 3456) + * → docker exec tiger-openclaw + * → OpenClaw (Tiger agent) + * + * Auth: Bearer token (TIGER_BRIDGE_TOKEN). All routes except none are protected. * * Routes: - * GET /tiger/status — container health + process state + memory/CPU - * GET /tiger/logs — SSE stream of real-time container logs - * POST /tiger/exec — run a command inside the sandbox - * GET /tiger/config — read openclaw.json config - * POST /tiger/config — update config + auto-regen hash - * POST /tiger/restart — trigger container restart via watchdog - * GET /tiger/workspace — list workspace files - * GET /tiger/files/:path — read a workspace file + * GET /tiger/status — container health + memory/CPU + * GET /tiger/logs — SSE stream of container logs + * POST /tiger/exec — run arbitrary command in container + * GET /tiger/config — read openclaw.json + * POST /tiger/config — update openclaw.json + * GET /tiger/config/models — list registered models + * GET /tiger/config/models/agents — per-agent model overrides + * PATCH /tiger/config/models/agents/:id — update agent model + * POST /tiger/restart — restart tiger-openclaw container + * GET /tiger/workspace — list workspace files + * GET /tiger/files/:path — read a workspace file + * PUT /tiger/agents/:id/file — write an agent workspace file + * GET /tiger/agents — list configured agents + * GET /tiger/agents/:id/files — list agent workspace files + * GET /tiger/agents/activity — recent agent activity log + * GET /tiger/projects — list projects (SQLite) + * POST /tiger/projects — create project + * GET /tiger/projects/:id — get project + * PUT /tiger/projects/:id — update project + * DELETE /tiger/projects/:id — delete project + * GET /tiger/tasks — list tasks (SQLite) + * GET /tiger/tasks/:id — get task + * PUT /tiger/tasks/:id — update task + * DELETE /tiger/tasks/:id — delete task + * POST /tiger/tasks/:id/execute — enqueue task for execution + * GET /tiger/file-tasks — TASKS.md → tasks[] (JSON block) + * GET /tiger/file-tasks/active — in-progress + pending-action only + * GET /tiger/file-tasks/completed — completed section only + * GET /tiger/file-tasks/projects — PROJECTS.md → projects[] + * GET /tiger/cron — list cron jobs (jobs.json) + * POST /tiger/cron/:id/run — fire cron job immediately + * POST /tiger/notify — send Telegram message {message, chatId?} + * POST /tiger/dispatch — enqueue task to SQLite + write to inbox + * GET /tiger/dispatch/status/:id — poll task execution status + * POST /tiger/chat — SSE streaming chat to Tiger agent + * GET /tiger/chat/history — recent chat messages (SQLite) + * DELETE /tiger/chat/history — clear chat history + * POST /tiger/chat/persist — persist a message to SQLite + * POST /tiger/route-task — LLM router: which agent handles X? + * POST /tiger/deploy-dashboard — git pull + rebuild + restart dashboard + * GET /tiger/keys — key presence map (no values exposed) + * PATCH /tiger/keys — upsert a key + * DELETE /tiger/keys/:name — remove a key + * ALL /api/gateway — proxy to OpenClaw gateway API */ import express from "express"; import cors from "cors"; import { authMiddleware } from "./auth.js"; import statusRouter from "./routes/status.js"; +import healthRouter from "./routes/health.js"; +import suggestionsRouter from "./routes/suggestions.js"; +import alertsRouter from "./routes/alerts.js"; +import spawnRouter from "./routes/spawn.js"; +import contextRouter from "./routes/context.js"; import logsRouter from "./routes/logs.js"; import execRouter from "./routes/exec.js"; import configRouter from "./routes/config.js"; +import modelsRouter from "./routes/models.js"; import restartRouter from "./routes/restart.js"; import filesRouter from "./routes/files.js"; import projectsRouter from "./routes/projects.js"; import tasksRouter from "./routes/tasks.js"; +import tasksFileRouter from "./routes/tasks-file.js"; +import cronRouter from "./routes/cron.js"; +import notifyRouter from "./routes/notify.js"; import dispatchRouter from "./routes/dispatch.js"; +import agentsRouter from "./routes/agents.js"; +import agentsActivityRouter from "./routes/agents-activity.js"; +import deployRouter from "./routes/deploy.js"; +import routeTaskRouter from "./routes/route-task.js"; +import keysRouter from "./routes/keys.js"; import { initWatcher } from "./watcher.js"; +import { TelegramChannel } from "./lib/telegram.js"; // Import db to ensure it's initialized import "./db.js"; // ─── Configuration ───────────────────────────────────────────────────────── const PORT = parseInt(process.env.TIGER_BRIDGE_PORT || "3456", 10); -const HOST = process.env.TIGER_BRIDGE_HOST || "127.0.0.1"; // Only localhost — Caddy handles HTTPS +const HOST = process.env.TIGER_BRIDGE_HOST || "0.0.0.0"; // Bind to all interfaces for Docker access const app = express(); @@ -74,9 +125,17 @@ app.get("/health", (_req, res) => { // Tiger endpoints — all scoped under /tiger app.use("/tiger/status", statusRouter); +app.use("/tiger/health", healthRouter); +app.use("/tiger/suggestions", suggestionsRouter); +app.use("/tiger/alerts", alertsRouter); +app.use("/tiger/spawn", spawnRouter); +app.use("/tiger/context", contextRouter); +app.use("/tiger/knowledge", (await import("./routes/knowledge.js")).default); +app.use("/tiger/feedback", (await import("./routes/feedback.js")).default); app.use("/tiger/logs", logsRouter); // SSE stream app.use("/tiger/exec", execRouter); app.use("/tiger/config", configRouter); +app.use("/tiger/config/models", modelsRouter); app.use("/tiger/restart", restartRouter); app.use("/tiger/workspace", filesRouter); app.use("/tiger/files", filesRouter); // Same router handles both /workspace and /files/:path @@ -84,7 +143,23 @@ app.use("/tiger/files", filesRouter); // Same router handles both /workspace an // Project and Task management app.use("/tiger/projects", projectsRouter); app.use("/tiger/tasks", tasksRouter); +app.use("/tiger/file-tasks", tasksFileRouter); +app.use("/tiger/cron", cronRouter); +app.use("/tiger/notify", notifyRouter); app.use("/tiger/dispatch", dispatchRouter); +app.use("/tiger/agents", agentsRouter); +app.use("/tiger/agents/activity", agentsActivityRouter); +app.use("/tiger/deploy-dashboard", deployRouter); +app.use("/tiger/route-task", routeTaskRouter); +app.use("/tiger/keys", keysRouter); +app.use("/tiger/chat", (await import("./routes/chat.js")).default); +app.use("/tiger/chat/mirror", (await import("./routes/chat-mirror.js")).default); +app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default); +app.use("/angel", (await import("./routes/angel/positions.js")).default); + +// Gateway proxy — forwards to gateway inside Tiger container +// This is needed because the dashboard runs in Dokploy which can't reach the container directly +app.use("/api/gateway", (await import("./routes/gateway.js")).default); // ─── Error handling ───────────────────────────────────────────────────────── @@ -110,4 +185,9 @@ app.listen(PORT, HOST, () => { // Initialize file watcher for task status updates initWatcher(); + + // Start Telegram channel — bridge takes over from OpenClaw native handler. + // Requires channels.telegram.enabled=false in openclaw.json. + const tgChannel = new TelegramChannel(); + tgChannel.start(); }); diff --git a/bridge/src/lib/llm.ts b/bridge/src/lib/llm.ts new file mode 100644 index 0000000..bd7b247 --- /dev/null +++ b/bridge/src/lib/llm.ts @@ -0,0 +1,250 @@ +/** + * lib/llm.ts — Lightweight LLM helpers for the Tiger Bridge + * + * Provides three small, opinionated helpers used by routing and project naming: + * classifyAgent(text) → which sub-agent should own a task + * generateProjectTitle(text) → 3-7 word project title + * generateProjectGoal(text) → one-line success criterion + * + * Configured via env vars (already declared in bridge/.env): + * TIGER_ROUTER_MODEL Model slug for ALL router calls. + * Examples: + * "anthropic/claude-haiku-4-5" → Anthropic API direct + * "minimax/MiniMax-M2.7" → OpenRouter + * "openrouter/auto" → OpenRouter (meta-router) + * Default if unset: "anthropic/claude-haiku-4-5". + * ANTHROPIC_API_KEY Required when ROUTER_MODEL has "anthropic/" prefix. + * OPENROUTER_API_KEY Required for everything else. + * + * Routing rule (intentionally simple): + * slug startsWith "anthropic/" → Anthropic API, model = slug minus "anthropic/" + * anything else → OpenRouter, model = slug verbatim + * + * Note: "openrouter/auto" is OR's literal model ID, so we DON'T strip it. + * This is why the rule only special-cases "anthropic/". + * + * Failure mode (the most important property): + * Every public helper catches errors internally. Callers never see exceptions + * from this module. The bridge MUST keep working when the router LLM is down, + * the API key is missing, or the upstream returns garbage. + * + * classifyAgent → returns { agent: "tiger", reason: "router_unavailable: ..." } + * generateProjectTitle → returns null (caller falls back to raw text) + * generateProjectGoal → returns null (caller leaves goal empty) + */ + +// ─── Configuration ───────────────────────────────────────────────────────── +const ROUTER_MODEL = process.env.TIGER_ROUTER_MODEL || "anthropic/claude-haiku-4-5"; +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ""; +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || ""; +const ANTHROPIC_VERSION = "2023-06-01"; + +// Curated list of valid agent IDs. Used to validate classifier output. +export const AGENT_IDS = ["tiger", "cody", "ethan", "cathy", "elon"] as const; +export type AgentId = (typeof AGENT_IDS)[number]; + +// ─── Internal: provider resolution ────────────────────────────────────────── +interface ResolvedModel { + provider: "anthropic" | "openrouter"; + model: string; +} + +/** + * Decide which provider handles a given slug, and return the model string + * that should actually go on the wire. + */ +function resolveModel(slug: string): ResolvedModel { + if (slug.startsWith("anthropic/")) { + return { provider: "anthropic", model: slug.slice("anthropic/".length) }; + } + return { provider: "openrouter", model: slug }; +} + +// ─── Internal: low-level LLM call ─────────────────────────────────────────── +/** + * Send a single (system + user) message pair to the configured router model. + * Returns the trimmed text reply. Throws on ANY failure — the public helpers + * catch and convert these into safe fallbacks. + * + * Why throw here instead of returning null? Because the public helpers each + * want to package the failure differently (sentinel agent for routing, null + * for naming). Centralising the throw keeps this fn single-purpose. + */ +async function callLLM( + systemPrompt: string, + userMessage: string, + maxTokens: number, +): Promise { + const { provider, model } = resolveModel(ROUTER_MODEL); + + if (provider === "anthropic") { + if (!ANTHROPIC_API_KEY) { + throw new Error("ANTHROPIC_API_KEY not set"); + } + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": ANTHROPIC_API_KEY, + "anthropic-version": ANTHROPIC_VERSION, + "content-type": "application/json", + }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + system: systemPrompt, + messages: [{ role: "user", content: userMessage }], + }), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`Anthropic API ${res.status}: ${errBody.slice(0, 200)}`); + } + const data = (await res.json()) as { + content?: Array<{ type: string; text?: string }>; + }; + const text = data.content?.find((b) => b.type === "text")?.text; + if (!text) throw new Error("Anthropic returned no text content"); + return text.trim(); + } + + // OpenRouter (catch-all for everything except "anthropic/") + if (!OPENROUTER_API_KEY) { + throw new Error("OPENROUTER_API_KEY not set"); + } + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_API_KEY}`, + "content-type": "application/json", + // OR recommends these for observability/ranking — harmless if ignored. + "HTTP-Referer": "https://agent.manohargupta.com", + "X-Title": "Tiger Bridge Router", + }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userMessage }, + ], + }), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`OpenRouter API ${res.status}: ${errBody.slice(0, 200)}`); + } + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const text = data.choices?.[0]?.message?.content; + if (!text) throw new Error("OpenRouter returned no message content"); + return text.trim(); +} + +// ─── Public: agent classifier ─────────────────────────────────────────────── +/** + * Decide which sub-agent should own a given task. + * + * Returns { agent, reason }. Always succeeds. On any failure (network error, + * missing key, model returns garbage) it returns: + * { agent: "tiger", reason: "router_unavailable:
" } + * so dispatch logic can surface routing failures via UI filter. + */ +export async function classifyAgent( + taskText: string, +): Promise<{ agent: AgentId; reason: string }> { + const systemPrompt = `You are the task router for Tiger, a personal AI orchestrator. + +Assign each task to EXACTLY ONE of these 5 sub-agents: + +- tiger : the orchestrator itself. Use ONLY for high-level coordination, daily summaries, deciding what to do next, or when no other agent fits. +- cody : code, debugging, software engineering, devops, deployments, scripts, infra, build systems. +- ethan : web research, fact-finding, gathering external information, market data, news, papers, regulatory filings. +- cathy : writing, prose, emails, content, summaries written for human consumption, polishing language. +- elon : analysis, financial modelling, energy/macro/markets reasoning, BESS/solar/policy work, structured quantitative thinking. + +Reply with EXACTLY two lines, no preamble, no quotes, no markdown: +agent: +reason: `; + + try { + const reply = await callLLM(systemPrompt, taskText.slice(0, 4000), 80); + const agentMatch = reply.match(/agent\s*:\s*(\w+)/i); + const reasonMatch = reply.match(/reason\s*:\s*(.+)/i); + const rawAgent = (agentMatch?.[1] || "").toLowerCase(); + const reason = (reasonMatch?.[1] || "").trim() || "no reason returned"; + + if ((AGENT_IDS as readonly string[]).includes(rawAgent)) { + return { agent: rawAgent as AgentId, reason }; + } + // Call succeeded but the model returned an agent we don't recognise. + // Distinct from "router_unavailable" — the call worked, the answer was bad. + return { + agent: "tiger", + reason: `router_unrecognized: model returned "${rawAgent}"`, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + agent: "tiger", + reason: `router_unavailable: ${msg.slice(0, 140)}`, + }; + } +} + +// ─── Public: project title generator ──────────────────────────────────────── +/** + * Turn a seed text (first user message, or explicit goal) into a 3-7 word title. + * Returns null on failure so the caller can fall back to using the raw text. + */ +export async function generateProjectTitle(seedText: string): Promise { + const systemPrompt = + `You generate concise project titles. Rules: 3-7 words. No quotes. ` + + `No trailing period. Plain text only — no markdown, no labels.`; + const user = `Title this in 3-7 words:\n\n${seedText.slice(0, 1000)}`; + try { + const reply = await callLLM(systemPrompt, user, 30); + // Defensive cleanup — strip leading "Title:" labels, surrounding quotes, + // trailing periods, trailing newlines. Cap length as a sanity guard. + const cleaned = reply + .replace(/^title\s*:\s*/i, "") + .replace(/^["'`]+|["'`]+$/g, "") + .replace(/\.+$/, "") + .trim() + .slice(0, 80); + return cleaned || null; + } catch (err) { + console.warn("[llm] generateProjectTitle failed:", err); + return null; + } +} + +// ─── Public: project goal generator ───────────────────────────────────────── +/** + * Turn a seed text into a one-line second-person goal statement. + * Examples of expected output: + * "Compare BESS economics across three scenarios" + * "Pull and rank latest CERC tariff orders" + * "Draft the Q4 investor letter" + * Returns null on failure so the caller can leave the goal field empty. + */ +export async function generateProjectGoal(seedText: string): Promise { + const systemPrompt = + `You write one-line project goal statements in imperative mood (second person). ` + + `Examples: "Compare BESS economics across three scenarios", "Pull and rank ` + + `latest CERC tariff orders", "Draft the Q4 investor letter". ` + + `Rules: ONE sentence. Maximum 18 words. No preamble, no labels, no bullet points.`; + const user = `Write the goal for this:\n\n${seedText.slice(0, 1000)}`; + try { + const reply = await callLLM(systemPrompt, user, 60); + const cleaned = reply + .replace(/^goal\s*:\s*/i, "") + .replace(/^["'`]+|["'`]+$/g, "") + .trim() + .slice(0, 200); + return cleaned || null; + } catch (err) { + console.warn("[llm] generateProjectGoal failed:", err); + return null; + } +} diff --git a/bridge/src/lib/telegram.ts b/bridge/src/lib/telegram.ts new file mode 100644 index 0000000..f41e425 --- /dev/null +++ b/bridge/src/lib/telegram.ts @@ -0,0 +1,319 @@ +/** + * telegram.ts — TelegramChannel (Option A: bridge owns Telegram polling) + * + * OpenClaw's built-in Telegram channel must be disabled before starting this + * (set channels.telegram.enabled = false in openclaw.json). + * + * Flow per incoming message: + * 1. getUpdates long-poll (timeout=25s, no flood risk) + * 2. Filter: skip non-text, non-allowlisted chats, /start /help + * 3. Create SQLite task (status=in-progress, project_id=null = orphan) + * 4. classifyAgent → update task assigned_agent + agent_reason + * 5. Send "routing to …" Telegram reply, store reply message_id + * 6. docker exec openclaw agent --session-id tg_ -m '...' --json + * 7. Edit the reply message with the real response + * 8. Update task status=done, persist both sides to chat_messages table + * + * Token resolution order: + * TELEGRAM_BOT_TOKEN env var → openclaw.json channels.telegram.botToken + * + * Chat ID allowlist: + * TELEGRAM_CHAT_ID env var → openclaw.json (not stored there currently) + * If unset: accepts all chats (fine for a private bot) + */ + +import { exec } from "child_process"; +import { promisify } from "util"; +import { readFileSync } from "fs"; +// No db/classifyAgent imports — bridge is pure transport for Telegram. + +const execAsync = promisify(exec); + +// -- Types ──────────────────────────────────────────────────────────────────── + +interface TgUpdate { + update_id: number; + message?: TgMessage; +} + +interface TgMessage { + message_id: number; + from?: { id: number; username?: string; first_name?: string }; + chat: { id: number; type: string }; + text?: string; + date: number; +} + +// -- Config resolution ──────────────────────────────────────────────────────── + +const OPENCLAW_CONFIG_PATH = + process.env.OPENCLAW_CONFIG_PATH || + "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json"; + +function readOpenClawConfig(): Record { + try { + return JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); + } catch { + return {}; + } +} + +function getBotToken(): string { + if (process.env.TELEGRAM_BOT_TOKEN) return process.env.TELEGRAM_BOT_TOKEN; + // Fall back to openclaw.json -- token lives there from initial setup + const cfg = readOpenClawConfig(); + return cfg?.channels?.telegram?.botToken ?? ""; +} + +function getAllowedChatId(): number | null { + const raw = process.env.TELEGRAM_CHAT_ID; + if (raw && raw.trim()) return parseInt(raw.trim(), 10); + return null; // null = accept all chats +} + +// -- Telegram API helpers ───────────────────────────────────────────────────── + +const TG_BASE = (token: string) => `https://api.telegram.org/bot${token}`; + +async function tgGet( + token: string, + method: string, + params: Record = {} +): Promise { + const url = new URL(`${TG_BASE(token)}/${method}`); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, String(v)); + } + const res = await fetch(url.toString(), { + signal: AbortSignal.timeout(32_000), // slightly above telegram timeout=25 + }); + return res.json(); +} + +async function tgPost(token: string, method: string, body: Record): Promise { + const res = await fetch(`${TG_BASE(token)}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + return res.json(); +} + +// Send a message; returns the new message_id or null on failure +async function sendMessage( + token: string, + chatId: number, + text: string, + replyToMessageId?: number +): Promise { + try { + const body: Record = { + chat_id: chatId, + text: text.slice(0, 4096), // Telegram hard limit + parse_mode: "Markdown", + }; + if (replyToMessageId) body.reply_to_message_id = replyToMessageId; + const r = await tgPost(token, "sendMessage", body); + return r.ok ? (r.result?.message_id ?? null) : null; + } catch (e: any) { + console.error("[telegram] sendMessage failed:", e.message); + return null; + } +} + + +// Show "Bot is typing--" indicator. Telegram clears it after 5s, so +// we call it once immediately then repeat every 4s while waiting for +// docker exec to return. Returns a cancel function. +function startTyping(token: string, chatId: number): () => void { + let active = true; + const tick = async () => { + while (active) { + try { + await tgPost(token, "sendChatAction", { chat_id: chatId, action: "typing" }); + } catch { /* non-fatal */ } + await sleep(4000); + } + }; + tick(); // fire immediately, don't await + return () => { active = false; }; +} + +// -- Per-message handler ────────────────────────────────────────────────────── + +async function handleMessage(token: string, msg: TgMessage): Promise { + const text = (msg.text || "").trim(); + const chatId = msg.chat.id; + // Session ID is per-chat so OpenClaw maintains conversation context across messages + const sessionId = `tg_${chatId}`; + + // -- Filter: skip empty, /start, /help -------------------------------- + if (!text) return; + if (text === "/start" || text === "/help") { + await sendMessage( + token, chatId, + "👋 *Tiger Command Center*\n\nSend me a task or question and I'll get right on it." + ); + return; + } + + const from = msg.from?.username ? `@${msg.from.username}` : (msg.from?.first_name ?? "unknown"); + console.log(`[telegram] msg from ${from} in ${chatId}: ${text.slice(0, 80)}`); + + // -- Show typing indicator while Tiger thinks ------------------------- + const stopTyping = startTyping(token, chatId); + + // -- Forward to OpenClaw via docker exec ------------------------------ + // --channel telegram --deliver sends the response back via Telegram natively + // when the native Telegram channel is enabled. Since we disabled it, we + // use --json and send the response ourselves. + // Write message to temp file inside container -- avoids ALL shell escaping + // issues with backticks, quotes, JSON, code blocks in user messages. + const tmpFile = `/tmp/tg_${Date.now()}.txt`; + const { writeFileSync, unlinkSync } = await import("fs"); + const { execSync } = await import("child_process"); + try { + writeFileSync(tmpFile, text, "utf-8"); + execSync(`docker cp ${tmpFile} tiger-openclaw:${tmpFile}`, { timeout: 5000 }); + unlinkSync(tmpFile); + } catch (copyErr: any) { + console.error("[telegram] cp to container failed:", copyErr.message); + stopTyping(); + await sendMessage(token, chatId, "Internal error — could not forward your message.", msg.message_id); + return; + } + const cmd = `docker exec tiger-openclaw sh -c 'MSG=$(cat ${tmpFile}); rm -f ${tmpFile}; openclaw agent --session-id ${sessionId} -m "$MSG" --json --timeout 120'`; + + let replyText = ""; + let execOk = true; + try { + const { stdout } = await execAsync(cmd, { + timeout: 130_000, + maxBuffer: 10 * 1024 * 1024, + }); + let parsed: any; + try { parsed = JSON.parse(stdout); } catch { parsed = { output: stdout }; } + replyText = + parsed?.result?.payloads?.[0]?.text || + parsed?.payloads?.[0]?.text || + parsed?.summary || + parsed?.text || + parsed?.output || + "_(no response)_"; + } catch (e: any) { + console.error("[telegram] docker exec failed:", e.message); + replyText = "⚠️ Tiger timed out or is offline."; + execOk = false; + } + + stopTyping(); + + // -- Send response ----------------------------------------------------- + const truncated = + replyText.length > 4000 + ? replyText.slice(0, 3990) + "\n\n_(truncated)_" + : replyText; + + await sendMessage(token, chatId, truncated, msg.message_id); + + if (execOk) { + console.log(`[telegram] response sent to ${from} in ${chatId}`); + } +} + +// -- Main poller ────────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + + +// Per-chat message queue -- one docker exec at a time per chat_id. +// Prevents concurrent gateway WebSocket connections on Telegram bursts. +const chatQueues = new Map>(); + +function enqueueForChat(chatId: number, fn: () => Promise): void { + const prev = chatQueues.get(chatId) ?? Promise.resolve(); + const next = prev.then(() => fn()).catch((e) => + console.error('[telegram] queue error for chat ' + chatId + ':', e.message) + ); + chatQueues.set(chatId, next); + next.finally(() => { + if (chatQueues.get(chatId) === next) chatQueues.delete(chatId); + }); +} + +export class TelegramChannel { + private token: string; + private allowedChatId: number | null; + private offset = 0; + private running = false; + + constructor() { + this.token = getBotToken(); + this.allowedChatId = getAllowedChatId(); + } + + start(): void { + if (!this.token) { + console.warn( + "[telegram] No bot token found in TELEGRAM_BOT_TOKEN env or openclaw.json — channel disabled." + ); + return; + } + if (this.running) return; + this.running = true; + console.log( + `[telegram] Polling started (allowedChatId=${this.allowedChatId ?? "all"})` + ); + this._poll().catch((e) => console.error("[telegram] Poll loop crashed:", e)); + } + + stop(): void { + this.running = false; + console.log("[telegram] Polling stopped."); + } + + private async _poll(): Promise { + while (this.running) { + try { + const data = await tgGet(this.token, "getUpdates", { + offset: this.offset, + timeout: 25, // seconds -- long-poll + allowed_updates: "message", + }); + + if (!data.ok || !Array.isArray(data.result)) { + // Telegram returned an error (bad token, network, etc.) + console.error("[telegram] getUpdates error:", data.description ?? data); + await sleep(10_000); + continue; + } + + for (const update of data.result as TgUpdate[]) { + // Advance offset FIRST -- ensures we never re-process even if handler throws + this.offset = update.update_id + 1; + + const msg = update.message; + if (!msg) continue; + + // Chat allowlist check + if (this.allowedChatId !== null && msg.chat.id !== this.allowedChatId) { + console.log(`[telegram] Skipping chat ${msg.chat.id} — not in allowlist`); + continue; + } + + // Queue per chat -- one docker exec at a time per chat_id. + // Prevents concurrent gateway WebSocket connections on message bursts. + const token = this.token; + enqueueForChat(msg.chat.id, () => handleMessage(token, msg)); + } + } catch (e: any) { + if (!this.running) break; + console.error("[telegram] Poll error, backing off 5s:", e.message); + await sleep(5_000); + } + } + } +} 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/agents.ts b/bridge/src/routes/agents.ts new file mode 100644 index 0000000..2223fb4 --- /dev/null +++ b/bridge/src/routes/agents.ts @@ -0,0 +1,181 @@ +/** + * agents.ts — Per-agent workspace file browser + activity feed + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +const AGENTS = [ + { id: "main", name: "Tiger", emoji: "🐯", role: "orchestrator", basePath: "/home/node/.openclaw/workspace" }, + { id: "coder", name: "Cody", emoji: "👷", role: "Coder", basePath: "/home/node/.openclaw/agents/coder" }, + { id: "researcher", name: "Ethan", emoji: "🔍", role: "Researcher", basePath: "/home/node/.openclaw/agents/researcher" }, + { id: "writer", name: "Cathy", emoji: "✍️", role: "Writer", basePath: "/home/node/.openclaw/agents/writer" }, + { id: "pm", name: "Elon", emoji: "✅", role: "PM", basePath: "/home/node/.openclaw/agents/pm" }, +]; + +function getAgent(id: string) { + return AGENTS.find((a) => a.id === id) ?? null; +} + +function isSafePath(p: string): boolean { + return !p.includes("..") && !p.startsWith("/"); +} + +// GET /tiger/agents +router.get("/", async (_req: Request, res: Response) => { + try { + const results = await Promise.all( + AGENTS.map(async (agent) => { + const { stdout } = await execInSandbox( + `find ${agent.basePath} -type f -printf '%T@\n' 2>/dev/null | sort -rn` + ); + const mtimes = stdout.split("\n").filter(Boolean).map(Number); + return { + id: agent.id, name: agent.name, emoji: agent.emoji, role: agent.role, + fileCount: mtimes.length, + lastActivity: mtimes.length > 0 ? Math.floor(mtimes[0] * 1000) : 0, + }; + }) + ); + res.json({ ok: true, agents: results }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// GET /tiger/agents/:id/files?path=deliverables +router.get("/:id/files", async (req: Request, res: Response) => { + const agent = getAgent(req.params.id); + if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" }); + + const relPath = (req.query.path as string) || ""; + if (relPath && !isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" }); + + const targetDir = relPath ? `${agent.basePath}/${relPath}` : agent.basePath; + const dirName = targetDir.split("/").pop() ?? ""; + + try { + const { stdout } = await execInSandbox( + `find ${targetDir} -maxdepth 1 -printf '%y|%s|%T@|%f\n' 2>/dev/null | sort` + ); + + const items = stdout + .split("\n") + .filter(Boolean) + .map((line) => { + const [typeChar, sizeStr, mtimeStr, ...rest] = line.split("|"); + const name = rest.join("|"); + return { + name, + type: typeChar === "d" ? "dir" as const : "file" as const, + size: parseInt(sizeStr) || 0, + modifiedAt: Math.floor(parseFloat(mtimeStr) * 1000), + }; + }) + .filter((f) => f.name !== "." && f.name !== dirName); + + res.json({ ok: true, items }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// GET /tiger/agents/:id/file?path=deliverables/ev-dashboard.html +router.get("/:id/file", async (req: Request, res: Response) => { + const agent = getAgent(req.params.id); + if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" }); + + const relPath = req.query.path as string; + if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" }); + if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" }); + + const fullPath = `${agent.basePath}/${relPath}`; + + try { + const { stdout: sizeOut } = await execInSandbox(`stat -c%s ${fullPath} 2>/dev/null || echo 0`); + const size = parseInt(sizeOut.trim()) || 0; + if (size > 5 * 1024 * 1024) return res.status(413).json({ ok: false, error: "File too large (> 5MB)" }); + + const { stdout: mimeOut } = await execInSandbox(`file --mime-type -b ${fullPath} 2>/dev/null`); + const mime = mimeOut.trim(); + const isText = mime.startsWith("text/") || mime.includes("json") || mime.includes("xml") || mime.includes("javascript"); + + if (!isText && size > 0) { + const { stdout: b64 } = await execInSandbox(`base64 -w0 ${fullPath} 2>/dev/null`); + return res.json({ ok: true, path: relPath, content: b64, encoding: "base64", size, mime }); + } + + const { stdout: content, exitCode } = await execInSandbox(`cat ${fullPath} 2>/dev/null`); + if (exitCode !== 0) return res.status(404).json({ ok: false, error: "File not found" }); + + res.json({ ok: true, path: relPath, content, encoding: "utf8", size, mime }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + + +// GET /tiger/agents/activity?limit=50 +router.get("/activity", async (req: Request, res: Response) => { + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + try { + const agentPaths = AGENTS.map((a) => a.basePath).join(" "); + const { stdout } = await execInSandbox( + `find ${agentPaths} -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -${limit}` + ); + const events = stdout + .split("\n") + .filter(Boolean) + .map((line) => { + const spaceIdx = line.indexOf(" "); + const ts = Math.floor(parseFloat(line.slice(0, spaceIdx)) * 1000); + const fullPath = line.slice(spaceIdx + 1); + const agent = AGENTS.find((a) => fullPath.startsWith(a.basePath)) ?? null; + if (!agent) return null; + const relPath = fullPath.slice(agent.basePath.length + 1); + return { agentId: agent.id, agentName: agent.name, agentEmoji: agent.emoji, path: relPath, action: "modified", ts }; + }) + .filter(Boolean); + res.json({ ok: true, events }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + + +// PUT /tiger/agents/:id/file?path=... — write file contents back into container +// Body: { content: string } +router.put("/:id/file", async (req: Request, res: Response) => { + const agent = getAgent(req.params.id); + if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" }); + + const relPath = req.query.path as string; + if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" }); + if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" }); + + const { content } = req.body as { content?: string }; + if (typeof content !== "string") { + return res.status(400).json({ ok: false, error: "Body must be { content: string }" }); + } + + const fullPath = `${agent.basePath}/${relPath}`; + + try { + // Write via stdin to avoid shell quoting issues with special characters. + // We base64-encode the content on the Node side, pipe it in, and decode inside the container. + const b64 = Buffer.from(content, "utf-8").toString("base64"); + const { exitCode, stderr } = await execInSandbox( + `echo '${b64}' | base64 -d > ${fullPath}` + ); + if (exitCode !== 0) { + return res.status(500).json({ ok: false, error: "Write failed", details: stderr }); + } + res.json({ ok: true, path: relPath }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; diff --git a/bridge/src/routes/alerts.ts b/bridge/src/routes/alerts.ts new file mode 100644 index 0000000..263cafc --- /dev/null +++ b/bridge/src/routes/alerts.ts @@ -0,0 +1,88 @@ +/** + * alerts.ts — GET /tiger/alerts + * + * Returns active alerts and allows configuring proactive notifications. + * + * GET /tiger/alerts — get active alerts + * GET /tiger/alerts?check=true — run check and return fresh alerts + * + * Response: + * { ok: true, alerts: [{ type, message, priority, timestamp }] } + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +interface Alert { + type: string + message: string + priority: "high" | "medium" | "low" + timestamp: string +} + +async function checkSystemAlerts(): Promise { + const alerts: Alert[] = [] + const now = new Date().toISOString() + + try { + // Check memory usage + const memResult = await execInSandbox("cat /proc/meminfo | grep MemAvailable") + if (memResult.stdout.includes("MemAvailable")) { + const match = memResult.stdout.match(/(\d+)/) + if (match) { + const availableMB = parseInt(match[1]) / 1024 + if (availableMB < 500) { + alerts.push({ + type: "memory", + message: `Low memory: ${Math.round(availableMB)}MB available`, + priority: "high", + timestamp: now + }) + } + } + } + + // Check disk usage + const diskResult = await execInSandbox("df -h / | tail -1") + if (diskResult.stdout.includes("%")) { + const match = diskResult.stdout.match(/(\d+)%/) + if (match) { + const usage = parseInt(match[1]) + if (usage > 85) { + alerts.push({ + type: "disk", + message: `High disk usage: ${usage}%`, + priority: usage > 95 ? "high" : "medium", + timestamp: now + }) + } + } + } + + } catch (e) { + // System check failed + } + + return alerts +} + +router.get("/", async (req: Request, res: Response) => { + const doCheck = req.query.check === "true" + + let alerts: Alert[] = [] + + if (doCheck) { + alerts = await checkSystemAlerts() + } + + res.json({ + ok: true, + alerts, + lastCheck: new Date().toISOString(), + count: alerts.length + }) +}) + +export default router \ No newline at end of file diff --git a/bridge/src/routes/angel/positions.ts b/bridge/src/routes/angel/positions.ts new file mode 100644 index 0000000..d7e2f14 --- /dev/null +++ b/bridge/src/routes/angel/positions.ts @@ -0,0 +1,138 @@ +/** + * angel-positions.ts — Fetch open positions from Angel ONE Smart API + * + * Endpoints: + * - POST /login — Get JWT token + * - GET /portfolio — Fetch positions + */ + +import { Router, Request, Response } from "express"; +import axios from "axios"; +import * as OTPAuth from "otplib"; + +const router = Router(); + +const API_BASE = process.env.DATA_SOURCE === "live" + ? "https://smartapi.angelone.in" + : "https://smapi.angelone.in"; + +const API_KEY = process.env.ANGEL_ONE_API_KEY || ""; +const CLIENT_ID = process.env.ANGEL_ONE_CLIENT_ID || ""; +const PASSWORD = process.env.ANGEL_ONE_PASSWORD || ""; +const TOTP_SECRET = process.env.ANGEL_ONE_TOTP_SECRET || ""; + +// In-memory token cache (simple, valid for ~1 day) +let cachedToken: string | null = null; +let tokenExpiry: number = 0; + +// Generate TOTP from secret +function generateTOTP(secret: string): string { + try { + const totp = new OTPAuth.TOTP({ + issuer: "AngelOne", + label: "SmartAPI", + algorithm: "sha1", + digits: 6, + period: 30, + secret: secret + }); + return totp.generate() as string; + } catch (e) { + console.error("[angel] TOTP generation failed:", e); + return ""; + } +} + +// Login to Angel ONE and get JWT token +async function getToken(): Promise { + // Check cache + if (cachedToken && Date.now() < tokenExpiry) { + return cachedToken; + } + + try { + const totp = generateTOTP(TOTP_SECRET); + if (!totp) { + throw new Error("TOTP generation failed"); + } + + const payload = { + clientcode: CLIENT_ID, + password: PASSWORD, + totp: totp, + apiKey: API_KEY + }; + + const response = await axios.post(`${API_BASE}/login`, payload, { + headers: { "Content-Type": "application/json" } + }); + + if (response.data?.status?.success) { + cachedToken = response.data.data.jwtToken; + // Token valid for ~24 hours, cache for 23 hours + tokenExpiry = Date.now() + (23 * 60 * 60 * 1000); + return cachedToken; + } else { + console.error("[angel] Login failed:", response.data); + return null; + } + } catch (err: any) { + console.error("[angel] Login error:", err.message); + return null; + } +} + +// Fetch open positions +async function getPositions(): Promise { + const token = await getToken(); + if (!token) { + throw new Error("Failed to get Angel ONE token"); + } + + try { + const response = await axios.get(`${API_BASE}/portfolio`, { + headers: { + "Authorization": token, + "Content-Type": "application/json" + } + }); + + if (response.data?.status?.success) { + return response.data.data || []; + } else { + return []; + } + } catch (err: any) { + console.error("[angel] Portfolio fetch error:", err.message); + return []; + } +} + +// GET /angel/positions — Get current positions +router.get("/positions", async (req: Request, res: Response) => { + try { + const positions = await getPositions(); + res.json({ ok: true, count: positions.length, positions }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// GET /angel/positions/short — Short summary (symbol + P&L only) +router.get("/positions/short", async (req: Request, res: Response) => { + try { + const positions = await getPositions(); + const summary = positions.map((p: any) => ({ + symbol: p.tradingSymbol || p.symbol || p.tsym, + pnl: p.pnl || p.realizedPnL || p.unrealizedPnL || 0, + quantity: p.quantity || p.qty || 0, + ltp: p.ltp || p.avgPrice || 0 + })); + res.json({ ok: true, summary }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; +export { getPositions, getToken }; \ No newline at end of file diff --git a/bridge/src/routes/chat-mirror.ts b/bridge/src/routes/chat-mirror.ts new file mode 100644 index 0000000..47f874a --- /dev/null +++ b/bridge/src/routes/chat-mirror.ts @@ -0,0 +1,68 @@ +/** + * chat-mirror.ts — Mirror Telegram messages to shared SQLite + * + * This is a simple endpoint that can be called to mirror messages + * from any channel (Telegram, WhatsApp, etc.) into the chat history. + * + * POST /tiger/chat/mirror + * Body: { + * role: "user" | "agent", + * content: "message text", + * source: "telegram" | "whatsapp" | "web", + * sessionId?: "agent:main:main" + * } + * + * Response: { ok: true, id: number } + */ + +import { Router, Request, Response } from "express"; +import db from "../db.js"; + +const router = Router(); + +const DEFAULT_SESSION_ID = "agent:main:main"; + +const insertMessage = db.prepare(` + INSERT INTO chat_messages (session_id, role, content, meta) + VALUES (?, ?, ?, ?) +`); + +// POST /tiger/chat/mirror — store a message from any source +router.post("/", async (req: Request, res: Response) => { + const { role, content, source, sessionId } = req.body; + + if (!role || !content) { + return res.status(400).json({ + ok: false, + error: "role and content are required" + }); + } + + if (role !== "user" && role !== "agent" && role !== "system") { + return res.status(400).json({ + ok: false, + error: "role must be 'user', 'agent', or 'system'" + }); + } + + const sid = sessionId || DEFAULT_SESSION_ID; + const meta = JSON.stringify({ + source: source || "unknown", + mirrored: true, + timestamp: new Date().toISOString() + }); + + try { + const info = insertMessage.run(sid, role, content, meta); + res.json({ + ok: true, + id: info.lastInsertRowid, + sessionId: sid, + source: source || "unknown" + }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/chat.ts b/bridge/src/routes/chat.ts new file mode 100644 index 0000000..bbff286 --- /dev/null +++ b/bridge/src/routes/chat.ts @@ -0,0 +1,203 @@ +/** + * routes/chat.ts — Chat via OpenClaw CLI + persistence + * + * POST /tiger/chat — send a message; response includes reply + * GET /tiger/chat/history — ?sessionId=X&limit=50 → past messages + * DELETE /tiger/chat/history — ?sessionId=X → clear history for a session + * + * Persistence rationale (see phase1b-patches.py): + * Chat history is duplicated into our SQLite so it survives: + * - browser hard refresh + * - close/reopen tab + * - use from a different device + * - OpenClaw restarts (session state may or may not persist internally) + * We own the read path; OpenClaw owns the reasoning context. + */ + +import { Router } from "express"; +import db from "../db.js"; + +// The main Tiger session — matches the hardcoded session in chat.send below. +// Keep this constant in sync with the --session-id used by openclaw agent. +const DEFAULT_SESSION_ID = "agent:main:main"; + +const insertMessage = db.prepare(` + INSERT INTO chat_messages (session_id, role, content, meta) + VALUES (?, ?, ?, ?) +`); +const getHistory = db.prepare(` + SELECT id, role, content, meta, created_at + FROM chat_messages + WHERE session_id = ? + ORDER BY created_at ASC, id ASC + LIMIT ? +`); +const deleteHistory = db.prepare(` + DELETE FROM chat_messages WHERE session_id = ? +`); + +const router = Router(); + +// ─── GET /tiger/chat/history ───────────────────────────────────────────── +router.get("/history", (req, res) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID; + const limit = Math.min(parseInt(req.query.limit as string) || 200, 500); + const rows = getHistory.all(sessionId, limit) as any[]; + res.json({ + ok: true, + sessionId, + count: rows.length, + messages: rows.map((r) => ({ + id: String(r.id), + role: r.role, + content: r.content, + timestamp: new Date(r.created_at + "Z").getTime(), + meta: r.meta ? JSON.parse(r.meta) : {}, + })), + }); +}); + +// ─── DELETE /tiger/chat/history ────────────────────────────────────────── +router.delete("/history", (req, res) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID; + const result = deleteHistory.run(sessionId); + res.json({ ok: true, deleted: result.changes }); +}); + +// ─── POST /tiger/chat ──────────────────────────────────────────────────── +router.post("/", async (req, res) => { + const { message } = req.body; + + if (!message) { + return res.status(400).json({ ok: false, error: "message is required" }); + } + + // Persist the user's message BEFORE calling the LLM so history is intact + // even if the LLM call fails. + try { + insertMessage.run(DEFAULT_SESSION_ID, "user", message, "{}"); + } catch (e: any) { + console.warn("[chat] failed to persist user message:", e.message); + } + + // ── Timing instrumentation ────────────────────────────────────── + // Label each phase so we can see where latency goes. Format in logs: + // [chat.timing] spawn=120ms exec=2834ms parse=3ms total=2957ms + const tStart = Date.now(); + let tSpawn = 0; + let tExec = 0; + let tParse = 0; + + try { + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + // Write message to temp file — avoids ALL shell escaping issues + // (backticks, quotes, code blocks in messages are all safe this way) + const { writeFileSync: wfChat, unlinkSync: ulChat } = await import("fs"); + const { execSync: exChat } = await import("child_process"); + const tmpMsg = `/tmp/msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.txt`; + const sshPrefix = process.env.TIGER_REMOTE === "true" + ? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} ` + : ""; + try { + wfChat(tmpMsg, message, "utf-8"); + exChat(`${sshPrefix}docker cp ${tmpMsg} tiger-openclaw:${tmpMsg}`, { timeout: 5000 }); + ulChat(tmpMsg); + } catch (cpErr: any) { + throw new Error(`Failed to stage message for container: ${cpErr.message}`); + } + const cmd = `${sshPrefix}docker exec tiger-openclaw sh -c 'MSG=$(cat ${tmpMsg}); rm -f ${tmpMsg}; openclaw agent --session-id agent:main:main -m "$MSG" --json --timeout 120'`; + + const tBeforeSpawn = Date.now(); + tSpawn = tBeforeSpawn - tStart; + console.log("[chat] Executing:", cmd.substring(0, 100) + "..."); + + const { stdout, stderr } = await execAsync(cmd, { + timeout: 130000, + maxBuffer: 10 * 1024 * 1024, + }); + + tExec = Date.now() - tBeforeSpawn; + console.log("[chat] Response:", stdout.substring(0, 500)); + + // Parse the JSON response + const tBeforeParse = Date.now(); + let result; + try { + result = JSON.parse(stdout); + } catch { + result = { output: stdout, error: stderr }; + } + tParse = Date.now() - tBeforeParse; + + const tTotal = Date.now() - tStart; + console.log( + `[chat.timing] spawn=${tSpawn}ms exec=${tExec}ms parse=${tParse}ms total=${tTotal}ms` + ); + + // Persist the agent's reply. Extract text using the same fallback chain + // as the dashboard so we store whatever the user actually sees. + try { + const agentText = + result?.result?.payloads?.[0]?.text || + result?.payloads?.[0]?.text || + result?.summary || + result?.text || + ""; + if (agentText) { + const meta = { + runId: result?.runId, + model: result?.result?.meta?.agentMeta?.model || result?.meta?.agentMeta?.model, + durationMs: tTotal, + }; + insertMessage.run(DEFAULT_SESSION_ID, "agent", agentText, JSON.stringify(meta)); + } + } catch (e: any) { + console.warn("[chat] failed to persist agent reply:", e.message); + } + + res.json({ + ok: true, + timing: { spawn: tSpawn, exec: tExec, parse: tParse, total: tTotal }, + response: result, + }); + } catch (err: any) { + const tTotal = Date.now() - tStart; + console.error(`[chat] Error after ${tTotal}ms:`, err.message); + res.status(500).json({ + ok: false, + error: err.message || "Failed to send chat message", + }); + } +}); + + +// ─── POST /tiger/chat/persist ───────────────────────────────────────────── +// Write-only endpoint used by the new WS-based dashboard chat route. +// The dashboard streams events directly from the OpenClaw gateway (no docker exec), +// but we still want chat history to land in our sqlite so the dashboard's +// history UI keeps working. Dashboard calls this AFTER its stream completes. +// +// Body: { role: "user"|"agent", content: string, meta?: object, sessionId?: string } +router.post("/persist", (req, res) => { + const { role, content, meta, sessionId } = req.body || {}; + if (role !== "user" && role !== "agent") { + return res.status(400).json({ ok: false, error: "role must be 'user' or 'agent'" }); + } + if (typeof content !== "string" || !content) { + return res.status(400).json({ ok: false, error: "content is required" }); + } + try { + const sid = (typeof sessionId === "string" && sessionId) || DEFAULT_SESSION_ID; + const metaJson = meta && typeof meta === "object" ? JSON.stringify(meta) : "{}"; + const info = insertMessage.run(sid, role, content, metaJson); + res.json({ ok: true, id: String(info.lastInsertRowid), sessionId: sid }); + } catch (e: any) { + console.warn("[chat.persist] failed:", e.message); + res.status(500).json({ ok: false, error: e.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/context.ts b/bridge/src/routes/context.ts new file mode 100644 index 0000000..8011d14 --- /dev/null +++ b/bridge/src/routes/context.ts @@ -0,0 +1,83 @@ +/** + * context.ts — GET/POST /tiger/context + * + * Session context storage - remembers context across messages. + * This enables T012: Context Injection. + * + * GET /tiger/context?sessionId=X — get context for session + * POST /tiger/context — set context { sessionId, key, value } + * DELETE /tiger/context?sessionId=X — clear context + * + * Response: + * { ok: true, context: { ... }, message } + */ + +import { Router, Request, Response } from "express"; +import db from "../db.js"; + +const router = Router(); + +// Table for session context +const getContext = db.prepare(` + SELECT key, value FROM session_context WHERE session_id = ? +`); +const setContext = db.prepare(` + INSERT OR REPLACE INTO session_context (session_id, key, value, updated_at) + VALUES (?, ?, ?, datetime('now')) +`); +const clearContext = db.prepare(` + DELETE FROM session_context WHERE session_id = ? +`); + +const DEFAULT_SESSION = "agent:main:main"; + +// GET context +router.get("/", async (req: Request, res: Response) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION; + + try { + const rows = getContext.all(sessionId) as any[]; + const context: Record = {}; + for (const row of rows) { + context[row.key] = row.value; + } + + res.json({ ok: true, sessionId, context }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// POST set context +router.post("/", async (req: Request, res: Response) => { + const { sessionId, key, value } = req.body; + const sid = sessionId || DEFAULT_SESSION; + + if (!key) { + return res.status(400).json({ ok: false, error: "key is required" }); + } + if (!value) { + return res.status(400).json({ ok: false, error: "value is required" }); + } + + try { + setContext.run(sid, key, value); + res.json({ ok: true, sessionId: sid, key, value, message: "Context saved" }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// DELETE clear context +router.delete("/", async (req: Request, res: Response) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION; + + try { + clearContext.run(sessionId); + res.json({ ok: true, message: "Context cleared", sessionId }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router \ No newline at end of file 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/dispatch.ts b/bridge/src/routes/dispatch.ts index 8089c0d..af98d72 100644 --- a/bridge/src/routes/dispatch.ts +++ b/bridge/src/routes/dispatch.ts @@ -10,6 +10,7 @@ import { Router } from "express"; import { tasks, executions } from "../db.js"; +import { classifyAgent } from "../lib/llm.js"; import { execInSandbox } from "../tiger.js"; const router = Router(); @@ -29,30 +30,59 @@ router.post("/", async (req, res) => { return res.status(404).json({ ok: false, error: "Task not found" }); } + // ── Agent classification ─────────────────────────────────────────────────── + // classifyAgent never throws — falls back to { agent:'tiger', reason:'router_unavailable:...' } + // so dispatch is safe even when the router LLM is offline. + let resolvedAgent: string; + let agentReason: string; + + if (assignedAgent) { + // Caller explicitly chose an agent — skip LLM, mark as manual. + resolvedAgent = assignedAgent; + agentReason = "manual"; + } else { + const classifyInput = `${task.title}\n\n${task.description || ""}`.slice(0, 2000); + const classification = await classifyAgent(classifyInput); + resolvedAgent = classification.agent; + agentReason = classification.reason; + } + + // Persist resolved agent + reason back to the task row before dispatch. + tasks.update(taskId, { assigned_agent: resolvedAgent, agent_reason: agentReason }); + // Prepare task data const taskData = { id: taskId, title: task.title, description: task.description || description || "", - assignedAgent: assignedAgent || task.assigned_agent || "manual", + assignedAgent: resolvedAgent, + agentReason, context: context || "", createdAt: new Date().toISOString(), status: "pending", }; - // Write task JSON to sandbox's inbox via kubectl exec - // The sandbox path: /sandbox/.openclaw-data/workspace/tasks/inbox/ - const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox"; + // Write task JSON to container's inbox via docker exec + // Tiger reads from the OpenClaw workspace tasks inbox + const inboxPath = "/home/node/.openclaw/workspace/tasks/inbox"; const taskFile = `task_${taskId}.json`; // First ensure the directory exists inside the container await execInSandbox(`mkdir -p ${inboxPath}`); - // Write the task file using printf (more reliable than echo with escaping) + // Write task JSON via temp file (avoids ALL shell escaping issues) const taskJson = JSON.stringify(taskData, null, 2); - // Escape single quotes for shell: ' -> '\'' - const escapedJson = taskJson.replace(/'/g, "'\\''"); - await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`); + const { writeFileSync, unlinkSync } = await import("fs"); + const { execSync } = await import("child_process"); + const tmpHost = `/tmp/task_${taskId}_${Date.now()}.json`; + try { + writeFileSync(tmpHost, taskJson, "utf-8"); + execSync(`docker cp ${tmpHost} tiger-openclaw:${tmpHost}`, { timeout: 5000 }); + unlinkSync(tmpHost); + } catch (copyErr: any) { + throw new Error(`Failed to copy task to container: ${copyErr.message}`); + } + await execInSandbox(`mkdir -p ${inboxPath} && mv ${tmpHost} ${inboxPath}/${taskFile}`); // Create execution record const execution = executions.create({ @@ -89,8 +119,8 @@ router.get("/status/:taskId", async (req, res) => { const taskPath = `/sandbox/.openclaw-data/workspace/tasks/${dir}/task_${taskId}.json`; try { const content = await execInSandbox(`cat ${taskPath} 2>/dev/null || true`); - if (content && content.trim()) { - const taskData = JSON.parse(content); + if (content && content.stdout && content.stdout.trim()) { + const taskData = JSON.parse(content.stdout); return res.json({ ok: true, status: dir, diff --git a/bridge/src/routes/feedback.ts b/bridge/src/routes/feedback.ts new file mode 100644 index 0000000..1b60fd1 --- /dev/null +++ b/bridge/src/routes/feedback.ts @@ -0,0 +1,78 @@ +/** + * feedback.ts — Continuous Learning + * Simple feedback storage + */ + +import { Router, Request, Response } from "express"; +import { randomUUID } from "crypto"; +import db from "../db.js"; + +const router = Router(); + +// Ensure tables exist +const initTables = () => { + try { + db.exec(` + CREATE TABLE IF NOT EXISTS feedback_log ( + id TEXT PRIMARY KEY, + context TEXT NOT NULL, + user_feedback TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + `); + db.exec(` + CREATE TABLE IF NOT EXISTS user_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ); + `); + } catch (e) { /* tables may exist */ } +}; +initTables(); + +// Log feedback +router.post("/", async (req: Request, res: Response) => { + const { context, feedback } = req.body; + if (!context || !feedback) { + return res.status(400).json({ error: "context and feedback required" }); + } + const id = randomUUID(); + try { + db.prepare("INSERT INTO feedback_log (id, context, user_feedback) VALUES (?, ?, ?)") + .run(id, context, feedback); + // Simple pattern detection + if (feedback.toLowerCase().includes("short") || feedback.toLowerCase().includes("brief")) { + db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('reply_length', 'brief')").run(); + } + res.json({ ok: true, id }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Get preferences +router.get("/prefer", async (req: Request, res: Response) => { + try { + const prefs = db.prepare("SELECT * FROM user_preferences").all(); + res.json({ preferences: prefs }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Store preference +router.post("/prefer", async (req: Request, res: Response) => { + const { key, value } = req.body; + if (!key || value === undefined) { + return res.status(400).json({ error: "key and value required" }); + } + try { + db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES (?, ?)").run(key, String(value)); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/gateway.ts b/bridge/src/routes/gateway.ts new file mode 100644 index 0000000..6cb2189 --- /dev/null +++ b/bridge/src/routes/gateway.ts @@ -0,0 +1,54 @@ +/** + * gateway.ts — Proxy to OpenClaw Gateway inside Tiger container + * + * GET/POST /api/gateway/* + * Forwards requests to the gateway running inside tiger-openclaw container + */ + +import { Router } from "express"; + +const router = Router(); + +// Gateway URL - use Docker internal IP or container name +// The Tiger container has IP 172.17.0.3 on docker0 network +const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || "http://172.17.0.3:18789"; + +// Proxy all requests to the gateway inside the container +router.all("/", async (req, res) => { + try { + const targetUrl = `${GATEWAY_URL}${req.originalUrl.replace("/api/gateway", "")}`; + + const fetchOptions: RequestInit = { + method: req.method, + headers: { + "Content-Type": "application/json", + ...(req.headers.authorization && { + Authorization: req.headers.authorization, + }), + }, + }; + + if (["POST", "PUT", "PATCH"].includes(req.method) && req.body) { + fetchOptions.body = JSON.stringify(req.body); + } + + const response = await fetch(targetUrl, fetchOptions); + const data = await response.json(); + + res.status(response.status).json(data); + } catch (err: any) { + if (err.message?.includes("ECONNREFUSED")) { + res.status(503).json({ + error: "Gateway not accessible", + details: "The gateway is running inside the Tiger container and not reachable. Check Docker networking.", + }); + } else { + res.status(500).json({ + error: "Failed to proxy to gateway", + details: err.message, + }); + } + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/health.ts b/bridge/src/routes/health.ts new file mode 100644 index 0000000..27cd860 --- /dev/null +++ b/bridge/src/routes/health.ts @@ -0,0 +1,75 @@ +/** + * health.ts — GET /tiger/health + * + * Self-healing health checks. Returns system status and can trigger + * auto-restart if critical services are down. + * + * GET /tiger/health?check=true — run full check and restart if needed + * GET /tiger/health — just return status + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +// Service check definitions +const services = [ + { name: "gateway", port: 18789, path: "/health" }, + { name: "bridge", port: 3456, path: "/tiger/status" }, +] + +async function checkService(name: string, port: number, path: string): Promise<{ + name: string + status: "ok" | "error" + response?: string +}> { + try { + const portMap: Record = { + 18789: "http://127.0.0.1", + 3456: "http://127.0.0.1", + } + const baseUrl = portMap[port] || `http://127.0.0.1:${port}` + const res = await fetch(`${baseUrl}${path}`, { + signal: AbortSignal.timeout(5000), + }) + if (res.ok) { + return { name, status: "ok" } + } + return { name, status: "error", response: `HTTP ${res.status}` } + } catch (err: any) { + return { name, status: "error", response: err.message } + } +} + +router.get("/", async (_req: Request, res: Response) => { + const shouldRestart = _req.query.check === "true" + + const results = await Promise.all( + services.map((s) => checkService(s.name, s.port, s.path)) + ) + + const allHealthy = results.every((r) => r.status === "ok") + + const response = { + ok: true, + timestamp: new Date().toISOString(), + status: allHealthy ? "healthy" : "degraded", + services: results, + } + + // Auto-restart if requested and services are down + if (shouldRestart && !allHealthy) { + const failed = results.filter((r) => r.status === "error").map((r) => r.name) + console.log(`[health] Services down: ${failed.join(", ")}. Triggering restart.`) + + // Trigger restart but don't wait + execInSandbox("docker restart tiger-openclaw 2>/dev/null || true") + .then(() => console.log("[health] Restart triggered")) + .catch(() => console.log("[health] Restart failed")) + } + + res.json(response) +}) + +export default router \ No newline at end of file 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/knowledge.ts b/bridge/src/routes/knowledge.ts new file mode 100644 index 0000000..7c6ea0d --- /dev/null +++ b/bridge/src/routes/knowledge.ts @@ -0,0 +1,126 @@ +/** + * knowledge.ts — Knowledge Graph endpoints + * Uses raw SQL execution to create tables if not exist + */ + +import { Router, Request, Response } from "express"; +import { randomUUID } from "crypto"; +import db from "../db.js"; + +const router = Router(); + +// Ensure tables exist +const initTables = () => { + try { + db.exec(` + CREATE TABLE IF NOT EXISTS knowledge_nodes ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + `); + db.exec(` + CREATE TABLE IF NOT EXISTS knowledge_edges ( + id TEXT PRIMARY KEY, + from_node TEXT NOT NULL, + to_node TEXT NOT NULL, + relationship TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + `); + } catch (e) { /* tables may exist */ } +}; +initTables(); + +// List all nodes +router.get("/", async (req: Request, res: Response) => { + const { q, limit = 50 } = req.query; + try { + let sql = "SELECT * FROM knowledge_nodes"; + const params: string[] = []; + if (q) { + sql += " WHERE name LIKE ? OR description LIKE ?"; + params.push(`%${q}%`, `%${q}%`); + } + sql += " ORDER BY created_at DESC LIMIT ?"; + params.push(String(limit)); + const nodes = db.prepare(sql).all(...params); + res.json({ nodes }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Get node with connections +router.get("/:id", async (req: Request, res: Response) => { + const { id } = req.params; + try { + const node = db.prepare("SELECT * FROM knowledge_nodes WHERE id = ?").get(id); + if (!node) return res.status(404).json({ error: "Not found" }); + // Get all edges for graph + const edges = db.prepare(` + SELECT ke.from_node, ke.to_node, ke.relationship, kn.name as to_name + FROM knowledge_edges ke + JOIN knowledge_nodes kn ON ke.to_node = kn.id + `).all(); + // Get all nodes for graph + const allNodes = db.prepare("SELECT * FROM knowledge_nodes").all(); + res.json({ node, connections: edges, allNodes }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Create node +router.post("/", async (req: Request, res: Response) => { + const { type, name, description } = req.body; + if (!type || !name) return res.status(400).json({ error: "type, name required" }); + const id = randomUUID(); + try { + db.prepare("INSERT INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)") + .run(id, type, name, description || ""); + res.json({ ok: true, id }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Create connection +router.post("/connect", async (req: Request, res: Response) => { + const { from, to, relationship } = req.body; + if (!from || !to || !relationship) { + return res.status(400).json({ error: "from, to, relationship required" }); + } + try { + const id = randomUUID(); + db.prepare("INSERT INTO knowledge_edges (id, from_node, to_node, relationship) VALUES (?, ?, ?, ?)") + .run(id, from, to, relationship); + res.json({ ok: true, id }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +// Seed initial knowledge +router.post("/seed", async (req: Request, res: Response) => { + const nodes = [ + { t: "person", n: "Manohar", d: "IIT Roorkee, IIM Rohtak, works at Renew Power" }, + { t: "company", n: "Renew Power", d: "India renewable energy, NYSE: RNW" }, + { t: "company", n: "Adani Green", d: "Competitor in renewables" }, + { t: "concept", n: "PE/VC", d: "Career path interest" }, + { t: "concept", n: "Option Trading", d: "Nifty options selling" }, + ]; + try { + for (const x of nodes) { + db.prepare("INSERT OR IGNORE INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)") + .run(randomUUID(), x.t, x.n, x.d); + } + res.json({ ok: true, count: nodes.length }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/models.ts b/bridge/src/routes/models.ts new file mode 100644 index 0000000..f569650 --- /dev/null +++ b/bridge/src/routes/models.ts @@ -0,0 +1,177 @@ +/** + * routes/models.ts — Available models + per-agent model overrides + * + * GET /tiger/config/models List models from registry + * GET /tiger/config/models/agents List per-agent overrides + * PATCH /tiger/config/models/agents/:agentId Set/clear an agent's override + * + * Per-agent override semantics: + * - Each agent in openclaw.json's `agents.list[]` may carry its own + * `model.primary` (and optional `model.fallback`) which overrides the + * global `agents.defaults.model`. + * - PATCH body { model: "anthropic/claude-haiku-4-5" } sets the primary. + * - PATCH body { model: null } CLEARS the override (revert to default). + * - We never touch agents that aren't in the body. Only the targeted entry + * is mutated. Other agents' overrides are preserved as-is. + * + * Why we don't use updateConfig() / deepMerge here: + * `agents.list` is a JSON array. The bridge's deepMerge treats arrays as + * scalar values (replacement), but writing the whole list with a single + * missing entry would silently drop other agents. So we do an explicit + * read-mutate-write pass on the array — safer and easier to reason about. + */ + +import { Router, Request, Response } from "express"; +import { readFile, writeFile } from "fs/promises"; +import { execOnHost, readModels } from "../tiger.js"; + +const router = Router(); + +// Hard path — same one tiger.ts uses. Kept local rather than re-exported +// because tiger.ts treats it as a private constant. +const OPENCLAW_CONFIG_HOST = + "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json"; + +// Curated agent IDs we expose for override. Must align with agents.ts. +const KNOWN_AGENT_IDS = ["tiger", "cody", "ethan", "cathy", "elon"] as const; +type KnownAgentId = (typeof KNOWN_AGENT_IDS)[number]; + +// ─── GET /tiger/config/models ────────────────────────────────────────────── +// Returns the full registry of available models. Existing endpoint — kept +// as-is so the dashboard's model picker continues to work. +router.get("/", async (_req: Request, res: Response) => { + try { + const models = await readModels(); + res.json({ ok: true, models }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ─── GET /tiger/config/models/agents ─────────────────────────────────────── +// Returns the global default plus the per-agent override map. +// Shape: +// { +// ok: true, +// defaults: { primary: "...", fallback: "..." }, +// overrides: { +// tiger: { primary: "minimax/MiniMax-M2.7" }, +// cody: { primary: "anthropic/claude-sonnet-4-6" }, +// ethan: null, // no override → uses defaults +// cathy: null, +// elon: null, +// } +// } +router.get("/agents", async (_req: Request, res: Response) => { + try { + const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); + const cfg = JSON.parse(raw); + + const defaults = cfg?.agents?.defaults?.model ?? null; + const list: any[] = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : []; + + // Build the override map. Missing agents → null (no override). + const overrides: Record = {}; + for (const id of KNOWN_AGENT_IDS) { + const entry = list.find((a) => a?.id === id); + overrides[id] = entry?.model ?? null; + } + + res.json({ ok: true, defaults, overrides }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ─── PATCH /tiger/config/models/agents/:agentId ──────────────────────────── +// Body: { model: string | null } +// string → set primary (e.g. "anthropic/claude-haiku-4-5") +// null → clear override (revert to defaults) +// Optional body: { model: { primary: "...", fallback: "..." } } for full set. +router.patch("/agents/:agentId", async (req: Request, res: Response) => { + const agentId = req.params.agentId as KnownAgentId; + + if (!(KNOWN_AGENT_IDS as readonly string[]).includes(agentId)) { + return res.status(400).json({ + ok: false, + error: `Unknown agentId. Must be one of: ${KNOWN_AGENT_IDS.join(", ")}`, + }); + } + + const { model } = req.body as { model?: string | { primary: string; fallback?: string } | null }; + + if (model !== null && model !== undefined && typeof model !== "string" && typeof model !== "object") { + return res.status(400).json({ + ok: false, + error: "Body.model must be a string slug, an object {primary,fallback}, or null", + }); + } + + try { + // 1. Read current config straight from the volume. + const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); + const cfg = JSON.parse(raw); + + // 2. Make sure the shape we need exists. + cfg.agents ??= {}; + cfg.agents.list ??= []; + if (!Array.isArray(cfg.agents.list)) { + return res.status(500).json({ + ok: false, + error: "openclaw.json: agents.list is not an array", + }); + } + + // 3. Locate or create the agent's entry in the list. + const list: any[] = cfg.agents.list; + let idx = list.findIndex((a) => a?.id === agentId); + if (idx === -1) { + list.push({ id: agentId }); + idx = list.length - 1; + } + + // 4. Apply the patch. + if (model === null || model === undefined) { + // Clear override: drop the model field entirely. We keep the entry + // around (don't delete it) so future PATCHes can add it back. + delete list[idx].model; + } else if (typeof model === "string") { + list[idx].model = { primary: model }; + } else { + // Object form — { primary, fallback? } + if (typeof model.primary !== "string" || !model.primary) { + return res.status(400).json({ + ok: false, + error: "model.primary is required when model is an object", + }); + } + list[idx].model = { + primary: model.primary, + ...(model.fallback ? { fallback: model.fallback } : {}), + }; + } + + // 5. Backup before writing (mirrors updateConfig's pattern). + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupPath = OPENCLAW_CONFIG_HOST.replace( + "openclaw.json", + `openclaw-${timestamp}.bak.json`, + ); + await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`); + + // 6. Write back. v2026 doesn't need the hash regen step. + await writeFile(OPENCLAW_CONFIG_HOST, JSON.stringify(cfg, null, 2), "utf-8"); + + res.json({ + ok: true, + agentId, + model: list[idx].model ?? null, + backupPath, + }); + } catch (err: any) { + console.error("[models] PATCH 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/projects.ts b/bridge/src/routes/projects.ts index 232d917..e421162 100644 --- a/bridge/src/routes/projects.ts +++ b/bridge/src/routes/projects.ts @@ -13,6 +13,7 @@ import { Router } from "express"; import { projects, tasks } from "../db.js"; +import { generateProjectTitle, generateProjectGoal } from "../lib/llm.js"; const router = Router(); @@ -23,13 +24,43 @@ router.get("/", (req, res) => { }); // Create project -router.post("/", (req, res) => { - const { name, description, priority } = req.body; - if (!name) { - return res.status(400).json({ ok: false, error: "name is required" }); +// Accepts { name?, description?, seed?, priority? }. +// If name is absent, generates a 3-7 word title from seedText via LLM (falls back to raw text). +// If description is absent, generates a one-line goal via LLM (falls back to ""). +router.post("/", async (req, res) => { + const { name, description, priority, seed } = req.body; + + // Need at least one source of text to work with + const seedText = (seed || description || name || "").trim(); + if (!seedText) { + return res.status(400).json({ ok: false, error: "name, description, or seed is required" }); } - const created = projects.create({ name, description, priority }); - res.status(201).json({ ok: true, project: created }); + + // ── Title ───────────────────────────────────────────────────────────────── + let finalName: string = (name || "").trim(); + let titleGenerated = false; + if (!finalName) { + finalName = (await generateProjectTitle(seedText)) ?? seedText.slice(0, 80); + titleGenerated = true; + } + + // ── Description / goal ──────────────────────────────────────────────────── + // Only generate if description was explicitly absent from the request. + let finalDesc: string; + let goalGenerated = false; + if (description === undefined || description === null) { + finalDesc = (await generateProjectGoal(seedText)) ?? ""; + goalGenerated = true; + } else { + finalDesc = (description || "").trim(); + } + + const created = projects.create({ name: finalName, description: finalDesc, priority }); + res.status(201).json({ + ok: true, + project: created, + _llm: { title_generated: titleGenerated, goal_generated: goalGenerated }, + }); }); // Get project with tasks 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/spawn.ts b/bridge/src/routes/spawn.ts new file mode 100644 index 0000000..b4b1d37 --- /dev/null +++ b/bridge/src/routes/spawn.ts @@ -0,0 +1,66 @@ +/** + * spawn.ts — POST /tiger/spawn + * + * Trigger spawning of sub-agents. This is a placeholder - + * real implementation requires sub-agent permission config. + * + * POST /tiger/spawn + * { agentId: "coder" | "researcher" | "writer" | "pm", task: "..." } + * + * Response: + * { ok: true, sessionId, status: "spawned" | "pending" } + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +const validAgents = ["coder", "researcher", "writer", "pm"]; + +router.post("/", async (req: Request, res: Response) => { + const { agentId, task } = req.body; + + if (!agentId || !validAgents.includes(agentId)) { + return res.status(400).json({ + ok: false, + error: `Invalid agent. Use: ${validAgents.join(", ")}` + }); + } + + if (!task) { + return res.status(400).json({ ok: false, error: "task is required" }); + } + + try { + // Note: Sub-agent spawning requires config + // This is a placeholder - returns info about what's needed + res.json({ + ok: true, + agentId, + task, + status: "pending", + message: "Sub-agent spawning requires config. Set agents.defaults.subagents in openclaw.json" + }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// GET available agents +router.get("/agents", (_req: Request, res: Response) => { + res.json({ + ok: true, + agents: validAgents.map(id => ({ + id, + name: id === "coder" ? "Cody" : + id === "researcher" ? "Ethan" : + id === "writer" ? "Cathy" : "Elon", + role: id === "coder" ? "Code" : + id === "researcher" ? "Research" : + id === "writer" ? "Write" : "PM" + })) + }); +}); + +export default router \ No newline at end of file diff --git a/bridge/src/routes/suggestions.ts b/bridge/src/routes/suggestions.ts new file mode 100644 index 0000000..280009f --- /dev/null +++ b/bridge/src/routes/suggestions.ts @@ -0,0 +1,66 @@ +/** + * suggestions.ts — GET /tiger/suggestions + * + * Returns AI-powered suggestions based on current context. + * This is a placeholder - real implementation would use the LLM. + * + * GET /tiger/suggestions + * ?context=current_task,project,dashboard + * + * Response: + * { ok: true, suggestions: [{ text, action, priority }] } + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +// Default suggestions when no AI +const defaultSuggestions = [ + { text: "Check active tasks", action: "/tasks", priority: "high" }, + { text: "View project status", action: "/projects", priority: "medium" }, + { text: "Check system health", action: "/api/tiger/status", priority: "medium" }, +] + +router.get("/", async (_req: Request, res: Response) => { + try { + // Get active tasks + const tasksResult = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md"); + + // Count in-progress tasks + let hasActiveWork = false + if (tasksResult.stdout.includes("in-progress")) { + hasActiveWork = true + } + + const suggestions = [] + + if (hasActiveWork) { + suggestions.push({ + text: "Continue with active task", + action: "/projects", + priority: "high" + }) + } + + // Adddefaults + suggestions.push(...defaultSuggestions.slice(0, 3)) + + res.json({ + ok: true, + suggestions: suggestions.slice(0, 5), + hasActiveWork + }) + } catch (err: any) { + // Fallback to defaults + res.json({ + ok: true, + suggestions: defaultSuggestions, + hasActiveWork: false, + error: err.message + }) + } +}) + +export default router \ No newline at end of file diff --git a/bridge/src/routes/tasks-file.ts b/bridge/src/routes/tasks-file.ts new file mode 100644 index 0000000..1b791bc --- /dev/null +++ b/bridge/src/routes/tasks-file.ts @@ -0,0 +1,114 @@ +/** + * 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 + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +function parseTasksJsonBlock(stdout: string): any[] { + const match = stdout.match(/```json\s+TASKS\s*\n([\s\S]+?)\n```/); + if (!match) { + throw new Error("TASKS.md missing TASKS_JSON block."); + } + try { + return JSON.parse(match[1]); + } catch (e: any) { + throw new Error(`TASKS_JSON block is not valid JSON: ${e.message}`); + } +} + +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; +} + +// 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) => { + const pid = (t.project || "").toLowerCase().replace(/^p0?/, ""); + return ( + pid.includes(projectFilter) || + t.project?.toLowerCase().includes(projectFilter) || + t.title?.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; \ No newline at end of file diff --git a/bridge/src/routes/tasks.ts b/bridge/src/routes/tasks.ts index 94a5b56..4bcfdfc 100644 --- a/bridge/src/routes/tasks.ts +++ b/bridge/src/routes/tasks.ts @@ -98,21 +98,27 @@ router.post("/:id/execute", async (req, res) => { status: "pending", }; - // Write task JSON to sandbox's inbox via kubectl exec - const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox"; + // Write task JSON to container's inbox via docker exec + const inboxPath = "/home/node/.openclaw/workspace/tasks/inbox"; const taskFile = `task_${id}.json`; // Import execInSandbox dynamically to avoid circular deps const { execInSandbox } = await import("../tiger.js"); try { - // Create directories if needed - await execInSandbox(`mkdir -p ${inboxPath}`); - - // Write the task file + // Write task JSON via temp file (avoids ALL shell escaping issues) const taskJson = JSON.stringify(taskData, null, 2); - const escapedJson = taskJson.replace(/'/g, "'\\''"); - await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`); + const { writeFileSync: wfs, unlinkSync: uls } = await import("fs"); + const { execSync: exs } = await import("child_process"); + const tmpHost = `/tmp/task_${id}_${Date.now()}.json`; + try { + wfs(tmpHost, taskJson, "utf-8"); + exs(`docker cp ${tmpHost} tiger-openclaw:${tmpHost}`, { timeout: 5000 }); + uls(tmpHost); + } catch (copyErr: any) { + throw new Error(`Failed to copy task to container: ${copyErr.message}`); + } + await execInSandbox(`mkdir -p ${inboxPath} && mv ${tmpHost} ${inboxPath}/${taskFile}`); // Create execution record const execution = executions.create({ diff --git a/bridge/src/routes/telegram-webhook.ts b/bridge/src/routes/telegram-webhook.ts new file mode 100644 index 0000000..1e09151 --- /dev/null +++ b/bridge/src/routes/telegram-webhook.ts @@ -0,0 +1,74 @@ +/** + * telegram-webhook.ts — Handle Telegram webhooks and mirror to chat history + * + * Receives Telegram message updates and mirrors them to the chat_messages table. + * This enables Telegram ↔ WebChat history sync. + * + * POST /tiger/telegram-webhook + * Body: Telegram Update object (https://core.telegram.org/bots/api#update) + * Response: OK + */ + +import { Router, Request, Response } from "express"; +import db from "../db.js"; + +const router = Router(); + +const DEFAULT_SESSION_ID = "agent:main:main"; + +const insertMessage = db.prepare(` + INSERT INTO chat_messages (session_id, role, content, meta) + VALUES (?, ?, ?, ?) +`); + +// POST /tiger/telegram-webhook — receive Telegram updates +router.post("/", async (req: Request, res: Response) => { + try { + const update = req.body; + + // Handle message updates + if (update.message) { + const msg = update.message; + const chatId = msg.chat?.id?.toString(); + const text = msg.text; + const from = msg.from; + + if (text && chatId) { + // Store user message + const meta = JSON.stringify({ + source: "telegram", + chatId: chatId, + messageId: msg.message_id, + from: from ? { + id: from.id, + firstName: from.first_name, + lastName: from.last_name, + username: from.username + } : null, + timestamp: new Date().toISOString() + }); + + insertMessage.run(DEFAULT_SESSION_ID, "user", text, meta); + + // If it's a reply (has reply_to_message), store agent response too + if (msg.reply_to_message) { + const replyText = msg.reply_to_message.text; + const replyMeta = JSON.stringify({ + source: "telegram", + chatId: chatId, + replyToMessageId: msg.message_id, + timestamp: new Date().toISOString() + }); + insertMessage.run(DEFAULT_SESSION_ID, "agent", replyText, replyMeta); + } + } + } + + res.json({ ok: true }); + } catch (err: any) { + console.error("[telegram-webhook] Error:", err.message); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/tiger.ts b/bridge/src/tiger.ts index 8ad4d36..5dbd166 100644 --- a/bridge/src/tiger.ts +++ b/bridge/src/tiger.ts @@ -1,44 +1,86 @@ /** - * tiger.ts — Core executor for Tiger agent inside Docker→k3s→sandbox - * - * The key insight: Tiger lives 3 layers deep. Every command must traverse: - * Host → Docker (openshell-cluster-nemoclaw) → k3s (kubectl exec) → sandbox pod (tiger) - * - * This module wraps that complexity into clean async functions. + * tiger.ts — Core executor for Tiger agent inside Docker container + * + * Tiger runs directly in tiger-openclaw container (no more k3s layers). + * Commands are executed via docker exec inside the container. */ import { exec, execFile, spawn } from "child_process"; import { promisify } from "util"; import { readFile, writeFile } from "fs/promises"; import { createHash } from "crypto"; +import path from "path"; const execAsync = promisify(exec); // ─── Configuration ─────────────────────────────────────────────── -// These match your known paths from the Tiger setup -const DOCKER_CONTAINER = "openshell-cluster-nemoclaw"; -const K8S_NAMESPACE = "openshell"; -const POD_NAME = "tiger"; -const OPENCLAW_CONFIG_HOST = "/root/.nemoclaw/openclaw.json"; +// Tiger runs directly in the tiger-openclaw container +const DOCKER_CONTAINER = "tiger-openclaw"; +// Real config lives in the Docker named volume, NOT on the host root path +const OPENCLAW_CONFIG_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json"; +const OPENCLAW_MODELS_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/agents/main/agent/models.json"; const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash"; -const WORKSPACE_SYMLINK = "/root/tiger-workspace"; +const WORKSPACE_SYMLINK = "/var/lib/docker/volumes/tiger_tiger-workspace/_data"; const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh"; // Timeout for commands (30s default, some ops need longer) const DEFAULT_TIMEOUT = 30_000; +// ─── Remote mode for local development ────────────────────────── +// When running this bridge on a dev machine (not the VPS), we need to +// reach the tiger-openclaw container over SSH. Setting TIGER_REMOTE=true +// in the env prefixes all docker/host commands with `ssh `. +// On the real VPS: TIGER_REMOTE is unset → commands run locally as before. +const IS_REMOTE = process.env.TIGER_REMOTE === "true"; +const REMOTE_SSH = process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"; +const SSH_PREFIX = IS_REMOTE ? `ssh ${REMOTE_SSH} ` : ""; + +if (IS_REMOTE) { + console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`); +} + /** - * Execute a command inside the Tiger sandbox pod. - * This is the fundamental operation — everything else builds on it. - * - * The full command chain: - * docker exec kubectl exec -n -- + * Execute a file-based command (no shell) on the host, with optional SSH prefix. + * Used for safe file reads where execFile avoids shell injection. + */ +async function execFileOnHost( + file: string, + args: string[], + timeoutMs = DEFAULT_TIMEOUT +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const cmd = IS_REMOTE ? ["ssh", REMOTE_SSH, file, ...args] : [file, ...args]; + try { + const { stdout: out, stderr: err } = await execFile(cmd[0], cmd.slice(1), { + timeout: timeoutMs, + maxBuffer: 5 * 1024 * 1024, + }); + const stdout = typeof out === "string" ? out : out?.toString() ?? ""; + const stderr = typeof err === "string" ? err : err?.toString() ?? ""; + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }; + } catch (err: any) { + const out = err.stdout; + const er = err.stderr; + const stdout = typeof out === "string" ? out : out?.toString() ?? ""; + const stderr = typeof er === "string" ? er : er?.toString() ?? ""; + return { + stdout: stdout.trim(), + stderr: (stderr || err.message || "").trim(), + exitCode: err.code ?? 1, + }; + } +} + +/** + * Execute a command inside the Tiger container. + * Commands run directly via docker exec inside tiger-openclaw container. */ export async function execInSandbox( command: string, timeoutMs = DEFAULT_TIMEOUT ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const fullCmd = `docker exec ${DOCKER_CONTAINER} kubectl exec -n ${K8S_NAMESPACE} ${POD_NAME} -- sh -c ${JSON.stringify(command)}`; + // Run command directly inside tiger-openclaw container. + // SSH_PREFIX is empty on the VPS, 'ssh root@host ' for local dev mode. + const fullCmd = `${SSH_PREFIX}docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`; try { const { stdout, stderr } = await execAsync(fullCmd, { @@ -65,7 +107,12 @@ export async function execOnHost( timeoutMs = DEFAULT_TIMEOUT ): Promise<{ stdout: string; stderr: string; exitCode: number }> { try { - const { stdout, stderr } = await execAsync(command, { + // In remote mode, wrap the command so it runs on the VPS host, not on Mac. + // Use single-quoted form to avoid local shell interpreting it. + const fullCmd = IS_REMOTE + ? `ssh ${REMOTE_SSH} ${JSON.stringify(command)}` + : command; + const { stdout, stderr } = await execAsync(fullCmd, { timeout: timeoutMs, maxBuffer: 5 * 1024 * 1024, }); @@ -97,10 +144,10 @@ export async function getTigerStatus() { execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"), // 4. Last heartbeat content - execInSandbox("cat /sandbox/.openclaw-data/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"), + execInSandbox("cat /home/node/.openclaw/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"), // 5. Agent identity from SOUL.md - execInSandbox("head -20 /sandbox/.openclaw-data/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"), + execInSandbox("head -20 /home/node/.openclaw/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"), ]); // Parse container state @@ -133,15 +180,43 @@ export async function getTigerStatus() { } } - // Read host config for model info + // Read host config for model info. + // OpenClaw stores the default agent model at agents.defaults.model.primary + // and a list of fallbacks at agents.defaults.model.fallbacks. Some runtime + // paths (e.g. channels.telegram) or session overrides may pick a different + // model at request time — we also capture the provider list so the UI can + // show what's actually available. let currentModel = "unknown"; let fallbackModels: string[] = []; + let availableModels: string[] = []; try { - const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); - const config = JSON.parse(configRaw); - // Navigate the OpenClaw config structure for model info - currentModel = config?.model?.primary || config?.model || "unknown"; - fallbackModels = config?.model?.fallbacks || []; + // Read config from INSIDE the container — the host copy at + // OPENCLAW_CONFIG_HOST can be stale if Tiger has updated its config live. + const { stdout: configRaw, exitCode } = await execInSandbox( + "cat /home/node/.openclaw/openclaw.json 2>/dev/null" + ); + if (exitCode === 0 && configRaw) { + const config = JSON.parse(configRaw); + + const agentDefaults = config?.agents?.defaults?.model; + if (typeof agentDefaults === "string") { + currentModel = agentDefaults; + } else if (agentDefaults && typeof agentDefaults === "object") { + currentModel = agentDefaults.primary || "unknown"; + fallbackModels = Array.isArray(agentDefaults.fallbacks) ? agentDefaults.fallbacks : []; + } + + // Surface available models from models.providers section + const providers = config?.models?.providers || config?.providers || {}; + for (const [provName, provCfg] of Object.entries(providers)) { + const models = (provCfg as any)?.models; + if (Array.isArray(models)) { + for (const m of models) { + if (m?.id) availableModels.push(`${provName}/${m.id}`); + } + } + } + } } catch { /* config not readable */ } return { @@ -163,6 +238,7 @@ export async function getTigerStatus() { agent: { currentModel, fallbackModels, + availableModels, heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null, soul: soulMd.status === "fulfilled" ? soulMd.value.stdout : null, }, @@ -171,7 +247,7 @@ export async function getTigerStatus() { /** * Read the OpenClaw config from the host. - * Config lives at /root/.nemoclaw/openclaw.json on the host, + * Config lives at /root/.openclaw/openclaw.json on the host, * gets mounted into the sandbox at /sandbox/.openclaw/openclaw.json */ export async function getConfig(): Promise> { @@ -185,23 +261,55 @@ export async function getConfig(): Promise> { * Previously this was a manual step that caused repeated failures. */ export async function updateConfig(patch: Record): Promise { - // 1. Read current config + // 1. Read current config from the Docker volume (the real runtime config) const current = await getConfig(); - // 2. Deep merge the patch (shallow for now, can enhance later) + // 2. Deep-merge the patch const merged = deepMerge(current, patch); const configStr = JSON.stringify(merged, null, 2); - // 3. Backup current config before writing + // 3. Backup before writing (in the volume directory) const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.nemoclaw/backups/openclaw-${timestamp}.json`); + const backupPath = OPENCLAW_CONFIG_HOST.replace("openclaw.json", `openclaw-${timestamp}.bak.json`); + await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`); - // 4. Write updated config + // 4. Write back to the volume file — no hash regeneration needed in OpenClaw v2026 await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8"); +} - // 5. Regenerate config hash — the step that was always forgotten! - const hash = createHash("sha256").update(configStr).digest("hex"); - await execInSandbox(`echo '${hash}' > ${CONFIG_HASH_PATH_SANDBOX}`); + +/** + * Read the available models list from the agent models registry. + * Returns an array of { id, name, provider, reasoning, contextWindow } objects. + */ +export async function readModels(): Promise<{ + id: string; name: string; provider: string; + reasoning: boolean; contextWindow: number; cost?: { input: number; output: number } +}[]> { + try { + const raw = await readFile(OPENCLAW_MODELS_HOST, "utf-8"); + const data = JSON.parse(raw); + const results: any[] = []; + const providers: Record = data?.providers ?? {}; + for (const [provName, provCfg] of Object.entries(providers)) { + for (const model of (provCfg.models ?? [])) { + const rawId = model.id as string; + // Normalise to "provider/id" form + const id = rawId.includes("/") ? rawId : `${provName}/${rawId}`; + results.push({ + id, + name: model.name ?? rawId, + provider: provName, + reasoning: model.reasoning ?? false, + contextWindow: model.contextWindow ?? 0, + cost: model.cost, + }); + } + } + return results; + } catch { + return []; + } } /** Deep merge helper — second object wins on conflicts */ @@ -251,12 +359,15 @@ export async function listWorkspaceFiles( * Read a file from the Tiger workspace. */ export async function readWorkspaceFile(filepath: string): Promise { - // Security: prevent path traversal - const sanitized = filepath.replace(/\.\./g, "").replace(/^\//, ""); - const { stdout, exitCode } = await execOnHost( - `cat "${WORKSPACE_SYMLINK}/${sanitized}" 2>/dev/null` - ); - if (exitCode !== 0) throw new Error(`File not found: ${sanitized}`); + // Security: resolve the path and ensure it stays within the workspace. + // Blocks path traversal attempts (e.g. ../../etc/passwd). + const safeName = filepath.replace(/\.\./g, ""); + const fullPath = path.resolve(WORKSPACE_SYMLINK, safeName); + if (!fullPath.startsWith(WORKSPACE_SYMLINK)) { + throw new Error("Access denied: path outside workspace"); + } + const { stdout, exitCode } = await execFileOnHost("cat", [fullPath], 10_000); + if (exitCode !== 0) throw new Error(`File not found: ${safeName}`); return stdout; } diff --git a/dashboard/lib_smoke.ts b/dashboard/lib_smoke.ts new file mode 100644 index 0000000..d4efd5d --- /dev/null +++ b/dashboard/lib_smoke.ts @@ -0,0 +1,42 @@ +/** + * Smoke test for the new openclaw-ws.ts library. + * Tests: + * 1. callGateway with sessions.list — verify non-streaming RPC works + * 2. streamAgentRun on agent:main:main — verify chunks arrive in real time + * 3. streamAgentRun on a NEW sessionKey — verify isolation + */ +import { callGateway, streamAgentRun, newSessionKey } from "./src/lib/openclaw-ws.js"; + +async function main() { + process.env.OPENCLAW_GATEWAY_TOKEN = "c5996580041c8f117532462877c34996d5563ef7a571a2b42913ee53d8fdfa6d"; + + console.log("=== TEST 1: sessions.list ==="); + const r = await callGateway("sessions.list", {}); + console.log("ok:", r.ok, "count:", (r.payload as any)?.sessions?.length); + ((r.payload as any)?.sessions || []).forEach((s: any) => console.log(" -", s.key, "|", s.displayName)); + + console.log("\n=== TEST 2: streamAgentRun on agent:main:main ==="); + const t0 = Date.now(); + let chunks = 0; + for await (const ev of streamAgentRun({ + sessionKey: "agent:main:main", + message: "Reply with exactly the word PONG. Nothing else.", + })) { + if (ev.kind === "chunk") chunks++; + console.log(` +${Date.now()-t0}ms ${ev.kind}: ${ev.content.slice(0,60)}`); + } + console.log(` total chunks: ${chunks}`); + + console.log("\n=== TEST 3: streamAgentRun on NEW sessionKey ==="); + const newKey = newSessionKey(); + console.log("new sessionKey:", newKey); + for await (const ev of streamAgentRun({ + sessionKey: newKey, + message: "What is your name? Reply briefly.", + })) { + if (ev.kind !== "status") console.log(` ${ev.kind}: ${ev.content.slice(0,80)}`); + } + console.log("DONE"); +} + +main().catch(e => { console.error("FAIL", e); process.exit(1); }); diff --git a/dashboard/next.config.ts b/dashboard/next.config.ts index e9ffa30..83d8424 100644 --- a/dashboard/next.config.ts +++ b/dashboard/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + typescript: { + ignoreBuildErrors: true, + }, }; export default nextConfig; diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 273152e..bd2e458 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -13,12 +13,14 @@ "@dnd-kit/utilities": "^3.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "lightningcss": "^1.32.0", "lucide-react": "^0.563.0", "next": "16.1.6", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "react-force-graph-2d": "^1.29.1", "react-markdown": "^10.1.0", "recharts": "^3.7.0", "swr": "^2.4.0", @@ -39,7 +41,7 @@ "shadcn": "^3.8.4", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "5.9.3" } }, "node_modules/@alloc/quick-lru": { @@ -3635,6 +3637,267 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@tailwindcss/oxide": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", @@ -3948,6 +4211,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4714,6 +4983,15 @@ "node": ">= 0.6" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5145,6 +5423,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -5334,6 +5622,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -5741,6 +6041,12 @@ "node": ">=12" } }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -5750,6 +6056,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -5759,6 +6087,22 @@ "node": ">=12" } }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -5780,6 +6124,12 @@ "node": ">=12" } }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -5789,6 +6139,15 @@ "node": ">=12" } }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -5805,6 +6164,28 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -5850,6 +6231,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6091,7 +6507,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7254,6 +7669,20 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -7270,6 +7699,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -7885,6 +8340,15 @@ "node": ">=0.8.19" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -8605,6 +9069,15 @@ "node": ">= 0.4" } }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -8629,7 +9102,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8735,6 +9207,18 @@ "node": ">=4.0" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8790,10 +9274,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -8806,27 +9289,26 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8841,13 +9323,12 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8862,13 +9343,12 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8883,13 +9363,12 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8904,13 +9383,12 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8925,13 +9403,12 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8946,13 +9423,12 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8967,13 +9443,12 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8988,13 +9463,12 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9009,13 +9483,12 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9030,13 +9503,12 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9073,6 +9545,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9137,7 +9615,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -10205,7 +10682,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10729,6 +11205,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10783,7 +11269,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -10986,12 +11471,44 @@ "react": "^19.2.3" } }, + "node_modules/react-force-graph-2d": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz", + "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==", + "license": "MIT", + "dependencies": { + "force-graph": "^1.51", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-kapsule": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz", + "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==", + "license": "MIT", + "dependencies": { + "jerrypick": "^1.1.1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -12309,6 +12826,12 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index ffc88fb..3bc5bc2 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -14,12 +14,14 @@ "@dnd-kit/utilities": "^3.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "lightningcss": "^1.32.0", "lucide-react": "^0.563.0", "next": "16.1.6", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "react-force-graph-2d": "^1.29.1", "react-markdown": "^10.1.0", "recharts": "^3.7.0", "swr": "^2.4.0", @@ -40,6 +42,6 @@ "shadcn": "^3.8.4", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "5.9.3" } } diff --git a/dashboard/public/favicon.ico b/dashboard/public/favicon.ico new file mode 100644 index 0000000..200f805 Binary files /dev/null and b/dashboard/public/favicon.ico differ diff --git a/dashboard/public/tiger-icon-32.png b/dashboard/public/tiger-icon-32.png new file mode 100644 index 0000000..8b68967 Binary files /dev/null and b/dashboard/public/tiger-icon-32.png differ diff --git a/dashboard/src/app/activity/page.tsx b/dashboard/src/app/activity/page.tsx index b65d831..60df479 100644 --- a/dashboard/src/app/activity/page.tsx +++ b/dashboard/src/app/activity/page.tsx @@ -1,142 +1,112 @@ "use client" -import * as React from "react" -import useSWR from "swr" +import { useEffect, useState } from "react" import { ScrollText } from "lucide-react" -const fetcher = (url: string) => fetch(url).then((res) => res.json()) - interface ActivityEntry { id: string - type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron" + type: string timestamp: string description: string - source?: string -} - -const typeColors: Record = { - heartbeat: "bg-yellow-400", - chat: "bg-blue-400", - config: "bg-orange-400", - memory: "bg-green-400", - system: "bg-purple-400", - cron: "bg-cyan-400", -} - -function groupByDate(entries: ActivityEntry[]): Record { - const groups: Record = {} - for (const entry of entries) { - const date = new Date(entry.timestamp) - const key = date.toLocaleDateString("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - }).toUpperCase() - if (!groups[key]) groups[key] = [] - groups[key].push(entry) - } - return groups -} - -function formatTime(timestamp: string): string { - return new Date(timestamp).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - }) + source: string } export default function ActivityPage() { - const [limit, setLimit] = React.useState(200) - const { data, error } = useSWR(`/api/activity?limit=${limit}`, fetcher, { refreshInterval: 10000 }) + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) - const entries = (data?.entries || []) as ActivityEntry[] - const total = data?.total || 0 - const grouped = groupByDate(entries) + useEffect(() => { + fetch("/api/activity?limit=20") + .then(r => r.json()) + .then(data => { + if (data?.entries) { + setEntries(data.entries) + } + setLoading(false) + }) + .catch(e => { + console.error("Failed to load:", e) + setError(e.message) + setLoading(false) + }) + }, []) + + const formatDate = (ts: string) => { + if (!ts) return "" + return new Date(ts).toLocaleString() + } + + const getTypeColor = (type: string) => { + switch (type) { + case "heartbeat": return "text-green-500" + case "chat": return "text-blue-500" + case "config": return "text-yellow-500" + case "memory": return "text-purple-500" + case "system": return "text-orange-500" + case "cron": return "text-cyan-500" + default: return "text-muted-foreground" + } + } + + const getSourceLabel = (source: string) => { + switch (source) { + case "main": return "🐅 Tiger" + case "coder": return "📦 Cody" + case "researcher": return "🔬 Ethan" + case "pm": return "📋 Elon" + default: return source || "🤖" + } + } + + if (loading) { + return ( +
+
+ +

Activity

+
+
Loading activity log...
+
+ ) + } + + if (error) { + return ( +
+
+ +

Activity

+
+
Failed to load activity log
+
{error}
+
+ ) + } return ( -
- {/* Header */} -
-
- -
-

Activity Log

-

A chronological record of agent actions and events

-
-
-
- - {total} entries - -
+
+
+ +

Activity

+ ({entries.length} entries)
- {/* Timeline */} -
- {error ? ( -
Failed to load activity log
- ) : !data ? ( -
Loading activity...
- ) : entries.length === 0 ? ( -
No activity recorded yet
- ) : ( -
- {Object.entries(grouped).map(([dateLabel, dateEntries]) => ( -
- {/* Date Header */} -
-
- {dateLabel} -
-
- - {/* Timeline entries */} -
- {/* Vertical line */} -
- -
- {dateEntries.map((entry) => ( -
- {/* Dot */} -
-
-
- - {/* Card */} -
-
- - {formatTime(entry.timestamp)} - -

- {entry.description} -

-
-
-
- ))} -
-
+
+ {entries.map((entry, i) => ( +
+
{getSourceLabel(entry.source)}
+
+
{entry.description}
+
+ {entry.type} + + {formatDate(entry.timestamp)}
- ))} - - {/* Load more */} - {entries.length < total && ( -
- -
- )} +
- )} + ))}
) -} +} \ No newline at end of file diff --git a/dashboard/src/app/agents/page.tsx b/dashboard/src/app/agents/page.tsx new file mode 100644 index 0000000..c4053f4 --- /dev/null +++ b/dashboard/src/app/agents/page.tsx @@ -0,0 +1,136 @@ +"use client" + +/** + * /agents — Agent overview page (Phase 1) + * + * Lists all 5 agents with their full details. Phase 3 will turn each card + * into a clickable drill-in with the per-agent model dropdown. OpenClaw + * supports per-agent model overrides via agents.list[].model with live + * config patching — no container restart needed. + */ + +import * as React from "react" +import useSWR from "swr" +import { Bot } from "lucide-react" +import { Card } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +interface Agent { + id: string + name: string + emoji: string + role: string + fileCount: number + lastActivity: number +} + +const fetcher = (url: string) => fetch(url).then((r) => r.json()) + +function relativeTime(ts: number): string { + if (!ts) return "—" + const diff = Date.now() - ts + const m = Math.floor(diff / 60_000) + if (m < 1) return "just now" + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.floor(h / 24)}d ago` +} + +function statusOf(ts: number): "active" | "recent" | "idle" { + if (!ts) return "idle" + const diff = Date.now() - ts + if (diff < 5 * 60_000) return "active" + if (diff < 60 * 60_000) return "recent" + return "idle" +} + +const STATUS_COLOR = { + active: "bg-green-500", + recent: "bg-amber-500", + idle: "bg-zinc-500", +} + +export default function AgentsPage() { + const { data, isLoading } = useSWR<{ ok: boolean; agents: Agent[] }>( + "/api/tiger/agents", + fetcher, + { refreshInterval: 30_000 } + ) + + const agents = data?.agents ?? [] + + return ( +
+
+

+ + Agents +

+

+ Tiger's orchestrator and 4 specialist sub-agents. Phase 3 will let you + override the model per agent here. +

+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {agents.map((agent) => { + const status = statusOf(agent.lastActivity) + return ( + +
+ {agent.emoji} +
+
+

{agent.name}

+ +
+

+ {agent.role} +

+
+
+ +
+
+ Last activity + {relativeTime(agent.lastActivity)} +
+
+ Workspace files + {agent.fileCount} +
+
+ Model + + inherits global + +
+
+
+ ) + })} +
+ )} + +
+ Coming in Phase 3: click any + agent to set a custom model (e.g., a cheaper model for Researcher, + a stronger model for Coder). +
+
+ ) +} diff --git a/dashboard/src/app/api/activity/route.ts b/dashboard/src/app/api/activity/route.ts index ee7a36f..716f201 100644 --- a/dashboard/src/app/api/activity/route.ts +++ b/dashboard/src/app/api/activity/route.ts @@ -1,226 +1,44 @@ import { NextResponse } from "next/server" -import fs from "fs" -import path from "path" -import os from "os" +import { bridgeGet } from "@/lib/bridge" -interface ActivityEntry { - id: string - type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron" - timestamp: string - description: string - source?: string -} - -function parseCommandsLog(logPath: string): ActivityEntry[] { - const entries: ActivityEntry[] = [] - try { - const content = fs.readFileSync(logPath, "utf-8").trim() - if (!content) return entries - for (const line of content.split("\n")) { - try { - const data = JSON.parse(line) as { - timestamp: string - action: string - sessionKey: string - senderId: string - source: string - } - const actionLabels: Record = { - new: "New chat session", - reset: "Session reset", - delete: "Session deleted", - } - const sourceLabels: Record = { - webchat: "Web Chat", - telegram: "Telegram", - whatsapp: "WhatsApp", - cli: "CLI", - } - const actionLabel = actionLabels[data.action] || data.action - const sourceLabel = sourceLabels[data.source] || data.source - entries.push({ - id: `cmd-${data.timestamp}-${data.action}`, - type: "chat", - timestamp: data.timestamp, - description: `${actionLabel} via ${sourceLabel}`, - source: data.source, - }) - } catch { - // skip malformed lines - } - } - } catch { - // file not found - } - return entries -} - -function parseGatewayLog(logPath: string): ActivityEntry[] { - const entries: ActivityEntry[] = [] - try { - const content = fs.readFileSync(logPath, "utf-8") - const lines = content.split("\n") - - // Track seen heartbeat dates to deduplicate (only first per gateway restart) - let lastHeartbeatDay = "" - - for (const line of lines) { - if (!line.trim()) continue - - // Parse timestamp from start of line: "2026-01-27T02:35:09.747Z [tag] message" - const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/) - if (!match) continue - - const timestamp = match[1] - const rest = match[2] - - // Heartbeat events - only include first per gateway restart (not every "started") - if (rest.startsWith("[heartbeat]")) { - const msg = rest.replace("[heartbeat]", "").trim() - // Skip repetitive "started" - only keep once per gateway boot - if (msg === "started") { - const day = timestamp.slice(0, 10) - if (day === lastHeartbeatDay) continue - lastHeartbeatDay = day - entries.push({ - id: `gw-hb-${timestamp}`, - type: "heartbeat", - timestamp, - description: "Heartbeat service started", - }) - } else { - // Non-"started" heartbeat messages are meaningful - entries.push({ - id: `gw-hb-${timestamp}`, - type: "heartbeat", - timestamp, - description: `Heartbeat: ${msg}`, - }) - } - continue - } - - // Config reload events - if (rest.startsWith("[reload]")) { - const msg = rest.replace("[reload]", "").trim() - const changedMatch = msg.match(/evaluating reload \((.+)\)/) - const changed = changedMatch ? changedMatch[1] : msg - entries.push({ - id: `gw-reload-${timestamp}`, - type: "config", - timestamp, - description: `Config reload: ${changed}`, - }) - continue - } - - // Gateway startup/lifecycle - only truly significant events - if (rest.startsWith("[gateway]")) { - const msg = rest.replace("[gateway]", "").trim() - if (msg.startsWith("listening on")) { - // Reset heartbeat dedup on new gateway start - lastHeartbeatDay = "" - entries.push({ - id: `gw-sys-${timestamp}`, - type: "system", - timestamp, - description: `Gateway started: ${msg}`, - }) - } else if (msg.startsWith("agent model:")) { - entries.push({ - id: `gw-sys-${timestamp}`, - type: "system", - timestamp, - description: `Active model: ${msg.replace("agent model:", "").trim()}`, - }) - } else if (msg.includes("signal")) { - entries.push({ - id: `gw-sys-${timestamp}`, - type: "system", - timestamp, - description: msg, - }) - } - continue - } - - // Telegram provider start (only once per restart) - if (rest.startsWith("[telegram]") && rest.includes("starting provider")) { - const botMatch = rest.match(/\(@[^)]+\)/) - entries.push({ - id: `gw-tg-${timestamp}`, - type: "system", - timestamp, - description: `Telegram provider started ${botMatch?.[0] || ""}`.trim(), - }) - continue - } - - // Cron execution events - if (rest.includes("cron.run") && rest.includes("[ws]") && rest.includes("res ✓")) { - entries.push({ - id: `gw-cron-${timestamp}`, - type: "cron", - timestamp, - description: "Cron job executed", - }) - continue - } - } - } catch { - // file not found - } - return entries -} - -function parseMemoryFiles(memoryDir: string): ActivityEntry[] { - const entries: ActivityEntry[] = [] - try { - const files = fs.readdirSync(memoryDir).filter((f) => f.endsWith(".md")) - for (const file of files) { - const filePath = path.join(memoryDir, file) - const stat = fs.statSync(filePath) - const slug = file.replace(/\.md$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "") - const title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) - entries.push({ - id: `mem-${file}`, - type: "memory", - timestamp: stat.mtime.toISOString(), - description: `Memory saved: ${title}`, - }) - } - } catch { - // directory not found - } - return entries -} +export const dynamic = "force-dynamic" export async function GET(request: Request) { try { const url = new URL(request.url) - const limit = parseInt(url.searchParams.get("limit") || "200", 10) + const limit = parseInt(url.searchParams.get("limit") || "50", 10) - const clawdbotLogsDir = path.join(os.homedir(), ".clawdbot", "logs") - const workspace = "/Users/manohar_air/clawd" - const memoryDir = path.join(workspace, "memory") + // Get activity from bridge endpoint that already works + const bridgeData = await bridgeGet("/tiger/agents/activity") as { + ok: boolean + events: Array<{ + agentId: string + agentName: string + agentEmoji: string + path: string + action: string + ts: number + }> + } - // Aggregate from all sources - const commandEntries = parseCommandsLog(path.join(clawdbotLogsDir, "commands.log")) - const gatewayEntries = parseGatewayLog(path.join(clawdbotLogsDir, "gateway.log")) - const memoryEntries = parseMemoryFiles(memoryDir) + if (!bridgeData?.ok || !bridgeData.events) { + return NextResponse.json({ entries: [], total: 0 }) + } - // Merge and sort by timestamp descending - const allEntries = [...commandEntries, ...gatewayEntries, ...memoryEntries] - allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - - // Apply limit - const entries = allEntries.slice(0, limit) + // Transform bridge format to activity format + const entries = bridgeData.events.slice(0, limit).map((e) => ({ + id: `${e.agentId}-${e.ts}`, + type: "system", + timestamp: new Date(e.ts).toISOString(), + description: `${e.agentName} modified ${e.path}`, + source: e.agentId, + })) return NextResponse.json({ entries, - total: allEntries.length, + total: bridgeData.events.length, }) - } catch { + } catch (err) { return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 }) } -} +} \ No newline at end of file diff --git a/dashboard/src/app/api/chat/history/route.ts b/dashboard/src/app/api/chat/history/route.ts new file mode 100644 index 0000000..41914d0 --- /dev/null +++ b/dashboard/src/app/api/chat/history/route.ts @@ -0,0 +1,50 @@ +/** + * /api/chat/history — proxy for bridge's chat history. + * GET — list persisted messages for the default session + * DELETE — clear them + * + * Why a proxy and not a direct bridge call from the client? + * - Keeps the bridge auth token on the server side (never leaks to browser) + * - Matches the pattern used by /api/chat (POST) and /api/tiger/status + */ + +import { NextRequest, NextResponse } from "next/server"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +export async function GET(request: NextRequest) { + const sessionId = request.nextUrl.searchParams.get("sessionId") || ""; + const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ""; + try { + const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, { + headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` }, + cache: "no-store", + }); + const data = await r.json(); + return NextResponse.json(data, { status: r.status }); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Bridge unreachable", details: err.message }, + { status: 502 } + ); + } +} + +export async function DELETE(request: NextRequest) { + const sessionId = request.nextUrl.searchParams.get("sessionId") || ""; + const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ""; + try { + const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` }, + }); + const data = await r.json(); + return NextResponse.json(data, { status: r.status }); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Bridge unreachable", details: err.message }, + { status: 502 } + ); + } +} diff --git a/dashboard/src/app/api/chat/route.ts b/dashboard/src/app/api/chat/route.ts new file mode 100644 index 0000000..f6a55ab --- /dev/null +++ b/dashboard/src/app/api/chat/route.ts @@ -0,0 +1,133 @@ +/** + * /api/chat — chat send endpoint, now with real WS-based streaming. + * + * Replaces the previous bridge → docker exec → fake-typing chain. + * + * Request: POST { message: string, sessionKey?: string } + * Response: SSE stream of `data: { type, content }` events + * types: status | chunk | done | error (matches existing client parser) + * + * Persistence: user message stored BEFORE LLM call (so it's not lost on failure); + * agent reply stored after final token via bridge /tiger/chat/persist. + */ + +import { NextRequest } from "next/server"; +import { streamAgentRun, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +export const maxDuration = 180; +export const dynamic = "force-dynamic"; + +async function persistMessage(role: "user" | "agent", content: string, sessionKey: string, meta?: any) { + // Best-effort persistence; never block the chat response on this. + try { + await fetch(`${BRIDGE_URL}/tiger/chat/persist`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${BRIDGE_TOKEN}`, + }, + body: JSON.stringify({ role, content, sessionId: sessionKey, meta: meta || {} }), + }); + } catch (err) { + console.warn("[chat] persist failed:", role, (err as Error).message); + } +} + +export async function GET(request: NextRequest) { + const sessionKey = request.nextUrl.searchParams.get("sessionKey") || DEFAULT_SESSION_KEY; + + try { + const res = await fetch(`${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(sessionKey)}&limit=50`, { + headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` }, + }); + const data = await res.json(); + return Response.json(data); + } catch (err) { + return Response.json({ error: (err as Error).message }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const message: string = body?.message; + const sessionKey: string = body?.sessionKey || DEFAULT_SESSION_KEY; + + if (!message || typeof message !== "string") { + return new Response(JSON.stringify({ error: "message is required" }), { + status: 400, headers: { "Content-Type": "application/json" }, + }); + } + + // Persist user message NOW, before the LLM call + await persistMessage("user", message, sessionKey); + + const t0 = Date.now(); + const encoder = new TextEncoder(); + + /** + * Build the SSE stream. + * The wire format `data: {"type":"chunk","content":"..."}\n\n` matches what + * chat-interface.tsx already parses. Types we emit: + * status (the "thinking" indicator on accept ack) + * chunk (each assistant delta from the gateway) + * done (terminal — full text + meta, persists the agent reply) + * error (anything goes wrong, including handshake failures) + */ + const stream = new ReadableStream({ + async start(controller) { + const sse = (obj: { type: string; content?: string; meta?: any }) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`)); + }; + + try { + let fullText = ""; + let meta: any = undefined; + + for await (const ev of streamAgentRun({ message, sessionKey })) { + if (ev.kind === "status") { + sse({ type: "status", content: "" }); + } else if (ev.kind === "chunk") { + fullText += ev.content; + sse({ type: "chunk", content: ev.content }); + } else if (ev.kind === "done") { + // Prefer the gateway's authoritative final text over our delta accumulation. + fullText = ev.content || fullText; + meta = ev.meta; + sse({ type: "done", content: fullText }); + } else if (ev.kind === "error") { + sse({ type: "error", content: ev.content }); + } + } + + const dt = Date.now() - t0; + console.log(`[chat] sessionKey=${sessionKey} duration=${dt}ms chars=${fullText.length}`); + + // Persist the agent reply AFTER streaming is complete. + if (fullText) { + await persistMessage("agent", fullText, sessionKey, { + ...meta, + durationMs: dt, + }); + } + } catch (err: any) { + console.error("[chat] stream error:", err); + sse({ type: "error", content: err?.message || "stream failed" }); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + // Disable nginx-style buffering when behind a proxy. + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/dashboard/src/app/api/chat/route.ts.pre-ws b/dashboard/src/app/api/chat/route.ts.pre-ws new file mode 100644 index 0000000..b28c25c --- /dev/null +++ b/dashboard/src/app/api/chat/route.ts.pre-ws @@ -0,0 +1,137 @@ +/** + * API route: POST /api/chat + * Sends chat messages via Tiger Bridge -> OpenClaw CLI + */ + +import { NextRequest, NextResponse } from "next/server"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +export const maxDuration = 120; + +export async function POST(request: NextRequest) { + const { message } = await request.json(); + + if (!message) { + return NextResponse.json({ error: "message is required" }, { status: 400 }); + } + + // End-to-end timing: measure the full /api/chat call so we can compare + // against the bridge's own timing (data.timing) to find overhead. + const t0 = Date.now(); + + try { + // Call the bridge + const response = await fetch(`${BRIDGE_URL}/tiger/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${BRIDGE_TOKEN}`, + }, + body: JSON.stringify({ message }), + }); + + const tBridgeDone = Date.now(); + const data = await response.json(); + + if (data?.timing) { + console.log( + `[chat.timing] bridge: ${JSON.stringify(data.timing)} | dashboard: bridge_call=${tBridgeDone - t0}ms` + ); + } + + console.log("[chat] Bridge response:", JSON.stringify(data).substring(0, 500)); + + if (!response.ok) { + return NextResponse.json( + { error: data.error || "Chat failed" }, + { status: response.status } + ); + } + + // Extract the text response - OpenClaw returns in several possible formats + let text = ""; + + if (data.response?.result?.payloads?.[0]?.text) { + text = data.response.result.payloads[0].text; + } else if (data.response?.payloads?.[0]?.text) { + text = data.response.payloads[0].text; + } else if (data.response?.summary) { + text = data.response.summary; + } else if (data.response?.text) { + text = data.response.text; + } else if (data.text) { + text = data.text; + } else { + // Fallback: stringify the whole response for debugging + text = JSON.stringify(data); + } + + console.log("[chat] Extracted text:", text.substring(0, 200)); + + // Return as SSE with word-by-word streaming. + // + // WHY SIMULATE STREAMING? + // The bridge gives us the entire reply in one shot (LLM call completes + // before the process returns). That means without this code the whole + // answer pops in at once — feels sluggish even though the infra is fine. + // Splitting on whitespace and drip-feeding gives the UI a "typing" feel + // without changing the backend. Total time until done is identical. + // + // When true token-level streaming is wired in the bridge (Phase 3), we + // can swap this out for real chunks from openclaw's event stream. + const encoder = new TextEncoder(); + const words = text.split(/(\s+)/); // keep whitespace tokens → smooth flow + // ~60 words-per-second cadence ≈ 16ms per word. Tune to taste. + const WORD_DELAY_MS = 25; // 40 wps — smooth typing feel with frame headroom + + const stream = new ReadableStream({ + async start(controller) { + // Send status marker first so UI can show the thinking indicator. + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "status", content: "" })}\n\n` + ) + ); + + // Drip-feed word tokens. Each is a "chunk" that appends to the + // streaming message bubble on the client. + for (const word of words) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "chunk", content: word })}\n\n` + ) + ); + if (WORD_DELAY_MS > 0) { + await new Promise((resolve) => setTimeout(resolve, WORD_DELAY_MS)); + } + } + + // Final done event carries the full text as a safety fallback + // (see the Bug D fix in chat-interface.tsx). + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "done", content: text })}\n\n` + ) + ); + + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (err: any) { + console.error("[chat] Error:", err.message); + return NextResponse.json( + { error: "Failed to communicate with Tiger Bridge" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/dashboard/src/app/api/chat/sessions/route.ts b/dashboard/src/app/api/chat/sessions/route.ts new file mode 100644 index 0000000..74d1187 --- /dev/null +++ b/dashboard/src/app/api/chat/sessions/route.ts @@ -0,0 +1,123 @@ +/** + * /api/chat/sessions — list, create, delete chat sessions. + * + * GET → list webchat-eligible sessions (Main + any "agent:main:webchat-*") + * via gateway sessions.list. Returns simplified shape for the UI. + * POST → mint a new session key. The session is auto-created in the + * gateway on first message (so we don't need to call anything + * here — just return the key for the UI to start using). + * DELETE ?key=agent:main:webchat-xyz → remove from gateway + clear sqlite history. + * The default "agent:main:main" session can never be deleted. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { callGateway, newSessionKey, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +/** Whitelist: only "agent:main:main" + "agent:main:webchat-*" sessions are dashboard-visible. */ +function isWebchatSession(key: string): boolean { + return key === DEFAULT_SESSION_KEY || key.startsWith("agent:main:webchat-"); +} + +/** Pretty label for the dropdown. */ +function deriveLabel(key: string, displayName?: string): string { + if (key === DEFAULT_SESSION_KEY) return "Main"; + if (displayName && displayName !== "undefined") return displayName; + // For "agent:main:webchat-abc12345" → "Chat abc12345" + const m = key.match(/^agent:main:webchat-(.+)$/); + if (m) return `Chat ${m[1].slice(0, 8)}`; + return key; +} + +export async function GET() { + try { + // Ask gateway for ALL sessions, then filter to webchat-visible ones. + const r = await callGateway("sessions.list", {}); + if (!r.ok) { + return NextResponse.json( + { ok: false, error: "gateway sessions.list failed", details: r.error }, + { status: 502 } + ); + } + const all = (r.payload as any)?.sessions || []; + const webchat = all + .filter((s: any) => isWebchatSession(s.key)) + .map((s: any) => ({ + key: s.key, + label: deriveLabel(s.key, s.displayName), + updatedAt: s.updatedAt || null, + messageCount: s.messageCount || 0, + isDefault: s.key === DEFAULT_SESSION_KEY, + })) + // Default first, then most-recently-updated + .sort((a: any, b: any) => { + if (a.isDefault) return -1; + if (b.isDefault) return 1; + return (b.updatedAt || 0) - (a.updatedAt || 0); + }); + + // Always ensure "Main" is in the list, even if gateway hasn't seen it yet + if (!webchat.find((s: any) => s.key === DEFAULT_SESSION_KEY)) { + webchat.unshift({ key: DEFAULT_SESSION_KEY, label: "Main", updatedAt: null, messageCount: 0, isDefault: true }); + } + + return NextResponse.json({ ok: true, sessions: webchat }); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "sessions list failed", details: err.message }, + { status: 502 } + ); + } +} + +export async function POST() { + // Mint a new key. Actual gateway session is created lazily on first message. + const key = newSessionKey(); + return NextResponse.json({ + ok: true, + session: { key, label: deriveLabel(key), updatedAt: null, messageCount: 0, isDefault: false }, + }); +} + +export async function DELETE(request: NextRequest) { + const key = request.nextUrl.searchParams.get("key") || ""; + if (!key) { + return NextResponse.json({ ok: false, error: "key query param required" }, { status: 400 }); + } + if (key === DEFAULT_SESSION_KEY) { + return NextResponse.json({ ok: false, error: "the Main session cannot be deleted" }, { status: 400 }); + } + if (!isWebchatSession(key)) { + return NextResponse.json({ ok: false, error: "only webchat sessions can be deleted from here" }, { status: 400 }); + } + + // 1. Best-effort: ask gateway to delete its session record. + // If the session has never been used (no first message yet) the gateway + // won't know about it — that's fine, we still want to clean sqlite. + let gatewayResult: { ok: boolean; error?: any } = { ok: true }; + try { + gatewayResult = await callGateway("sessions.delete", { key }); + } catch (err: any) { + gatewayResult = { ok: false, error: err.message }; + } + + // 2. Clear our sqlite history for this session via the bridge. + let bridgeResult = { ok: true } as any; + try { + const r = await fetch( + `${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(key)}`, + { method: "DELETE", headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` } } + ); + bridgeResult = await r.json(); + } catch (err: any) { + bridgeResult = { ok: false, error: err.message }; + } + + return NextResponse.json({ + ok: bridgeResult.ok, + gateway: gatewayResult, + bridge: bridgeResult, + }); +} diff --git a/dashboard/src/app/api/gw/[...method]/route.ts b/dashboard/src/app/api/gw/[...method]/route.ts index 3898db1..b646978 100644 --- a/dashboard/src/app/api/gw/[...method]/route.ts +++ b/dashboard/src/app/api/gw/[...method]/route.ts @@ -1,6 +1,17 @@ import { NextRequest, NextResponse } from "next/server" -import { getGateway } from "@/lib/gateway" +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456" +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "" + +// Map gateway-style methods to bridge endpoints +const METHOD_MAP: Record = { + "status.canvas": "/tiger/status", + "config.get": "/tiger/config", + "config.set": "/tiger/config", +} + +// Proxy to the bridge instead of trying to reach the gateway directly +// (gateway runs inside Tiger container - not accessible from dashboard) export async function POST( request: NextRequest, { params }: { params: Promise<{ method: string[] }> } @@ -8,11 +19,21 @@ export async function POST( const { method: methodParts } = await params const method = methodParts.join(".") + // Map gateway method to bridge endpoint, or default to /tiger/status + const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}` + try { const body = await request.json().catch(() => ({})) - const gw = getGateway() - const result = await gw.request(method, body) - return NextResponse.json({ ok: true, data: result }) + const res = await fetch(`${BRIDGE_URL}${bridgePath}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${BRIDGE_TOKEN}`, + }, + body: JSON.stringify(body), + }) + const data = await res.json() + return NextResponse.json({ ok: res.ok, data }) } catch (error) { const message = error instanceof Error ? error.message : "Gateway request failed" return NextResponse.json({ ok: false, error: message }, { status: 502 }) @@ -26,12 +47,16 @@ export async function GET( const { method: methodParts } = await params const method = methodParts.join(".") + const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}` + try { - const gw = getGateway() - const result = await gw.request(method) - return NextResponse.json({ ok: true, data: result }) + const res = await fetch(`${BRIDGE_URL}${bridgePath}`, { + headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` }, + }) + const data = await res.json() + return NextResponse.json({ ok: res.ok, data }) } catch (error) { const message = error instanceof Error ? error.message : "Gateway request failed" return NextResponse.json({ ok: false, error: message }, { status: 502 }) } -} +} \ No newline at end of file diff --git a/dashboard/src/app/api/gw/stream/route.ts b/dashboard/src/app/api/gw/stream/route.ts index 8ffadbb..bd84bf3 100644 --- a/dashboard/src/app/api/gw/stream/route.ts +++ b/dashboard/src/app/api/gw/stream/route.ts @@ -1,55 +1,41 @@ -import { getGateway } from "@/lib/gateway" +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456" +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "" export const dynamic = "force-dynamic" export async function GET() { - const gw = getGateway() - - // Ensure connected - try { - if (!gw.isConnected()) { - await gw.connect() - } - } catch { - return new Response("Gateway offline", { status: 502 }) - } - const encoder = new TextEncoder() - let cleanupFn: (() => void) | null = null const stream = new ReadableStream({ start(controller) { - const handler = ({ event, payload, seq }: { event: string; payload: unknown; seq: number }) => { - if (event === "tick") return - const data = JSON.stringify({ event, payload, seq }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) - } - - gw.on("gateway-event", handler) - + // Send initial connected message controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n`) + encoder.encode( + `data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n` + ) ) - const keepalive = setInterval(() => { - controller.enqueue(encoder.encode(`: keepalive\n\n`)) - }, 15000) + // Poll tiger status instead of gateway directly + const interval = setInterval(async () => { + try { + const res = await fetch(`${BRIDGE_URL}/tiger/status`, { + headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` }, + }) + const data = await res.json() + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ event: "health", payload: { status: data.status, ...data } })}\n\n` + ) + ) + } catch { + controller.enqueue(encoder.encode(`: keepalive\n\n`)) + } + }, 10000) - const disconnectHandler = () => { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ event: "stream.disconnected", payload: { connected: false } })}\n\n`) - ) - } - gw.on("disconnected", disconnectHandler) - - cleanupFn = () => { - gw.off("gateway-event", handler) - gw.off("disconnected", disconnectHandler) - clearInterval(keepalive) - } + ;(controller as any)._cleanup = () => clearInterval(interval) }, - cancel() { - cleanupFn?.() + cancel(controller: any) { + controller?._cleanup?.() }, }) @@ -60,4 +46,4 @@ export async function GET() { Connection: "keep-alive", }, }) -} +} \ No newline at end of file diff --git a/dashboard/src/app/api/status/route.ts b/dashboard/src/app/api/status/route.ts index e8f2ac8..31c4a8b 100644 --- a/dashboard/src/app/api/status/route.ts +++ b/dashboard/src/app/api/status/route.ts @@ -1,8 +1,10 @@ import { NextResponse } from "next/server" import os from "os" -import fs from "fs" -import path from "path" -import { getGateway } from "@/lib/gateway" + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456" +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "" + +export const dynamic = "force-dynamic" export async function GET() { try { @@ -10,147 +12,39 @@ export async function GET() { const totalMem = os.totalmem() const memUsage = Math.round(((totalMem - freeMem) / totalMem) * 100) - // Try gateway first for rich data - try { - const gw = getGateway() - if (!gw.isConnected()) await gw.connect() - - const [health, skills, cron, heartbeat, identity, models, config] = await Promise.allSettled([ - gw.request("health"), - gw.request("skills.status"), - gw.request("cron.list"), - gw.request("last-heartbeat"), - gw.request("agent.identity.get"), - gw.request("models.list"), - gw.request("config.get"), - ]) - - const healthData = health.status === "fulfilled" ? health.value as Record : null - const skillsData = skills.status === "fulfilled" ? skills.value as Record : null - const cronData = cron.status === "fulfilled" ? cron.value as unknown[] : null - const heartbeatData = heartbeat.status === "fulfilled" ? heartbeat.value as Record : null - const identityData = identity.status === "fulfilled" ? identity.value as Record : null - const modelsData = models.status === "fulfilled" ? models.value as Record : null - const configData = config.status === "fulfilled" ? config.value as Record : null - - const skillsList = (skillsData?.skills || skillsData?.installed || []) as unknown[] - const cronList = Array.isArray(cronData) ? cronData : ((cronData as Record | null)?.jobs as unknown[] | undefined) || [] - - // Extract current model from config - try multiple response shapes - // Gateway config.get may return: raw config, { config: ... }, or nested differently - const rawConfig = (configData?.config as Record) || configData - const agentsConfig = (rawConfig?.agents as Record) || undefined - const defaultsConfig = (agentsConfig?.defaults as Record) || undefined - const modelConfig = (defaultsConfig?.model as Record) || undefined - let currentModel = (modelConfig?.primary as string) || null - let fallbackModels = ((modelConfig?.fallbacks || []) as string[]) - - // Fallback: read directly from config file if gateway didn't return model info - if (!currentModel) { - try { - const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json") - const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8")) - currentModel = fileConfig?.agents?.defaults?.model?.primary || null - if (!fallbackModels.length) { - fallbackModels = fileConfig?.agents?.defaults?.model?.fallbacks || [] - } - } catch { - // config file not readable - } - } - - // Also extract the raw config hash for conflict-safe patching - const configHash = configData?._hash || configData?.hash || null - - // Extract models list: { models: [{id, name, provider, contextWindow, reasoning, input}] } - const modelsList = (modelsData?.models || []) as unknown[] - - // Read HEARTBEAT.md for heartbeat task info - let heartbeatContent: string | null = null - try { - const workspace = (configData?.agents as Record | undefined)?.defaults as Record | undefined - const wsPath = (workspace?.workspace as string) || "/Users/manohar_air/clawd" - const hbPath = path.join(wsPath, "HEARTBEAT.md") - heartbeatContent = fs.readFileSync(hbPath, "utf-8").trim() - } catch { - // HEARTBEAT.md not found - } - - return NextResponse.json({ - status: "online", - gateway: true, - system: { - memoryUsage: memUsage, - uptime: os.uptime(), - platform: os.platform(), - }, - agent: { - name: identityData?.name || "Tarzan", - vibe: identityData?.vibe || "", - emoji: identityData?.emoji || "", - skills: skillsList.length, - cronJobs: cronList.filter((j: unknown) => (j as Record)?.enabled).length, - cronTotal: cronList.length, - lastHeartbeat: heartbeatData?.timestamp || heartbeatData?.lastChecked || null, - heartbeatContent, - currentModel, - fallbackModels, - }, - models: modelsList, - configHash, - health: healthData, - }) - } catch { - // Gateway not available - fall back to HTTP probe - } - - // Fallback: HTTP probe + file reads + // Use bridge's /tiger/status instead of gateway directly + // Gateway runs inside Tiger container and is not directly accessible let agentStatus = "offline" - try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 1000) - const response = await fetch("http://127.0.0.1:18789/__clawdbot__/canvas/", { - signal: controller.signal, - cache: "no-store", - }) - clearTimeout(timeoutId) - if (response.ok) agentStatus = "online" - } catch { - // offline - } + let gatewayConnected = false + let tigerStatus: any = null - // Even without gateway, try to read config file for model info - let fallbackModel: string | null = null - let fallbackFallbacks: string[] = [] - let fallbackHeartbeat: string | null = null try { - const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json") - const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8")) - fallbackModel = fileConfig?.agents?.defaults?.model?.primary || null - fallbackFallbacks = fileConfig?.agents?.defaults?.model?.fallbacks || [] - } catch { /* ignore */ } - try { - fallbackHeartbeat = fs.readFileSync(path.join("/Users/manohar_air/clawd", "HEARTBEAT.md"), "utf-8").trim() - } catch { /* ignore */ } + const res = await fetch(`${BRIDGE_URL}/tiger/status`, { + headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` }, + }) + if (res.ok) { + tigerStatus = await res.json() + agentStatus = tigerStatus?.status === "online" ? "online" : "degraded" + gatewayConnected = tigerStatus?.status === "online" + } + } catch { /* offline */ } return NextResponse.json({ status: agentStatus, - gateway: false, - system: { - memoryUsage: memUsage, - uptime: os.uptime(), - platform: os.platform(), - }, + gateway: gatewayConnected, + system: { memoryUsage: memUsage, uptime: os.uptime(), platform: os.platform() }, agent: { + name: "Tiger", skills: 0, cronJobs: 0, - lastHeartbeat: null, - heartbeatContent: fallbackHeartbeat, - currentModel: fallbackModel, - fallbackModels: fallbackFallbacks, + lastHeartbeat: tigerStatus?.agent?.heartbeat, + currentModel: tigerStatus?.agent?.currentModel, + fallbackModels: tigerStatus?.agent?.fallbackModels || [], + container: tigerStatus?.container?.status, + memoryUsagePct: tigerStatus?.system?.memoryUsagePct, }, }) } catch (error) { return NextResponse.json({ error: "Failed to fetch status" }, { status: 500 }) } -} +} \ No newline at end of file diff --git a/dashboard/src/app/api/tiger/activity/route.ts b/dashboard/src/app/api/tiger/activity/route.ts new file mode 100644 index 0000000..2d9b010 --- /dev/null +++ b/dashboard/src/app/api/tiger/activity/route.ts @@ -0,0 +1,15 @@ +// GET /api/tiger/activity?limit=50 — proxy to bridge +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const limit = searchParams.get("limit") ?? "50"; + try { + const result = await bridgeGet("/tiger/agents/activity", { limit }); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/agents-activity/route.ts b/dashboard/src/app/api/tiger/agents-activity/route.ts new file mode 100644 index 0000000..4de396e --- /dev/null +++ b/dashboard/src/app/api/tiger/agents-activity/route.ts @@ -0,0 +1,20 @@ +/** + * /api/tiger/agents-activity — Agent Activity Proxy + * + * Reads per-agent activity from Tiger's workspace via bridge endpoint. + */ + +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/agents/activity"); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/agents/[id]/file/route.ts b/dashboard/src/app/api/tiger/agents/[id]/file/route.ts new file mode 100644 index 0000000..4fd650e --- /dev/null +++ b/dashboard/src/app/api/tiger/agents/[id]/file/route.ts @@ -0,0 +1,37 @@ +// GET + PUT /api/tiger/agents/[id]/file?path=... +import { NextResponse } from "next/server"; +import { bridgeGet, bridgePut } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const path = searchParams.get("path") ?? ""; + if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 }); + try { + const result = await bridgeGet(`/tiger/agents/${id}/file`, { path }); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const path = searchParams.get("path") ?? ""; + if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 }); + try { + const body = await request.json(); + const result = await bridgePut(`/tiger/agents/${id}/file`, { path }, body as Record); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/agents/[id]/files/route.ts b/dashboard/src/app/api/tiger/agents/[id]/files/route.ts new file mode 100644 index 0000000..ae715e5 --- /dev/null +++ b/dashboard/src/app/api/tiger/agents/[id]/files/route.ts @@ -0,0 +1,21 @@ +// GET /api/tiger/agents/[id]/files?path=... — proxy to bridge +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const path = searchParams.get("path") ?? ""; + try { + const query: Record = {}; + if (path) query.path = path; + const result = await bridgeGet(`/tiger/agents/${id}/files`, query); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/agents/route.ts b/dashboard/src/app/api/tiger/agents/route.ts new file mode 100644 index 0000000..5d49bec --- /dev/null +++ b/dashboard/src/app/api/tiger/agents/route.ts @@ -0,0 +1,13 @@ +// GET /api/tiger/agents — proxy to bridge +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/agents"); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/bridge-restart/route.ts b/dashboard/src/app/api/tiger/bridge-restart/route.ts new file mode 100644 index 0000000..c489b00 --- /dev/null +++ b/dashboard/src/app/api/tiger/bridge-restart/route.ts @@ -0,0 +1,21 @@ +// POST /api/tiger/bridge-restart — restart the tiger-bridge systemd service +// Responds immediately, then triggers the restart with a short delay so the +// HTTP response is fully written before the process exits. +import { NextResponse } from "next/server"; +import { exec } from "child_process"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + // Schedule restart after response is flushed + setTimeout(() => { + exec("systemctl restart tiger-bridge", (err) => { + if (err) console.error("[bridge-restart] systemctl failed:", err.message); + }); + }, 600); + + return NextResponse.json({ + ok: true, + message: "Bridge restart initiated. Dashboard will reconnect in ~5s.", + }); +} diff --git a/dashboard/src/app/api/tiger/config/models/agents/[id]/route.ts b/dashboard/src/app/api/tiger/config/models/agents/[id]/route.ts new file mode 100644 index 0000000..96da157 --- /dev/null +++ b/dashboard/src/app/api/tiger/config/models/agents/[id]/route.ts @@ -0,0 +1,23 @@ +// PATCH /api/tiger/config/models/agents/[id] — set or clear a per-agent model override +// Next.js 15+ requires params to be awaited (they are now a Promise) +import { NextRequest, NextResponse } from "next/server"; +import { bridgePatch } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await request.json(); + const result = await bridgePatch( + `/tiger/config/models/agents/${id}`, + body as Record + ); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/config/models/agents/route.ts b/dashboard/src/app/api/tiger/config/models/agents/route.ts new file mode 100644 index 0000000..c7083c2 --- /dev/null +++ b/dashboard/src/app/api/tiger/config/models/agents/route.ts @@ -0,0 +1,14 @@ +// GET /api/tiger/config/models/agents — per-agent model overrides + defaults +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/config/models/agents"); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/config/models/route.ts b/dashboard/src/app/api/tiger/config/models/route.ts new file mode 100644 index 0000000..5b5b8d0 --- /dev/null +++ b/dashboard/src/app/api/tiger/config/models/route.ts @@ -0,0 +1,13 @@ +// GET /api/tiger/config/models — proxy to bridge +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/config/models"); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/cron/[id]/run/route.ts b/dashboard/src/app/api/tiger/cron/[id]/run/route.ts new file mode 100644 index 0000000..9e24ebc --- /dev/null +++ b/dashboard/src/app/api/tiger/cron/[id]/run/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { bridgePost } from '@/lib/bridge'; +export const dynamic = 'force-dynamic'; +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const result = await bridgePost('/tiger/cron/' + id + '/run', {}); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/cron/route.ts b/dashboard/src/app/api/tiger/cron/route.ts new file mode 100644 index 0000000..e2a71cc --- /dev/null +++ b/dashboard/src/app/api/tiger/cron/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { bridgeGet } from '@/lib/bridge'; +export const dynamic = 'force-dynamic'; +export async function GET() { + try { + const result = await bridgeGet('/tiger/cron'); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/dispatch/route.ts b/dashboard/src/app/api/tiger/dispatch/route.ts index 3c529ee..398e5b0 100644 --- a/dashboard/src/app/api/tiger/dispatch/route.ts +++ b/dashboard/src/app/api/tiger/dispatch/route.ts @@ -21,17 +21,3 @@ export async function POST(request: Request) { } } -// GET /api/tiger/dispatch/status/:taskId -export async function GET( - request: Request, - { params }: { params: Promise<{ taskId: string }> } -) { - const { taskId } = await params; - try { - const result = await bridgePost(`/tiger/dispatch/status/${taskId}`, {}); - return NextResponse.json(result); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ ok: false, error: message }, { status: 502 }); - } -} \ No newline at end of file diff --git a/dashboard/src/app/api/tiger/file-projects/route.ts b/dashboard/src/app/api/tiger/file-projects/route.ts new file mode 100644 index 0000000..3017b13 --- /dev/null +++ b/dashboard/src/app/api/tiger/file-projects/route.ts @@ -0,0 +1,21 @@ +/** + * /api/tiger/file-projects — Tiger Workspace Projects Proxy + * + * Reads projects from Tiger's PROJECTS.md (via /tiger/file-tasks/projects bridge endpoint). + */ + +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +// GET /api/tiger/file-projects — all projects from PROJECTS.md +export async function GET() { + try { + const result = await bridgeGet("/tiger/file-tasks/projects"); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/file-tasks/active/route.ts b/dashboard/src/app/api/tiger/file-tasks/active/route.ts new file mode 100644 index 0000000..3a339c1 --- /dev/null +++ b/dashboard/src/app/api/tiger/file-tasks/active/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { bridgeGet } from '@/lib/bridge'; +export const dynamic = 'force-dynamic'; +export async function GET() { + try { + const result = await bridgeGet('/tiger/file-tasks/active'); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/file-tasks/projects/route.ts b/dashboard/src/app/api/tiger/file-tasks/projects/route.ts new file mode 100644 index 0000000..3ff062c --- /dev/null +++ b/dashboard/src/app/api/tiger/file-tasks/projects/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { bridgeGet } from '@/lib/bridge'; +export const dynamic = 'force-dynamic'; +export async function GET() { + try { + const result = await bridgeGet('/tiger/file-tasks/projects'); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/file-tasks/route.ts b/dashboard/src/app/api/tiger/file-tasks/route.ts new file mode 100644 index 0000000..ada992d --- /dev/null +++ b/dashboard/src/app/api/tiger/file-tasks/route.ts @@ -0,0 +1,35 @@ +/** + * /api/tiger/file-tasks — Tiger Workspace Tasks Proxy + * + * Reads tasks from Tiger's TASKS.md (via /tiger/file-tasks bridge endpoint). + * This is the authoritative task list — not from SQLite. + */ + +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +// GET /api/tiger/file-tasks — all tasks from TASKS.md +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const section = searchParams.get("section"); // active | completed | all + const project = searchParams.get("project"); // dashboard, oil, etc + + let endpoint = "/tiger/file-tasks"; + if (section === "active") endpoint = "/tiger/file-tasks/active"; + else if (section === "completed") endpoint = "/tiger/file-tasks/completed"; + + // Add project filter if provided + if (project) { + endpoint += `?project=${encodeURIComponent(project)}`; + } + + const result = await bridgeGet(endpoint); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/keys/route.ts b/dashboard/src/app/api/tiger/keys/route.ts new file mode 100644 index 0000000..912e2a0 --- /dev/null +++ b/dashboard/src/app/api/tiger/keys/route.ts @@ -0,0 +1,26 @@ +// GET /api/tiger/keys — returns key presence (never values) +// PATCH /api/tiger/keys — set one or more keys + +import { NextRequest, NextResponse } from "next/server"; +import { bridgeGet, bridgePatch } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/keys"); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} + +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const result = await bridgePatch("/tiger/keys", body as Record); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/knowledge/route.ts b/dashboard/src/app/api/tiger/knowledge/route.ts new file mode 100644 index 0000000..8be1873 --- /dev/null +++ b/dashboard/src/app/api/tiger/knowledge/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server" + +const BRIDGE = "http://127.0.0.1:3456" +const TOKEN = "14fb879429386b69beac339bbd98e43011ec29485da17592410da34ed97e0236" + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get("url") || "/knowledge" + + try { + const res = await fetch(`${BRIDGE}${url}`, { + headers: { Authorization: `Bearer ${TOKEN}` } + }) + const data = await res.json() + return Response.json(data) + } catch (err) { + return Response.json({ error: (err as Error).message }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})) + const url = request.nextUrl.searchParams.get("url") || "/knowledge" + + try { + const res = await fetch(`${BRIDGE}${url}`, { + method: "POST", + headers: { + Authorization: `Bearer ${TOKEN}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }) + const data = await res.json() + return Response.json(data) + } catch (err) { + return Response.json({ error: (err as Error).message }, { status: 500 }) + } +} \ No newline at end of file diff --git a/dashboard/src/app/favicon.ico b/dashboard/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/dashboard/src/app/favicon.ico and /dev/null differ diff --git a/dashboard/src/app/icon.svg b/dashboard/src/app/icon.svg new file mode 100644 index 0000000..2585677 --- /dev/null +++ b/dashboard/src/app/icon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + T + + + + + + + + + \ No newline at end of file diff --git a/dashboard/src/app/knowledge/page.tsx b/dashboard/src/app/knowledge/page.tsx new file mode 100644 index 0000000..8da1ece --- /dev/null +++ b/dashboard/src/app/knowledge/page.tsx @@ -0,0 +1,235 @@ +"use client" + +import { useEffect, useState, useRef } from "react" +import dynamic from 'next/dynamic' +import { Brain, Search, ScrollText, Wrench, Activity, Clock, Network, MessageSquare } from "lucide-react" + +const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }) + +interface KnowledgeNode { + id: string + type: string + name: string + description: string +} + +interface FeedbackPref { + key: string + value: string +} + +interface GraphNode { + id: string + name: string + type: string + val: number +} + +interface GraphLink { + source: { id: string } + target: { id: string } + name: string +} + +export default function KnowledgePage() { + const [nodes, setNodes] = useState([]) + const [prefs, setPrefs] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [showGraph, setShowGraph] = useState(true) + const graphRef = useRef(null) + const [error, setError] = useState(null) + + const sections = [ + { title: "Memory", href: "/memory", icon: ScrollText, description: "Tiger's persistent memory" }, + { title: "Skills", href: "/skills", icon: Wrench, description: "Registry of capabilities" }, + { title: "Activity", href: "/activity", icon: Activity, description: "Timeline of events" }, + { title: "Schedule", href: "/cron", icon: Clock, description: "Scheduled tasks" }, + ] + + useEffect(() => { + Promise.all([ + fetch("/api/tiger/knowledge").then(r => r.json()), + fetch("/api/tiger/knowledge?url=/feedback/prefer").then(r => r.json()) + ]).then(([kg, fp]) => { + if (kg?.nodes) setNodes(kg.nodes) + if (fp?.preferences) setPrefs(fp.preferences) + setLoading(false) + }).catch(e => { + setError(e.message) + setLoading(false) + }) + }, []) + + const filteredNodes = search + ? nodes.filter(n => n.name.toLowerCase().includes(search.toLowerCase())) + : nodes + + const getTypeColor = (type: string) => { + switch (type) { + case "person": return "#60a5fa" + case "company": return "#4ade80" + case "concept": return "#c084fc" + default: return "#94a3b8" + } + } + + const getTypeIcon = (type: string) => { + switch (type) { + case "person": return "👤" + case "company": return "🏢" + case "concept": return "💡" + default: return "📌" + } + } + + // Build graph data from nodes + const graphNodes: GraphNode[] = nodes.map(n => ({ id: n.id, name: n.name, type: n.type, val: 10 })) + const graphLinks: GraphLink[] = nodes.flatMap(n => + nodes.filter(m => m.id !== n.id).slice(0, 1).map(m => ({ + source: { id: n.id }, + target: { id: m.id }, + name: "related" + })) + ) + + const connections = [ + { from: "Manohar", rel: "works-at", to: "Renew Power" }, + { from: "Manohar", rel: "interested-in", to: "PE/VC" }, + { from: "Renew Power", rel: "competitor", to: "Adani Green" }, + ] + + if (loading) { + return ( +
+
+ +

Knowledge

+
+
Loading...
+
+ ) + } + + return ( +
+ {/* TOP - Tiger's Brain */} +
+
+ +

Knowledge

+
+ +

Tiger's Brain

+
+ {sections.map(s => ( + + +
{s.title}
+
{s.description}
+
+ ))} +
+
+ + {/* BOTTOM */} +
+ {/* Graph/List Toggle */} +
+
+
+ +

Knowledge Graph

+
+ +
+ + setSearch(e.target.value)} + className="w-full p-2 rounded border" + /> + + {showGraph ? ( +
+ getTypeColor(n.type)} + nodeLabel={(n: any) => `${n.name} (${n.type})`} + linkColor={() => "#475569"} + backgroundColor="#1a1a2e" + width={500} + height={350} + /> +
+ ) : ( +
+ {filteredNodes.map(node => ( +
+
+ {getTypeIcon(node.type)} + {node.name} +
+ {node.description &&
{node.description}
} +
+ ))} +
+ )} + + {/* Legend */} +
+ ● Person + ● Company + ● Concept +
+
+ + {/* Connections + Learned */} +
+
+
+ +

Connections

+
+
+ {connections.map((c, i) => ( +
+ {c.from} + → {c.rel} → + {c.to} +
+ ))} +
+
+ +
+ +

Learned Preferences

+
+
+ {prefs.length === 0 ? ( +
Correct me to learn
+ ) : ( + prefs.map((p, i) => ( +
+ {p.key} + {p.value} +
+ )) + )} +
+
+
+ + {error &&
Error: {error}
} +
+ ) +} \ No newline at end of file diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx index 1979245..61e1f05 100644 --- a/dashboard/src/app/layout.tsx +++ b/dashboard/src/app/layout.tsx @@ -7,6 +7,7 @@ import { ThemeProvider } from "@/components/theme-provider" import { ModeToggle } from "@/components/mode-toggle" import "./globals.css"; import { Agentation } from 'agentation'; +import { ChatProvider } from "@/contexts/chat-context"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -19,8 +20,16 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Command Center", - description: "Tarzan's Dashboard for Agent Management", + title: "Tiger Command Center", + description: "Tiger Agent Management Dashboard", + icons: { + icon: [ + { url: "/icon.svg", type: "image/svg+xml" }, + { url: "/favicon.ico", sizes: "any" }, + ], + shortcut: "/icon.svg", + apple: "/icon.svg", + }, }; export default function RootLayout({ @@ -40,14 +49,34 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - + +
-

Tarzan's Dashboard

+
+ {/* Tiger Command icon — same SVG as favicon */} + + Tiger Dashboard +
@@ -57,7 +86,8 @@ export default function RootLayout({
-
+
+ diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index d91b473..a838f6a 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -1,380 +1,32 @@ "use client" -import useSWR from 'swr' -import { StatCard } from "@/components/stat-card" -import { - Activity, - Bot, - Clock, - AlertCircle, - Zap, - Cpu, - Check, - Loader2, - RefreshCw, - Server, - Terminal, - MemoryStick, -} from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { useBridgeRequest } from "@/hooks/use-bridge" -import { cn } from "@/lib/utils" -import * as React from "react" - -const fetcher = (url: string) => fetch(url).then((res) => res.json()) - -interface TigerStatus { - status: "online" | "degraded" | "offline" - container: { - status: string - exitCode: number - startedAt: string - } - openclaw: { - running: boolean - processInfo: string - } - system: { - memoryUsagePct: number - memoryTotalMb: number - uptime: string - } - agent: { - currentModel: string - fallbackModels: string[] - heartbeat: string | null - soul: string | null - } -} - -function formatUptime(startedAt: string): string { - if (!startedAt) return "—" - const start = new Date(startedAt) - const now = new Date() - const diffMs = now.getTime() - start.getTime() - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) - const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) - - if (diffHours > 24) { - const days = Math.floor(diffHours / 24) - return `${days}d ${diffHours % 24}h` - } - return `${diffHours}h ${diffMins}m` -} - -export default function DashboardPage() { - const { data: status, error: statusError, isLoading } = useSWR('/api/tiger/status', fetcher, { - refreshInterval: 5000, - revalidateOnFocus: true, - }) - const { request } = useBridgeRequest() - const [restarting, setRestarting] = React.useState(false) - const [restartSuccess, setRestartSuccess] = React.useState(false) - - const isOffline = statusError || status?.status === "offline" - const isCrashed = status?.container?.exitCode === 255 - - const handleRestart = async () => { - setRestarting(true) - setRestartSuccess(false) - try { - await request("/api/tiger/restart", "POST") - setRestartSuccess(true) - setTimeout(() => setRestartSuccess(false), 3000) - } catch (e) { - console.error("Failed to restart:", e) - } finally { - setRestarting(false) - } - } +import { CommandBar } from "@/components/command-bar" +import { AgentStrip } from "@/components/agent-strip" +import { DigestCard } from "@/components/digest-card" +import { TelegramThreadCard } from "@/components/telegram-thread-card" +import { StatusFooter } from "@/components/status-footer" +import { ScheduleCard } from "@/components/schedule-card" +export default function HomePage() { return ( -
+
+ {/* HERO — the command bar is the front door of Tiger. */} + - {/* Crash Recovery Banner */} - {isCrashed && ( -
-
- -
- Tiger Crashed - (exit code 255 — MiniMax API unreachable) -
-
- -
- )} + {/* AGENTS — one strip, all 5 agents, live state at a glance. */} + - {/* Stat Cards Row */} -
- - -
-
-

Container

-

- {isLoading ? "..." : status?.container?.status || "Unknown"} -

-
- -
-
-
- - - -
-
-

OpenClaw

-

- {isLoading ? "..." : status?.openclaw?.running ? "Running" : "Stopped"} -

-
- -
-
-
- - - -
-
-

Memory

-

- {isLoading ? "..." : `${status?.system?.memoryUsagePct || 0}%`} -

-
- -
-
-
- - - -
-
-

Uptime

-

- {isLoading ? "..." : formatUptime(status?.container?.startedAt || "")} -

-
- -
-
-
+ {/* CONTEXT ROW — digest (left) + Telegram thread (right) */} +
+ +
- {/* Error State */} - {isOffline && !isLoading && ( -
- - Failed to connect to Tiger Bridge. Ensure the bridge server is running on the VPS. -
- )} + {/* SCHEDULE ROW — Tiger's cron jobs + next-run times */} + -
- - {/* Container Health Card */} - - - - - Tiger Health - - - {status?.status === "online" ? ( - - All systems operational - - ) : status?.status === "degraded" ? ( - - Degraded mode - - ) : ( - "Connection lost" - )} - - - -
- {/* Container Status */} -
- Container Status -
- - {status?.container?.status || "—"} -
-
- - {/* Exit Code */} - {status?.container?.exitCode !== undefined && status?.container?.exitCode !== 0 && ( -
- Exit Code - - {status?.container?.exitCode} - {status?.container?.exitCode === 255 && " (API unreachable)"} - -
- )} - - {/* OpenClaw Process */} -
- OpenClaw Process - - {status?.openclaw?.running ? "Running" : "Not running"} - -
- - {/* Memory Usage */} -
- Memory Usage - - {status?.system?.memoryUsagePct || 0}% of {status?.system?.memoryTotalMb || 0}MB - -
- - {/* Uptime */} -
- Container Uptime - {formatUptime(status?.container?.startedAt || "")} -
- - {/* Restart Button */} -
- -
-
-
-
- - {/* Agent Model Card */} - - - - - Agent Model - - Current AI model configuration - - -
- {/* Primary Model */} -
-
Primary Model
-
- {isLoading ? "..." : status?.agent?.currentModel || "Not configured"} -
-
- - {/* Fallback Models */} - {status?.agent?.fallbackModels && status.agent.fallbackModels.length > 0 && ( -
-
Fallback Models
-
- {status.agent.fallbackModels.map((model, i) => ( -
- {model} -
- ))} -
-
- )} - - {/* Heartbeat */} - {status?.agent?.heartbeat && ( -
-
Last Heartbeat
-
-                    {status.agent.heartbeat.slice(0, 500)}
-                  
-
- )} -
-
-
-
- - {/* Quick Links */} - + {/* FOOTER — system health strip. Becomes a banner on crash. */} +
) -} \ No newline at end of file +} diff --git a/dashboard/src/app/projects/page.tsx b/dashboard/src/app/projects/page.tsx index 48fc579..d9678e8 100644 --- a/dashboard/src/app/projects/page.tsx +++ b/dashboard/src/app/projects/page.tsx @@ -1,189 +1,361 @@ -/** - * Projects Page — Project management with tasks - * - * Lists projects as cards and provides project detail view with Kanban. - */ - "use client" +/** + * /projects — Dual-source project view with inline task+agent expansion + * + * PRIMARY → Tiger's PROJECTS.md (source of truth, read-only) + * SECONDARY → SQLite projects (dashboard-queued, waiting for Tiger) + * + * Click any project → expands to show tasks from TASKS.md for that project, + * each task row showing: stage name, assigned agent (emoji+name), status badge. + */ + import * as React from "react" -import { FolderOpen, Plus, Loader2, MoreVertical, Pencil, Trash2 } from "lucide-react" +import useSWR from "swr" +import { + FolderOpen, Plus, Loader2, ChevronDown, ChevronRight, + Inbox, Trash2, MoreVertical, +} from "lucide-react" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, DialogContent, DialogDescription, DialogHeader, + DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { useBridgeRequest } from "@/hooks/use-bridge" import { cn } from "@/lib/utils" -interface Project { +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface FileProject { + id: string + name: string + description: string + created: string + tasks_count: string + status: string +} + +interface FileTask { + id: string + title: string + status: string + status_raw: string + assigned_agent: string + project: string + isProject?: boolean + isSubTask?: boolean + parentId?: string +} + +interface DbProject { id: string name: string description: string status: string priority: string created_at: string - updated_at: string } -interface Task { - id: string - project_id: string - title: string - description: string - status: string - priority: string - assigned_agent: string | null - progress: number - created_at: string - updated_at: string -} +// ─── Constants ──────────────────────────────────────────────────────────────── const PRIORITY_COLORS: Record = { - low: "bg-gray-500/10 text-gray-400 border-gray-500/20", + low: "bg-gray-500/10 text-gray-400 border-gray-500/20", medium: "bg-blue-500/10 text-blue-400 border-blue-500/20", - high: "bg-amber-500/10 text-amber-400 border-amber-500/20", + high: "bg-amber-500/10 text-amber-400 border-amber-500/20", urgent: "bg-red-500/10 text-red-400 border-red-500/20", } -const STATUS_COLORS: Record = { - active: "bg-green-500/10 text-green-400 border-green-500/20", - paused: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", - completed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", - archived: "bg-gray-500/10 text-gray-400 border-gray-500/20", +const TASK_STATUS_COLORS: Record = { + "in-progress": "bg-amber-500/10 text-amber-400", + review: "bg-purple-500/10 text-purple-400", + done: "bg-emerald-500/10 text-emerald-400", + backlog: "bg-zinc-500/10 text-zinc-400", } -export default function ProjectsPage() { - const { request } = useBridgeRequest() - const [projects, setProjects] = React.useState([]) - const [selectedProject, setSelectedProject] = React.useState(null) - const [tasks, setTasks] = React.useState([]) - const [loading, setLoading] = React.useState(false) - const [loadingTasks, setLoadingTasks] = React.useState(false) - const [error, setError] = React.useState(null) - const [isCreateOpen, setIsCreateOpen] = React.useState(false) - const [newProjectName, setNewProjectName] = React.useState("") - const [newProjectDesc, setNewProjectDesc] = React.useState("") - const [newProjectPriority, setNewProjectPriority] = React.useState("medium") - const [creating, setCreating] = React.useState(false) +const AGENT_EMOJI: Record = { + tiger: "🐯", main: "🐯", + cody: "💻", coder: "💻", + ethan: "🔍", researcher: "🔍", + cathy: "✍️", writer: "✍️", + elon: "📊", pm: "📊", +} - // Load projects - const loadProjects = React.useCallback(async () => { - setLoading(true) - setError(null) - try { - const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] } - if (data.ok && data.projects) { - setProjects(data.projects) - } else { - setError("Failed to load projects") - } - } catch (e: unknown) { - setError("Failed to load projects") - } finally { - setLoading(false) - } - }, [request]) +const AGENT_COLORS: Record = { + tiger: "text-orange-400", main: "text-orange-400", + cody: "text-blue-400", coder: "text-blue-400", + ethan: "text-green-400", researcher: "text-green-400", + cathy: "text-pink-400", writer: "text-pink-400", + elon: "text-violet-400", pm: "text-violet-400", +} - // Load tasks for selected project - const loadTasks = React.useCallback(async (projectId: string) => { - setLoadingTasks(true) - try { - const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] } - // Get tasks from project - const projectData = await request(`/api/tiger/projects/${projectId}`) as { ok: boolean; project?: { tasks?: Task[] } } - if (projectData.ok && projectData.project?.tasks) { - setTasks(projectData.project.tasks) - } else { - // Fallback: get all tasks and filter - const allTasks = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] } - if (allTasks.ok && allTasks.tasks) { - setTasks(allTasks.tasks.filter(t => t.project_id === projectId)) - } - } - } catch (e: unknown) { - console.error("Failed to load tasks:", e) - } finally { - setLoadingTasks(false) - } - }, [request]) +// ─── Helpers ────────────────────────────────────────────────────────────────── - React.useEffect(() => { - loadProjects() - }, [loadProjects]) +const fetcher = (url: string) => fetch(url).then((r) => r.json()) - React.useEffect(() => { - if (selectedProject) { - loadTasks(selectedProject.id) - } - }, [selectedProject, loadTasks]) +function statusBadgeClass(status: string) { + const s = status.toLowerCase() + if (s.includes("progress") || s.includes("active") || s.includes("🔴") || s.includes("🔄")) + return "bg-green-500/10 text-green-400 border-green-500/20" + if (s.includes("review")) + return "bg-purple-500/10 text-purple-400 border-purple-500/20" + if (s.includes("done") || s.includes("complete") || s.includes("approved")) + return "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" + return "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" +} - const handleCreateProject = async () => { - if (!newProjectName.trim()) return - setCreating(true) - try { - await request("/api/tiger/projects", "POST", { - name: newProjectName, - description: newProjectDesc, - priority: newProjectPriority, - }) - setNewProjectName("") - setNewProjectDesc("") - setNewProjectPriority("medium") - setIsCreateOpen(false) - loadProjects() - } catch (e: unknown) { - console.error("Failed to create project:", e) - } finally { - setCreating(false) - } - } +function cleanText(s: string) { + // Strip emoji and markdown bold markers for display + return s.replace(/\*\*/g, "").replace(/[✅⏳🔄❌🛢️🔍💻📊✍️🐯]/g, "").trim() +} - const handleDeleteProject = async (id: string) => { - try { - await request("/api/tiger/projects", "POST", { _method: "DELETE", id }) - loadProjects() - if (selectedProject?.id === id) { - setSelectedProject(null) - setTasks([]) - } - } catch (e: unknown) { - console.error("Failed to delete project:", e) - } - } +// ─── Task row inside expanded project ───────────────────────────────────────── - // Group tasks by status - const tasksByStatus = React.useMemo(() => { - const grouped: Record = { - backlog: [], - ready: [], - "in-progress": [], - review: [], - done: [], - } - tasks.forEach(task => { - if (grouped[task.status]) { - grouped[task.status].push(task) - } - }) - return grouped - }, [tasks]) +function TaskRow({ task }: { task: FileTask }) { + const agentKey = task.assigned_agent?.toLowerCase() ?? "" + return ( +
+ {/* Status badge */} + + {task.status_raw ? cleanText(task.status_raw).slice(0, 20) : task.status} + + + {/* Stage/task name */} + + {cleanText(task.title)} + + + {/* Agent */} + {agentKey && agentKey !== "unassigned" && ( + + {AGENT_EMOJI[agentKey] ?? "🤖"} + {agentKey} + + )} +
+ ) +} + +// ─── Tiger's project card (read-only, with task expand) ─────────────────────── + +function FileProjectCard({ project }: { project: FileProject }) { + const [expanded, setExpanded] = React.useState(false) + + // Fetch tasks from TASKS.md filtered to this project name + const projectName = cleanText(project.name) + const { data: tasksData, isLoading: tasksLoading } = useSWR<{ + ok: boolean; tasks: FileTask[] + }>( + expanded + ? `/api/tiger/file-tasks?project=${encodeURIComponent(projectName)}` + : null, + fetcher + ) + + // Show sub-tasks (stage rows) and the project-level task (agent + status) + const allTasks = tasksData?.tasks ?? [] + const projectTask = allTasks.find((t) => t.isProject) + const subTasks = allTasks.filter((t) => t.isSubTask) return ( -
+ + +
+ {/* Expand toggle + title */} + + + {/* Status badge */} + + {cleanText(project.status).replace(/in progress/i, "Active")} + +
+ + {project.description && ( + + {project.description} + + )} +
+ + + {/* Meta row */} +
+ Created {project.created} + · + {project.tasks_count} + {/* Show primary agent when collapsed */} + {!expanded && projectTask?.assigned_agent && ( + <> + · + + {AGENT_EMOJI[projectTask.assigned_agent] ?? ""} {projectTask.assigned_agent} + + + )} +
+ + {/* Expanded task list */} + {expanded && ( +
+ {tasksLoading ? ( +
+ +
+ ) : allTasks.length === 0 ? ( +

+ No tasks found in TASKS.md for this project. +

+ ) : ( +
+ {/* Project-level agent row */} + {projectTask && ( +
+ Lead: + + {AGENT_EMOJI[projectTask.assigned_agent] ?? ""} + {projectTask.assigned_agent} + + + {cleanText(projectTask.status_raw || projectTask.status)} + +
+ )} + + {/* Sub-task rows (review pipeline stages etc) */} + {subTasks.length > 0 ? ( + subTasks.map((t) => ) + ) : ( +

+ No task breakdown available — Tiger tracks this at project level. +

+ )} +
+ )} +
+ )} +
+
+ ) +} + +// ─── Dashboard-queued project card ─────────────────────────────────────────── + +function DbProjectCard({ project, onDelete }: { project: DbProject; onDelete: (id: string) => void }) { + return ( + + +
+
+ {project.name} + {project.description && ( + {project.description} + )} +
+ + + + + + onDelete(project.id)} className="text-destructive"> + Delete + + + +
+
+ +
+ + {project.priority} + + Queued — waiting for Tiger +
+
+
+ ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function ProjectsPage() { + const { data: fileData, isLoading: fileLoading } = useSWR<{ + ok: boolean; projects: FileProject[] + }>("/api/tiger/file-tasks/projects", fetcher, { refreshInterval: 60_000 }) + + const { data: dbData, isLoading: dbLoading, mutate: mutateDb } = useSWR<{ + ok: boolean; projects: DbProject[] + }>("/api/tiger/projects", fetcher, { refreshInterval: 60_000 }) + + const fileProjects = fileData?.projects ?? [] + const dbProjects = dbData?.projects ?? [] + const isLoading = fileLoading || dbLoading + + const [isCreateOpen, setIsCreateOpen] = React.useState(false) + const [form, setForm] = React.useState({ name: "", seed: "", description: "", priority: "medium" }) + const [creating, setCreating] = React.useState(false) + const [createError, setCreateError] = React.useState("") + + const handleCreate = async () => { + const payload: Record = { priority: form.priority } + if (form.name.trim()) payload.name = form.name.trim() + if (form.seed.trim()) payload.seed = form.seed.trim() + if (form.description.trim()) payload.description = form.description.trim() + if (!payload.name && !payload.seed) { setCreateError("Enter a project name or seed text."); return } + setCreating(true); setCreateError("") + try { + const res = await fetch("/api/tiger/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + const result = await res.json() + if (!result.ok) throw new Error(result.error ?? "Failed") + setForm({ name: "", seed: "", description: "", priority: "medium" }) + setIsCreateOpen(false) + mutateDb() + } catch (e: any) { setCreateError(e.message) } + finally { setCreating(false) } + } + + const handleDelete = async (id: string) => { + await fetch(`/api/tiger/projects/${id}`, { method: "DELETE" }) + mutateDb() + } + + return ( +
{/* Header */}
@@ -192,182 +364,90 @@ export default function ProjectsPage() { Projects

- Manage projects and track tasks across your team + Tiger's projects from PROJECTS.md. + Click a project to see its tasks and agents.

- - + - Create Project - Create a new project to organize tasks. + Queue a Project for Tiger + + Tiger will pick this up and add it to PROJECTS.md. + -
+
- setNewProjectName(e.target.value)} - placeholder="Project name" - className="mt-1" - /> + setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. BESS Economics Model" className="mt-1" />
- - setNewProjectDesc(e.target.value)} - placeholder="Project description" - className="mt-1" - /> + +