Compare commits
7 commits
572418f0ea
...
b250751888
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b250751888 | ||
|
|
7e352eea7b | ||
|
|
50a6520c20 | ||
|
|
0fcc209020 | ||
|
|
197c43dfee | ||
|
|
0142b1bfe7 | ||
|
|
03123d1ff7 |
13 changed files with 881 additions and 661 deletions
353
ARCHITECTURE.md
353
ARCHITECTURE.md
|
|
@ -1,15 +1,17 @@
|
||||||
# Tiger Command Center — Architecture
|
# Tiger Command Center — Architecture
|
||||||
|
|
||||||
*Last updated: 2026-05-03. Covers all services through the hardening session.*
|
*Last updated: 2026-06-10. Covers the gateway migration, real sub-agent
|
||||||
|
spawning, the TASKS.md inbox loop, the Telegram transcript mirror, and the
|
||||||
|
unified audit trail.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. System Overview
|
## 1. System Overview
|
||||||
|
|
||||||
Self-hosted AI agent orchestration on a Hetzner VPS (77.42.82.225, 8 GB RAM, Helsinki).
|
Self-hosted AI agent orchestration on a Hetzner VPS (8 GB RAM, Helsinki;
|
||||||
Three host services + one containerised AI runtime behind Traefik.
|
Tailscale 100.75.128.45). Three host services + one containerised AI
|
||||||
|
runtime behind Traefik, with ALL model traffic routed through a self-hosted
|
||||||
Topology:
|
LiteLLM gateway — no third-party balance can silently kill the system.
|
||||||
|
|
||||||
```
|
```
|
||||||
Internet/Manohar
|
Internet/Manohar
|
||||||
|
|
@ -18,275 +20,120 @@ Internet/Manohar
|
||||||
dokploy-traefik (v3.6.7)
|
dokploy-traefik (v3.6.7)
|
||||||
|
|
|
|
||||||
+-- agent.manohargupta.com --> tiger-dashboard (Next.js, :3100)
|
+-- agent.manohargupta.com --> tiger-dashboard (Next.js, :3100)
|
||||||
| |
|
| | /api/* proxies (token server-side)
|
||||||
| tiger-bridge (Express, :3456, 127.0.0.1 only)
|
| v
|
||||||
| | docker exec
|
| tiger-bridge (Express+tsx, :3456, localhost)
|
||||||
|
| | docker exec / volume reads
|
||||||
|
| v
|
||||||
| tiger-openclaw (OpenClaw v2026.3.12)
|
| tiger-openclaw (OpenClaw v2026.3.12)
|
||||||
| |
|
| |
|
||||||
| MiniMax-M2.7 -> openrouter/auto -> trinity:free
|
+-- llm.manohargupta.com ----> litellm-gateway <-- ALL model calls
|
||||||
|
| |-- MiniMax API (own key): minimax-3 (primary),
|
||||||
|
| | minimax-2.7, minimax-2.7-fast
|
||||||
|
| +-- Anthropic API (own key): claude-haiku, claude-sonnet
|
||||||
|
|
|
|
||||||
Telegram @Tiger_4321_bot <-- /tiger/notify <-- Tiger agent
|
+-- angel.manohargupta.com --> position-tracker (standalone repo/deploy)
|
||||||
|
|
|
||||||
|
Telegram @Tiger_4321_bot <--> OpenClaw native channel (long-polling, owns the bot)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 2. Model Routing (post-OpenRouter)
|
||||||
|
|
||||||
## 2. Services
|
OpenRouter was removed 2026-06-10 after its credits ran dry and silently
|
||||||
|
broke both Tiger and the bridge's classifier. Everything now goes through
|
||||||
|
the self-hosted gateway:
|
||||||
|
|
||||||
### 2.1 tiger-openclaw (Docker container)
|
- **OpenClaw** (`openclaw.json`): custom provider `litellm`
|
||||||
|
(`baseUrl: https://llm.manohargupta.com/v1`, `api: openai-completions`).
|
||||||
|
Primary `litellm/minimax-3` (1M ctx), fallbacks `litellm/minimax-2.7` →
|
||||||
|
`litellm/claude-haiku` (cross-provider: survives a MiniMax outage).
|
||||||
|
- **Bridge** (`lib/llm.ts`): slugs starting `anthropic/` go to Anthropic
|
||||||
|
direct; everything else goes to the gateway. Env: `LLM_GATEWAY_URL`,
|
||||||
|
`LLM_GATEWAY_KEY`, `TIGER_ROUTER_MODEL` (default `minimax-3`).
|
||||||
|
- **Gateway config**: `/root/litellm/litellm_config.yaml`
|
||||||
|
(`request_timeout: 300` to match the cron budget).
|
||||||
|
|
||||||
| Property | Value |
|
## 3. Sub-Agent Execution (the orchestration layer)
|
||||||
|----------|-------|
|
|
||||||
| 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).
|
`bridge/src/lib/agents.ts` is the canonical specialist registry:
|
||||||
|
**cody** (code), **ethan** (research), **cathy** (writing), **elon** (PM).
|
||||||
|
Legacy ids coder/researcher/writer/pm are accepted as aliases.
|
||||||
|
|
||||||
Model chain (agents.defaults.model in openclaw.json):
|
A spawn (`POST /tiger/spawn`) runs an isolated OpenClaw session
|
||||||
primary : minimax/MiniMax-M2.7
|
(`--session-id spawn-<agent>-<id>`) with the specialist persona prepended.
|
||||||
fallback1: openrouter/auto
|
Message transport is docker-cp of a temp file (escaping-proof). Runs are
|
||||||
fallback2: openrouter/arcee-ai/trinity-large-preview:free (free - billing safety net)
|
tracked in the `executions` table and serialized (`MAX_CONCURRENT=1` —
|
||||||
|
parallel turns push the 8GB host into swap and everything times out).
|
||||||
|
Completion fires a Telegram notification via `/tiger/notify`.
|
||||||
|
|
||||||
Cron jobs (cron/jobs.json):
|
Upgrade path: define real per-agent entries in `openclaw.json agents.list`
|
||||||
Tiger: Hourly Task Check-in 0 * * * * IST 90s timeout
|
(own IDENTITY.md + workspace each), then change the `--agent` flag in
|
||||||
Tiger: Weekly Digest 0 9 * * 1 IST 90s timeout
|
spawn.ts. Documented in lib/agents.ts; deferred until the RAM situation is
|
||||||
|
resolved.
|
||||||
|
|
||||||
Both use delivery.mode="none" — they notify via curl to /tiger/notify, not OpenClaw delivery channel.
|
## 4. TASKS.md Inbox Loop
|
||||||
"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)
|
`workspace/TASKS.md` has a `## 📥 INBOX` section. `bridge/src/lib/inbox.ts`
|
||||||
|
checks every 30 min (09:00–20:00 IST): takes the first `- [ ]` line,
|
||||||
|
classifies it (`classifyAgent`), spawns the specialist, rewrites the line to
|
||||||
|
`- [⏳ run-id → agent]`. Manual trigger: `POST /tiger/inbox/drain`.
|
||||||
|
Bridge-side scheduling means zero model tokens burned on empty checks and
|
||||||
|
no bearer tokens embedded in cron prompts.
|
||||||
|
|
||||||
Language : TypeScript/Express -> bridge/dist/
|
## 5. Telegram
|
||||||
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.
|
- **The bot is owned by OpenClaw's native channel** (long-polling). The
|
||||||
|
bridge's `TelegramChannel`, `telegram-webhook.ts` and `chat-mirror.ts`
|
||||||
|
are legacy: Telegram forbids webhook + getUpdates on one token, so the
|
||||||
|
webhook design could never receive a message.
|
||||||
|
- **The dashboard mirror reads the native session transcript** —
|
||||||
|
`routes/chat-telegram.ts` resolves the `telegram:` session from
|
||||||
|
`sessions.json` and serves the JSONL with cursor pagination and mtime
|
||||||
|
caching. It filters to what Telegram actually saw: assistant messages
|
||||||
|
carrying toolCall blocks (working narration) are skipped, thinking blocks
|
||||||
|
ignored, injected metadata/system boilerplate stripped from user messages.
|
||||||
|
|
||||||
### 2.3 tiger-dashboard (systemd: tiger-dashboard.service)
|
## 6. Audit Trail
|
||||||
|
|
||||||
Framework : Next.js 14, App Router
|
`GET /tiger/activity/audit` merges, at read time, every durable action
|
||||||
Port : 3100
|
store: `executions` (spawns), `tasks` (lifecycle), `outputs` (artifacts),
|
||||||
URL : agent.manohargupta.com (via Traefik)
|
and OpenClaw's cron run JSONL. Cursor-paginated (`before=<ISO>`), type
|
||||||
Source : /root/OpenClawDashboard/dashboard/src/
|
filters. The dashboard `/activity` page adds recent file-modification
|
||||||
WorkingDir : /root/OpenClawDashboard/dashboard
|
events on the first page. Read-time merging means history is complete
|
||||||
|
retroactively and no action can happen without its audit row.
|
||||||
|
|
||||||
All API calls are server-side route handlers — bearer token never reaches the browser.
|
## 7. Crons (OpenClaw, tz Asia/Kolkata)
|
||||||
|
|
||||||
Build discipline: NEVER run npm run build while next start is live.
|
| Job | Schedule | Timeout |
|
||||||
In-memory and on-disk manifests split-brain -> ChunkLoadError in browser. Correct:
|
|---|---|---|
|
||||||
systemctl stop tiger-dashboard
|
| Trade Baseline Reset | 9:15 daily | 60s |
|
||||||
npm run build
|
| Trade P&L Monitor | every 2 min | 60s |
|
||||||
systemctl start tiger-dashboard
|
| Hourly Trade Summary + News | hourly | 90s |
|
||||||
|
| Hourly Task Check-in | 0 9-21 | 300s |
|
||||||
|
| EOD Trade Summary | 16:00 Mon–Fri | 300s |
|
||||||
|
| Weekly Digest | Mon 9:00 | 300s |
|
||||||
|
|
||||||
### 2.4 Traefik (dokploy-traefik v3.6.7)
|
Timeout budget rationale: agent turns on this RAM-starved host can take
|
||||||
|
minutes; 300s is the ceiling that made chronically-failing jobs pass.
|
||||||
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
|
## 8. Security Posture
|
||||||
|
|
||||||
UFW: 22, 80, 443 open publicly.
|
- Bridge: Bearer auth on all routes; token in `bridge/.env` +
|
||||||
3456 (bridge) only from Docker bridge subnets.
|
`dashboard/.env.local` + embedded in cron payloads (rotate all four
|
||||||
3000 (Dokploy), 3100 (dashboard) not directly exposed -- only via Traefik.
|
together — `jobs.json` has it twice). Rotated 2026-06-10 after the old
|
||||||
|
token leaked via a hardcode in `agents-activity.ts` to the public GitHub
|
||||||
|
mirror. NEVER hardcode tokens in source: this repo mirrors publicly.
|
||||||
|
- Git: Forgejo (origin, SSH port 2222, key `id_ed25519_forgejo`) + GitHub
|
||||||
|
mirror. Push both.
|
||||||
|
- position-tracker binds 127.0.0.1:3457; public access via Traefik at
|
||||||
|
angel.manohargupta.com.
|
||||||
|
- Known weak spots: litellm-db password, `/opt/dashboard` fossil with a
|
||||||
|
stale token, dual Telegram pollers (bridge poller should be disabled).
|
||||||
|
|
||||||
Bearer token: 64-char hex. Never logged, never sent to browser. Rotate via bridge/.env.
|
## 9. Known Constraints
|
||||||
Traefik BasicAuth: bcrypt, single $ in YAML files. Realm: Tiger Command Center.
|
|
||||||
OpenClaw gateway: bind: lan (Docker bridge only). Token in openclaw.json.
|
- **RAM**: ~13GB workload on 8GB physical; 6+GB swap in steady state. This
|
||||||
/tiger/exec: auth-gated. Arbitrary command execution requires bearer token.
|
is the root cause of historical cron timeouts and the reason spawn
|
||||||
/tiger/keys GET: presence map only. Key values never returned by any endpoint.
|
concurrency is 1. Decision pending: evict homelab services vs upgrade.
|
||||||
|
- OpenClaw v2026.3.12 predates MiniMax-M3, hence the explicit
|
||||||
|
`litellm/minimax-3` provider-prefixed model id.
|
||||||
|
|
|
||||||
121
README.md
121
README.md
|
|
@ -1,91 +1,60 @@
|
||||||
# Clawd Agent Dashboard
|
# Tiger Command Center
|
||||||
|
|
||||||
> A premium, dark-mode "Command Center" for the Clawd AI Agent.
|
> Self-hosted AI orchestration: one Tiger, four specialists, every action audited.
|
||||||
|
|
||||||

|
The control plane for **Tiger**, an OpenClaw-based AI agent running on a
|
||||||
|
Hetzner VPS, reachable at `agent.manohargupta.com`. Tiger orchestrates four
|
||||||
|
specialist sub-agents — **Cody** (code), **Ethan** (research), **Cathy**
|
||||||
|
(writing), **Elon** (planning) — handles Telegram, watches Angel One
|
||||||
|
positions, and drains a TASKS.md inbox while you do real work.
|
||||||
|
|
||||||
## Overview
|
## What lives here
|
||||||
|
|
||||||
The **Clawd Dashboard** is a centralized interface designed to monitor and interact with the Clawd AI agent. It provides real-time visibility into the agent's memory, logs, scheduled tasks (cron jobs), and capabilities (skills), all wrapped in a sleek, responsive UI.
|
| Path | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `dashboard/` | Next.js 14 command center UI (`tiger-dashboard`, :3100) |
|
||||||
|
| `bridge/` | Express control-plane API (`tiger-bridge`, :3456, localhost-only) |
|
||||||
|
| `skills/` | OpenClaw skills (spawn-delegate, angel-positions, inbox-manager, sys-health, youtube-full) |
|
||||||
|
| `ARCHITECTURE.md` | The real system map — read this first |
|
||||||
|
| `TOOLS.md` | Tool/skill quick reference |
|
||||||
|
|
||||||
## Features
|
## Core capabilities
|
||||||
|
|
||||||
- **📊 System Status**: Real-time heartbeat monitoring of the `clawdbot` process.
|
- **Sub-agent spawning** — `POST /tiger/spawn` runs a specialist in an
|
||||||
- **🧠 Memory Management**: View and edit the agent's core memory (`MEMORY.md`) and daily logs.
|
isolated OpenClaw session; result lands on Telegram. Tracked in `executions`.
|
||||||
- **🛠️ Skills Registry**: Browse, edit, and manage the agent's capabilities and MCP tools.
|
- **TASKS.md inbox** — drop `- [ ]` lines under `## 📥 INBOX`; the bridge
|
||||||
- **⏱️ Cron Jobs**: detailed view and control over scheduled background tasks.
|
dispatches the top item to the right specialist every 30 min (9–20 IST).
|
||||||
- **💬 Chat Interface**: Integrated chat window to communicate directly with the agent.
|
- **Telegram mirror** — the homepage thread reads OpenClaw's native session
|
||||||
- **🌗 Dark Mode**: Built with a "Slate & Violet" aesthetic optimized for low-light environments.
|
transcript: full history, both directions, perfectly in sync.
|
||||||
|
- **Audit trail** — `/activity` merges spawns, cron runs, task lifecycle,
|
||||||
|
and outputs into one paginated, filterable timeline.
|
||||||
|
- **Own model gateway** — every model call routes through
|
||||||
|
`llm.manohargupta.com` (LiteLLM on own MiniMax/Anthropic keys). Primary:
|
||||||
|
MiniMax-M3.
|
||||||
|
|
||||||
## Tech Stack
|
## Running it
|
||||||
|
|
||||||
- **Framework**: [Next.js 14](https://nextjs.org/) (App Router)
|
Both services are systemd units on the host:
|
||||||
- **UI Components**: [Shadcn/UI](https://ui.shadcn.com/) (Radix Primitives)
|
|
||||||
- **Styling**: [Tailwind CSS](https://tailwindcss.com/)
|
|
||||||
- **Icons**: [Lucide React](https://lucide.dev/)
|
|
||||||
- **State Management**: [SWR](https://swr.vercel.app/) / React Query
|
|
||||||
- **Backend**: Next.js API Routes (Serverless)
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- npm or pnpm
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
1. Clone the repository:
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/manohar6839/clawd-dashboard.git
|
|
||||||
cd clawd-dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
cd dashboard && npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Configure Environment:
|
|
||||||
- Copy example configs:
|
|
||||||
```bash
|
|
||||||
cp config/mcporter.example.json config/mcporter.json
|
|
||||||
cp config/cron.example.json config/cron.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running the Dashboard
|
|
||||||
|
|
||||||
Start the development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dashboard
|
systemctl restart tiger-bridge # Express via tsx — no build step
|
||||||
|
cd dashboard && npm run build && systemctl restart tiger-dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
The dashboard will be available at [http://localhost:3000](http://localhost:3000).
|
Env contracts:
|
||||||
|
- `bridge/.env` — `TIGER_BRIDGE_TOKEN`, `LLM_GATEWAY_URL`, `LLM_GATEWAY_KEY`,
|
||||||
|
`TIGER_ROUTER_MODEL`, Telegram credentials
|
||||||
|
- `dashboard/.env.local` — `TIGER_BRIDGE_URL`, `TIGER_BRIDGE_TOKEN`
|
||||||
|
|
||||||
## Project Structure
|
⚠️ The bridge token is also embedded in OpenClaw cron payloads
|
||||||
|
(`cron/jobs.json`, twice). Rotate all four locations together.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
Forgejo is canonical (`git.manohargupta.com/manohar/OpenClawDashboard`, SSH
|
||||||
|
port 2222); GitHub (`manohar6839/NemoClawDashboard`) is a **public** mirror —
|
||||||
|
never commit secrets. Push to both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main && git push github main
|
||||||
```
|
```
|
||||||
clawd/
|
|
||||||
├── .agent/ # Agent self-knowledge & documentation
|
|
||||||
├── dashboard/ # Next.js Application
|
|
||||||
│ ├── src/app/ # App Router Pages (Memory, Skills, Cron, Chat)
|
|
||||||
│ └── src/components/ # Shared UI Components
|
|
||||||
├── config/ # Agent configuration (Cron, MCP)
|
|
||||||
├── memory/ # Agent daily logs
|
|
||||||
├── tools/ # External tool scripts
|
|
||||||
└── MEMORY.md # Core Agent Memory
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
1. Fork the repository.
|
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`).
|
|
||||||
3. Commit your changes (`git commit -m 'feat: Add amazing feature'`).
|
|
||||||
4. Push to the branch (`git push origin feature/amazing-feature`).
|
|
||||||
5. Open a Pull Request.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT © [Manohar Air](https://github.com/manohar6839)
|
|
||||||
|
|
|
||||||
19
TOOLS.md
19
TOOLS.md
|
|
@ -14,3 +14,22 @@
|
||||||
- **Location**: `skills/youtube-full`
|
- **Location**: `skills/youtube-full`
|
||||||
- **Capabilities**: Search videos, get transcripts, monitor channels/playlists.
|
- **Capabilities**: Search videos, get transcripts, monitor channels/playlists.
|
||||||
- **Reference**: Read `skills/youtube-full/SKILL.md` for instructions.
|
- **Reference**: Read `skills/youtube-full/SKILL.md` for instructions.
|
||||||
|
## Specialist Delegation
|
||||||
|
|
||||||
|
- **Skill**: `spawn-delegate` — hand work to Cody/Ethan/Cathy/Elon via the
|
||||||
|
bridge; results arrive on Telegram. Read `skills/spawn-delegate/SKILL.md`.
|
||||||
|
|
||||||
|
## Trading Positions
|
||||||
|
|
||||||
|
- **Skill**: `angel-positions` — read-only live P&L from
|
||||||
|
`angel.manohargupta.com/api/positions`. Never executes trades.
|
||||||
|
|
||||||
|
## Task Inbox
|
||||||
|
|
||||||
|
- **Skill**: `inbox-manager` — add/list/drain `## 📥 INBOX` items in
|
||||||
|
TASKS.md; the bridge auto-dispatches the top item every 30 min.
|
||||||
|
|
||||||
|
## System Health
|
||||||
|
|
||||||
|
- **Skill**: `sys-health` — host RAM/swap from `/proc/meminfo`, LLM gateway
|
||||||
|
liveliness, bridge status, recent audit events.
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,8 @@ app.use("/tiger/notify", notifyRouter);
|
||||||
app.use("/tiger/dispatch", dispatchRouter);
|
app.use("/tiger/dispatch", dispatchRouter);
|
||||||
app.use("/tiger/agents", agentsRouter);
|
app.use("/tiger/agents", agentsRouter);
|
||||||
app.use("/tiger/agents/activity", agentsActivityRouter);
|
app.use("/tiger/agents/activity", agentsActivityRouter);
|
||||||
|
// Complete audit trail (executions + tasks + outputs + cron runs, paginated)
|
||||||
|
app.use("/tiger/activity/audit", (await import("./routes/activity-audit.js")).default);
|
||||||
app.use("/tiger/deploy-dashboard", deployRouter);
|
app.use("/tiger/deploy-dashboard", deployRouter);
|
||||||
app.use("/tiger/route-task", routeTaskRouter);
|
app.use("/tiger/route-task", routeTaskRouter);
|
||||||
app.use("/tiger/keys", keysRouter);
|
app.use("/tiger/keys", keysRouter);
|
||||||
|
|
|
||||||
252
bridge/src/routes/activity-audit.ts
Normal file
252
bridge/src/routes/activity-audit.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
/**
|
||||||
|
* activity-audit.ts — GET /tiger/activity/audit : the complete audit trail
|
||||||
|
*
|
||||||
|
* Purpose: ONE chronological, paginated record of everything the system DID,
|
||||||
|
* so nothing slips by unaudited. Merged sources, each a durable store (the
|
||||||
|
* old activity feed only showed recent in-memory file events):
|
||||||
|
*
|
||||||
|
* executions (sqlite) — every spawn / sub-agent run, with outcome
|
||||||
|
* tasks (sqlite) — task lifecycle (created / status changes)
|
||||||
|
* outputs (sqlite) — every artifact an agent wrote
|
||||||
|
* cron runs (volume) — OpenClaw's JSONL run history for every job
|
||||||
|
*
|
||||||
|
* Event shape (normalized):
|
||||||
|
* { id, ts (ISO), type, actor, summary, status?, ref? }
|
||||||
|
* type ∈ spawn | task | output | cron
|
||||||
|
*
|
||||||
|
* Pagination: ?limit=100&before=<ISO ts> walks backwards through history.
|
||||||
|
* Optional ?types=spawn,cron filters at the source.
|
||||||
|
*
|
||||||
|
* Design note: sources are merged at read time rather than double-written
|
||||||
|
* into a new audit table — no write-path changes, no risk of an action
|
||||||
|
* happening without its audit row, history is complete retroactively.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { readFileSync, readdirSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import db from "../db.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const DATA_DIR =
|
||||||
|
process.env.OPENCLAW_DATA_DIR ||
|
||||||
|
"/var/lib/docker/volumes/tiger_tiger-config/_data";
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
ts: string; // ISO timestamp
|
||||||
|
type: "spawn" | "task" | "output" | "cron";
|
||||||
|
actor: string;
|
||||||
|
summary: string;
|
||||||
|
status?: string;
|
||||||
|
ref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SQLite sources ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function executionEvents(beforeIso: string | null, limit: number): AuditEvent[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, agent, command, exit_code, started_at, completed_at
|
||||||
|
FROM executions
|
||||||
|
${beforeIso ? "WHERE started_at < ?" : ""}
|
||||||
|
ORDER BY started_at DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(...(beforeIso ? [beforeIso, limit] : [limit])) as Array<{
|
||||||
|
id: string;
|
||||||
|
agent: string | null;
|
||||||
|
command: string | null;
|
||||||
|
exit_code: number | null;
|
||||||
|
started_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: `exec:${r.id}`,
|
||||||
|
ts: toIso(r.started_at),
|
||||||
|
type: "spawn" as const,
|
||||||
|
actor: r.agent ?? "unknown",
|
||||||
|
summary: (r.command ?? "").replace(/^spawn:\s*/, "").slice(0, 160),
|
||||||
|
status:
|
||||||
|
r.exit_code === null ? "running" : r.exit_code === 0 ? "done" : "error",
|
||||||
|
ref: r.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskEvents(beforeIso: string | null, limit: number): AuditEvent[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, title, status, assigned_agent, updated_at
|
||||||
|
FROM tasks
|
||||||
|
${beforeIso ? "WHERE updated_at < ?" : ""}
|
||||||
|
ORDER BY updated_at DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(...(beforeIso ? [beforeIso, limit] : [limit])) as Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
assigned_agent: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: `task:${r.id}:${r.updated_at}`,
|
||||||
|
ts: toIso(r.updated_at),
|
||||||
|
type: "task" as const,
|
||||||
|
actor: r.assigned_agent ?? "tiger",
|
||||||
|
summary: r.title.slice(0, 160),
|
||||||
|
status: r.status,
|
||||||
|
ref: r.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputEvents(beforeIso: string | null, limit: number): AuditEvent[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, filename, file_path, execution_id, created_at
|
||||||
|
FROM outputs
|
||||||
|
${beforeIso ? "WHERE created_at < ?" : ""}
|
||||||
|
ORDER BY created_at DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(...(beforeIso ? [beforeIso, limit] : [limit])) as Array<{
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
file_path: string;
|
||||||
|
execution_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: `output:${r.id}`,
|
||||||
|
ts: toIso(r.created_at),
|
||||||
|
type: "output" as const,
|
||||||
|
actor: "agent",
|
||||||
|
summary: `wrote ${r.filename} (${r.file_path})`.slice(0, 160),
|
||||||
|
ref: r.execution_id ?? r.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cron run history (OpenClaw JSONL on the volume) ────────────────────────
|
||||||
|
// Cached by directory listing + sizes; cron runs append-only files.
|
||||||
|
|
||||||
|
let cronCache: { stamp: string; events: AuditEvent[] } | null = null;
|
||||||
|
|
||||||
|
/** jobId → human name, from cron/jobs.json (cached per cron rebuild). */
|
||||||
|
function loadJobNames(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(
|
||||||
|
readFileSync(join(DATA_DIR, "cron", "jobs.json"), "utf-8"),
|
||||||
|
) as { jobs?: Array<{ id?: string; name?: string }> };
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const j of raw.jobs ?? []) {
|
||||||
|
if (j.id && j.name) map[j.id] = j.name;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cronEvents(): AuditEvent[] {
|
||||||
|
const runsDir = join(DATA_DIR, "cron", "runs");
|
||||||
|
if (!existsSync(runsDir)) return [];
|
||||||
|
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = readdirSync(runsDir).filter((f) => f.endsWith(".jsonl"));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap cache key: file list + sizes via a stat pass would be ideal; the
|
||||||
|
// run files are small, so name-count + latest mtime via re-read every 30s
|
||||||
|
// would also be fine. Keep it simple: rebuild when the listing changes.
|
||||||
|
const stamp = files.join("|");
|
||||||
|
if (cronCache && cronCache.stamp === stamp) return cronCache.events;
|
||||||
|
|
||||||
|
const jobNames = loadJobNames();
|
||||||
|
const events: AuditEvent[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = readFileSync(join(runsDir, file), "utf-8");
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const line of content.split("\n")) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let run: Record<string, any>;
|
||||||
|
try {
|
||||||
|
run = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Run files log lifecycle actions; only "finished" carries the outcome.
|
||||||
|
if (run.action && run.action !== "finished") continue;
|
||||||
|
const ts = run.startedAt ?? run.ts ?? run.runAtMs ?? run.timestamp;
|
||||||
|
if (!ts) continue;
|
||||||
|
const iso = typeof ts === "number" ? new Date(ts).toISOString() : toIso(String(ts));
|
||||||
|
const jobId = String(run.jobId ?? file.replace(/\.jsonl$/, ""));
|
||||||
|
const name = run.jobName ?? run.name ?? jobNames[jobId] ?? jobId;
|
||||||
|
const status = run.status ?? (run.error ? "error" : "ok");
|
||||||
|
events.push({
|
||||||
|
id: `cron:${file}:${iso}`,
|
||||||
|
ts: iso,
|
||||||
|
type: "cron",
|
||||||
|
actor: "cron",
|
||||||
|
summary: String(name).slice(0, 160),
|
||||||
|
status: String(status),
|
||||||
|
ref: jobId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cronCache = { stamp, events };
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** sqlite datetime('now') yields "YYYY-MM-DD HH:MM:SS" (UTC, no zone) — make it ISO. */
|
||||||
|
function toIso(s: string): string {
|
||||||
|
if (!s) return new Date(0).toISOString();
|
||||||
|
if (s.includes("T")) return s;
|
||||||
|
return s.replace(" ", "T") + "Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Route ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get("/", (req: Request, res: Response) => {
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(parseInt(String(req.query.limit ?? "100"), 10) || 100, 1),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
const before = req.query.before ? String(req.query.before) : null;
|
||||||
|
const typeFilter = req.query.types
|
||||||
|
? new Set(String(req.query.types).split(","))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const wants = (t: AuditEvent["type"]) => !typeFilter || typeFilter.has(t);
|
||||||
|
|
||||||
|
let events: AuditEvent[] = [];
|
||||||
|
if (wants("spawn")) events.push(...executionEvents(before, limit));
|
||||||
|
if (wants("task")) events.push(...taskEvents(before, limit));
|
||||||
|
if (wants("output")) events.push(...outputEvents(before, limit));
|
||||||
|
if (wants("cron")) {
|
||||||
|
let cron = cronEvents();
|
||||||
|
if (before) cron = cron.filter((e) => e.ts < before);
|
||||||
|
events.push(...cron);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.sort((a, b) => (a.ts < b.ts ? 1 : -1)); // newest first
|
||||||
|
const page = events.slice(0, limit);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
events: page,
|
||||||
|
hasMore: events.length > page.length,
|
||||||
|
oldestTs: page.length > 0 ? page[page.length - 1].ts : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -51,6 +51,38 @@ interface SessionIndexEntry {
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenClaw injects machinery into user messages before they reach the model:
|
||||||
|
* - leading blocks like:
|
||||||
|
* Conversation info (untrusted metadata): ```json {...}```
|
||||||
|
* Sender (untrusted metadata): ```json {...}```
|
||||||
|
* - whole synthetic messages ("A new session was started via /new...",
|
||||||
|
* heartbeat prompts, system reminders)
|
||||||
|
* None of that was typed by the human in Telegram, so the mirror strips it.
|
||||||
|
* Returning "" makes the caller drop the message entirely.
|
||||||
|
*/
|
||||||
|
function cleanUserText(raw: string): string {
|
||||||
|
let text = raw;
|
||||||
|
|
||||||
|
// Strip any leading "<Label> (untrusted metadata): ```json ... ```" blocks.
|
||||||
|
// They always appear before the real message; loop in case there are several.
|
||||||
|
const metaBlock = /^\s*[A-Za-z ]+\(untrusted metadata\):\s*```json[\s\S]*?```\s*/;
|
||||||
|
while (metaBlock.test(text)) {
|
||||||
|
text = text.replace(metaBlock, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthetic system messages — not human input, drop them outright.
|
||||||
|
const synthetic = [
|
||||||
|
/^A new session was started via/,
|
||||||
|
/^\[?HEARTBEAT/i,
|
||||||
|
/^System:/,
|
||||||
|
/^GroupChat context/,
|
||||||
|
];
|
||||||
|
if (synthetic.some((re) => re.test(text.trim()))) return "";
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
/** Newest telegram session: key + transcript path. */
|
/** Newest telegram session: key + transcript path. */
|
||||||
function resolveTelegramSession(): { key: string; file: string } | null {
|
function resolveTelegramSession(): { key: string; file: string } | null {
|
||||||
let index: Record<string, SessionIndexEntry>;
|
let index: Record<string, SessionIndexEntry>;
|
||||||
|
|
@ -111,16 +143,26 @@ function parseTranscript(file: string): ThreadMessage[] {
|
||||||
|
|
||||||
const content: unknown = entry.message?.content;
|
const content: unknown = entry.message?.content;
|
||||||
let text = "";
|
let text = "";
|
||||||
|
let hasToolCall = false;
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
text = content;
|
text = content;
|
||||||
} else if (Array.isArray(content)) {
|
} else if (Array.isArray(content)) {
|
||||||
text = content
|
for (const c of content) {
|
||||||
.filter((c) => c && c.type === "text" && typeof c.text === "string")
|
if (!c) continue;
|
||||||
.map((c) => c.text)
|
if (c.type === "toolCall") hasToolCall = true;
|
||||||
.join("\n");
|
if (c.type === "text" && typeof c.text === "string") text += c.text + "\n";
|
||||||
|
// type === "thinking" is deliberately ignored
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assistant messages that carry toolCall blocks are intermediate working
|
||||||
|
// turns ("Let me check if codexbar is available...") — OpenClaw never
|
||||||
|
// sent those to Telegram, so the mirror must not show them either.
|
||||||
|
if (role === "assistant" && hasToolCall) continue;
|
||||||
|
|
||||||
|
if (role === "user") text = cleanUserText(text);
|
||||||
text = text.trim();
|
text = text.trim();
|
||||||
if (!text) continue; // tool-call-only assistant turns have no text
|
if (!text) continue; // tool-call-only turns / fully-injected messages
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
seq,
|
seq,
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,192 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
/**
|
||||||
import { ScrollText } from "lucide-react"
|
* /activity — the complete audit trail.
|
||||||
|
*
|
||||||
|
* Every durable action in one timeline: sub-agent spawns, task lifecycle,
|
||||||
|
* artifacts written, cron runs, file modifications. Type filters + "Load
|
||||||
|
* older" pagination walk the entire history so nothing escapes audit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { ScrollText, ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
interface ActivityEntry {
|
interface ActivityEntry {
|
||||||
id: string
|
id: string
|
||||||
|
ts: string
|
||||||
type: string
|
type: string
|
||||||
timestamp: string
|
actor: string
|
||||||
description: string
|
summary: string
|
||||||
source: string
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{ id: "spawn", label: "Spawns" },
|
||||||
|
{ id: "cron", label: "Cron" },
|
||||||
|
{ id: "task", label: "Tasks" },
|
||||||
|
{ id: "output", label: "Outputs" },
|
||||||
|
{ id: "file", label: "Files" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
spawn: "text-violet-400 border-violet-400/30",
|
||||||
|
cron: "text-sky-400 border-sky-400/30",
|
||||||
|
task: "text-amber-400 border-amber-400/30",
|
||||||
|
output: "text-emerald-400 border-emerald-400/30",
|
||||||
|
file: "text-muted-foreground border-border",
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
done: "text-emerald-400",
|
||||||
|
ok: "text-emerald-400",
|
||||||
|
running: "text-sky-400",
|
||||||
|
error: "text-red-400",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const [entries, setEntries] = useState<ActivityEntry[]>([])
|
const [entries, setEntries] = useState<ActivityEntry[]>([])
|
||||||
|
const [active, setActive] = useState<Set<string>>(new Set(TYPES.map((t) => t.id)))
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchPage = useCallback(
|
||||||
|
async (before?: string) => {
|
||||||
|
const qs = new URLSearchParams({ limit: "100" })
|
||||||
|
if (before) qs.set("before", before)
|
||||||
|
if (active.size < TYPES.length) qs.set("types", Array.from(active).join(","))
|
||||||
|
const r = await fetch(`/api/activity?${qs.toString()}`)
|
||||||
|
return r.json() as Promise<{
|
||||||
|
entries?: ActivityEntry[]
|
||||||
|
hasMore?: boolean
|
||||||
|
oldestTs?: string | null
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
},
|
||||||
|
[active],
|
||||||
|
)
|
||||||
|
|
||||||
|
// (Re)load whenever filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/activity?limit=20")
|
setLoading(true)
|
||||||
.then(r => r.json())
|
setError(null)
|
||||||
.then(data => {
|
fetchPage()
|
||||||
if (data?.entries) {
|
.then((data) => {
|
||||||
|
if (data.entries) {
|
||||||
setEntries(data.entries)
|
setEntries(data.entries)
|
||||||
}
|
setHasMore(Boolean(data.hasMore))
|
||||||
setLoading(false)
|
} else setError(data.error || "No data")
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch((e: Error) => setError(e.message))
|
||||||
console.error("Failed to load:", e)
|
.finally(() => setLoading(false))
|
||||||
setError(e.message)
|
}, [fetchPage])
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const formatDate = (ts: string) => {
|
const loadOlder = async () => {
|
||||||
if (!ts) return ""
|
if (loadingMore || entries.length === 0) return
|
||||||
return new Date(ts).toLocaleString()
|
setLoadingMore(true)
|
||||||
}
|
try {
|
||||||
|
const data = await fetchPage(entries[entries.length - 1].ts)
|
||||||
const getTypeColor = (type: string) => {
|
if (data.entries && data.entries.length > 0) {
|
||||||
switch (type) {
|
setEntries((prev) => {
|
||||||
case "heartbeat": return "text-green-500"
|
const known = new Set(prev.map((e) => e.id))
|
||||||
case "chat": return "text-blue-500"
|
return [...prev, ...data.entries!.filter((e) => !known.has(e.id))]
|
||||||
case "config": return "text-yellow-500"
|
})
|
||||||
case "memory": return "text-purple-500"
|
setHasMore(Boolean(data.hasMore))
|
||||||
case "system": return "text-orange-500"
|
} else setHasMore(false)
|
||||||
case "cron": return "text-cyan-500"
|
} finally {
|
||||||
default: return "text-muted-foreground"
|
setLoadingMore(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSourceLabel = (source: string) => {
|
const toggle = (id: string) =>
|
||||||
switch (source) {
|
setActive((prev) => {
|
||||||
case "main": return "🐅 Tiger"
|
const next = new Set(prev)
|
||||||
case "coder": return "📦 Cody"
|
if (next.has(id)) {
|
||||||
case "researcher": return "🔬 Ethan"
|
if (next.size > 1) next.delete(id) // never filter down to nothing
|
||||||
case "pm": return "📋 Elon"
|
} else next.add(id)
|
||||||
default: return source || "🤖"
|
return next
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
const fmt = (ts: string) =>
|
||||||
return (
|
new Date(ts).toLocaleString([], {
|
||||||
<div className="p-6">
|
day: "2-digit",
|
||||||
<div className="flex items-center gap-2 mb-4">
|
month: "short",
|
||||||
<ScrollText className="h-5 w-5" />
|
hour: "2-digit",
|
||||||
<h1 className="text-2xl font-bold">Activity</h1>
|
minute: "2-digit",
|
||||||
</div>
|
})
|
||||||
<div className="text-muted-foreground">Loading activity log...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<ScrollText className="h-5 w-5" />
|
|
||||||
<h1 className="text-2xl font-bold">Activity</h1>
|
|
||||||
</div>
|
|
||||||
<div className="text-red-500">Failed to load activity log</div>
|
|
||||||
<div className="text-sm text-muted-foreground mt-2">{error}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6 max-w-4xl">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<ScrollText className="h-5 w-5" />
|
<ScrollText className="h-5 w-5 text-primary" />
|
||||||
<h1 className="text-2xl font-bold">Activity</h1>
|
<h1 className="text-xl font-semibold">Activity</h1>
|
||||||
<span className="text-muted-foreground text-sm">({entries.length} entries)</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Complete audit trail — spawns, cron runs, tasks, outputs, file changes.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap gap-2 mb-5">
|
||||||
{entries.map((entry, i) => (
|
{TYPES.map((t) => (
|
||||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border bg-card/30">
|
<button
|
||||||
<div className="text-lg">{getSourceLabel(entry.source)}</div>
|
key={t.id}
|
||||||
<div className="flex-1 min-w-0">
|
onClick={() => toggle(t.id)}
|
||||||
<div className="text-sm truncate">{entry.description}</div>
|
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
active.has(t.id)
|
||||||
<span className={getTypeColor(entry.type)}>{entry.type}</span>
|
? TYPE_COLORS[t.id] + " bg-muted/30"
|
||||||
<span>•</span>
|
: "text-muted-foreground/40 border-border/40"
|
||||||
<span>{formatDate(entry.timestamp)}</span>
|
}`}
|
||||||
</div>
|
>
|
||||||
</div>
|
{t.label}
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="text-sm text-muted-foreground py-8">Loading…</div>}
|
||||||
|
{error && <div className="text-sm text-red-500 py-8">Error: {error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div className="text-sm text-muted-foreground py-8">No events.</div>
|
||||||
|
)}
|
||||||
|
{entries.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className="flex items-start gap-3 py-2.5 border-b border-border/40 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] text-muted-foreground/70 w-28 shrink-0 pt-0.5">
|
||||||
|
{fmt(e.ts)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] uppercase tracking-wide border rounded px-1.5 py-0.5 shrink-0 ${TYPE_COLORS[e.type] ?? TYPE_COLORS.file}`}
|
||||||
|
>
|
||||||
|
{e.type}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 break-words">
|
||||||
|
<span className="text-muted-foreground">{e.actor}</span>{" "}
|
||||||
|
{e.summary}
|
||||||
|
{e.status && (
|
||||||
|
<span className={`ml-2 text-[11px] ${STATUS_COLORS[e.status] ?? "text-muted-foreground"}`}>
|
||||||
|
{e.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadOlder}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="self-center mt-4 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 py-1.5 px-3 rounded hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
{loadingMore ? "Loading…" : "Load older"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,44 +1,82 @@
|
||||||
|
/**
|
||||||
|
* /api/activity — unified audit feed for the Activity page.
|
||||||
|
*
|
||||||
|
* Merges two bridge sources:
|
||||||
|
* /tiger/activity/audit durable history: spawns, tasks, outputs, cron
|
||||||
|
* runs — paginated, complete
|
||||||
|
* /tiger/agents/activity recent file-modification events (in-memory,
|
||||||
|
* recent-only by nature; merged for first page)
|
||||||
|
*
|
||||||
|
* ?limit=100&before=<ISO>&types=spawn,cron,task,output,file
|
||||||
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { bridgeGet } from "@/lib/bridge"
|
import { bridgeGet } from "@/lib/bridge"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
interface AuditEvent {
|
||||||
|
id: string
|
||||||
|
ts: string
|
||||||
|
type: string
|
||||||
|
actor: string
|
||||||
|
summary: string
|
||||||
|
status?: string
|
||||||
|
ref?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const limit = Math.min(parseInt(url.searchParams.get("limit") || "100", 10), 500)
|
||||||
|
const before = url.searchParams.get("before") || ""
|
||||||
|
const types = url.searchParams.get("types") || ""
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url)
|
const qs = new URLSearchParams({ limit: String(limit) })
|
||||||
const limit = parseInt(url.searchParams.get("limit") || "50", 10)
|
if (before) qs.set("before", before)
|
||||||
|
if (types) qs.set("types", types.split(",").filter((t) => t !== "file").join(","))
|
||||||
|
|
||||||
// Get activity from bridge endpoint that already works
|
const audit = (await bridgeGet(`/tiger/activity/audit?${qs.toString()}`)) as {
|
||||||
const bridgeData = await bridgeGet("/tiger/agents/activity") as {
|
|
||||||
ok: boolean
|
ok: boolean
|
||||||
events: Array<{
|
events?: AuditEvent[]
|
||||||
agentId: string
|
hasMore?: boolean
|
||||||
agentName: string
|
oldestTs?: string | null
|
||||||
agentEmoji: string
|
|
||||||
path: string
|
|
||||||
action: string
|
|
||||||
ts: number
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bridgeData?.ok || !bridgeData.events) {
|
let events: AuditEvent[] = audit?.events ?? []
|
||||||
return NextResponse.json({ entries: [], total: 0 })
|
|
||||||
|
// File events only exist for the recent window — merge them into the
|
||||||
|
// first page (no `before` cursor) when not filtered out.
|
||||||
|
const wantFiles = !types || types.split(",").includes("file")
|
||||||
|
if (!before && wantFiles) {
|
||||||
|
try {
|
||||||
|
const fileData = (await bridgeGet("/tiger/agents/activity")) as {
|
||||||
|
ok: boolean
|
||||||
|
events?: Array<{ agentId: string; agentName: string; path: string; action: string; ts: number }>
|
||||||
|
}
|
||||||
|
if (fileData?.ok && fileData.events) {
|
||||||
|
events = events.concat(
|
||||||
|
fileData.events.map((e) => ({
|
||||||
|
id: `file:${e.agentId}:${e.ts}`,
|
||||||
|
ts: new Date(e.ts).toISOString(),
|
||||||
|
type: "file",
|
||||||
|
actor: e.agentName,
|
||||||
|
summary: `${e.action || "modified"} ${e.path}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch { /* file source down — audit sources still serve */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform bridge format to activity format
|
events.sort((a, b) => (a.ts < b.ts ? 1 : -1))
|
||||||
const entries = bridgeData.events.slice(0, limit).map((e) => ({
|
const page = events.slice(0, limit)
|
||||||
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({
|
return NextResponse.json({
|
||||||
entries,
|
entries: page,
|
||||||
total: bridgeData.events.length,
|
hasMore: Boolean(audit?.hasMore) || events.length > page.length,
|
||||||
|
oldestTs: page.length > 0 ? page[page.length - 1].ts : null,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch {
|
||||||
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,230 +1,47 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
/**
|
||||||
import { TrendingUp, TrendingDown, RefreshCw, Activity, DollarSign, BarChart2, Layers } from "lucide-react"
|
* /positions — intentionally NOT a positions UI.
|
||||||
import { StatCard } from "@/components/stat-card"
|
*
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
* Live positions have a dedicated home: the standalone position-tracker at
|
||||||
import { Badge } from "@/components/ui/badge"
|
* https://angel.manohargupta.com (its own repo, own deploy, market-hours
|
||||||
import { Button } from "@/components/ui/button"
|
* aware). This dashboard previously replicated that UI here, which meant
|
||||||
import { cn } from "@/lib/utils"
|
* two implementations drifting against the same Angel One data. One owner
|
||||||
|
* per concern: the tracker owns positions; this page just hands you over.
|
||||||
|
*/
|
||||||
|
|
||||||
interface Position {
|
import { useEffect } from "react"
|
||||||
key: string
|
import { ExternalLink, TrendingUp } from "lucide-react"
|
||||||
tradingsymbol: string
|
import { Card } from "@/components/ui/card"
|
||||||
exchange: string
|
|
||||||
instrumenttype: string
|
|
||||||
producttype: string
|
|
||||||
netqty: number
|
|
||||||
ltp: number
|
|
||||||
avg_price: number
|
|
||||||
unrealised_pnl: number
|
|
||||||
realised_pnl: number
|
|
||||||
total_pnl: number
|
|
||||||
is_closed: number
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Summary {
|
const TRACKER_URL = "https://angel.manohargupta.com"
|
||||||
totalUnrealised: number
|
|
||||||
totalRealised: number
|
|
||||||
totalPnl: number
|
|
||||||
openPositions: number
|
|
||||||
asOf?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(n: number) {
|
|
||||||
const sign = n >= 0 ? "+" : ""
|
|
||||||
return `${sign}₹${Math.abs(n).toLocaleString("en-IN", { maximumFractionDigits: 0 })}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function PnlCell({ value }: { value: number }) {
|
|
||||||
return (
|
|
||||||
<span className={cn("font-mono tabular-nums", value > 0 ? "text-emerald-500" : value < 0 ? "text-rose-500" : "text-muted-foreground")}>
|
|
||||||
{fmt(value)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PositionsPage() {
|
export default function PositionsPage() {
|
||||||
const [positions, setPositions] = React.useState<Position[]>([])
|
useEffect(() => {
|
||||||
const [summary, setSummary] = React.useState<Summary | null>(null)
|
// Auto-redirect after a beat — the card below is the no-JS / slow-net fallback.
|
||||||
const [loading, setLoading] = React.useState(true)
|
const t = setTimeout(() => {
|
||||||
const [refreshing, setRefreshing] = React.useState(false)
|
window.location.href = TRACKER_URL
|
||||||
const [error, setError] = React.useState<string | null>(null)
|
}, 800)
|
||||||
const [lastUpdated, setLastUpdated] = React.useState<Date | null>(null)
|
return () => clearTimeout(t)
|
||||||
|
|
||||||
const load = React.useCallback(async (silent = false) => {
|
|
||||||
if (!silent) setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/positions", { cache: "no-store" })
|
|
||||||
const data = await res.json()
|
|
||||||
if (!data.ok) throw new Error(data.error ?? "Failed to load")
|
|
||||||
setPositions(data.positions ?? [])
|
|
||||||
setSummary(data.summary ?? null)
|
|
||||||
setLastUpdated(new Date())
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
setRefreshing(true)
|
|
||||||
try {
|
|
||||||
await fetch("/api/positions", { method: "POST" })
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
await load(true)
|
|
||||||
setRefreshing(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
load()
|
|
||||||
const id = setInterval(() => load(true), 30_000)
|
|
||||||
return () => clearInterval(id)
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
const open = positions.filter(p => p.netqty !== 0 && !p.is_closed)
|
|
||||||
const closed = positions.filter(p => p.netqty === 0 && p.is_closed && p.realised_pnl !== 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex min-h-[60vh] items-center justify-center p-6">
|
||||||
<div className="flex items-center justify-between">
|
<Card className="bg-card/40 p-8 max-w-md text-center">
|
||||||
<div>
|
<TrendingUp className="h-8 w-8 text-primary mx-auto mb-4" />
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
<h1 className="text-lg font-semibold mb-2">Positions live on the tracker</h1>
|
||||||
<BarChart2 className="h-6 w-6 text-primary" />
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
Positions
|
Redirecting you to the dedicated position tracker — single source of
|
||||||
</h1>
|
truth for live P&L, bands and alerts.
|
||||||
<p className="text-muted-foreground text-sm">
|
</p>
|
||||||
{lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString("en-IN", { timeZone: "Asia/Kolkata", hour12: false })} IST` : "Live positions from Angel One"}
|
<a
|
||||||
</p>
|
href={TRACKER_URL}
|
||||||
</div>
|
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || loading}>
|
>
|
||||||
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
|
Open angel.manohargupta.com
|
||||||
Refresh
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Button>
|
</a>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Card className="border-rose-500/30 bg-rose-500/10">
|
|
||||||
<CardContent className="pt-4 text-sm text-rose-400">{error}</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Summary stat cards */}
|
|
||||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
|
||||||
<StatCard
|
|
||||||
title="Total P&L"
|
|
||||||
value={summary ? fmt(summary.totalPnl) : "—"}
|
|
||||||
icon={summary && summary.totalPnl >= 0 ? TrendingUp : TrendingDown}
|
|
||||||
className={summary && summary.totalPnl < 0 ? "border-rose-500/30" : "border-emerald-500/30"}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Unrealised"
|
|
||||||
value={summary ? fmt(summary.totalUnrealised) : "—"}
|
|
||||||
icon={Activity}
|
|
||||||
description="Open positions"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Realised"
|
|
||||||
value={summary ? fmt(summary.totalRealised) : "—"}
|
|
||||||
icon={DollarSign}
|
|
||||||
description="Closed today"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Open Positions"
|
|
||||||
value={loading ? "…" : open.length}
|
|
||||||
icon={Layers}
|
|
||||||
description={closed.length > 0 ? `${closed.length} closed today` : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Open positions table */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Open Positions ({open.length})</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{loading ? (
|
|
||||||
<div className="p-8 text-center text-muted-foreground text-sm">Loading…</div>
|
|
||||||
) : open.length === 0 ? (
|
|
||||||
<div className="p-8 text-center text-muted-foreground text-sm">No open positions</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b text-muted-foreground text-xs">
|
|
||||||
<th className="px-4 py-2 text-left font-medium">Symbol</th>
|
|
||||||
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
|
||||||
<th className="px-4 py-2 text-right font-medium">Avg</th>
|
|
||||||
<th className="px-4 py-2 text-right font-medium">LTP</th>
|
|
||||||
<th className="px-4 py-2 text-right font-medium">Unrealised</th>
|
|
||||||
<th className="px-4 py-2 text-right font-medium">Total P&L</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium">Type</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{open.map(p => (
|
|
||||||
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
|
|
||||||
<td className="px-4 py-2.5 font-mono font-medium">{p.tradingsymbol}</td>
|
|
||||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
|
||||||
<span className={p.netqty > 0 ? "text-emerald-500" : "text-rose-500"}>
|
|
||||||
{p.netqty > 0 ? "+" : ""}{p.netqty}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">₹{p.avg_price.toFixed(2)}</td>
|
|
||||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">₹{p.ltp.toFixed(2)}</td>
|
|
||||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.unrealised_pnl} /></td>
|
|
||||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.total_pnl} /></td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<Badge variant="secondary" className="text-xs font-normal">
|
|
||||||
{p.instrumenttype || p.producttype}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Closed today */}
|
|
||||||
{!loading && closed.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base text-muted-foreground">Closed Today ({closed.length})</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b text-muted-foreground text-xs">
|
|
||||||
<th className="px-4 py-2 text-left font-medium">Symbol</th>
|
|
||||||
<th className="px-4 py-2 text-right font-medium">Realised P&L</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium">Type</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{closed.map(p => (
|
|
||||||
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
|
|
||||||
<td className="px-4 py-2.5 font-mono font-medium text-muted-foreground">{p.tradingsymbol}</td>
|
|
||||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.realised_pnl} /></td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<Badge variant="outline" className="text-xs font-normal opacity-60">
|
|
||||||
{p.instrumenttype || p.producttype}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
skills/angel-positions/SKILL.md
Normal file
25
skills/angel-positions/SKILL.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
name: angel-positions
|
||||||
|
description: Read Manohar's live Angel One trading positions and P&L from the standalone position-tracker. Use when asked about positions, profit/loss, exposure, or how the portfolio is doing right now. Read-only — this skill can never place, modify, or close trades.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Angel One Positions (read-only)
|
||||||
|
|
||||||
|
The standalone position-tracker (own repo/deploy) is the single source of
|
||||||
|
truth for live positions. Endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://angel.manohargupta.com/api/positions
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: array of positions with `tradingsymbol`, `exchange`, `netqty`,
|
||||||
|
`ltp`, `avg_price`, `unrealised_pnl` (₹), `realised_pnl` (₹).
|
||||||
|
|
||||||
|
## Reporting rules
|
||||||
|
- Always report P&L in ₹ with the sign, per position and total.
|
||||||
|
- Note market hours: NSE/BSE trade 09:15–15:30 IST Mon–Fri; outside those
|
||||||
|
hours, LTP is stale — say so.
|
||||||
|
- Alert threshold context: ₹5,000 absolute move is the alerting band the
|
||||||
|
tracker uses; reference it when relevant.
|
||||||
|
- NEVER suggest trades. Report and analyze only. If asked to execute
|
||||||
|
anything, decline — execution stays with Manohar.
|
||||||
39
skills/inbox-manager/SKILL.md
Normal file
39
skills/inbox-manager/SKILL.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
name: inbox-manager
|
||||||
|
description: Manage the TASKS.md INBOX — the queue the bridge drains every 30 minutes (9:00–20:00 IST), dispatching each item to the right specialist automatically. Use when Manohar says "add to my tasks/inbox/queue", wants something done later or recurring-ish, or asks what's pending. Also supports triggering an immediate drain.
|
||||||
|
metadata:
|
||||||
|
{
|
||||||
|
"moltbot":
|
||||||
|
{
|
||||||
|
"emoji": "📥",
|
||||||
|
"requires": { "env": ["TIGER_BRIDGE_TOKEN"] },
|
||||||
|
"primaryEnv": "TIGER_BRIDGE_TOKEN",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
# INBOX Manager
|
||||||
|
|
||||||
|
`~/.openclaw/workspace/TASKS.md` has a `## 📥 INBOX` section. Each
|
||||||
|
`- [ ] task` line gets auto-dispatched: the bridge classifies it, spawns the
|
||||||
|
right specialist, rewrites the line to `- [⏳ run-id → agent]`, and the
|
||||||
|
result lands on Telegram.
|
||||||
|
|
||||||
|
## Add a task
|
||||||
|
Append ONE line under the `## 📥 INBOX` header (before the next `## `):
|
||||||
|
```
|
||||||
|
- [ ] research upcoming SECI BESS tenders and summarize timelines
|
||||||
|
```
|
||||||
|
Write tasks self-contained — the specialist gets no chat context.
|
||||||
|
|
||||||
|
## List pending
|
||||||
|
Read TASKS.md and report the unchecked `- [ ]` lines under INBOX, in order
|
||||||
|
(top item dispatches first).
|
||||||
|
|
||||||
|
## Drain now (don't wait for the 30-min cycle)
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://172.17.0.1:3456/tiger/inbox/drain \
|
||||||
|
-H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
|
||||||
|
```
|
||||||
|
Dispatches exactly ONE item (the top one). Outside 9:00–20:00 IST the
|
||||||
|
manual drain still works (force=true server-side).
|
||||||
49
skills/spawn-delegate/SKILL.md
Normal file
49
skills/spawn-delegate/SKILL.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
name: spawn-delegate
|
||||||
|
description: Delegate work to Tiger's specialist sub-agents (Cody=code, Ethan=research, Cathy=writing, Elon=planning) and check on their runs. Use whenever a task is substantial enough to hand off — the specialist runs in its own session and reports to Telegram when done, so you stay free for the conversation. Also use to list recent runs or fetch a run's full result.
|
||||||
|
metadata:
|
||||||
|
{
|
||||||
|
"moltbot":
|
||||||
|
{
|
||||||
|
"emoji": "🤝",
|
||||||
|
"requires": { "env": ["TIGER_BRIDGE_TOKEN"] },
|
||||||
|
"primaryEnv": "TIGER_BRIDGE_TOKEN",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spawn / Delegate to Specialists
|
||||||
|
|
||||||
|
The bridge (host) exposes real sub-agent execution. You hold the bearer
|
||||||
|
token in `$TIGER_BRIDGE_TOKEN`. Bridge base URL from inside this container:
|
||||||
|
`http://172.17.0.1:3456`.
|
||||||
|
|
||||||
|
## Who does what
|
||||||
|
- **cody** — code, debugging, devops, scripts, infra
|
||||||
|
- **ethan** — research, market/policy analysis, due diligence
|
||||||
|
- **cathy** — writing, summaries, reports, drafts
|
||||||
|
- **elon** — planning, prioritization, breaking down projects
|
||||||
|
|
||||||
|
## Delegate a task
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://172.17.0.1:3456/tiger/spawn \
|
||||||
|
-H "Authorization: Bearer $TIGER_BRIDGE_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agentId":"ethan","task":"<one clear, self-contained task>","context":"<optional background>"}'
|
||||||
|
```
|
||||||
|
Returns `{runId, sessionId, queued}`. Runs execute one at a time
|
||||||
|
(RAM-constrained host) — `queued > 0` means it waits its turn.
|
||||||
|
Completion is announced on Telegram automatically; you do NOT need to poll.
|
||||||
|
|
||||||
|
## Check runs
|
||||||
|
```bash
|
||||||
|
# recent runs + queue state
|
||||||
|
curl -s http://172.17.0.1:3456/tiger/spawn/runs -H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
|
||||||
|
# one run, full reply
|
||||||
|
curl -s http://172.17.0.1:3456/tiger/spawn/runs/<runId> -H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Write the task so it stands alone — the specialist has NO chat context.
|
||||||
|
- One task per spawn. Split compound requests.
|
||||||
|
- Don't spawn for things you can answer in one turn yourself.
|
||||||
41
skills/sys-health/SKILL.md
Normal file
41
skills/sys-health/SKILL.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
name: sys-health
|
||||||
|
description: Check the health of the Tiger Command Center itself — RAM/swap pressure on the VPS, LLM gateway reachability, bridge status, recent audit events, cron run outcomes. Use when asked "is everything running", "why is it slow", after failures, or proactively when your own turns feel sluggish.
|
||||||
|
metadata:
|
||||||
|
{
|
||||||
|
"moltbot":
|
||||||
|
{
|
||||||
|
"emoji": "🩺",
|
||||||
|
"requires": { "env": ["TIGER_BRIDGE_TOKEN"] },
|
||||||
|
"primaryEnv": "TIGER_BRIDGE_TOKEN",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
# System Health
|
||||||
|
|
||||||
|
## Memory pressure (the #1 failure mode on this host)
|
||||||
|
/proc inside the container shows HOST memory:
|
||||||
|
```bash
|
||||||
|
grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo
|
||||||
|
```
|
||||||
|
Interpretation: MemAvailable under ~800MB or swap more than half used means
|
||||||
|
agent turns will crawl and cron jobs may hit their 300s timeouts. Say so
|
||||||
|
explicitly and recommend deferring heavy work.
|
||||||
|
|
||||||
|
## LLM gateway (all models route through it)
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}' https://llm.manohargupta.com/health/liveliness
|
||||||
|
```
|
||||||
|
Anything but 200 = every agent in the system is degraded.
|
||||||
|
|
||||||
|
## Bridge + recent activity
|
||||||
|
```bash
|
||||||
|
curl -s http://172.17.0.1:3456/tiger/status -H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
|
||||||
|
# last 20 audited events (spawns, cron, tasks, outputs):
|
||||||
|
curl -s 'http://172.17.0.1:3456/tiger/activity/audit?limit=20' -H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Report format
|
||||||
|
Lead with a one-line verdict (healthy / degraded / critical), then only the
|
||||||
|
metrics that are off. Quantify with numbers and units.
|
||||||
Loading…
Add table
Reference in a new issue