Compare commits
7 commits
0970160f29
...
572418f0ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
572418f0ea | ||
|
|
438488c8c8 | ||
|
|
1a8358bb6a | ||
|
|
61e386f7fe | ||
|
|
c3cd924cdd | ||
| e48441be4e | |||
| 6127b6de24 |
12 changed files with 1294 additions and 167 deletions
|
|
@ -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
132
bridge/src/lib/agents.ts
Normal 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
151
bridge/src/lib/inbox.ts
Normal 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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
172
bridge/src/routes/chat-telegram.ts
Normal file
172
bridge/src/routes/chat-telegram.ts
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
34
dashboard/src/app/api/chat/telegram-thread/route.ts
Normal file
34
dashboard/src/app/api/chat/telegram-thread/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
dashboard/src/app/api/positions/route.ts
Normal file
33
dashboard/src/app/api/positions/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
230
dashboard/src/app/positions/page.tsx
Normal file
230
dashboard/src/app/positions/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue