Compare commits

...

7 commits

Author SHA1 Message Date
Manohar
572418f0ea feat(dashboard): live Telegram thread mirror on homepage
- /api/chat/telegram-thread proxy (token stays server-side)
- TelegramThreadCard rewrite: scrollable full history, 'Load older'
  cursor pagination, 15s polling with referential-equality short-circuit,
  scroll-anchored prepends, auto-stick to bottom only when already there
2026-06-10 02:47:03 +00:00
Manohar
438488c8c8 feat(bridge): TASKS.md inbox drainer + telegram transcript mirror route
- lib/inbox.ts: every 30min (09-20 IST) dispatch the first '- [ ]' item
  under '## INBOX' in TASKS.md: classifyAgent -> spawnTask -> rewrite the
  line with the run id. Bridge-side scheduling: no model tokens burned on
  empty checks, no bearer token embedded in cron prompts.
  Manual trigger: POST /tiger/inbox/drain
- routes/chat-telegram.ts: GET /tiger/chat/telegram reads OpenClaw's native
  telegram session transcript (JSONL) with cursor pagination and mtime
  caching. Replaces the webhook/chat-mirror design, which could never work:
  the bot is owned by OpenClaw's long-polling channel, and Telegram forbids
  webhook + getUpdates on one token, so chat_messages never saw a row.
- index.ts: wire both + start inbox scheduler
2026-06-10 02:47:03 +00:00
Manohar
1a8358bb6a feat(bridge): real sub-agent spawning (replaces spawn.ts placeholder)
- lib/agents.ts: canonical specialist registry (cody/ethan/cathy/elon),
  legacy alias normalization (coder/researcher/writer/pm), personas,
  documented upgrade path to true per-agent OpenClaw config
- spawn.ts: executes isolated OpenClaw sessions via docker exec with
  temp-file message transport, tracks runs in the executions table,
  serializes turns (MAX_CONCURRENT=1, RAM-constrained host), reports
  completion to Telegram via /tiger/notify
- new: GET /runs, GET /runs/:id for dashboard status
2026-06-10 02:46:48 +00:00
Manohar
61e386f7fe feat(bridge): route llm.ts via self-hosted LiteLLM gateway
OpenRouter credits ran dry and silently killed classifyAgent (task routing).
Non-anthropic slugs now go to llm.manohargupta.com (own MiniMax/Anthropic
keys). New env: LLM_GATEWAY_URL, LLM_GATEWAY_KEY. Default router model:
minimax-3.
2026-06-10 02:46:48 +00:00
Manohar
c3cd924cdd fix(bridge): read bridge token from env in agents-activity
The bearer token was hardcoded in source and leaked via the public GitHub
mirror. Token itself rotated server-side as part of this deploy.
2026-06-10 02:46:48 +00:00
e48441be4e Merge remote-tracking branch 'origin/main' 2026-06-06 09:44:39 +05:30
6127b6de24 feat: add /positions dashboard page with live P&L stats and table
- New /api/positions route proxies to angel.manohargupta.com (positions + pnl-history)
- Positions page: 4 stat cards (total/unrealised/realised/open count), open table, closed-today table
- Auto-refreshes every 30s; manual refresh triggers force-poll on tracker
- Add Positions nav item to app-sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:40:56 +05:30
12 changed files with 1294 additions and 167 deletions

View file

@ -154,7 +154,18 @@ app.use("/tiger/route-task", routeTaskRouter);
app.use("/tiger/keys", keysRouter); app.use("/tiger/keys", keysRouter);
app.use("/tiger/chat", (await import("./routes/chat.js")).default); 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/chat/mirror", (await import("./routes/chat-mirror.js")).default);
// Telegram mirror v2 — reads OpenClaw's native session transcript directly.
// (chat-mirror + telegram-webhook above are the legacy write-side, kept for
// API compatibility but no longer the data source for the dashboard card.)
app.use("/tiger/chat/telegram", (await import("./routes/chat-telegram.js")).default);
app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default); app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default);
// TASKS.md inbox — manual drain trigger (the scheduler below runs it on its own)
const { drainInboxOnce, startInboxScheduler } = await import("./lib/inbox.js");
app.post("/tiger/inbox/drain", async (_req, res) => {
const result = await drainInboxOnce(true);
res.json({ ok: !result.startsWith("error"), result });
});
app.use("/angel", (await import("./routes/angel/positions.js")).default); app.use("/angel", (await import("./routes/angel/positions.js")).default);
// Gateway proxy — forwards to gateway inside Tiger container // Gateway proxy — forwards to gateway inside Tiger container
@ -186,6 +197,10 @@ app.listen(PORT, HOST, () => {
// Initialize file watcher for task status updates // Initialize file watcher for task status updates
initWatcher(); initWatcher();
// TASKS.md inbox drainer — dispatches one pending item per cycle to a
// spawned specialist. See lib/inbox.ts for the contract.
startInboxScheduler();
// Start Telegram channel — bridge takes over from OpenClaw native handler. // Start Telegram channel — bridge takes over from OpenClaw native handler.
// Requires channels.telegram.enabled=false in openclaw.json. // Requires channels.telegram.enabled=false in openclaw.json.
const tgChannel = new TelegramChannel(); const tgChannel = new TelegramChannel();

132
bridge/src/lib/agents.ts Normal file
View file

@ -0,0 +1,132 @@
/**
* lib/agents.ts Sub-agent registry (single source of truth)
*
* Why this file exists:
* Agent identity was previously scattered across spawn.ts, agents-activity.ts,
* dispatch.ts and the dashboard, with TWO competing id schemes:
* - classifier ids: cody / ethan / cathy / elon (lib/llm.ts AGENT_IDS)
* - legacy UI ids: coder / researcher / writer / pm
* This registry canonicalizes on the classifier ids and maps legacy
* aliases onto them, so every layer can call normalizeAgentId() and agree.
*
* Personas:
* Sub-agents currently run as isolated *sessions* of the `main` OpenClaw
* agent (one shared workspace, separate conversation histories). The
* persona block below is prepended to the task message, acting as the
* specialist's system prompt for that session.
*
* Upgrade path (documented, not yet taken): define real per-agent entries
* in openclaw.json `agents.list`, each with its own IDENTITY.md and
* workspace, then change ONE line in spawn.ts the `--agent` flag.
*/
export type SpecialistId = "cody" | "ethan" | "cathy" | "elon";
export interface SpecialistAgent {
id: SpecialistId;
/** Display name used across the dashboard and Telegram reports. */
name: string;
/** Short role label for UI chips. */
role: string;
/** Legacy ids that must keep working (old UI, old API callers). */
aliases: string[];
/** Persona preamble injected at the top of every spawned session. */
persona: string;
}
export const SPECIALISTS: Record<SpecialistId, SpecialistAgent> = {
cody: {
id: "cody",
name: "Cody",
role: "Code",
aliases: ["coder"],
persona: [
"You are Cody, Tiger's software engineering specialist.",
"Scope: code, debugging, devops, deployments, scripts, infra, build systems.",
"Style: read existing code before changing it; smallest correct diff;",
"state assumptions explicitly; never run destructive commands without flagging.",
].join(" "),
},
ethan: {
id: "ethan",
name: "Ethan",
role: "Research",
aliases: ["researcher"],
persona: [
"You are Ethan, Tiger's research specialist.",
"Scope: market research, policy analysis, technical investigation, due diligence.",
"Style: cite sources, separate facts from inference, quantify with units,",
"end with a short actionable summary.",
].join(" "),
},
cathy: {
id: "cathy",
name: "Cathy",
role: "Write",
aliases: ["writer"],
persona: [
"You are Cathy, Tiger's writing specialist.",
"Scope: documents, summaries, reports, communication drafts.",
"Style: clear structure, no filler, match the register the task asks for.",
].join(" "),
},
elon: {
id: "elon",
name: "Elon",
role: "PM",
aliases: ["pm"],
persona: [
"You are Elon, Tiger's project management specialist.",
"Scope: planning, prioritization, breaking work into tasks, status synthesis.",
"Style: concrete next actions with owners and order; surface blockers first.",
].join(" "),
},
};
/** All ids + aliases that POST /tiger/spawn accepts. */
export const ACCEPTED_AGENT_IDS: string[] = Object.values(SPECIALISTS).flatMap(
(a) => [a.id, ...a.aliases],
);
/**
* Map any accepted id/alias ("coder", "cody", "CODY") to its canonical
* specialist, or null if unknown. "tiger"/"main" are deliberately NOT
* spawnable Tiger is the orchestrator, not a sub-agent.
*/
export function normalizeAgentId(raw: string): SpecialistAgent | null {
const id = (raw || "").trim().toLowerCase();
for (const agent of Object.values(SPECIALISTS)) {
if (agent.id === id || agent.aliases.includes(id)) return agent;
}
return null;
}
/**
* Build the message a spawned session receives: persona + task + optional
* context + reporting contract. The reporting contract matters the spawn
* runner parses the final reply and relays it to Telegram, so we ask for a
* result the human can read in one glance.
*/
export function buildSpawnPrompt(
agent: SpecialistAgent,
task: string,
context?: string,
): string {
const parts = [
`[SUB-AGENT SESSION — ${agent.name} (${agent.role})]`,
agent.persona,
"",
"TASK:",
task.trim(),
];
if (context && context.trim()) {
parts.push("", "CONTEXT:", context.trim());
}
parts.push(
"",
"When finished, end your reply with a line starting with 'RESULT:' " +
"summarizing the outcome in 1-3 sentences. If you could not complete " +
"the task, start that line with 'BLOCKED:' and say what you need.",
);
return parts.join("\n");
}

151
bridge/src/lib/inbox.ts Normal file
View file

@ -0,0 +1,151 @@
/**
* lib/inbox.ts TASKS.md as Tiger's inbox, drained by the bridge
*
* The productivity loop this enables:
* You drop a one-line task into the `## 📥 INBOX` section of TASKS.md
* (from Telegram via Tiger, from the dashboard workspace editor, or by
* hand). Every DRAIN_INTERVAL the bridge picks the FIRST unchecked item,
* asks classifyAgent() which specialist owns it, spawns that specialist
* (lib/agents.ts + routes/spawn.ts), and rewrites the line in place with
* the run id so nothing is picked twice. Completion is reported to
* Telegram by the spawn runner.
*
* Why the BRIDGE schedules this instead of an OpenClaw cron:
* - an OpenClaw cron job is itself an agent turn it would burn a model
* call just to decide whether there is work, every hour
* - the cron prompt would need the bridge bearer token embedded in it
* (a secret inside a prompt bad pattern)
* - the bridge can check TASKS.md for free and only spend model tokens
* (one classify call) when there is actually an item to dispatch
* The existing "Hourly Task Check-in" cron stays it is Tiger's
* *narrative* status report; this is the *mechanical* dispatcher.
*
* INBOX line contract (inside TASKS.md):
* - [ ] research BESS tender pipeline in Gujarat pending
* - [ exec_ab12cd ethan] research BESS tender ... dispatched
* The drainer only ever touches `- [ ]` lines, one per cycle.
*/
import { writeFileSync, unlinkSync } from "fs";
import { exec } from "child_process";
import { promisify } from "util";
import { classifyAgent } from "./llm.js";
import { spawnTask } from "../routes/spawn.js";
const execAsync = promisify(exec);
const DOCKER_CONTAINER = "tiger-openclaw";
const TASKS_PATH = "/home/node/.openclaw/workspace/TASKS.md";
const INBOX_HEADER = "## 📥 INBOX";
/** Check every 30 minutes, only act inside working hours (IST). */
const DRAIN_INTERVAL_MS = 30 * 60 * 1000;
const WORK_HOURS_IST = { start: 9, end: 20 };
const PENDING_LINE = /^- \[ \] (.+)$/;
let draining = false;
function istHour(): number {
return Number(
new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Kolkata",
hour: "2-digit",
hour12: false,
}).format(new Date()),
);
}
async function readTasksFile(): Promise<string> {
const { stdout } = await execAsync(
`docker exec ${DOCKER_CONTAINER} cat ${TASKS_PATH}`,
{ timeout: 10_000, maxBuffer: 1024 * 1024 },
);
return stdout;
}
async function writeTasksFile(content: string): Promise<void> {
// docker cp (same escaping-proof transport as spawn/telegram message passing)
const tmp = `/tmp/tasks_inbox_${Date.now()}.md`;
writeFileSync(tmp, content, "utf-8");
try {
await execAsync(`docker cp ${tmp} ${DOCKER_CONTAINER}:${TASKS_PATH}`, {
timeout: 10_000,
});
} finally {
unlinkSync(tmp);
}
}
/**
* One drain cycle: dispatch at most ONE pending inbox item.
* Exported so routes can trigger it manually (POST /tiger/inbox/drain).
* Returns a human-readable outcome for logs/API.
*/
export async function drainInboxOnce(force = false): Promise<string> {
if (draining) return "skipped: drain already in progress";
const hour = istHour();
if (!force && (hour < WORK_HOURS_IST.start || hour >= WORK_HOURS_IST.end)) {
return `skipped: outside work hours (IST hour ${hour})`;
}
draining = true;
try {
let content: string;
try {
content = await readTasksFile();
} catch {
return "skipped: TASKS.md not readable";
}
const lines = content.split("\n");
const headerIdx = lines.findIndex((l) => l.trim().startsWith(INBOX_HEADER));
if (headerIdx === -1) return "skipped: no INBOX section in TASKS.md";
// Scan from the header to the next section header (or EOF).
let target = -1;
let taskText = "";
for (let i = headerIdx + 1; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("## ")) break; // next section — inbox ended
const m = line.match(PENDING_LINE);
if (m) {
target = i;
taskText = m[1].trim();
break;
}
}
if (target === -1) return "ok: inbox empty";
// Route → spawn → mark, in that order. If classify fails (e.g. the LLM
// gateway is down) we leave the line untouched and retry next cycle.
const { agent: agentId, reason } = await classifyAgent(taskText);
const spawnable = agentId === "tiger" ? "elon" : agentId; // orchestrator work → PM
const ticket = spawnTask({ agentId: spawnable, task: taskText });
lines[target] = `- [⏳ ${ticket.runId}${ticket.agent.id}] ${taskText}`;
await writeTasksFile(lines.join("\n"));
console.log(
`[inbox] dispatched "${taskText.slice(0, 60)}" → ${ticket.agent.id} ` +
`(${ticket.runId}; classifier said ${agentId}: ${reason.slice(0, 80)})`,
);
return `dispatched: ${ticket.runId}${ticket.agent.id}`;
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
console.error("[inbox] drain failed:", m);
return `error: ${m}`;
} finally {
draining = false;
}
}
/** Call once from index.ts at startup. */
export function startInboxScheduler(): void {
setInterval(() => {
void drainInboxOnce();
}, DRAIN_INTERVAL_MS);
console.log(
`[inbox] scheduler started — every ${DRAIN_INTERVAL_MS / 60000}min, ` +
`${WORK_HOURS_IST.start}:00${WORK_HOURS_IST.end}:00 IST`,
);
}

View file

@ -6,22 +6,24 @@
* generateProjectTitle(text) 3-7 word project title * generateProjectTitle(text) 3-7 word project title
* generateProjectGoal(text) one-line success criterion * generateProjectGoal(text) one-line success criterion
* *
* Configured via env vars (already declared in bridge/.env): * Configured via env vars (declared in bridge/.env):
* TIGER_ROUTER_MODEL Model slug for ALL router calls. * TIGER_ROUTER_MODEL Model slug for ALL router calls.
* Examples: * Examples:
* "anthropic/claude-haiku-4-5" Anthropic API direct * "anthropic/claude-haiku-4-5" Anthropic API direct
* "minimax/MiniMax-M2.7" OpenRouter * "minimax-3" self-hosted LiteLLM gateway
* "openrouter/auto" OpenRouter (meta-router) * Default if unset: "minimax-3" (gateway).
* Default if unset: "anthropic/claude-haiku-4-5".
* ANTHROPIC_API_KEY Required when ROUTER_MODEL has "anthropic/" prefix. * ANTHROPIC_API_KEY Required when ROUTER_MODEL has "anthropic/" prefix.
* OPENROUTER_API_KEY Required for everything else. * LLM_GATEWAY_URL Self-hosted gateway base URL.
* Default: https://llm.manohargupta.com/v1
* LLM_GATEWAY_KEY Bearer key for the gateway (LiteLLM master/virtual key).
* *
* Routing rule (intentionally simple): * Routing rule (intentionally simple):
* slug startsWith "anthropic/" Anthropic API, model = slug minus "anthropic/" * slug startsWith "anthropic/" Anthropic API, model = slug minus "anthropic/"
* anything else OpenRouter, model = slug verbatim * anything else LiteLLM gateway, model = slug verbatim
* *
* Note: "openrouter/auto" is OR's literal model ID, so we DON'T strip it. * OpenRouter was removed 2026-06-10: its credits ran dry and silently took
* This is why the rule only special-cases "anthropic/". * classifyAgent down with it. The gateway runs on Manohar's own MiniMax /
* Anthropic keys, so there is no third-party balance to surprise us.
* *
* Failure mode (the most important property): * Failure mode (the most important property):
* Every public helper catches errors internally. Callers never see exceptions * Every public helper catches errors internally. Callers never see exceptions
@ -34,9 +36,10 @@
*/ */
// ─── Configuration ───────────────────────────────────────────────────────── // ─── Configuration ─────────────────────────────────────────────────────────
const ROUTER_MODEL = process.env.TIGER_ROUTER_MODEL || "anthropic/claude-haiku-4-5"; const ROUTER_MODEL = process.env.TIGER_ROUTER_MODEL || "minimax-3";
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ""; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || ""; const LLM_GATEWAY_URL = (process.env.LLM_GATEWAY_URL || "https://llm.manohargupta.com/v1").replace(/\/$/, "");
const LLM_GATEWAY_KEY = process.env.LLM_GATEWAY_KEY || "";
const ANTHROPIC_VERSION = "2023-06-01"; const ANTHROPIC_VERSION = "2023-06-01";
// Curated list of valid agent IDs. Used to validate classifier output. // Curated list of valid agent IDs. Used to validate classifier output.
@ -45,7 +48,7 @@ export type AgentId = (typeof AGENT_IDS)[number];
// ─── Internal: provider resolution ────────────────────────────────────────── // ─── Internal: provider resolution ──────────────────────────────────────────
interface ResolvedModel { interface ResolvedModel {
provider: "anthropic" | "openrouter"; provider: "anthropic" | "gateway";
model: string; model: string;
} }
@ -57,7 +60,7 @@ function resolveModel(slug: string): ResolvedModel {
if (slug.startsWith("anthropic/")) { if (slug.startsWith("anthropic/")) {
return { provider: "anthropic", model: slug.slice("anthropic/".length) }; return { provider: "anthropic", model: slug.slice("anthropic/".length) };
} }
return { provider: "openrouter", model: slug }; return { provider: "gateway", model: slug };
} }
// ─── Internal: low-level LLM call ─────────────────────────────────────────── // ─── Internal: low-level LLM call ───────────────────────────────────────────
@ -107,18 +110,15 @@ async function callLLM(
return text.trim(); return text.trim();
} }
// OpenRouter (catch-all for everything except "anthropic/") // Self-hosted LiteLLM gateway (catch-all for everything except "anthropic/")
if (!OPENROUTER_API_KEY) { if (!LLM_GATEWAY_KEY) {
throw new Error("OPENROUTER_API_KEY not set"); throw new Error("LLM_GATEWAY_KEY not set");
} }
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { const res = await fetch(`${LLM_GATEWAY_URL}/chat/completions`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${OPENROUTER_API_KEY}`, Authorization: `Bearer ${LLM_GATEWAY_KEY}`,
"content-type": "application/json", "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({ body: JSON.stringify({
model, model,
@ -131,13 +131,13 @@ async function callLLM(
}); });
if (!res.ok) { if (!res.ok) {
const errBody = await res.text().catch(() => "<no body>"); const errBody = await res.text().catch(() => "<no body>");
throw new Error(`OpenRouter API ${res.status}: ${errBody.slice(0, 200)}`); throw new Error(`LLM gateway ${res.status}: ${errBody.slice(0, 200)}`);
} }
const data = (await res.json()) as { const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>; choices?: Array<{ message?: { content?: string } }>;
}; };
const text = data.choices?.[0]?.message?.content; const text = data.choices?.[0]?.message?.content;
if (!text) throw new Error("OpenRouter returned no message content"); if (!text) throw new Error("LLM gateway returned no message content");
return text.trim(); return text.trim();
} }

View file

@ -37,9 +37,12 @@ function timeAgo(timestamp: number | null): string {
router.get("/", async (_req: Request, res: Response) => { router.get("/", async (_req: Request, res: Response) => {
try { try {
// Use execInSandbox to call /tiger/agents from inside OpenClaw container // Use execInSandbox to call /tiger/agents from inside OpenClaw container.
// Token comes from env — a previous version hardcoded it here, which
// leaked it to the public GitHub mirror (rotated 2026-06-10).
const token = process.env.TIGER_BRIDGE_TOKEN || "";
const { stdout } = await execInSandbox( const { stdout } = await execInSandbox(
`curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer 14fb879429386b69beac339bbd98e43011ec29485da17592410da34ed97e0236"` `curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer ${token}"`
); );
let rawData: any; let rawData: any;

View file

@ -0,0 +1,172 @@
/**
* chat-telegram.ts GET /tiger/chat/telegram : the REAL Telegram mirror
*
* History of this feature (why the old one showed nothing):
* The original design (telegram-webhook.ts + chat-mirror.ts) waited for
* Telegram to POST updates to the bridge. But the bot is handled by
* OpenClaw's NATIVE telegram channel via long-polling, and Telegram's API
* forbids webhook + getUpdates on the same bot token so the webhook was
* never registered, chat_messages never received a single Telegram row,
* and the dashboard card stayed empty. Even if it had worked, it could
* only see inbound messages, never Tiger's replies.
*
* This route reads the conversation from the source of truth instead:
* OpenClaw's session transcript (JSONL) for the telegram:direct session.
* It is the same file Tiger's own context is built from, so the dashboard
* is in perfect sync by construction both directions, full history,
* nothing to register, nothing to double-write.
*
* GET /tiger/chat/telegram?limit=50&before=<seq>
* { ok, sessionKey, messages: [{ seq, role, text, timestamp }], hasMore, oldestSeq }
* - messages are ascending by seq (chronological)
* - `before` pages backwards through history (omit for the newest page)
*
* Transcript line shape (verified live on tiger-config volume):
* { type:"message", timestamp:"2026-06-09T21:08:38.574Z",
* message:{ role:"user"|"assistant"|"toolResult", content:[{type:"text",text}] } }
*/
import { Router, Request, Response } from "express";
import { readFileSync, statSync } from "fs";
import { join } from "path";
const router = Router();
// The bridge runs on the host as root, so it reads the docker volume directly —
// no docker-exec hop on a route that the dashboard polls every few seconds.
const DATA_DIR =
process.env.OPENCLAW_DATA_DIR ||
"/var/lib/docker/volumes/tiger_tiger-config/_data";
const SESSIONS_DIR = join(DATA_DIR, "agents", "main", "sessions");
interface ThreadMessage {
seq: number;
role: "user" | "agent";
text: string;
timestamp: string;
}
interface SessionIndexEntry {
sessionId?: string;
updatedAt?: number;
}
/** Newest telegram session: key + transcript path. */
function resolveTelegramSession(): { key: string; file: string } | null {
let index: Record<string, SessionIndexEntry>;
try {
index = JSON.parse(
readFileSync(join(SESSIONS_DIR, "sessions.json"), "utf-8"),
) as Record<string, SessionIndexEntry>;
} catch {
return null;
}
const candidates = Object.entries(index)
.filter(([key]) => key.includes(":telegram:") || key.includes(":tg_"))
.sort((a, b) => (b[1].updatedAt ?? 0) - (a[1].updatedAt ?? 0));
for (const [key, entry] of candidates) {
if (entry.sessionId) {
return { key, file: join(SESSIONS_DIR, `${entry.sessionId}.jsonl`) };
}
}
return null;
}
// ─── Parse cache ─────────────────────────────────────────────────────────────
// The card polls for new messages; re-parsing the whole transcript each poll
// is wasted work. Cache the parsed array keyed on (path, mtime, size).
let cache: { file: string; mtimeMs: number; size: number; messages: ThreadMessage[] } | null = null;
function parseTranscript(file: string): ThreadMessage[] {
const st = statSync(file);
if (
cache &&
cache.file === file &&
cache.mtimeMs === st.mtimeMs &&
cache.size === st.size
) {
return cache.messages;
}
const messages: ThreadMessage[] = [];
const lines = readFileSync(file, "utf-8").split("\n");
let seq = 0;
for (const line of lines) {
if (!line.trim()) continue;
seq += 1;
let entry: Record<string, any>;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.type !== "message") continue;
const role = entry.message?.role;
if (role !== "user" && role !== "assistant") continue; // skip toolResult etc.
const content: unknown = entry.message?.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
text = content
.filter((c) => c && c.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n");
}
text = text.trim();
if (!text) continue; // tool-call-only assistant turns have no text
messages.push({
seq,
role: role === "user" ? "user" : "agent",
text,
timestamp: typeof entry.timestamp === "string" ? entry.timestamp : "",
});
}
cache = { file, mtimeMs: st.mtimeMs, size: st.size, messages };
return messages;
}
router.get("/", (req: Request, res: Response) => {
const limit = Math.min(
Math.max(parseInt(String(req.query.limit ?? "50"), 10) || 50, 1),
200,
);
const before = parseInt(String(req.query.before ?? ""), 10) || null;
const session = resolveTelegramSession();
if (!session) {
return res.status(404).json({
ok: false,
error: "No telegram session found in OpenClaw session index",
});
}
let all: ThreadMessage[];
try {
all = parseTranscript(session.file);
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: `transcript read failed: ${m}` });
}
const upTo = before === null ? all : all.filter((m) => m.seq < before);
const page = upTo.slice(-limit);
res.json({
ok: true,
sessionKey: session.key,
messages: page,
hasMore: upTo.length > page.length,
oldestSeq: page.length > 0 ? page[0].seq : null,
});
});
export default router;

View file

@ -1,66 +1,322 @@
/** /**
* spawn.ts POST /tiger/spawn * spawn.ts POST /tiger/spawn : REAL sub-agent execution
* *
* Trigger spawning of sub-agents. This is a placeholder - * Replaces the long-standing placeholder. A spawn is an isolated OpenClaw
* real implementation requires sub-agent permission config. * session of the `main` agent running with a specialist persona prepended
* (see lib/agents.ts for the registry and the per-agent upgrade path).
* *
* POST /tiger/spawn * Flow per spawn:
* { agentId: "coder" | "researcher" | "writer" | "pm", task: "..." } * 1. validate + normalize agent id (accepts cody/ethan/cathy/elon + legacy aliases)
* 2. insert a row into `executions` (status = running while exit_code IS NULL)
* 3. enqueue at most MAX_CONCURRENT sessions run at once. The VPS is
* memory-constrained; parallel agent turns push it into swap and every
* turn times out. Serializing is a feature, not a limitation.
* 4. run `openclaw agent --session-id spawn-<agent>-<n> ... --json` inside
* the tiger-openclaw container. The message travels via docker cp of a
* temp file same battle-tested pattern as lib/telegram.ts, immune to
* shell-escaping bugs from quotes/backticks/JSON in task text.
* 5. parse the reply, complete the executions row, fire a Telegram
* notification through the bridge's own /tiger/notify route.
* *
* Response: * Routes:
* { ok: true, sessionId, status: "spawned" | "pending" } * POST /tiger/spawn { agentId, task, context?, taskId? }
* GET /tiger/spawn/runs recent spawn runs (+ live queue state)
* GET /tiger/spawn/runs/:id one run with full output
* GET /tiger/spawn/agents the specialist registry
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js"; import { exec } from "child_process";
import { promisify } from "util";
import { writeFileSync, unlinkSync } from "fs";
import { randomUUID } from "crypto";
import db, { generateId } from "../db.js";
import {
SPECIALISTS,
ACCEPTED_AGENT_IDS,
normalizeAgentId,
buildSpawnPrompt,
type SpecialistAgent,
} from "../lib/agents.js";
const execAsync = promisify(exec);
const router = Router(); const router = Router();
const validAgents = ["coder", "researcher", "writer", "pm"]; const DOCKER_CONTAINER = "tiger-openclaw";
/** One agent turn at a time see header comment about RAM. Raise after the
* server is upgraded / the homelab is evicted. */
const MAX_CONCURRENT = 1;
/** Keep below the 300s cron budget so cron-triggered spawns can't be the
* thing that blows the cron's own timeout. */
const SPAWN_TIMEOUT_SECONDS = 240;
router.post("/", async (req: Request, res: Response) => { const BRIDGE_SELF_URL = process.env.TIGER_BRIDGE_SELF_URL || "http://127.0.0.1:3456";
const { agentId, task } = req.body; const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
if (!agentId || !validAgents.includes(agentId)) { // ─── Run bookkeeping ─────────────────────────────────────────────────────────
return res.status(400).json({
ok: false, interface SpawnRequest {
error: `Invalid agent. Use: ${validAgents.join(", ")}` runId: string;
agent: SpecialistAgent;
task: string;
context?: string;
sessionId: string;
}
interface SpawnOutcome {
ok: boolean;
reply: string;
error?: string;
}
let activeCount = 0;
const queue: Array<() => Promise<void>> = [];
function pump(): void {
while (activeCount < MAX_CONCURRENT && queue.length > 0) {
const job = queue.shift();
if (!job) break;
activeCount += 1;
void job().finally(() => {
activeCount -= 1;
pump();
}); });
} }
}
if (!task) {
return res.status(400).json({ ok: false, error: "task is required" }); // ─── Core runner (exported so lib/inbox.ts can spawn without HTTP) ──────────
export interface SpawnTicket {
runId: string;
sessionId: string;
agent: { id: string; name: string };
queued: number;
}
export function spawnTask(input: {
agentId: string;
task: string;
context?: string;
taskId?: string;
}): SpawnTicket {
const agent = normalizeAgentId(input.agentId);
if (!agent) {
throw new Error(
`Unknown agent '${input.agentId}'. Accepted: ${ACCEPTED_AGENT_IDS.join(", ")}`,
);
} }
const task = (input.task || "").trim();
if (!task) throw new Error("task is required");
const runId = generateId("exec");
const sessionId = `spawn-${agent.id}-${randomUUID().slice(0, 8)}`;
// exit_code NULL = still running; completed_at NULL until the turn ends.
db.prepare(
`INSERT INTO executions (id, task_id, agent, command)
VALUES (?, ?, ?, ?)`,
).run(runId, input.taskId ?? null, agent.id, `spawn: ${task.slice(0, 300)}`);
const req: SpawnRequest = { runId, agent, task, context: input.context, sessionId };
queue.push(() => executeSpawn(req));
pump();
return {
runId,
sessionId,
agent: { id: agent.id, name: agent.name },
queued: queue.length,
};
}
async function executeSpawn(req: SpawnRequest): Promise<void> {
const { runId, agent, task, context, sessionId } = req;
const prompt = buildSpawnPrompt(agent, task, context);
const tmpFile = `/tmp/spawn_${runId}.txt`;
let outcome: SpawnOutcome;
try { try {
// Note: Sub-agent spawning requires config // Stage the message inside the container (escaping-proof transport).
// This is a placeholder - returns info about what's needed writeFileSync(tmpFile, prompt, "utf-8");
res.json({ await execAsync(`docker cp ${tmpFile} ${DOCKER_CONTAINER}:${tmpFile}`, {
ok: true, timeout: 10_000,
agentId,
task,
status: "pending",
message: "Sub-agent spawning requires config. Set agents.defaults.subagents in openclaw.json"
}); });
} catch (err: any) { unlinkSync(tmpFile);
res.status(500).json({ ok: false, error: err.message });
const cmd =
`docker exec ${DOCKER_CONTAINER} sh -c '` +
`MSG=$(cat ${tmpFile}); rm -f ${tmpFile}; ` +
`openclaw agent --session-id ${sessionId} -m "$MSG" --json ` +
`--timeout ${SPAWN_TIMEOUT_SECONDS}'`;
const { stdout } = await execAsync(cmd, {
timeout: (SPAWN_TIMEOUT_SECONDS + 30) * 1000,
maxBuffer: 10 * 1024 * 1024,
});
outcome = { ok: true, reply: extractReply(stdout) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[spawn] ${runId} (${agent.id}) failed:`, message);
outcome = { ok: false, reply: "", error: message };
try { unlinkSync(tmpFile); } catch { /* already gone */ }
}
db.prepare(
`UPDATE executions
SET stdout = ?, stderr = ?, exit_code = ?, completed_at = datetime('now')
WHERE id = ?`,
).run(outcome.reply, outcome.error ?? "", outcome.ok ? 0 : 1, runId);
await notifyCompletion(req, outcome);
}
/** Pull the text reply out of `openclaw agent --json` output. */
function extractReply(stdout: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
return stdout.trim();
}
const p = parsed as Record<string, any>;
return (
p?.result?.payloads?.[0]?.text ||
p?.payloads?.[0]?.text ||
p?.summary ||
p?.text ||
p?.output ||
stdout.trim()
);
}
/** Report the outcome to Telegram via the bridge's own notify route. */
async function notifyCompletion(req: SpawnRequest, outcome: SpawnOutcome): Promise<void> {
const { agent, task, runId } = req;
const resultLine =
outcome.reply
.split("\n")
.reverse()
.find((l) => l.startsWith("RESULT:") || l.startsWith("BLOCKED:")) ??
outcome.reply.slice(-300);
const message = outcome.ok
? `🤖 *${agent.name}* finished: ${task.slice(0, 120)}\n\n${resultLine.slice(0, 800)}\n\n_run ${runId}_`
: `⚠️ *${agent.name}* failed: ${task.slice(0, 120)}\n\n${(outcome.error ?? "unknown error").slice(0, 300)}\n\n_run ${runId}_`;
try {
await fetch(`${BRIDGE_SELF_URL}/tiger/notify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BRIDGE_TOKEN}`,
},
body: JSON.stringify({ message }),
});
} catch (err) {
// Notification failure must never mark the run failed — log and move on.
const m = err instanceof Error ? err.message : String(err);
console.error(`[spawn] notify failed for ${runId}:`, m);
}
}
// ─── HTTP surface ────────────────────────────────────────────────────────────
interface ExecutionRow {
id: string;
task_id: string | null;
agent: string | null;
command: string | null;
stdout: string;
stderr: string;
exit_code: number | null;
started_at: string;
completed_at: string | null;
}
function rowStatus(row: ExecutionRow): "running" | "done" | "error" {
if (row.exit_code === null) return "running";
return row.exit_code === 0 ? "done" : "error";
}
router.post("/", (req: Request, res: Response) => {
const { agentId, task, context, taskId } = req.body as {
agentId?: string;
task?: string;
context?: string;
taskId?: string;
};
try {
const ticket = spawnTask({
agentId: agentId ?? "",
task: task ?? "",
context,
taskId,
});
res.json({ ok: true, status: "spawned", ...ticket });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
res.status(400).json({ ok: false, error: message });
} }
}); });
// GET available agents router.get("/runs", (_req: Request, res: Response) => {
router.get("/agents", (_req: Request, res: Response) => { const rows = db
.prepare(
`SELECT id, task_id, agent, command, exit_code, started_at, completed_at
FROM executions
WHERE command LIKE 'spawn:%'
ORDER BY started_at DESC
LIMIT 50`,
)
.all() as ExecutionRow[];
res.json({ res.json({
ok: true, ok: true,
agents: validAgents.map(id => ({ active: activeCount,
id, queued: queue.length,
name: id === "coder" ? "Cody" : runs: rows.map((r) => ({
id === "researcher" ? "Ethan" : runId: r.id,
id === "writer" ? "Cathy" : "Elon", agent: r.agent,
role: id === "coder" ? "Code" : task: (r.command ?? "").replace(/^spawn:\s*/, ""),
id === "researcher" ? "Research" : status: rowStatus(r),
id === "writer" ? "Write" : "PM" startedAt: r.started_at,
})) completedAt: r.completed_at,
})),
}); });
}); });
export default router router.get("/runs/:id", (req: Request, res: Response) => {
const row = db
.prepare(`SELECT * FROM executions WHERE id = ?`)
.get(req.params.id) as ExecutionRow | undefined;
if (!row) return res.status(404).json({ ok: false, error: "run not found" });
res.json({
ok: true,
run: {
runId: row.id,
agent: row.agent,
task: (row.command ?? "").replace(/^spawn:\s*/, ""),
status: rowStatus(row),
reply: row.stdout,
error: row.stderr,
startedAt: row.started_at,
completedAt: row.completed_at,
},
});
});
router.get("/agents", (_req: Request, res: Response) => {
res.json({
ok: true,
agents: Object.values(SPECIALISTS).map((a) => ({
id: a.id,
name: a.name,
role: a.role,
aliases: a.aliases,
})),
});
});
export default router;

View file

@ -0,0 +1,34 @@
/**
* /api/chat/telegram-thread proxy for the bridge's Telegram mirror.
*
* GET ?limit=50&before=<seq>
* Same proxy pattern as /api/chat/history: the bridge bearer token stays on
* the server, the browser only ever talks to this route.
*/
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 limit = request.nextUrl.searchParams.get("limit") || "50";
const before = request.nextUrl.searchParams.get("before") || "";
const qs = new URLSearchParams({ limit });
if (before) qs.set("before", before);
try {
const r = await fetch(`${BRIDGE_URL}/tiger/chat/telegram?${qs.toString()}`, {
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
cache: "no-store",
});
const data = await r.json();
return NextResponse.json(data, { status: r.status });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json(
{ ok: false, error: "Bridge unreachable", details: message },
{ status: 502 },
);
}
}

View file

@ -0,0 +1,33 @@
import { NextResponse } from "next/server"
export const dynamic = "force-dynamic"
const ANGEL_API_URL = process.env.ANGEL_API_URL || "https://angel.manohargupta.com"
async function angelFetch(path: string) {
const res = await fetch(`${ANGEL_API_URL}${path}`, { cache: "no-store" })
if (!res.ok) throw new Error(`angel API ${path} failed: ${res.status}`)
return res.json()
}
export async function GET() {
try {
const [posData, histData] = await Promise.all([
angelFetch("/api/positions"),
angelFetch("/api/pnl-history"),
])
return NextResponse.json({ ok: true, positions: posData.data ?? [], summary: histData.summary ?? {} })
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 })
}
}
export async function POST() {
try {
const res = await fetch(`${ANGEL_API_URL}/api/refresh`, { method: "POST", cache: "no-store" })
if (!res.ok) throw new Error(`refresh failed: ${res.status}`)
return NextResponse.json({ ok: true })
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 })
}
}

View file

@ -0,0 +1,230 @@
"use client"
import * as React from "react"
import { TrendingUp, TrendingDown, RefreshCw, Activity, DollarSign, BarChart2, Layers } from "lucide-react"
import { StatCard } from "@/components/stat-card"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface Position {
key: string
tradingsymbol: string
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 {
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() {
const [positions, setPositions] = React.useState<Position[]>([])
const [summary, setSummary] = React.useState<Summary | null>(null)
const [loading, setLoading] = React.useState(true)
const [refreshing, setRefreshing] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [lastUpdated, setLastUpdated] = React.useState<Date | null>(null)
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<BarChart2 className="h-6 w-6 text-primary" />
Positions
</h1>
<p className="text-muted-foreground text-sm">
{lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString("en-IN", { timeZone: "Asia/Kolkata", hour12: false })} IST` : "Live positions from Angel One"}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || loading}>
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
Refresh
</Button>
</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>
{/* 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>
)
}

View file

@ -10,6 +10,7 @@
* Agents per-sub-agent detail and (Phase 3) per-agent model overrides * Agents per-sub-agent detail and (Phase 3) per-agent model overrides
* Knowledge Tiger's brain: memory, skills, activity, scheduled jobs * Knowledge Tiger's brain: memory, skills, activity, scheduled jobs
* Workspace file tree of /sandbox + diffs * Workspace file tree of /sandbox + diffs
* Positions live P&L + open positions
* Cost finance-grade cost dashboard * Cost finance-grade cost dashboard
* Logs raw streaming logs (dev drill-down) * Logs raw streaming logs (dev drill-down)
* Settings * Settings
@ -27,6 +28,7 @@ import {
Bot, Bot,
Brain, Brain,
FolderOpen, FolderOpen,
BarChart2,
DollarSign, DollarSign,
ScrollText, ScrollText,
Settings2, Settings2,
@ -47,15 +49,16 @@ import {
// Primary navigation — the main verbs of using Tiger. // Primary navigation — the main verbs of using Tiger.
// Ordered by frequency-of-use so the most-tapped items sit at the top. // Ordered by frequency-of-use so the most-tapped items sit at the top.
const navMain = [ const navMain = [
{ title: "Home", url: "/", icon: Home }, { title: "Home", url: "/", icon: Home },
{ title: "Chat", url: "/chat", icon: MessageSquare }, { title: "Chat", url: "/chat", icon: MessageSquare },
{ title: "Projects", url: "/projects", icon: Briefcase }, { title: "Projects", url: "/projects", icon: Briefcase },
{ title: "Agents", url: "/agents", icon: Bot }, { title: "Agents", url: "/agents", icon: Bot },
{ title: "Knowledge", url: "/knowledge", icon: Brain }, { title: "Knowledge", url: "/knowledge", icon: Brain },
{ title: "Workspace", url: "/workspace", icon: FolderOpen }, { title: "Workspace", url: "/workspace", icon: FolderOpen },
{ title: "Activity", url: "/activity", icon: ScrollText }, { title: "Positions", url: "/positions", icon: BarChart2 },
{ title: "Cost", url: "/cost", icon: DollarSign }, { title: "Activity", url: "/activity", icon: ScrollText },
{ title: "Logs", url: "/logs", icon: ScrollText }, { title: "Cost", url: "/cost", icon: DollarSign },
{ title: "Logs", url: "/logs", icon: ScrollText },
] ]
// Secondary navigation — sits in the footer, less-frequent admin stuff. // Secondary navigation — sits in the footer, less-frequent admin stuff.

View file

@ -1,122 +1,220 @@
"use client" "use client"
import { useEffect, useState } from "react" /**
import { Card } from "@/components/ui/card" * TelegramThreadCard live mirror of the Telegram conversation with Tiger.
import { Send } from "lucide-react" *
* Data source: /api/chat/telegram-thread bridge /tiger/chat/telegram, which
* reads OpenClaw's native session transcript (the same file Tiger's context
* comes from). Both directions, full history, in sync by construction.
*
* Behaviour:
* - loads the newest page, scrolled to the bottom (like Telegram itself)
* - "Load older" at the top pages backwards through the entire history
* - polls for new messages every 15s; only repaints when something changed
* - preserves scroll position when older messages are prepended
*/
interface TelegramMessage { import { useCallback, useEffect, useRef, useState } from "react"
role: string import { Card } from "@/components/ui/card"
content: string import { Send, ChevronUp, RefreshCw } from "lucide-react"
timestamp: number
meta?: Record<string, unknown> interface ThreadMessage {
seq: number
role: "user" | "agent"
text: string
timestamp: string
} }
interface ThreadResponse {
ok: boolean
messages?: ThreadMessage[]
hasMore?: boolean
error?: string
}
const PAGE_SIZE = 40
const POLL_MS = 15_000
export function TelegramThreadCard() { export function TelegramThreadCard() {
const [messages, setMessages] = useState<TelegramMessage[]>([]) const [messages, setMessages] = useState<ThreadMessage[]>([])
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingOlder, setLoadingOlder] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// Tracks whether the user is parked at the bottom — only then do we
// auto-scroll on new messages, so reading history is never interrupted.
const stickToBottom = useRef(true)
const fetchPage = useCallback(
async (before?: number): Promise<ThreadResponse> => {
const qs = new URLSearchParams({ limit: String(PAGE_SIZE) })
if (before) qs.set("before", String(before))
const r = await fetch(`/api/chat/telegram-thread?${qs.toString()}`)
return r.json()
},
[],
)
// Initial load
useEffect(() => { useEffect(() => {
fetch("/api/chat/history?limit=5") fetchPage()
.then(r => r.json()) .then((data) => {
.then(data => { if (data.ok && data.messages) {
if (data?.messages) { setMessages(data.messages)
setMessages(data.messages.slice(-5).reverse()) setHasMore(Boolean(data.hasMore))
} else {
setError(data.error || "No data")
} }
setLoading(false)
}) })
.catch(e => { .catch((e: Error) => setError(e.message))
console.error("Failed to load:", e) .finally(() => setLoading(false))
setError(e.message) }, [fetchPage])
setLoading(false)
})
}, [])
const hasData = messages.length > 0 // Poll for new messages
useEffect(() => {
const t = setInterval(() => {
fetchPage()
.then((data) => {
if (!data.ok || !data.messages) return
setMessages((prev) => {
const newest = data.messages!
if (
prev.length > 0 &&
newest.length > 0 &&
prev[prev.length - 1].seq === newest[newest.length - 1].seq
) {
return prev // nothing new — keep referential equality, no repaint
}
// Merge: keep any older pages we already loaded, append the fresh tail.
const known = new Set(prev.map((m) => m.seq))
const fresh = newest.filter((m) => !known.has(m.seq))
return fresh.length > 0 ? [...prev, ...fresh] : prev
})
})
.catch(() => { /* transient poll errors are fine — next tick retries */ })
}, POLL_MS)
return () => clearInterval(t)
}, [fetchPage])
// Simple timestamp formatter // Auto-scroll to bottom on new tail messages (only if user was at bottom)
const formatTime = (ts: number) => { useEffect(() => {
if (!ts) return "" const el = scrollRef.current
const diff = Date.now() - ts if (el && stickToBottom.current) {
const mins = Math.floor(diff / 60000) el.scrollTop = el.scrollHeight
if (mins < 1) return "just now" }
if (mins < 60) return `${mins}m ago` }, [messages])
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago` const onScroll = () => {
return new Date(ts).toLocaleDateString() const el = scrollRef.current
if (!el) return
stickToBottom.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 40
} }
// Simple truncate const loadOlder = async () => {
const truncate = (text: string, max = 40) => { if (loadingOlder || messages.length === 0) return
if (!text) return "" setLoadingOlder(true)
return text.length > max ? text.slice(0, max) + "..." : text const el = scrollRef.current
const prevHeight = el?.scrollHeight ?? 0
try {
const data = await fetchPage(messages[0].seq)
if (data.ok && data.messages && data.messages.length > 0) {
setMessages((prev) => [...data.messages!, ...prev])
setHasMore(Boolean(data.hasMore))
// Keep the viewport anchored on the message the user was reading.
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight - prevHeight
})
} else {
setHasMore(false)
}
} finally {
setLoadingOlder(false)
}
} }
if (loading) { const formatTime = (iso: string) => {
return ( if (!iso) return ""
<Card className="bg-card/40 p-4 flex flex-col"> const d = new Date(iso)
<div className="flex items-center gap-2 mb-3"> const now = new Date()
<Send className="h-4 w-4 text-primary" /> const sameDay = d.toDateString() === now.toDateString()
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span> return sameDay
</div> ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
<div className="flex-1 flex items-center justify-center"> : d.toLocaleDateString([], { day: "numeric", month: "short" }) +
<span className="text-sm text-muted-foreground">Loading...</span> " " +
</div> d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
</Card>
)
}
if (error) {
return (
<Card className="bg-card/40 p-4 flex flex-col">
<div className="flex items-center gap-2 mb-3">
<Send className="h-4 w-4 text-primary" />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
</div>
<div className="flex-1 flex items-center justify-center">
<span className="text-sm text-red-500">Error: {error}</span>
</div>
</Card>
)
} }
return ( return (
<Card className="bg-card/40 p-4 flex flex-col"> <Card className="bg-card/40 p-4 flex flex-col">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-2"> <Send className="h-4 w-4 text-primary" />
<Send className="h-4 w-4 text-primary" /> <span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Chat history</span> Telegram thread
</div> </span>
<a href="/chat?session=telegram" className="text-xs text-primary hover:underline">Open chat </a> {!loading && !error && (
<span className="ml-auto text-[10px] text-muted-foreground/60 flex items-center gap-1">
<RefreshCw className="h-3 w-3" /> live
</span>
)}
</div> </div>
{hasData ? ( {loading && (
<ul className="space-y-2 flex-1"> <div className="flex-1 flex items-center justify-center h-80">
{messages.map((msg, i) => ( <span className="text-sm text-muted-foreground">Loading...</span>
<li key={i} className="text-sm"> </div>
<div className="flex items-baseline gap-2"> )}
<span className="font-medium text-xs">
{msg.role === "user" ? "You" : "Tiger"} {error && (
</span> <div className="flex-1 flex items-center justify-center h-80">
<span className="text-[10px] text-muted-foreground"> <span className="text-sm text-red-500">Error: {error}</span>
{formatTime(msg.timestamp)} </div>
</span> )}
{!loading && !error && (
<div
ref={scrollRef}
onScroll={onScroll}
className="h-80 overflow-y-auto pr-1 flex flex-col gap-2"
>
{hasMore && (
<button
onClick={loadOlder}
disabled={loadingOlder}
className="self-center text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1 py-1 px-2 rounded hover:bg-muted/40 transition-colors"
>
<ChevronUp className="h-3 w-3" />
{loadingOlder ? "Loading..." : "Load older"}
</button>
)}
{messages.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<span className="text-sm text-muted-foreground">
No messages yet
</span>
</div>
)}
{messages.map((m) => (
<div
key={m.seq}
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
m.role === "user"
? "self-end bg-primary/15 text-foreground"
: "self-start bg-muted/50 text-foreground"
}`}
>
<div>{m.text}</div>
<div className="mt-1 text-[10px] text-muted-foreground/70 text-right">
{m.role === "user" ? "you" : "tiger"} · {formatTime(m.timestamp)}
</div> </div>
<div className="text-xs text-muted-foreground/80 truncate"> </div>
{truncate(msg.content)}
</div>
</li>
))} ))}
</ul>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center py-6 px-2">
<Send className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-sm text-muted-foreground">No messages yet.</p>
<p className="text-[11px] text-muted-foreground/60 mt-1 max-w-[260px]">
Start a conversation to see messages here.
</p>
</div> </div>
)} )}
</Card> </Card>
) )
} }