production: reminder cron, dashboard overhaul, shadcn components, setup wizard
- /api/cron/reminders: processes pending reminders every 15min, sends WhatsApp with email fallback - /api/cron/overdue: marks overdue pledges daily (7d deferred, 14d immediate) - /api/pledges: GET handler with filtering, search, pagination, sort by dueDate - Dashboard overview: stats, collection progress bar, needs attention, upcoming payments - Dashboard pledges: proper table with status tabs, search, actions, pagination - New shadcn components: Table, Tabs, DropdownMenu, Progress - Setup wizard: 4-step onboarding (org → bank → event → QR code) - Settings API: PUT handler for org create/update - Org resolver: single-tenant fallback to first org - Cron jobs installed: reminders every 15min, overdue check at 6am - Auto-generates installment dates when not provided - HOSTNAME=0.0.0.0 in compose for multi-network binding
This commit is contained in:
198
lib/agent-worker.ts
Normal file
198
lib/agent-worker.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { toolDefs, executeTool } from "./tools.js";
|
||||
import * as store from "./store.js";
|
||||
import * as tg from "./telegram.js";
|
||||
|
||||
const client = new Anthropic();
|
||||
const MODEL = "claude-sonnet-4-20250514";
|
||||
const MAX_TURNS = 50;
|
||||
|
||||
// Map of agentId -> resolve function for when user replies
|
||||
const waitingForUser = new Map<number, (response: string) => void>();
|
||||
|
||||
export function isWaitingForUser(agentId: number): boolean {
|
||||
return waitingForUser.has(agentId);
|
||||
}
|
||||
|
||||
export function resolveUserResponse(agentId: number, response: string): void {
|
||||
const resolve = waitingForUser.get(agentId);
|
||||
if (resolve) {
|
||||
waitingForUser.delete(agentId);
|
||||
resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSystemPrompt(task: string): string {
|
||||
return `You are an autonomous coding agent. You have been assigned a specific task.
|
||||
|
||||
YOUR TASK:
|
||||
${task}
|
||||
|
||||
GUIDELINES:
|
||||
- Work independently to complete the task
|
||||
- Use the bash tool for running commands, git, etc.
|
||||
- Use read_file, write_file, edit_file for file operations
|
||||
- When you're done, call the "done" tool with a summary
|
||||
- If you need user input or a decision, call "ask_user"
|
||||
- Be efficient — don't explain what you're about to do, just do it
|
||||
- If something fails, try to fix it yourself before asking the user
|
||||
|
||||
WORKING DIRECTORY: ${process.cwd()}`;
|
||||
}
|
||||
|
||||
export async function runAgent(agentId: number): Promise<void> {
|
||||
const agent = store.getAgent(agentId);
|
||||
if (!agent) return;
|
||||
|
||||
store.updateAgent(agentId, { status: "working" });
|
||||
store.addLog(agentId, "system", `Agent started: ${agent.task}`);
|
||||
|
||||
const messages: Anthropic.MessageParam[] = [
|
||||
{ role: "user", content: agent.task },
|
||||
];
|
||||
|
||||
let turns = 0;
|
||||
|
||||
try {
|
||||
while (turns < MAX_TURNS) {
|
||||
turns++;
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 8096,
|
||||
system: buildSystemPrompt(agent.task),
|
||||
tools: toolDefs as any,
|
||||
messages,
|
||||
});
|
||||
|
||||
// Collect assistant content
|
||||
const assistantContent = response.content;
|
||||
messages.push({ role: "assistant", content: assistantContent });
|
||||
|
||||
// Log text blocks (no Telegram notification — reduces noise)
|
||||
for (const block of assistantContent) {
|
||||
if (block.type === "text" && block.text.trim()) {
|
||||
store.addLog(agentId, "assistant", block.text);
|
||||
}
|
||||
}
|
||||
|
||||
// If no tool use, we're done
|
||||
if (response.stop_reason !== "tool_use") {
|
||||
store.updateAgent(agentId, {
|
||||
status: "done",
|
||||
summary: "Completed (no more actions)",
|
||||
});
|
||||
store.addLog(agentId, "system", "Agent finished (end_turn)");
|
||||
await tg.send(
|
||||
`✅ *Agent #${agentId}* finished.\nTask: ${agent.task}`,
|
||||
agent.chat_id,
|
||||
{ reply_to: agent.thread_msg_id || undefined }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process tool calls
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = [];
|
||||
|
||||
for (const block of assistantContent) {
|
||||
if (block.type !== "tool_use") continue;
|
||||
|
||||
const toolName = block.name;
|
||||
const toolInput = block.input as Record<string, unknown>;
|
||||
|
||||
store.addLog(
|
||||
agentId,
|
||||
"tool",
|
||||
`${toolName}: ${JSON.stringify(toolInput).slice(0, 500)}`
|
||||
);
|
||||
|
||||
if (toolName === "ask_user") {
|
||||
// Pause and wait for user response
|
||||
store.updateAgent(agentId, { status: "waiting" });
|
||||
await tg.send(
|
||||
`❓ *Agent #${agentId}* needs your input:\n\n${toolInput.question}`,
|
||||
agent.chat_id,
|
||||
{
|
||||
reply_to: agent.thread_msg_id || undefined,
|
||||
keyboard: [
|
||||
[{ text: "💬 Reply", callback_data: `talk_${agentId}` }],
|
||||
],
|
||||
}
|
||||
);
|
||||
store.addLog(agentId, "system", `Waiting for user: ${toolInput.question}`);
|
||||
|
||||
// Wait for user response
|
||||
const userResponse = await new Promise<string>((resolve) => {
|
||||
waitingForUser.set(agentId, resolve);
|
||||
});
|
||||
|
||||
store.updateAgent(agentId, { status: "working" });
|
||||
store.addLog(agentId, "user", `User replied: ${userResponse}`);
|
||||
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: `User responded: ${userResponse}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolName === "done") {
|
||||
const summary = (toolInput.summary as string) || "Task completed";
|
||||
store.updateAgent(agentId, { status: "done", summary });
|
||||
store.addLog(agentId, "system", `Done: ${summary}`);
|
||||
await tg.send(
|
||||
`✅ *Agent #${agentId}* completed!\n\n*Summary:* ${summary}\n*Task:* ${agent.task}`,
|
||||
agent.chat_id,
|
||||
{ reply_to: agent.thread_msg_id || undefined }
|
||||
);
|
||||
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: summary,
|
||||
});
|
||||
// Push tool results and stop
|
||||
messages.push({ role: "user", content: toolResults });
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
const { result } = executeTool(toolName, toolInput);
|
||||
store.addLog(
|
||||
agentId,
|
||||
"tool_result",
|
||||
`${toolName} → ${result.slice(0, 500)}`
|
||||
);
|
||||
|
||||
toolResults.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: result,
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({ role: "user", content: toolResults });
|
||||
}
|
||||
|
||||
// Hit max turns
|
||||
store.updateAgent(agentId, {
|
||||
status: "done",
|
||||
summary: `Stopped after ${MAX_TURNS} turns`,
|
||||
});
|
||||
await tg.send(
|
||||
`⚠️ *Agent #${agentId}* hit max turns (${MAX_TURNS}). Task: ${agent.task}`,
|
||||
agent.chat_id,
|
||||
{ reply_to: agent.thread_msg_id || undefined }
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error(`[agent ${agentId}] error:`, e);
|
||||
store.updateAgent(agentId, { status: "error", error: e.message });
|
||||
store.addLog(agentId, "error", e.message);
|
||||
await tg.send(
|
||||
`❌ *Agent #${agentId}* error:\n${e.message?.slice(0, 500)}`,
|
||||
agent.chat_id,
|
||||
{ reply_to: agent.thread_msg_id || undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
341
lib/bot.ts
Normal file
341
lib/bot.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import * as tg from "./telegram.js";
|
||||
import * as store from "./store.js";
|
||||
import { runAgent, isWaitingForUser, resolveUserResponse } from "./agent-worker.js";
|
||||
|
||||
// Track which agents are running (in-process)
|
||||
const runningAgents = new Set<number>();
|
||||
|
||||
// Track "talk mode" — chatId -> agentId they're talking to
|
||||
const talkMode = new Map<number, number>();
|
||||
|
||||
const STATUS_EMOJI: Record<string, string> = {
|
||||
spawning: "🔄",
|
||||
working: "⚡",
|
||||
waiting: "❓",
|
||||
done: "✅",
|
||||
error: "❌",
|
||||
killed: "🛑",
|
||||
};
|
||||
|
||||
async function handleMessage(msg: NonNullable<tg.TelegramUpdate["message"]>) {
|
||||
const chatId = msg.chat.id;
|
||||
const text = (msg.text || "").trim();
|
||||
|
||||
if (!tg.isAllowed(chatId)) return;
|
||||
|
||||
// Check if user is in talk mode with an agent
|
||||
if (talkMode.has(chatId) && !text.startsWith("/")) {
|
||||
const agentId = talkMode.get(chatId)!;
|
||||
talkMode.delete(chatId);
|
||||
|
||||
if (isWaitingForUser(agentId)) {
|
||||
resolveUserResponse(agentId, text);
|
||||
await tg.send(`💬 Sent to Agent #${agentId}`, chatId);
|
||||
} else {
|
||||
// Agent is working but user wants to interject — add as follow-up
|
||||
// For now just queue it
|
||||
store.addLog(agentId, "user", text);
|
||||
await tg.send(
|
||||
`📝 Noted for Agent #${agentId}. It's currently working — your message will be seen when it next checks.`,
|
||||
chatId
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a reply to an agent thread
|
||||
if (msg.reply_to_message && !text.startsWith("/")) {
|
||||
const replyToId = msg.reply_to_message.message_id;
|
||||
const agent = store.findAgentByThreadMsg(replyToId);
|
||||
if (agent && isWaitingForUser(agent.id)) {
|
||||
resolveUserResponse(agent.id, text);
|
||||
await tg.send(`💬 Sent to Agent #${agent.id}`, chatId);
|
||||
return;
|
||||
}
|
||||
// Also check all agents for this chat — find closest thread
|
||||
const agents = store.listAgents(String(chatId));
|
||||
for (const a of agents) {
|
||||
if (a.thread_msg_id === replyToId && isWaitingForUser(a.id)) {
|
||||
resolveUserResponse(a.id, text);
|
||||
await tg.send(`💬 Sent to Agent #${a.id}`, chatId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commands
|
||||
if (text.startsWith("/new ") || text.startsWith("/new@")) {
|
||||
const task = text.replace(/^\/new(@\w+)?\s*/, "").trim();
|
||||
if (!task) {
|
||||
await tg.send("Usage: `/new <task description>`", chatId);
|
||||
return;
|
||||
}
|
||||
await spawnAgent(chatId, task);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === "/board" || text.startsWith("/board@")) {
|
||||
await showBoard(chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith("/kill ") || text.startsWith("/kill@")) {
|
||||
const idStr = text.replace(/^\/kill(@\w+)?\s*/, "").trim();
|
||||
const id = parseInt(idStr);
|
||||
if (isNaN(id)) {
|
||||
await tg.send("Usage: `/kill <agent_id>`", chatId);
|
||||
return;
|
||||
}
|
||||
await killAgent(chatId, id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith("/logs ") || text.startsWith("/logs@")) {
|
||||
const idStr = text.replace(/^\/logs(@\w+)?\s*/, "").trim();
|
||||
const id = parseInt(idStr);
|
||||
if (isNaN(id)) {
|
||||
await tg.send("Usage: `/logs <agent_id>`", chatId);
|
||||
return;
|
||||
}
|
||||
await showLogs(chatId, id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.startsWith("/talk ") || text.startsWith("/talk@")) {
|
||||
const idStr = text.replace(/^\/talk(@\w+)?\s*/, "").trim();
|
||||
const id = parseInt(idStr);
|
||||
if (isNaN(id)) {
|
||||
await tg.send("Usage: `/talk <agent_id>`", chatId);
|
||||
return;
|
||||
}
|
||||
talkMode.set(chatId, id);
|
||||
await tg.send(
|
||||
`💬 You're now talking to *Agent #${id}*. Send your message (or /cancel):`,
|
||||
chatId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
||||
if (talkMode.has(chatId)) {
|
||||
talkMode.delete(chatId);
|
||||
await tg.send("Cancelled talk mode.", chatId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === "/clear" || text.startsWith("/clear@")) {
|
||||
await clearChat(chatId, msg.message_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text === "/help" || text === "/start" || text.startsWith("/help@") || text.startsWith("/start@")) {
|
||||
await tg.send(
|
||||
`🤖 *Agent Orchestrator*
|
||||
|
||||
Commands:
|
||||
/new <task> — Spawn a new agent
|
||||
/board — View all agents
|
||||
/logs <id> — Agent activity log
|
||||
/talk <id> — Send message to agent
|
||||
/kill <id> — Stop an agent
|
||||
/clear — Clear chat messages
|
||||
/help — This message
|
||||
|
||||
Or *reply* to any agent message to talk to it directly.`,
|
||||
chatId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown
|
||||
if (text.startsWith("/")) {
|
||||
await tg.send("Unknown command. Try /help", chatId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCallback(cb: NonNullable<tg.TelegramUpdate["callback_query"]>) {
|
||||
const chatId = cb.message?.chat?.id;
|
||||
if (!chatId || !tg.isAllowed(chatId)) return;
|
||||
|
||||
const data = cb.data || "";
|
||||
await tg.answerCallback(cb.id);
|
||||
|
||||
if (data.startsWith("logs_")) {
|
||||
const id = parseInt(data.replace("logs_", ""));
|
||||
await showLogs(chatId, id);
|
||||
} else if (data.startsWith("talk_")) {
|
||||
const id = parseInt(data.replace("talk_", ""));
|
||||
talkMode.set(chatId, id);
|
||||
await tg.send(
|
||||
`💬 Talking to *Agent #${id}*. Send your message:`,
|
||||
chatId
|
||||
);
|
||||
} else if (data.startsWith("kill_")) {
|
||||
const id = parseInt(data.replace("kill_", ""));
|
||||
await killAgent(chatId, id);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAgent(chatId: number, task: string) {
|
||||
const agent = store.createAgent(task, String(chatId));
|
||||
|
||||
// Send initial message and save its ID for threading
|
||||
const res = await tg.send(
|
||||
`🤖 *Agent #${agent.id}* spawned\n*Task:* ${task}\n*Status:* spawning...`,
|
||||
chatId,
|
||||
{ keyboard: tg.agentKeyboard(agent.id) }
|
||||
);
|
||||
|
||||
if (res?.result?.message_id) {
|
||||
store.updateAgent(agent.id, { thread_msg_id: res.result.message_id });
|
||||
}
|
||||
|
||||
// Run agent in background (non-blocking)
|
||||
runningAgents.add(agent.id);
|
||||
runAgent(agent.id)
|
||||
.catch((e) => console.error(`[agent ${agent.id}] fatal:`, e))
|
||||
.finally(() => runningAgents.delete(agent.id));
|
||||
}
|
||||
|
||||
async function showBoard(chatId: number) {
|
||||
const agents = store.listAgents(String(chatId));
|
||||
|
||||
if (agents.length === 0) {
|
||||
await tg.send("No agents yet. Use `/new <task>` to spawn one.", chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
let board = "📋 *Agent Board*\n\n";
|
||||
board += "```\n";
|
||||
board += " # | Status | Task\n";
|
||||
board += "----|--------|---------------------------\n";
|
||||
|
||||
for (const a of agents.slice(0, 20)) {
|
||||
const emoji = STATUS_EMOJI[a.status] || "❔";
|
||||
const taskShort = a.task.length > 30 ? a.task.slice(0, 27) + "..." : a.task;
|
||||
board += ` ${String(a.id).padStart(2)} | ${emoji} ${a.status.padEnd(4).slice(0, 4)} | ${taskShort}\n`;
|
||||
}
|
||||
board += "```\n";
|
||||
|
||||
// Show summaries for done agents
|
||||
const done = agents.filter((a) => a.summary);
|
||||
if (done.length > 0) {
|
||||
board += "\n*Completed:*\n";
|
||||
for (const a of done.slice(0, 5)) {
|
||||
board += `• #${a.id}: ${a.summary}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
await tg.send(board, chatId);
|
||||
}
|
||||
|
||||
async function killAgent(chatId: number, agentId: number) {
|
||||
const agent = store.getAgent(agentId);
|
||||
if (!agent) {
|
||||
await tg.send(`Agent #${agentId} not found.`, chatId);
|
||||
return;
|
||||
}
|
||||
if (agent.chat_id !== String(chatId)) {
|
||||
await tg.send(`Agent #${agentId} doesn't belong to you.`, chatId);
|
||||
return;
|
||||
}
|
||||
if (agent.status === "done" || agent.status === "killed") {
|
||||
await tg.send(`Agent #${agentId} is already ${agent.status}.`, chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
store.updateAgent(agentId, { status: "killed", summary: "Killed by user" });
|
||||
store.addLog(agentId, "system", "Killed by user");
|
||||
|
||||
// If waiting for user, resolve with cancellation
|
||||
if (isWaitingForUser(agentId)) {
|
||||
resolveUserResponse(agentId, "[USER CANCELLED THIS AGENT]");
|
||||
}
|
||||
|
||||
await tg.send(`🛑 Agent #${agentId} killed.`, chatId);
|
||||
}
|
||||
|
||||
async function showLogs(chatId: number, agentId: number) {
|
||||
const agent = store.getAgent(agentId);
|
||||
if (!agent) {
|
||||
await tg.send(`Agent #${agentId} not found.`, chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = store.getLogs(agentId, 15);
|
||||
if (logs.length === 0) {
|
||||
await tg.send(`No logs for Agent #${agentId}.`, chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
let text = `📋 *Logs — Agent #${agentId}*\n_${agent.task}_\n\n`;
|
||||
for (const log of logs.reverse()) {
|
||||
const role = log.role.toUpperCase().padEnd(6).slice(0, 6);
|
||||
const content = log.content.length > 150 ? log.content.slice(0, 147) + "..." : log.content;
|
||||
text += `\`${role}\` ${content}\n\n`;
|
||||
}
|
||||
|
||||
await tg.send(text, chatId, { keyboard: tg.agentKeyboard(agentId) });
|
||||
}
|
||||
|
||||
async function clearChat(chatId: number, commandMsgId: number) {
|
||||
// Delete the /clear command message itself first
|
||||
await tg.deleteMessage(chatId, commandMsgId);
|
||||
|
||||
// Telegram only allows deleting messages less than 48h old.
|
||||
// We walk backwards from the command message ID, trying to delete recent messages.
|
||||
const statusMsg = await tg.send("🧹 Clearing chat...", chatId);
|
||||
const statusMsgId: number | undefined = statusMsg?.result?.message_id;
|
||||
|
||||
let deleted = 0;
|
||||
let misses = 0;
|
||||
const MAX_MISSES = 10; // stop after 10 consecutive failures (hit old messages or gap)
|
||||
|
||||
// Walk backwards from the /clear message
|
||||
for (let id = commandMsgId - 1; id > 0 && misses < MAX_MISSES; id--) {
|
||||
const ok = await tg.deleteMessage(chatId, id);
|
||||
if (ok) {
|
||||
deleted++;
|
||||
misses = 0;
|
||||
} else {
|
||||
misses++;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the status message too, then send a clean confirmation
|
||||
if (statusMsgId) await tg.deleteMessage(chatId, statusMsgId);
|
||||
await tg.send(`🧹 Cleared ${deleted} messages.`, chatId);
|
||||
}
|
||||
|
||||
// --- Main loop ---
|
||||
|
||||
async function main() {
|
||||
console.log("🤖 Telegram Agent Orchestrator starting...");
|
||||
console.log(` Polling for updates...`);
|
||||
|
||||
await tg.send("🤖 Agent Orchestrator is online! Send /help to get started.");
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const updates = await tg.poll();
|
||||
for (const update of updates) {
|
||||
if (update.message) {
|
||||
handleMessage(update.message).catch((e) =>
|
||||
console.error("[handle msg]", e)
|
||||
);
|
||||
}
|
||||
if (update.callback_query) {
|
||||
handleCallback(update.callback_query).catch((e) =>
|
||||
console.error("[handle cb]", e)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[main loop]", e);
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
179
lib/store.ts
Normal file
179
lib/store.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync } from "fs";
|
||||
|
||||
mkdirSync("data", { recursive: true });
|
||||
const db = new Database("data/agents.db");
|
||||
|
||||
db.run("PRAGMA journal_mode = WAL");
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'spawning',
|
||||
chat_id TEXT NOT NULL,
|
||||
thread_msg_id INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
summary TEXT,
|
||||
error TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS agent_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tool_use TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||
)
|
||||
`);
|
||||
|
||||
export interface Agent {
|
||||
id: number;
|
||||
task: string;
|
||||
status: string;
|
||||
chat_id: string;
|
||||
thread_msg_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
summary: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AgentLog {
|
||||
id: number;
|
||||
agent_id: number;
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- Agent CRUD ---
|
||||
|
||||
export function createAgent(task: string, chatId: string): Agent {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO agents (task, status, chat_id) VALUES (?, 'spawning', ?)`
|
||||
);
|
||||
stmt.run(task, chatId);
|
||||
const id = Number(db.query("SELECT last_insert_rowid() as id").get()!.id);
|
||||
return getAgent(id)!;
|
||||
}
|
||||
|
||||
export function getAgent(id: number): Agent | undefined {
|
||||
return db.query(`SELECT * FROM agents WHERE id = ?`).get(id) as
|
||||
| Agent
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function updateAgent(
|
||||
id: number,
|
||||
updates: Partial<Pick<Agent, "status" | "thread_msg_id" | "summary" | "error">>
|
||||
): void {
|
||||
const fields: string[] = ["updated_at = datetime('now')"];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (updates.status !== undefined) {
|
||||
fields.push("status = ?");
|
||||
values.push(updates.status);
|
||||
}
|
||||
if (updates.thread_msg_id !== undefined) {
|
||||
fields.push("thread_msg_id = ?");
|
||||
values.push(updates.thread_msg_id);
|
||||
}
|
||||
if (updates.summary !== undefined) {
|
||||
fields.push("summary = ?");
|
||||
values.push(updates.summary);
|
||||
}
|
||||
if (updates.error !== undefined) {
|
||||
fields.push("error = ?");
|
||||
values.push(updates.error);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE agents SET ${fields.join(", ")} WHERE id = ?`).run(
|
||||
...values
|
||||
);
|
||||
}
|
||||
|
||||
export function listAgents(chatId?: string): Agent[] {
|
||||
if (chatId) {
|
||||
return db
|
||||
.query(`SELECT * FROM agents WHERE chat_id = ? ORDER BY id DESC`)
|
||||
.all(chatId) as Agent[];
|
||||
}
|
||||
return db.query(`SELECT * FROM agents ORDER BY id DESC`).all() as Agent[];
|
||||
}
|
||||
|
||||
export function getActiveAgents(): Agent[] {
|
||||
return db
|
||||
.query(
|
||||
`SELECT * FROM agents WHERE status IN ('spawning', 'working', 'waiting') ORDER BY id`
|
||||
)
|
||||
.all() as Agent[];
|
||||
}
|
||||
|
||||
export function findAgentByThreadMsg(messageId: number): Agent | undefined {
|
||||
return db
|
||||
.query(`SELECT * FROM agents WHERE thread_msg_id = ?`)
|
||||
.get(messageId) as Agent | undefined;
|
||||
}
|
||||
|
||||
// --- Logs ---
|
||||
|
||||
export function addLog(agentId: number, role: string, content: string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO agent_logs (agent_id, role, content) VALUES (?, ?, ?)`
|
||||
).run(agentId, role, content);
|
||||
}
|
||||
|
||||
export function getLogs(agentId: number, limit = 20): AgentLog[] {
|
||||
return db
|
||||
.query(
|
||||
`SELECT * FROM agent_logs WHERE agent_id = ? ORDER BY id DESC LIMIT ?`
|
||||
)
|
||||
.all(agentId, limit) as AgentLog[];
|
||||
}
|
||||
|
||||
// --- Messages (conversation history) ---
|
||||
|
||||
export function addMessage(
|
||||
agentId: number,
|
||||
role: string,
|
||||
content: string,
|
||||
toolUse?: string
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO agent_messages (agent_id, role, content, tool_use) VALUES (?, ?, ?, ?)`
|
||||
).run(agentId, role, content, toolUse || null);
|
||||
}
|
||||
|
||||
export function getMessages(
|
||||
agentId: number
|
||||
): Array<{ role: string; content: string; tool_use: string | null }> {
|
||||
return db
|
||||
.query(
|
||||
`SELECT role, content, tool_use FROM agent_messages WHERE agent_id = ? ORDER BY id ASC`
|
||||
)
|
||||
.all(agentId) as Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
tool_use: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default db;
|
||||
142
lib/telegram.ts
Normal file
142
lib/telegram.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
|
||||
const DEFAULT_CHAT_ID = process.env.TELEGRAM_CHAT_ID!;
|
||||
const ALLOWED_IDS = new Set(
|
||||
(process.env.TELEGRAM_ALLOWED_CHAT_IDS || DEFAULT_CHAT_ID)
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
);
|
||||
|
||||
const api = async (method: string, body?: Record<string, unknown>) => {
|
||||
const res = await fetch(
|
||||
`https://api.telegram.org/bot${BOT_TOKEN}/${method}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}
|
||||
);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export function isAllowed(chatId: number | string): boolean {
|
||||
return ALLOWED_IDS.has(String(chatId));
|
||||
}
|
||||
|
||||
export async function send(
|
||||
text: string,
|
||||
chatId: string | number = DEFAULT_CHAT_ID,
|
||||
opts?: { reply_to?: number; keyboard?: InlineKeyboard }
|
||||
): Promise<any> {
|
||||
if (!isAllowed(chatId)) return null;
|
||||
|
||||
// Telegram limits messages to 4096 chars
|
||||
const truncated =
|
||||
text.length > 4000 ? text.slice(0, 4000) + "\n\n... (truncated)" : text;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
chat_id: chatId,
|
||||
text: truncated,
|
||||
parse_mode: "Markdown",
|
||||
};
|
||||
if (opts?.reply_to) body.reply_to_message_id = opts.reply_to;
|
||||
if (opts?.keyboard) {
|
||||
body.reply_markup = { inline_keyboard: opts.keyboard };
|
||||
}
|
||||
return api("sendMessage", body);
|
||||
}
|
||||
|
||||
export async function editMessage(
|
||||
chatId: string | number,
|
||||
messageId: number,
|
||||
text: string,
|
||||
keyboard?: InlineKeyboard
|
||||
): Promise<any> {
|
||||
const truncated =
|
||||
text.length > 4000 ? text.slice(0, 4000) + "\n\n... (truncated)" : text;
|
||||
const body: Record<string, unknown> = {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
text: truncated,
|
||||
parse_mode: "Markdown",
|
||||
};
|
||||
if (keyboard) body.reply_markup = { inline_keyboard: keyboard };
|
||||
return api("editMessageText", body);
|
||||
}
|
||||
|
||||
export async function answerCallback(
|
||||
callbackId: string,
|
||||
text?: string
|
||||
): Promise<any> {
|
||||
return api("answerCallbackQuery", {
|
||||
callback_query_id: callbackId,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
export type InlineKeyboard = Array<
|
||||
Array<{ text: string; callback_data: string }>
|
||||
>;
|
||||
|
||||
export function agentKeyboard(agentId: number): InlineKeyboard {
|
||||
return [
|
||||
[
|
||||
{ text: "📋 Logs", callback_data: `logs_${agentId}` },
|
||||
{ text: "💬 Talk", callback_data: `talk_${agentId}` },
|
||||
{ text: "🛑 Kill", callback_data: `kill_${agentId}` },
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
|
||||
export interface TelegramUpdate {
|
||||
update_id: number;
|
||||
message?: {
|
||||
message_id: number;
|
||||
from?: { id: number; first_name: string };
|
||||
chat: { id: number; type: string };
|
||||
text?: string;
|
||||
reply_to_message?: { message_id: number };
|
||||
};
|
||||
callback_query?: {
|
||||
id: string;
|
||||
from: { id: number };
|
||||
message?: { message_id: number; chat: { id: number } };
|
||||
data?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function poll(): Promise<TelegramUpdate[]> {
|
||||
try {
|
||||
const data = await api("getUpdates", {
|
||||
offset,
|
||||
timeout: 30,
|
||||
allowed_updates: ["message", "callback_query"],
|
||||
});
|
||||
const updates: TelegramUpdate[] = data.result || [];
|
||||
if (updates.length > 0) {
|
||||
offset = updates[updates.length - 1].update_id + 1;
|
||||
}
|
||||
return updates;
|
||||
} catch (e) {
|
||||
console.error("[telegram] poll error:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMessage(
|
||||
chatId: string | number,
|
||||
messageId: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await api("deleteMessage", {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
});
|
||||
return !!res?.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default { send, editMessage, deleteMessage, poll, isAllowed, agentKeyboard, answerCallback };
|
||||
194
lib/tools.ts
Normal file
194
lib/tools.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { execSync } from "child_process";
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const toolDefs: ToolDefinition[] = [
|
||||
{
|
||||
name: "bash",
|
||||
description:
|
||||
"Execute a bash command. Returns stdout and stderr. Use for running commands, installing packages, git, etc. Timeout: 120s.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description: "The bash command to execute",
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_file",
|
||||
description:
|
||||
"Read the contents of a file. Returns the text content. Use for examining code, configs, etc.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file" },
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Max lines to read (default: all)",
|
||||
},
|
||||
offset: {
|
||||
type: "number",
|
||||
description: "Line to start from, 1-indexed (default: 1)",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "write_file",
|
||||
description:
|
||||
"Write content to a file. Creates parent directories if needed. Overwrites existing files.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file" },
|
||||
content: {
|
||||
type: "string",
|
||||
description: "Content to write",
|
||||
},
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "edit_file",
|
||||
description:
|
||||
"Edit a file by replacing exact text. oldText must match exactly including whitespace.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file" },
|
||||
old_text: {
|
||||
type: "string",
|
||||
description: "Exact text to find",
|
||||
},
|
||||
new_text: {
|
||||
type: "string",
|
||||
description: "Replacement text",
|
||||
},
|
||||
},
|
||||
required: ["path", "old_text", "new_text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "done",
|
||||
description:
|
||||
"Call this when the task is fully complete. Provide a short summary of what was accomplished.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: {
|
||||
type: "string",
|
||||
description: "Short summary of what was done",
|
||||
},
|
||||
},
|
||||
required: ["summary"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ask_user",
|
||||
description:
|
||||
"Ask the user a question when you need clarification or a decision. The user will respond via Telegram.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
question: {
|
||||
type: "string",
|
||||
description: "The question to ask",
|
||||
},
|
||||
},
|
||||
required: ["question"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function executeTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>
|
||||
): { result: string; isDone?: boolean; isQuestion?: boolean } {
|
||||
try {
|
||||
switch (name) {
|
||||
case "bash": {
|
||||
const cmd = input.command as string;
|
||||
try {
|
||||
const output = execSync(cmd, {
|
||||
encoding: "utf-8",
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const trimmed = output.length > 10000
|
||||
? output.slice(0, 10000) + "\n...(truncated)"
|
||||
: output;
|
||||
return { result: trimmed || "(no output)" };
|
||||
} catch (e: any) {
|
||||
const stderr = e.stderr || "";
|
||||
const stdout = e.stdout || "";
|
||||
return {
|
||||
result: `Exit code: ${e.status}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`.slice(
|
||||
0,
|
||||
5000
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case "read_file": {
|
||||
const path = input.path as string;
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const offset = ((input.offset as number) || 1) - 1;
|
||||
const limit = (input.limit as number) || lines.length;
|
||||
const slice = lines.slice(offset, offset + limit).join("\n");
|
||||
return {
|
||||
result:
|
||||
slice.length > 10000
|
||||
? slice.slice(0, 10000) + "\n...(truncated)"
|
||||
: slice,
|
||||
};
|
||||
}
|
||||
|
||||
case "write_file": {
|
||||
const path = input.path as string;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, input.content as string, "utf-8");
|
||||
return { result: `Written ${(input.content as string).length} bytes to ${path}` };
|
||||
}
|
||||
|
||||
case "edit_file": {
|
||||
const path = input.path as string;
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const oldText = input.old_text as string;
|
||||
const newText = input.new_text as string;
|
||||
if (!content.includes(oldText)) {
|
||||
return { result: `ERROR: old_text not found in ${path}` };
|
||||
}
|
||||
writeFileSync(path, content.replace(oldText, newText), "utf-8");
|
||||
return { result: `Edited ${path}` };
|
||||
}
|
||||
|
||||
case "done": {
|
||||
return { result: input.summary as string, isDone: true };
|
||||
}
|
||||
|
||||
case "ask_user": {
|
||||
return { result: input.question as string, isQuestion: true };
|
||||
}
|
||||
|
||||
default:
|
||||
return { result: `Unknown tool: ${name}` };
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { result: `Tool error: ${e.message}` };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user