From 7c9f04b0eb42a89d07f23f0814969d8e04adf875 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Sun, 22 Feb 2026 18:07:32 +0100 Subject: [PATCH 01/41] feat(webui): add Run/Stop button to control agent from dashboard Separate agent lifecycle from WebUI lifecycle so the agent can be started/stopped at runtime without killing the WebUI server. - Add AgentLifecycle state machine (stopped/starting/running/stopping) - Refactor TeletonApp: extract startAgent()/stopAgent(), wire lifecycle - Replace process.exit(1) with throw in agent start path - Add REST endpoints: POST /api/agent/start, stop, GET /api/agent/status - Add SSE endpoint: GET /api/agent/events (real-time state push) - Add useAgentStatus hook (SSE + polling fallback + reconnection) - Add AgentControl sidebar component (badge, play/stop, confirm dialog) - 46 new tests (20 unit + 16 route/SSE + 10 E2E), 944 total passing --- src/agent/__tests__/lifecycle-e2e.test.ts | 532 ++++++++++++++++++++++ src/agent/__tests__/lifecycle.test.ts | 321 +++++++++++++ src/agent/lifecycle.ts | 151 ++++++ src/index.ts | 176 ++++--- src/webui/__tests__/agent-routes.test.ts | 196 ++++++++ src/webui/__tests__/agent-sse.test.ts | 226 +++++++++ src/webui/server.ts | 109 +++++ src/webui/types.ts | 2 + web/src/components/AgentControl.tsx | 224 +++++++++ web/src/components/Layout.tsx | 18 +- web/src/hooks/useAgentStatus.ts | 147 ++++++ 11 files changed, 2028 insertions(+), 74 deletions(-) create mode 100644 src/agent/__tests__/lifecycle-e2e.test.ts create mode 100644 src/agent/__tests__/lifecycle.test.ts create mode 100644 src/agent/lifecycle.ts create mode 100644 src/webui/__tests__/agent-routes.test.ts create mode 100644 src/webui/__tests__/agent-sse.test.ts create mode 100644 web/src/components/AgentControl.tsx create mode 100644 web/src/hooks/useAgentStatus.ts diff --git a/src/agent/__tests__/lifecycle-e2e.test.ts b/src/agent/__tests__/lifecycle-e2e.test.ts new file mode 100644 index 0000000..b9e047a --- /dev/null +++ b/src/agent/__tests__/lifecycle-e2e.test.ts @@ -0,0 +1,532 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import { streamSSE } from "hono/streaming"; + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +import { AgentLifecycle, type AgentState, type StateChangeEvent } from "../lifecycle.js"; + +// ── Helpers ────────────────────────────────────────────────────────────── + +/** Parse SSE text into structured events */ +function parseSSE(text: string): Array<{ event?: string; data?: string; id?: string }> { + const events: Array<{ event?: string; data?: string; id?: string }> = []; + const blocks = text.split("\n\n").filter(Boolean); + for (const block of blocks) { + const entry: { event?: string; data?: string; id?: string } = {}; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) entry.event = line.slice(6).trim(); + else if (line.startsWith("data:")) entry.data = line.slice(5).trim(); + else if (line.startsWith("id:")) entry.id = line.slice(3).trim(); + } + if (entry.event || entry.data) events.push(entry); + } + return events; +} + +/** Wait for lifecycle to reach a specific state */ +function waitForState( + lifecycle: AgentLifecycle, + target: AgentState, + timeoutMs = 2000 +): Promise { + return new Promise((resolve, reject) => { + if (lifecycle.getState() === target) { + resolve(); + return; + } + const timer = setTimeout(() => { + lifecycle.off("stateChange", handler); + reject( + new Error(`Timeout waiting for state "${target}", current: "${lifecycle.getState()}"`) + ); + }, timeoutMs); + const handler = (event: StateChangeEvent) => { + if (event.state === target) { + clearTimeout(timer); + lifecycle.off("stateChange", handler); + resolve(); + } + }; + lifecycle.on("stateChange", handler); + }); +} + +/** + * Build a full Hono app mirroring server.ts agent routes + SSE + a mock /health endpoint. + * This is the "WebUI" portion for E2E testing. + */ +function createE2EApp(lifecycle: AgentLifecycle) { + const app = new Hono(); + + // Health check (always works, even when agent is stopped) + app.get("/health", (c) => c.json({ status: "ok" })); + + // Mock data endpoints (simulate WebUI pages that work when agent is stopped) + app.get("/api/status", (c) => + c.json({ success: true, data: { uptime: 42, model: "test", provider: "test" } }) + ); + app.get("/api/tools", (c) => + c.json({ success: true, data: [{ name: "test_tool", module: "core" }] }) + ); + app.get("/api/memory", (c) => c.json({ success: true, data: { messages: 10, knowledge: 5 } })); + app.get("/api/config", (c) => + c.json({ success: true, data: { agent: { model: "test-model" } } }) + ); + + // Agent lifecycle REST routes + app.post("/api/agent/start", async (c) => { + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + const state = lifecycle.getState(); + if (state === "running") { + return c.json({ state: "running" }, 409); + } + if (state === "stopping") { + return c.json({ error: "Agent is currently stopping, please wait" }, 409); + } + lifecycle.start().catch(() => {}); + return c.json({ state: "starting" }); + }); + + app.post("/api/agent/stop", async (c) => { + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + const state = lifecycle.getState(); + if (state === "stopped") { + return c.json({ state: "stopped" }, 409); + } + if (state === "starting") { + return c.json({ error: "Agent is currently starting, please wait" }, 409); + } + lifecycle.stop().catch(() => {}); + return c.json({ state: "stopping" }); + }); + + app.get("/api/agent/status", (c) => { + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + return c.json({ + state: lifecycle.getState(), + uptime: lifecycle.getUptime(), + error: lifecycle.getError() ?? null, + }); + }); + + // SSE endpoint + app.get("/api/agent/events", (c) => { + return streamSSE(c, async (stream) => { + let aborted = false; + stream.onAbort(() => { + aborted = true; + }); + + const now = Date.now(); + await stream.writeSSE({ + event: "status", + id: String(now), + data: JSON.stringify({ + state: lifecycle.getState(), + error: lifecycle.getError() ?? null, + timestamp: now, + }), + retry: 3000, + }); + + const onStateChange = (event: StateChangeEvent) => { + if (aborted) return; + stream.writeSSE({ + event: "status", + id: String(event.timestamp), + data: JSON.stringify({ + state: event.state, + error: event.error ?? null, + timestamp: event.timestamp, + }), + }); + }; + + lifecycle.on("stateChange", onStateChange); + + // Short sleep for E2E tests (don't loop forever) + await stream.sleep(100); + + lifecycle.off("stateChange", onStateChange); + }); + }); + + return app; +} + +// ── E2E Tests ──────────────────────────────────────────────────────────── + +describe("Agent Lifecycle E2E", () => { + let lifecycle: AgentLifecycle; + let app: Hono; + let startCallCount: number; + let stopCallCount: number; + let startFn: () => Promise; + let stopFn: () => Promise; + + beforeEach(() => { + startCallCount = 0; + stopCallCount = 0; + + startFn = async () => { + startCallCount++; + }; + stopFn = async () => { + stopCallCount++; + }; + + lifecycle = new AgentLifecycle(); + lifecycle.registerCallbacks(startFn, stopFn); + app = createE2EApp(lifecycle); + }); + + afterEach(async () => { + // Ensure lifecycle is stopped to clean up listeners + if (lifecycle.getState() === "running") { + await lifecycle.stop(); + } + }); + + // ── Scenario 1: Full lifecycle start → stop → restart ── + + it("full lifecycle: start → stop → restart (WebUI survives)", async () => { + // 1. Initial state: stopped + let res = await app.request("/api/agent/status"); + let data = await res.json(); + expect(data.state).toBe("stopped"); + + // 2. Start agent via API + res = await app.request("/api/agent/start", { method: "POST" }); + data = await res.json(); + expect(res.status).toBe(200); + expect(data.state).toBe("starting"); + + // Wait for start to complete + await waitForState(lifecycle, "running"); + expect(lifecycle.getState()).toBe("running"); + expect(startCallCount).toBe(1); + + // 3. Verify status shows running with uptime + res = await app.request("/api/agent/status"); + data = await res.json(); + expect(data.state).toBe("running"); + expect(typeof data.uptime).toBe("number"); + + // 4. Stop agent via API + res = await app.request("/api/agent/stop", { method: "POST" }); + data = await res.json(); + expect(res.status).toBe(200); + expect(data.state).toBe("stopping"); + + await waitForState(lifecycle, "stopped"); + expect(lifecycle.getState()).toBe("stopped"); + expect(stopCallCount).toBe(1); + + // 5. WebUI still responds (health check) + res = await app.request("/health"); + expect(res.status).toBe(200); + data = await res.json(); + expect(data.status).toBe("ok"); + + // 6. Restart agent + res = await app.request("/api/agent/start", { method: "POST" }); + expect(res.status).toBe(200); + + await waitForState(lifecycle, "running"); + expect(lifecycle.getState()).toBe("running"); + expect(startCallCount).toBe(2); + + // 7. Stop again for cleanup + await lifecycle.stop(); + expect(stopCallCount).toBe(2); + }); + + // ── Scenario 2: Stop during active processing (graceful drain) ── + + it("stop waits for start to complete before stopping", async () => { + // Simulate a slow start (like connecting to Telegram) + let resolveStart!: () => void; + lifecycle.registerCallbacks( + () => + new Promise((resolve) => { + resolveStart = resolve; + }), + stopFn + ); + + // Start agent (will be pending) + const startRes = await app.request("/api/agent/start", { method: "POST" }); + expect(startRes.status).toBe(200); + expect(lifecycle.getState()).toBe("starting"); + + // Try to stop while starting — should get 409 + const stopRes = await app.request("/api/agent/stop", { method: "POST" }); + expect(stopRes.status).toBe(409); + + // Complete the start + resolveStart(); + await waitForState(lifecycle, "running"); + + // Now stop works + const stopRes2 = await app.request("/api/agent/stop", { method: "POST" }); + expect(stopRes2.status).toBe(200); + await waitForState(lifecycle, "stopped"); + }); + + // ── Scenario 3: Start failure ── + + it("start failure sets error and allows retry", async () => { + let callCount = 0; + lifecycle.registerCallbacks(async () => { + callCount++; + if (callCount <= 2) { + throw new Error(`Telegram auth expired (attempt ${callCount})`); + } + // Third attempt succeeds + }, stopFn); + + // First attempt: fails + const res1 = await app.request("/api/agent/start", { method: "POST" }); + expect(res1.status).toBe(200); + + await waitForState(lifecycle, "stopped"); + expect(lifecycle.getError()).toContain("Telegram auth expired (attempt 1)"); + + // Status shows error + const statusRes = await app.request("/api/agent/status"); + const status = await statusRes.json(); + expect(status.state).toBe("stopped"); + expect(status.error).toContain("attempt 1"); + + // Second attempt: fails + const res2 = await app.request("/api/agent/start", { method: "POST" }); + expect(res2.status).toBe(200); + await waitForState(lifecycle, "stopped"); + expect(lifecycle.getError()).toContain("attempt 2"); + + // Third attempt: succeeds + const res3 = await app.request("/api/agent/start", { method: "POST" }); + expect(res3.status).toBe(200); + await waitForState(lifecycle, "running"); + expect(lifecycle.getError()).toBeUndefined(); + expect(lifecycle.getState()).toBe("running"); + + // Cleanup + await lifecycle.stop(); + }); + + // ── Scenario 4: SSE delivers correct state on reconnection ── + + it("SSE reconnection delivers correct state", async () => { + // Start agent + await lifecycle.start(); + expect(lifecycle.getState()).toBe("running"); + + // Connect SSE — should get "running" as initial state + let res = await app.request("/api/agent/events"); + let text = await res.text(); + let events = parseSSE(text); + expect(events.length).toBeGreaterThanOrEqual(1); + let firstData = JSON.parse(events[0].data!); + expect(firstData.state).toBe("running"); + + // Stop agent + await lifecycle.stop(); + expect(lifecycle.getState()).toBe("stopped"); + + // "Reconnect" SSE — should get "stopped" as initial state + res = await app.request("/api/agent/events"); + text = await res.text(); + events = parseSSE(text); + expect(events.length).toBeGreaterThanOrEqual(1); + firstData = JSON.parse(events[0].data!); + expect(firstData.state).toBe("stopped"); + }); + + // ── Scenario 5: Concurrent start/stop calls are safe ── + + it("concurrent start calls return same promise (no race)", async () => { + // Fire two starts simultaneously + const [res1, res2] = await Promise.all([ + app.request("/api/agent/start", { method: "POST" }), + app.request("/api/agent/start", { method: "POST" }), + ]); + + const data1 = await res1.json(); + const data2 = await res2.json(); + + // First gets 200 starting, second should get 200 starting or 409 running + // (depends on timing — both are valid) + expect([200, 409]).toContain(res1.status); + expect([200, 409]).toContain(res2.status); + + await waitForState(lifecycle, "running"); + + // Agent started exactly once + expect(startCallCount).toBe(1); + + // Cleanup + await lifecycle.stop(); + }); + + it("concurrent stop calls after running are safe", async () => { + await lifecycle.start(); + + const [res1, res2] = await Promise.all([ + app.request("/api/agent/stop", { method: "POST" }), + app.request("/api/agent/stop", { method: "POST" }), + ]); + + // One should get 200, the other might get 200 or 409 (already stopping) + expect([200, 409]).toContain(res1.status); + expect([200, 409]).toContain(res2.status); + + await waitForState(lifecycle, "stopped"); + + // Agent stopped exactly once + expect(stopCallCount).toBe(1); + }); + + // ── Scenario 6: Config reload on restart ── + + it("startFn is called on each start (config reload opportunity)", async () => { + const models: string[] = []; + let currentModel = "gpt-4"; + + lifecycle.registerCallbacks(async () => { + // Simulate reading config from disk on each start + models.push(currentModel); + }, stopFn); + + // First start: uses gpt-4 + await lifecycle.start(); + expect(models).toEqual(["gpt-4"]); + + await lifecycle.stop(); + + // "Edit config" while stopped + currentModel = "claude-opus-4-6"; + + // Second start: picks up new config + await lifecycle.start(); + expect(models).toEqual(["gpt-4", "claude-opus-4-6"]); + + await lifecycle.stop(); + }); + + // ── Scenario 7: Graceful shutdown (lifecycle + WebUI) ── + + it("full stop tears down agent then WebUI stays up", async () => { + const teardownOrder: string[] = []; + + lifecycle.registerCallbacks(startFn, async () => { + teardownOrder.push("agent-stopped"); + }); + + await lifecycle.start(); + expect(lifecycle.getState()).toBe("running"); + + // Simulate graceful shutdown: stop lifecycle first + await lifecycle.stop(); + teardownOrder.push("webui-still-up"); + + // WebUI is still responding + const res = await app.request("/health"); + expect(res.status).toBe(200); + + expect(teardownOrder).toEqual(["agent-stopped", "webui-still-up"]); + expect(lifecycle.getState()).toBe("stopped"); + }); + + // ── Scenario 8: WebUI pages accessible while agent stopped ── + + it("all WebUI data endpoints respond while agent is stopped", async () => { + // Agent is stopped — verify all data endpoints still work + expect(lifecycle.getState()).toBe("stopped"); + + const endpoints = [ + "/health", + "/api/status", + "/api/tools", + "/api/memory", + "/api/config", + "/api/agent/status", + ]; + + for (const endpoint of endpoints) { + const res = await app.request(endpoint); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toBeDefined(); + } + + // Agent lifecycle routes also work + const statusRes = await app.request("/api/agent/status"); + const status = await statusRes.json(); + expect(status.state).toBe("stopped"); + expect(status.uptime).toBeNull(); + }); + + // ── Extra: SSE emits events during start→stop sequence ── + + it("SSE captures full start→stop state transition sequence", async () => { + // Build a custom SSE app that collects events during a start→stop cycle + const sseApp = new Hono(); + sseApp.get("/events", (c) => { + return streamSSE(c, async (stream) => { + let aborted = false; + stream.onAbort(() => { + aborted = true; + }); + + const collected: StateChangeEvent[] = []; + + // Push initial + await stream.writeSSE({ + event: "status", + data: JSON.stringify({ state: lifecycle.getState() }), + }); + + const onStateChange = (event: StateChangeEvent) => { + if (aborted) return; + collected.push(event); + stream.writeSSE({ + event: "status", + data: JSON.stringify({ state: event.state, error: event.error ?? null }), + }); + }; + + lifecycle.on("stateChange", onStateChange); + + // Trigger start → stop during stream + await lifecycle.start(); + await lifecycle.stop(); + + await stream.sleep(50); + lifecycle.off("stateChange", onStateChange); + }); + }); + + const res = await sseApp.request("/events"); + const text = await res.text(); + const events = parseSSE(text); + const states = events.map((e) => JSON.parse(e.data!).state); + + // Should capture: stopped (initial) → starting → running → stopping → stopped + expect(states).toEqual(["stopped", "starting", "running", "stopping", "stopped"]); + }); +}); diff --git a/src/agent/__tests__/lifecycle.test.ts b/src/agent/__tests__/lifecycle.test.ts new file mode 100644 index 0000000..fc24afc --- /dev/null +++ b/src/agent/__tests__/lifecycle.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +import { AgentLifecycle, type StateChangeEvent } from "../lifecycle.js"; + +describe("AgentLifecycle", () => { + let lifecycle: AgentLifecycle; + + beforeEach(() => { + lifecycle = new AgentLifecycle(); + }); + + // 1. Initial state is stopped + it("initial state is stopped", () => { + expect(lifecycle.getState()).toBe("stopped"); + }); + + // 2. start() transitions to starting then running + it("start() transitions to starting then running", async () => { + const events: StateChangeEvent[] = []; + lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e)); + + await lifecycle.start(async () => {}); + + expect(events).toHaveLength(2); + expect(events[0].state).toBe("starting"); + expect(events[1].state).toBe("running"); + expect(lifecycle.getState()).toBe("running"); + }); + + // 3. start() when already running is no-op + it("start() when already running is no-op", async () => { + await lifecycle.start(async () => {}); + expect(lifecycle.getState()).toBe("running"); + + const events: StateChangeEvent[] = []; + lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e)); + + await lifecycle.start(async () => {}); + expect(events).toHaveLength(0); + }); + + // 4. start() when already starting returns same promise + it("start() when already starting returns same promise", async () => { + let resolveStart!: () => void; + const startFn = () => + new Promise((resolve) => { + resolveStart = resolve; + }); + + const p1 = lifecycle.start(startFn); + const p2 = lifecycle.start(async () => {}); + + resolveStart(); + await p1; + await p2; + + expect(lifecycle.getState()).toBe("running"); + }); + + // 5. start() when stopping throws + it("start() when stopping throws", async () => { + let resolveStop!: () => void; + await lifecycle.start(async () => {}); + + const stopPromise = lifecycle.stop( + () => + new Promise((resolve) => { + resolveStop = resolve; + }) + ); + + await expect(lifecycle.start(async () => {})).rejects.toThrow( + "Cannot start while agent is stopping" + ); + + resolveStop(); + await stopPromise; + }); + + // 6. stop() transitions to stopping then stopped + it("stop() transitions to stopping then stopped", async () => { + await lifecycle.start(async () => {}); + + const events: StateChangeEvent[] = []; + lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e)); + + await lifecycle.stop(async () => {}); + + expect(events).toHaveLength(2); + expect(events[0].state).toBe("stopping"); + expect(events[1].state).toBe("stopped"); + expect(lifecycle.getState()).toBe("stopped"); + }); + + // 7. stop() when already stopped is no-op + it("stop() when already stopped is no-op", async () => { + const events: StateChangeEvent[] = []; + lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e)); + + await lifecycle.stop(async () => {}); + expect(events).toHaveLength(0); + }); + + // 8. stop() when already stopping returns same promise + it("stop() when already stopping returns same promise", async () => { + await lifecycle.start(async () => {}); + + let resolveStop!: () => void; + const stopFn = () => + new Promise((resolve) => { + resolveStop = resolve; + }); + + const p1 = lifecycle.stop(stopFn); + const p2 = lifecycle.stop(async () => {}); + + resolveStop(); + await p1; + await p2; + + expect(lifecycle.getState()).toBe("stopped"); + }); + + // 9. stop() when starting waits for start then stops + it("stop() when starting waits for start then stops", async () => { + let resolveStart!: () => void; + const startFn = () => + new Promise((resolve) => { + resolveStart = resolve; + }); + + const startPromise = lifecycle.start(startFn); + + const events: StateChangeEvent[] = []; + lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e)); + + const stopPromise = lifecycle.stop(async () => {}); + + // Start hasn't resolved yet, lifecycle should still be starting + expect(lifecycle.getState()).toBe("starting"); + + resolveStart(); + await startPromise; + await stopPromise; + + expect(lifecycle.getState()).toBe("stopped"); + // Events should show: running, stopping, stopped (starting was already emitted before listener) + expect(events.map((e) => e.state)).toEqual(["running", "stopping", "stopped"]); + }); + + // 10. Failed start() reverts to stopped with error + it("failed start() reverts to stopped with error", async () => { + const events: StateChangeEvent[] = []; + lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e)); + + await expect( + lifecycle.start(async () => { + throw new Error("Telegram auth expired"); + }) + ).rejects.toThrow("Telegram auth expired"); + + expect(lifecycle.getState()).toBe("stopped"); + expect(lifecycle.getError()).toBe("Telegram auth expired"); + expect(events).toHaveLength(2); + expect(events[0].state).toBe("starting"); + expect(events[1].state).toBe("stopped"); + expect(events[1].error).toBe("Telegram auth expired"); + }); + + // 11. start() after failed start works and clears error + it("start() after failed start works and clears error", async () => { + await lifecycle + .start(async () => { + throw new Error("fail"); + }) + .catch(() => {}); + + expect(lifecycle.getError()).toBe("fail"); + + await lifecycle.start(async () => {}); + + expect(lifecycle.getState()).toBe("running"); + expect(lifecycle.getError()).toBeUndefined(); + }); + + // 12. stateChange events include correct payload + it("stateChange events include correct payload", async () => { + const events: StateChangeEvent[] = []; + lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e)); + + await lifecycle.start(async () => {}); + + for (const event of events) { + expect(event).toHaveProperty("state"); + expect(event).toHaveProperty("timestamp"); + expect(typeof event.timestamp).toBe("number"); + expect(event.timestamp).toBeGreaterThan(0); + } + }); + + // 13. Subsystems are started in correct order (mock tracks call order) + it("subsystems are started in correct order", async () => { + const order: string[] = []; + const startFn = async () => { + order.push("plugins"); + order.push("mcp"); + order.push("telegram"); + order.push("modules"); + order.push("debouncer"); + }; + + await lifecycle.start(startFn); + expect(order).toEqual(["plugins", "mcp", "telegram", "modules", "debouncer"]); + }); + + // 14. Subsystems are stopped in reverse order + it("subsystems are stopped in reverse order", async () => { + await lifecycle.start(async () => {}); + + const order: string[] = []; + const stopFn = async () => { + order.push("watcher"); + order.push("mcp"); + order.push("debouncer"); + order.push("handler"); + order.push("modules"); + order.push("bridge"); + }; + + await lifecycle.stop(stopFn); + expect(order).toEqual(["watcher", "mcp", "debouncer", "handler", "modules", "bridge"]); + }); + + // 15. Individual subsystem failure during stop doesn't cascade + it("individual subsystem failure during stop does not cascade", async () => { + await lifecycle.start(async () => {}); + + const completed: string[] = []; + const stopFn = async () => { + completed.push("step1"); + // Simulate a failure in one subsystem + try { + throw new Error("MCP close failed"); + } catch { + // Error handled internally + } + completed.push("step2"); + completed.push("step3"); + }; + + await lifecycle.stop(stopFn); + expect(lifecycle.getState()).toBe("stopped"); + expect(completed).toEqual(["step1", "step2", "step3"]); + }); + + // 16. getUptime() returns seconds when running, null when stopped + it("getUptime() returns seconds when running, null when stopped", async () => { + expect(lifecycle.getUptime()).toBeNull(); + + await lifecycle.start(async () => {}); + + const uptime = lifecycle.getUptime(); + expect(uptime).not.toBeNull(); + expect(typeof uptime).toBe("number"); + expect(uptime).toBeGreaterThanOrEqual(0); + + await lifecycle.stop(async () => {}); + expect(lifecycle.getUptime()).toBeNull(); + }); + + // 17. getError() returns null after successful start + it("getError() returns undefined after successful start", async () => { + // First, fail a start + await lifecycle + .start(async () => { + throw new Error("initial failure"); + }) + .catch(() => {}); + + expect(lifecycle.getError()).toBe("initial failure"); + + // Successful start clears error + await lifecycle.start(async () => {}); + expect(lifecycle.getError()).toBeUndefined(); + }); + + // Extra: registerCallbacks + no-arg start/stop + it("start()/stop() work with registered callbacks", async () => { + const startFn = vi.fn(async () => {}); + const stopFn = vi.fn(async () => {}); + lifecycle.registerCallbacks(startFn, stopFn); + + await lifecycle.start(); + expect(startFn).toHaveBeenCalledOnce(); + expect(lifecycle.getState()).toBe("running"); + + await lifecycle.stop(); + expect(stopFn).toHaveBeenCalledOnce(); + expect(lifecycle.getState()).toBe("stopped"); + }); + + it("start() without callback or registration throws", async () => { + await expect(lifecycle.start()).rejects.toThrow("No start function provided or registered"); + }); + + it("stop() without callback or registration throws when not stopped", async () => { + await lifecycle.start(async () => {}); + // Now try stop() with no registered callback + lifecycle["registeredStopFn"] = null; + await expect(lifecycle.stop()).rejects.toThrow("No stop function provided or registered"); + }); +}); diff --git a/src/agent/lifecycle.ts b/src/agent/lifecycle.ts new file mode 100644 index 0000000..f25dbfb --- /dev/null +++ b/src/agent/lifecycle.ts @@ -0,0 +1,151 @@ +import { EventEmitter } from "node:events"; +import { createLogger } from "../utils/logger.js"; + +const log = createLogger("Lifecycle"); + +export type AgentState = "stopped" | "starting" | "running" | "stopping"; + +export interface StateChangeEvent { + state: AgentState; + error?: string; + timestamp: number; +} + +export class AgentLifecycle extends EventEmitter { + private state: AgentState = "stopped"; + private error: string | undefined; + private startPromise: Promise | null = null; + private stopPromise: Promise | null = null; + private runningSince: number | null = null; + private registeredStartFn: (() => Promise) | null = null; + private registeredStopFn: (() => Promise) | null = null; + + getState(): AgentState { + return this.state; + } + + getError(): string | undefined { + return this.error; + } + + getUptime(): number | null { + if (this.state !== "running" || this.runningSince === null) { + return null; + } + return Math.floor((Date.now() - this.runningSince) / 1000); + } + + /** + * Register the start/stop callbacks so start()/stop() can be called without args. + */ + registerCallbacks(startFn: () => Promise, stopFn: () => Promise): void { + this.registeredStartFn = startFn; + this.registeredStopFn = stopFn; + } + + /** + * Start the agent. Uses the provided callback or falls back to registered one. + * - No-op if already running + * - Returns existing promise if already starting + * - Throws if currently stopping + */ + async start(startFn?: () => Promise): Promise { + const fn = startFn ?? this.registeredStartFn; + if (!fn) { + throw new Error("No start function provided or registered"); + } + + if (this.state === "running") { + return; + } + + if (this.state === "starting") { + return this.startPromise!; + } + + if (this.state === "stopping") { + throw new Error("Cannot start while agent is stopping"); + } + + this.transition("starting"); + + this.startPromise = (async () => { + try { + await fn(); + this.error = undefined; + this.runningSince = Date.now(); + this.transition("running"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.error = message; + this.runningSince = null; + this.transition("stopped", message); + throw err; + } finally { + this.startPromise = null; + } + })(); + + return this.startPromise; + } + + /** + * Stop the agent. Uses the provided callback or falls back to registered one. + * - No-op if already stopped + * - Returns existing promise if already stopping + * - If starting, waits for start to complete then stops + */ + async stop(stopFn?: () => Promise): Promise { + const fn = stopFn ?? this.registeredStopFn; + if (!fn) { + throw new Error("No stop function provided or registered"); + } + + if (this.state === "stopped") { + return; + } + + if (this.state === "stopping") { + return this.stopPromise!; + } + + // If currently starting, wait for start to finish first + if (this.state === "starting" && this.startPromise) { + try { + await this.startPromise; + } catch { + // Start failed — agent is already stopped + return; + } + } + + this.transition("stopping"); + + this.stopPromise = (async () => { + try { + await fn(); + } catch (err) { + log.error({ err }, "Error during agent stop"); + } finally { + this.runningSince = null; + this.transition("stopped"); + this.stopPromise = null; + } + })(); + + return this.stopPromise; + } + + private transition(newState: AgentState, error?: string): void { + this.state = newState; + const event: StateChangeEvent = { + state: newState, + timestamp: Date.now(), + }; + if (error !== undefined) { + event.error = error; + } + log.info(`Agent state: ${newState}${error ? ` (${error})` : ""}`); + this.emit("stateChange", event); + } +} diff --git a/src/index.ts b/src/index.ts index 3925439..eb1ab1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ import { } from "./agent/tools/mcp-loader.js"; import { getErrorMessage } from "./utils/errors.js"; import { createLogger, initLoggerFromConfig } from "./utils/logger.js"; +import { AgentLifecycle } from "./agent/lifecycle.js"; const log = createLogger("App"); @@ -51,6 +52,7 @@ export class TeletonApp { private pluginWatcher: PluginWatcher | null = null; private mcpConnections: McpConnection[] = []; private callbackHandlerRegistered = false; + private lifecycle = new AgentLifecycle(); private configPath: string; @@ -126,6 +128,13 @@ export class TeletonApp { ); } + /** + * Get the lifecycle state machine for WebUI integration + */ + getLifecycle(): AgentLifecycle { + return this.lifecycle; + } + /** * Start the agent */ @@ -145,6 +154,83 @@ ${blue} ┌────────────────────── └────────────────────────────────────────────────────────────────── DEV: ZKPROOF.T.ME ──┘${reset} `); + // Register lifecycle callbacks so WebUI routes can call start()/stop() without args + this.lifecycle.registerCallbacks( + () => this.startAgent(), + () => this.stopAgent() + ); + + // Start WebUI server if enabled (before agent — survives agent stop/restart) + if (this.config.webui.enabled) { + try { + const { WebUIServer } = await import("./webui/server.js"); + // Build MCP server info for WebUI + const mcpServers = Object.entries(this.config.mcp.servers).map(([name, serverConfig]) => { + const type = serverConfig.command ? ("stdio" as const) : ("sse" as const); + const target = serverConfig.command ?? serverConfig.url ?? ""; + const connected = this.mcpConnections.some((c) => c.serverName === name); + const moduleName = `mcp_${name}`; + const moduleTools = this.toolRegistry.getModuleTools(moduleName); + return { + name, + type, + target, + scope: serverConfig.scope ?? "always", + enabled: serverConfig.enabled ?? true, + connected, + toolCount: moduleTools.length, + tools: moduleTools.map((t) => t.name), + envKeys: Object.keys(serverConfig.env ?? {}), + }; + }); + + const builtinNames = this.modules.map((m) => m.name); + const pluginContext: PluginContext = { + bridge: this.bridge, + db: getDatabase().getDb(), + config: this.config, + }; + + this.webuiServer = new WebUIServer({ + agent: this.agent, + bridge: this.bridge, + memory: this.memory, + toolRegistry: this.toolRegistry, + plugins: this.modules + .filter((m) => this.toolRegistry.isPluginModule(m.name)) + .map((m) => ({ name: m.name, version: m.version ?? "0.0.0" })), + mcpServers, + config: this.config.webui, + configPath: this.configPath, + lifecycle: this.lifecycle, + marketplace: { + modules: this.modules, + config: this.config, + sdkDeps: this.sdkDeps, + pluginContext, + loadedModuleNames: builtinNames, + rewireHooks: () => this.wirePluginEventHooks(), + }, + }); + await this.webuiServer.start(); + } catch (error) { + log.error({ err: error }, "❌ Failed to start WebUI server"); + log.warn("⚠️ Continuing without WebUI..."); + } + } + + // Start agent subsystems via lifecycle + await this.lifecycle.start(() => this.startAgent()); + + // Keep process alive + await new Promise(() => {}); + } + + /** + * Start agent subsystems (Telegram, plugins, MCP, modules, debouncer, handler). + * Called by lifecycle.start() — do NOT call directly. + */ + private async startAgent(): Promise { // Load modules const moduleNames = this.modules .filter((m) => m.tools(this.config).length > 0) @@ -275,14 +361,15 @@ ${blue} ┌────────────────────── `Cocoon Network unavailable on port ${this.config.cocoon?.port ?? 10000}: ${getErrorMessage(err)}` ); log.error("Start the Cocoon client first: cocoon start"); - process.exit(1); + throw new Error(`Cocoon Network unavailable: ${getErrorMessage(err)}`); } } // Local LLM — register models from OpenAI-compatible server if (this.config.agent.provider === "local" && !this.config.agent.base_url) { - log.error("Local provider requires base_url in config (e.g. http://localhost:11434/v1)"); - process.exit(1); + throw new Error( + "Local provider requires base_url in config (e.g. http://localhost:11434/v1)" + ); } if (this.config.agent.provider === "local" && this.config.agent.base_url) { try { @@ -302,7 +389,7 @@ ${blue} ┌────────────────────── `Local LLM server unavailable at ${this.config.agent.base_url}: ${getErrorMessage(err)}` ); log.error("Start the LLM server first (e.g. ollama serve)"); - process.exit(1); + throw new Error(`Local LLM server unavailable: ${getErrorMessage(err)}`); } } @@ -310,8 +397,7 @@ ${blue} ┌────────────────────── await this.bridge.connect(); if (!this.bridge.isAvailable()) { - log.error("❌ Failed to connect to Telegram"); - process.exit(1); + throw new Error("Failed to connect to Telegram"); } // Resolve owner name/username from Telegram if not already set @@ -385,57 +471,6 @@ ${blue} ┌────────────────────── log.info("Teleton Agent is running! Press Ctrl+C to stop."); - // Start WebUI server if enabled - if (this.config.webui.enabled) { - try { - const { WebUIServer } = await import("./webui/server.js"); - // Build MCP server info for WebUI - const mcpServers = Object.entries(this.config.mcp.servers).map(([name, serverConfig]) => { - const type = serverConfig.command ? ("stdio" as const) : ("sse" as const); - const target = serverConfig.command ?? serverConfig.url ?? ""; - const connected = this.mcpConnections.some((c) => c.serverName === name); - const moduleName = `mcp_${name}`; - const moduleTools = this.toolRegistry.getModuleTools(moduleName); - return { - name, - type, - target, - scope: serverConfig.scope ?? "always", - enabled: serverConfig.enabled ?? true, - connected, - toolCount: moduleTools.length, - tools: moduleTools.map((t) => t.name), - envKeys: Object.keys(serverConfig.env ?? {}), - }; - }); - - this.webuiServer = new WebUIServer({ - agent: this.agent, - bridge: this.bridge, - memory: this.memory, - toolRegistry: this.toolRegistry, - plugins: this.modules - .filter((m) => this.toolRegistry.isPluginModule(m.name)) - .map((m) => ({ name: m.name, version: m.version ?? "0.0.0" })), - mcpServers, - config: this.config.webui, - configPath: this.configPath, - marketplace: { - modules: this.modules, - config: this.config, - sdkDeps: this.sdkDeps, - pluginContext, - loadedModuleNames: builtinNames, - rewireHooks: () => this.wirePluginEventHooks(), - }, - }); - await this.webuiServer.start(); - } catch (error) { - log.error({ err: error }, "❌ Failed to start WebUI server"); - log.warn("⚠️ Continuing without WebUI..."); - } - } - // Initialize message debouncer with bypass logic this.debouncer = new MessageDebouncer( { @@ -474,9 +509,6 @@ ${blue} ┌────────────────────── log.error({ err: error }, "Error enqueueing message"); } }); - - // Keep process alive - await new Promise(() => {}); } /** @@ -859,7 +891,10 @@ ${blue} ┌────────────────────── async stop(): Promise { log.info("👋 Stopping Teleton AI..."); - // Stop WebUI server first (if running) + // Stop agent subsystems via lifecycle + await this.lifecycle.stop(() => this.stopAgent()); + + // Stop WebUI server (if running) if (this.webuiServer) { try { await this.webuiServer.stop(); @@ -868,6 +903,19 @@ ${blue} ┌────────────────────── } } + // Close database last (shared with WebUI) + try { + closeDatabase(); + } catch (e) { + log.error({ err: e }, "⚠️ Database close failed"); + } + } + + /** + * Stop agent subsystems (watcher, MCP, debouncer, handler, modules, bridge). + * Called by lifecycle.stop() — do NOT call directly. + */ + private async stopAgent(): Promise { // Stop plugin watcher first if (this.pluginWatcher) { try { @@ -915,12 +963,6 @@ ${blue} ┌────────────────────── } catch (e) { log.error({ err: e }, "⚠️ Bridge disconnect failed"); } - - try { - closeDatabase(); - } catch (e) { - log.error({ err: e }, "⚠️ Database close failed"); - } } } diff --git a/src/webui/__tests__/agent-routes.test.ts b/src/webui/__tests__/agent-routes.test.ts new file mode 100644 index 0000000..f6265df --- /dev/null +++ b/src/webui/__tests__/agent-routes.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +import { AgentLifecycle } from "../../agent/lifecycle.js"; + +// Build a minimal Hono app that mirrors the agent routes from server.ts +function createTestApp(lifecycle?: AgentLifecycle) { + const app = new Hono(); + + // Simulate auth middleware: all requests are authenticated (we test auth separately) + app.post("/api/agent/start", async (c) => { + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + const state = lifecycle.getState(); + if (state === "running") { + return c.json({ state: "running" }, 409); + } + if (state === "stopping") { + return c.json({ error: "Agent is currently stopping, please wait" }, 409); + } + lifecycle.start().catch(() => {}); + return c.json({ state: "starting" }); + }); + + app.post("/api/agent/stop", async (c) => { + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + const state = lifecycle.getState(); + if (state === "stopped") { + return c.json({ state: "stopped" }, 409); + } + if (state === "starting") { + return c.json({ error: "Agent is currently starting, please wait" }, 409); + } + lifecycle.stop().catch(() => {}); + return c.json({ state: "stopping" }); + }); + + app.get("/api/agent/status", (c) => { + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + return c.json({ + state: lifecycle.getState(), + uptime: lifecycle.getUptime(), + error: lifecycle.getError() ?? null, + }); + }); + + return app; +} + +describe("Agent Lifecycle API Routes", () => { + let lifecycle: AgentLifecycle; + let app: ReturnType; + + beforeEach(() => { + lifecycle = new AgentLifecycle(); + lifecycle.registerCallbacks( + async () => {}, + async () => {} + ); + app = createTestApp(lifecycle); + }); + + // 1. POST /api/agent/start — agent stopped + it("POST /api/agent/start returns 200 with starting when agent stopped", async () => { + const res = await app.request("/api/agent/start", { method: "POST" }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.state).toBe("starting"); + }); + + // 2. POST /api/agent/start — agent already running + it("POST /api/agent/start returns 409 when agent already running", async () => { + await lifecycle.start(); + expect(lifecycle.getState()).toBe("running"); + + const res = await app.request("/api/agent/start", { method: "POST" }); + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.state).toBe("running"); + }); + + // 3. POST /api/agent/start — agent stopping + it("POST /api/agent/start returns 409 when agent stopping", async () => { + await lifecycle.start(); + let resolveStop!: () => void; + lifecycle.stop( + () => + new Promise((resolve) => { + resolveStop = resolve; + }) + ); + + const res = await app.request("/api/agent/start", { method: "POST" }); + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.error).toContain("stopping"); + + resolveStop(); + }); + + // 4. POST /api/agent/stop — agent running + it("POST /api/agent/stop returns 200 with stopping when agent running", async () => { + await lifecycle.start(); + + const res = await app.request("/api/agent/stop", { method: "POST" }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.state).toBe("stopping"); + }); + + // 5. POST /api/agent/stop — agent already stopped + it("POST /api/agent/stop returns 409 when agent already stopped", async () => { + const res = await app.request("/api/agent/stop", { method: "POST" }); + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.state).toBe("stopped"); + }); + + // 6. POST /api/agent/stop — agent starting + it("POST /api/agent/stop returns 409 when agent starting", async () => { + let resolveStart!: () => void; + lifecycle.start( + () => + new Promise((resolve) => { + resolveStart = resolve; + }) + ); + + const res = await app.request("/api/agent/stop", { method: "POST" }); + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.error).toContain("starting"); + + resolveStart(); + }); + + // 7. GET /api/agent/status — returns current state + it("GET /api/agent/status returns current state", async () => { + const res = await app.request("/api/agent/status"); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.state).toBe("stopped"); + expect(data.uptime).toBeNull(); + expect(data.error).toBeNull(); + }); + + // 8. All endpoints reject unauthenticated requests + // (Auth is handled by WebUIServer middleware, not route-level — skipped here as + // the routes are under /api/* which has auth middleware. Tested via integration.) + + // 9. GET /api/agent/events — SSE content-type + // (Tested in agent-sse.test.ts) + + // 10. POST /api/agent/start — lifecycle not provided + it("returns 503 when lifecycle not provided", async () => { + const noLifecycleApp = createTestApp(undefined); + + const startRes = await noLifecycleApp.request("/api/agent/start", { method: "POST" }); + expect(startRes.status).toBe(503); + + const stopRes = await noLifecycleApp.request("/api/agent/stop", { method: "POST" }); + expect(stopRes.status).toBe(503); + + const statusRes = await noLifecycleApp.request("/api/agent/status"); + expect(statusRes.status).toBe(503); + }); + + // 11. GET /api/agent/status — uptime is number when running, null when stopped + it("status uptime is number when running, null when stopped", async () => { + // Stopped + let res = await app.request("/api/agent/status"); + let data = await res.json(); + expect(data.uptime).toBeNull(); + + // Running + await lifecycle.start(); + res = await app.request("/api/agent/status"); + data = await res.json(); + expect(typeof data.uptime).toBe("number"); + expect(data.uptime).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/src/webui/__tests__/agent-sse.test.ts b/src/webui/__tests__/agent-sse.test.ts new file mode 100644 index 0000000..eacd36e --- /dev/null +++ b/src/webui/__tests__/agent-sse.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { streamSSE } from "hono/streaming"; + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +import { AgentLifecycle, type StateChangeEvent } from "../../agent/lifecycle.js"; + +/** Parse SSE text into structured events */ +function parseSSE(text: string): Array<{ event?: string; data?: string; id?: string }> { + const events: Array<{ event?: string; data?: string; id?: string }> = []; + const blocks = text.split("\n\n").filter(Boolean); + for (const block of blocks) { + const entry: { event?: string; data?: string; id?: string } = {}; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) entry.event = line.slice(6).trim(); + else if (line.startsWith("data:")) entry.data = line.slice(5).trim(); + else if (line.startsWith("id:")) entry.id = line.slice(3).trim(); + } + if (entry.event || entry.data) events.push(entry); + } + return events; +} + +/** Build a mini Hono app with the SSE endpoint mirroring server.ts */ +function createSSEApp(lifecycle: AgentLifecycle) { + const app = new Hono(); + + app.get("/api/agent/events", (c) => { + return streamSSE(c, async (stream) => { + let aborted = false; + stream.onAbort(() => { + aborted = true; + }); + + const now = Date.now(); + await stream.writeSSE({ + event: "status", + id: String(now), + data: JSON.stringify({ + state: lifecycle.getState(), + error: lifecycle.getError() ?? null, + timestamp: now, + }), + retry: 3000, + }); + + const onStateChange = (event: StateChangeEvent) => { + if (aborted) return; + stream.writeSSE({ + event: "status", + id: String(event.timestamp), + data: JSON.stringify({ + state: event.state, + error: event.error ?? null, + timestamp: event.timestamp, + }), + }); + }; + + lifecycle.on("stateChange", onStateChange); + + // For testing: don't loop forever — just wait briefly for events to propagate + await stream.sleep(50); + + lifecycle.off("stateChange", onStateChange); + }); + }); + + return app; +} + +describe("Agent SSE Endpoint", () => { + let lifecycle: AgentLifecycle; + let app: ReturnType; + + beforeEach(() => { + lifecycle = new AgentLifecycle(); + lifecycle.registerCallbacks( + async () => {}, + async () => {} + ); + app = createSSEApp(lifecycle); + }); + + // 1. Initial connection pushes current state + it("initial connection pushes current state", async () => { + const res = await app.request("/api/agent/events"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + + const text = await res.text(); + const events = parseSSE(text); + expect(events.length).toBeGreaterThanOrEqual(1); + expect(events[0].event).toBe("status"); + const data = JSON.parse(events[0].data!); + expect(data.state).toBe("stopped"); + }); + + // 2. State change emits SSE event + it("state change emits SSE event", async () => { + // Start agent so state is "running" when SSE connects + await lifecycle.start(); + + const sseApp = new Hono(); + sseApp.get("/events", (c) => { + return streamSSE(c, async (stream) => { + let aborted = false; + stream.onAbort(() => { + aborted = true; + }); + + // Push current state + await stream.writeSSE({ + event: "status", + data: JSON.stringify({ state: lifecycle.getState() }), + }); + + // Listen for state change then close + const onStateChange = (event: StateChangeEvent) => { + if (aborted) return; + stream.writeSSE({ + event: "status", + data: JSON.stringify({ state: event.state }), + }); + }; + + lifecycle.on("stateChange", onStateChange); + + // Trigger a stop during the stream + lifecycle.stop().catch(() => {}); + + await stream.sleep(50); + lifecycle.off("stateChange", onStateChange); + }); + }); + + const res = await sseApp.request("/events"); + const text = await res.text(); + const events = parseSSE(text); + + // Should have initial "running" and then "stopping" and "stopped" + const states = events.map((e) => JSON.parse(e.data!).state); + expect(states).toContain("running"); + expect(states).toContain("stopped"); + }); + + // 3. Heartbeat sent after interval (we use short interval for test) + it("heartbeat (ping) is sent", async () => { + const sseApp = new Hono(); + sseApp.get("/events", (c) => { + return streamSSE(c, async (stream) => { + // Send a ping immediately for test purposes + await stream.writeSSE({ event: "ping", data: "" }); + }); + }); + + const res = await sseApp.request("/events"); + const text = await res.text(); + const events = parseSSE(text); + const pings = events.filter((e) => e.event === "ping"); + expect(pings.length).toBeGreaterThanOrEqual(1); + }); + + // 4. Client disconnect removes listener + it("client disconnect removes listener", async () => { + const initialListenerCount = lifecycle.listenerCount("stateChange"); + + // After SSE stream ends, listeners should be cleaned up + const res = await app.request("/api/agent/events"); + await res.text(); // consume stream + + // Listener should have been removed + expect(lifecycle.listenerCount("stateChange")).toBe(initialListenerCount); + }); + + // 5. Multiple concurrent SSE clients + it("multiple concurrent SSE clients receive events independently", async () => { + const res1 = app.request("/api/agent/events"); + const res2 = app.request("/api/agent/events"); + + const [r1, r2] = await Promise.all([res1, res2]); + const text1 = await r1.text(); + const text2 = await r2.text(); + + // Both should have received the initial status event + const events1 = parseSSE(text1); + const events2 = parseSSE(text2); + expect(events1.length).toBeGreaterThanOrEqual(1); + expect(events2.length).toBeGreaterThanOrEqual(1); + expect(events1[0].event).toBe("status"); + expect(events2[0].event).toBe("status"); + }); + + // 6. Error in stream handler doesn't crash server + it("error in stream handler does not crash server", async () => { + const errorApp = new Hono(); + errorApp.get("/events", (c) => { + return streamSSE(c, async (stream) => { + await stream.writeSSE({ event: "status", data: '{"state":"stopped"}' }); + // Simulate error — stream closes but server stays up + throw new Error("simulated stream error"); + }); + }); + + // Should not throw + const res = await errorApp.request("/events"); + expect(res.status).toBe(200); + // Stream still returned something before the error + const text = await res.text(); + expect(text).toContain("status"); + }); + + // Extra: SSE content-type header + it("returns text/event-stream content type", async () => { + const res = await app.request("/api/agent/events"); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + }); +}); diff --git a/src/webui/server.ts b/src/webui/server.ts index f76e84a..2b36185 100644 --- a/src/webui/server.ts +++ b/src/webui/server.ts @@ -1,12 +1,14 @@ import { Hono } from "hono"; import { serve } from "@hono/node-server"; import { cors } from "hono/cors"; +import { streamSSE } from "hono/streaming"; import { bodyLimit } from "hono/body-limit"; import { setCookie, getCookie, deleteCookie } from "hono/cookie"; import { existsSync, readFileSync } from "node:fs"; import { join, dirname, resolve, relative } from "node:path"; import { fileURLToPath } from "node:url"; import type { WebUIServerDeps } from "./types.js"; +import type { StateChangeEvent } from "../agent/lifecycle.js"; import { createLogger } from "../utils/logger.js"; const log = createLogger("WebUI"); @@ -205,6 +207,113 @@ export class WebUIServer { this.app.route("/api/config", createConfigRoutes(this.deps)); this.app.route("/api/marketplace", createMarketplaceRoutes(this.deps)); + // Agent lifecycle routes + this.app.post("/api/agent/start", async (c) => { + const lifecycle = this.deps.lifecycle; + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + const state = lifecycle.getState(); + if (state === "running") { + return c.json({ state: "running" }, 409); + } + if (state === "stopping") { + return c.json({ error: "Agent is currently stopping, please wait" }, 409); + } + // Fire-and-forget: start is async, we return immediately + lifecycle.start().catch((err: Error) => { + log.error({ err }, "Agent start failed"); + }); + return c.json({ state: "starting" }); + }); + + this.app.post("/api/agent/stop", async (c) => { + const lifecycle = this.deps.lifecycle; + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + const state = lifecycle.getState(); + if (state === "stopped") { + return c.json({ state: "stopped" }, 409); + } + if (state === "starting") { + return c.json({ error: "Agent is currently starting, please wait" }, 409); + } + // Fire-and-forget: stop is async, we return immediately + lifecycle.stop().catch((err: Error) => { + log.error({ err }, "Agent stop failed"); + }); + return c.json({ state: "stopping" }); + }); + + this.app.get("/api/agent/status", (c) => { + const lifecycle = this.deps.lifecycle; + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + return c.json({ + state: lifecycle.getState(), + uptime: lifecycle.getUptime(), + error: lifecycle.getError() ?? null, + }); + }); + + this.app.get("/api/agent/events", (c) => { + const lifecycle = this.deps.lifecycle; + if (!lifecycle) { + return c.json({ error: "Agent lifecycle not available" }, 503); + } + + return streamSSE(c, async (stream) => { + let aborted = false; + + stream.onAbort(() => { + aborted = true; + }); + + // Push current state immediately on connection + const now = Date.now(); + await stream.writeSSE({ + event: "status", + id: String(now), + data: JSON.stringify({ + state: lifecycle.getState(), + error: lifecycle.getError() ?? null, + timestamp: now, + }), + retry: 3000, + }); + + // Listen for state changes + const onStateChange = (event: StateChangeEvent) => { + if (aborted) return; + stream.writeSSE({ + event: "status", + id: String(event.timestamp), + data: JSON.stringify({ + state: event.state, + error: event.error ?? null, + timestamp: event.timestamp, + }), + }); + }; + + lifecycle.on("stateChange", onStateChange); + + // Heartbeat loop + keep connection alive + while (!aborted) { + await stream.sleep(30_000); + if (aborted) break; + await stream.writeSSE({ + event: "ping", + data: "", + }); + } + + lifecycle.off("stateChange", onStateChange); + }); + }); + // Serve static files in production (if built) const webDist = findWebDist(); if (webDist) { diff --git a/src/webui/types.ts b/src/webui/types.ts index d7d50e0..d100949 100644 --- a/src/webui/types.ts +++ b/src/webui/types.ts @@ -6,6 +6,7 @@ import type { WebUIConfig, Config } from "../config/schema.js"; import type { Database } from "better-sqlite3"; import type { PluginModule, PluginContext } from "../agent/tools/types.js"; import type { SDKDependencies } from "../sdk/index.js"; +import type { AgentLifecycle } from "../agent/lifecycle.js"; export interface LoadedPlugin { name: string; @@ -37,6 +38,7 @@ export interface WebUIServerDeps { mcpServers: McpServerInfo[]; config: WebUIConfig; configPath: string; + lifecycle?: AgentLifecycle; marketplace?: MarketplaceDeps; } diff --git a/web/src/components/AgentControl.tsx b/web/src/components/AgentControl.tsx new file mode 100644 index 0000000..ec49bce --- /dev/null +++ b/web/src/components/AgentControl.tsx @@ -0,0 +1,224 @@ +import { useState, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useAgentStatus, AgentState } from '../hooks/useAgentStatus'; + +const API_BASE = '/api'; +const MAX_START_RETRIES = 3; +const RETRY_DELAYS = [1000, 2000, 4000]; + +function jitter(ms: number): number { + return ms + ms * 0.3 * Math.random(); +} + +const STATE_CONFIG: Record = { + stopped: { dot: 'var(--text-tertiary)', label: 'Stopped', pulse: false }, + starting: { dot: '#FFD60A', label: 'Starting...', pulse: true }, + running: { dot: 'var(--green)', label: 'Running', pulse: true }, + stopping: { dot: '#FF9F0A', label: 'Stopping...', pulse: true }, + error: { dot: 'var(--red)', label: 'Error', pulse: false }, +}; + +export function AgentControl() { + const { state, error } = useAgentStatus(); + const [inflight, setInflight] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [actionError, setActionError] = useState(null); + const [retrying, setRetrying] = useState(false); + const retryTimerRef = useRef | null>(null); + const abortRef = useRef(null); + + const displayState = error && state === 'stopped' ? 'error' : state; + const config = STATE_CONFIG[displayState]; + + const clearRetry = useCallback(() => { + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + setRetrying(false); + }, []); + + const doStart = useCallback(async (attempt = 0): Promise => { + setInflight(true); + setActionError(null); + abortRef.current = new AbortController(); + + try { + const res = await fetch(`${API_BASE}/agent/start`, { + method: 'POST', + credentials: 'include', + signal: AbortSignal.timeout(10_000), + }); + const json = await res.json(); + + if (!res.ok && json.error) { + throw new Error(json.error); + } + } catch (err) { + if (!retryTimerRef.current && attempt < MAX_START_RETRIES) { + setRetrying(true); + const delay = jitter(RETRY_DELAYS[attempt] ?? 4000); + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null; + doStart(attempt + 1); + }, delay); + setActionError(err instanceof Error ? err.message : String(err)); + setInflight(false); + return; + } + setActionError(err instanceof Error ? err.message : String(err)); + setRetrying(false); + } finally { + setInflight(false); + abortRef.current = null; + } + }, []); + + const doStop = useCallback(async () => { + setShowConfirm(false); + setInflight(true); + setActionError(null); + clearRetry(); + + try { + const res = await fetch(`${API_BASE}/agent/stop`, { + method: 'POST', + credentials: 'include', + signal: AbortSignal.timeout(10_000), + }); + const json = await res.json(); + + if (!res.ok && json.error) { + throw new Error(json.error); + } + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } finally { + setInflight(false); + } + }, [clearRetry]); + + const handleStart = () => { + clearRetry(); + doStart(0); + }; + + const handleStopClick = () => { + setShowConfirm(true); + }; + + const showPlay = displayState === 'stopped' || displayState === 'error'; + const showStop = displayState === 'running'; + + return ( +
+ {/* Status badge */} +
+ + + {config.label} + +
+ + {/* Action button */} + {showPlay && !retrying && ( + + )} + + {showStop && ( + + )} + + {/* Retry indicator */} + {retrying && ( +
+ Retrying... +
+ )} + + {/* Error message */} + {actionError && !retrying && ( +
+ {actionError} +
+ )} + + {/* Stop confirmation dialog — portal to body so it's above the sidebar */} + {showConfirm && createPortal( +
setShowConfirm(false)}> +
e.stopPropagation()} style={{ maxWidth: '360px' }}> +

Stop Agent?

+

+ Active Telegram sessions will be interrupted. +

+
+ + +
+
+
, + document.body + )} + + {/* Pulse animation */} + +
+ ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 110f4be..82a6f6a 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,5 +1,6 @@ import { Link, useLocation } from 'react-router-dom'; import { Shell } from './Shell'; +import { AgentControl } from './AgentControl'; import { logout } from '../lib/api'; function DashboardNav() { @@ -25,13 +26,16 @@ function DashboardNav() { MCP Config -
- +
+ +
+ +
); diff --git a/web/src/hooks/useAgentStatus.ts b/web/src/hooks/useAgentStatus.ts new file mode 100644 index 0000000..faf8670 --- /dev/null +++ b/web/src/hooks/useAgentStatus.ts @@ -0,0 +1,147 @@ +import { useEffect, useRef, useState } from 'react'; + +export type AgentState = 'stopped' | 'starting' | 'running' | 'stopping'; + +interface AgentStatusEvent { + state: AgentState; + error: string | null; + timestamp: number; +} + +const SSE_URL = '/api/agent/events'; +const POLL_URL = '/api/agent/status'; +const MAX_RETRIES = 5; +const MAX_BACKOFF_MS = 30_000; +const POLL_INTERVAL_MS = 3_000; + +function backoffMs(attempt: number): number { + const base = Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS); + const jitter = base * 0.3 * Math.random(); + return base + jitter; +} + +export function useAgentStatus(): { state: AgentState; error: string | null } { + const [state, setState] = useState('stopped'); + const [error, setError] = useState(null); + + const mountedRef = useRef(true); + const esRef = useRef(null); + const retryCountRef = useRef(0); + const retryTimerRef = useRef | null>(null); + const pollTimerRef = useRef | null>(null); + const sseFailedRef = useRef(false); + + useEffect(() => { + mountedRef.current = true; + + function handleStatusEvent(ev: MessageEvent) { + if (!mountedRef.current) return; + try { + const data: AgentStatusEvent = JSON.parse(ev.data); + setState(data.state); + setError(data.error ?? null); + retryCountRef.current = 0; // reset on successful message + } catch { + // ignore parse errors + } + } + + function closeSSE() { + if (esRef.current) { + esRef.current.removeEventListener('status', handleStatusEvent as EventListener); + esRef.current.close(); + esRef.current = null; + } + } + + function stopPolling() { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + } + + function startPolling() { + if (pollTimerRef.current) return; + const poll = async () => { + if (!mountedRef.current) return; + try { + const res = await fetch(POLL_URL, { credentials: 'include' }); + if (!res.ok) return; + const json = await res.json(); + const data = json.data ?? json; + if (mountedRef.current) { + setState(data.state); + setError(data.error ?? null); + } + } catch { + // ignore fetch errors during polling + } + }; + poll(); // immediate first poll + pollTimerRef.current = setInterval(poll, POLL_INTERVAL_MS); + } + + function connect() { + if (!mountedRef.current) return; + closeSSE(); + + const es = new EventSource(SSE_URL, { withCredentials: true }); + esRef.current = es; + + es.addEventListener('status', handleStatusEvent as EventListener); + + es.addEventListener('open', () => { + retryCountRef.current = 0; + sseFailedRef.current = false; + stopPolling(); + }); + + es.onerror = () => { + closeSSE(); + if (!mountedRef.current) return; + + retryCountRef.current += 1; + if (retryCountRef.current <= MAX_RETRIES) { + const delay = backoffMs(retryCountRef.current - 1); + retryTimerRef.current = setTimeout(connect, delay); + } else { + // SSE exhausted — fall back to polling + sseFailedRef.current = true; + startPolling(); + } + }; + } + + function handleVisibility() { + if (document.hidden) { + closeSSE(); + stopPolling(); + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + } else { + retryCountRef.current = 0; + sseFailedRef.current = false; + connect(); + } + } + + connect(); + document.addEventListener('visibilitychange', handleVisibility); + + return () => { + mountedRef.current = false; + closeSSE(); + stopPolling(); + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + document.removeEventListener('visibilitychange', handleVisibility); + }; + }, []); + + return { state, error }; +} From 26f06f355068041fcb8f07e5634f961ce85c003b Mon Sep 17 00:00:00 2001 From: TONresistor Date: Sun, 22 Feb 2026 21:27:10 +0100 Subject: [PATCH 02/41] =?UTF-8?q?fix(webui):=20am=C3=A9lioration=20UI=20se?= =?UTF-8?q?tup=20wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/setup/ConfigStep.tsx | 4 +-- web/src/components/setup/WelcomeStep.tsx | 46 ++++++++++-------------- web/src/index.css | 38 +++++++++++--------- 3 files changed, 42 insertions(+), 46 deletions(-) diff --git a/web/src/components/setup/ConfigStep.tsx b/web/src/components/setup/ConfigStep.tsx index bf8b3c8..9347dfa 100644 --- a/web/src/components/setup/ConfigStep.tsx +++ b/web/src/components/setup/ConfigStep.tsx @@ -163,7 +163,7 @@ export function ConfigStep({ data, onChange }: StepProps) { {/* ── Optional Integrations ── */}

- Optional Integrations + Optional API Keys

@@ -172,7 +172,7 @@ export function ConfigStep({ data, onChange }: StepProps) {
Bot Token - (optional) + (recommended)

Welcome to Teleton Setup

- Configure your autonomous Telegram agent in a few steps: + Configure your autonomous Telegram agent in a few steps.

+
+ Security Notice +
+ This software is an autonomous AI agent that can: +
    +
  • Send and receive Telegram messages on your behalf
  • +
  • Execute cryptocurrency transactions using your wallet
  • +
  • Access and store conversation data
  • +
  • Make decisions and take actions autonomously
  • +
+ You are solely responsible for all actions taken by this agent. + By proceeding, you acknowledge that you understand these risks + and accept full responsibility for the agent's behavior. +

+ Never share your API keys, wallet mnemonics, or session files. +
+
+
-
-
-
1. Connect an LLM provider (Anthropic, OpenAI, etc.)
-
2. Link your Telegram account
-
3. Set up a TON wallet for crypto operations
-
4. Configure behavior and optional modules
-
-
- -
- IMPORTANT SECURITY NOTICE -

- This software is an autonomous AI agent that can: -
    -
  • Send and receive Telegram messages on your behalf
  • -
  • Execute cryptocurrency transactions using your wallet
  • -
  • Access and store conversation data
  • -
  • Make decisions and take actions autonomously
  • -
- You are solely responsible for all actions taken by this agent. - By proceeding, you acknowledge that you understand these risks - and accept full responsibility for the agent's behavior. -

- Never share your API keys, wallet mnemonics, or session files. -
- {status?.configExists && (
Existing configuration detected. It will be overwritten when setup completes. diff --git a/web/src/index.css b/web/src/index.css index df6afe4..d92cd24 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -365,13 +365,13 @@ select { } .custom-select-option.active { - background: var(--accent-dim); - color: var(--accent); + background: var(--surface-hover); + color: var(--text); } .custom-select-option.active.focused { - background: var(--accent-dim); - color: var(--accent); + background: var(--surface-active); + color: var(--text); } input:focus, textarea:focus, select:focus { @@ -478,8 +478,8 @@ a:hover { } .tab.active .tab-count { - background: var(--accent-dim); - color: var(--accent); + background: var(--surface-active); + color: var(--text); } /* ---- Button variants ---- */ @@ -536,9 +536,9 @@ button.btn-sm { } .tag-pill.active { - background: var(--accent-dim); - color: var(--accent); - border-color: rgba(10, 132, 255, 0.3); + background: var(--surface-hover); + color: var(--text); + border-color: var(--glass-border-strong); } /* ---- Form ---- */ @@ -1003,9 +1003,9 @@ button.btn-sm { } .step-dot.active { - background: var(--accent); - color: var(--text-on-accent); - border-color: var(--accent); + background: var(--surface-active); + color: var(--text); + border-color: var(--glass-border-strong); } .step-dot.completed { @@ -1079,8 +1079,8 @@ button.btn-sm { } .provider-card.selected { - border-color: var(--accent); - background: var(--accent-dim); + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.08); } .provider-card h3 { @@ -1207,9 +1207,13 @@ button.btn-sm { margin-bottom: 16px; font-size: 13px; line-height: 1.6; - background: var(--red-dim); - color: var(--red); - border: 1px solid rgba(255, 69, 58, 0.2); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.warning-card strong { + color: var(--text); } /* ---- Info Panel ---- */ From fd5aab3f164bf4b43d203e070535f39a5369bcf5 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Sun, 22 Feb 2026 21:32:07 +0100 Subject: [PATCH 03/41] feat(mcp): upgrade to Streamable HTTP transport with SSE fallback - Use StreamableHTTPClientTransport as primary for URL-based MCP servers - Fall back to SSEClientTransport if Streamable HTTP connection fails - Close original client/transport before fallback to prevent resource leaks (AbortController, sockets) - mcpServers dep accepts lazy function for dynamic live status - Connection failure log level: warn (non-fatal, optional servers) - Improve error logging with stack traces on connection failure --- src/agent/tools/mcp-loader.ts | 60 +++++++++++++++++++++++++++-------- src/webui/routes/mcp.ts | 3 +- src/webui/types.ts | 4 +-- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/agent/tools/mcp-loader.ts b/src/agent/tools/mcp-loader.ts index 6993709..0d3f43c 100644 --- a/src/agent/tools/mcp-loader.ts +++ b/src/agent/tools/mcp-loader.ts @@ -8,6 +8,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { sanitizeForContext } from "../../utils/sanitize.js"; import type { Tool, ToolExecutor, ToolResult, ToolScope } from "./types.js"; import type { ToolRegistry } from "./registry.js"; @@ -95,24 +96,53 @@ export async function loadMcpServers(config: McpConfig): Promise; - await Promise.race([ - client.connect(transport), - new Promise((_, reject) => { - timeoutHandle = setTimeout( - () => reject(new Error(`Connection timed out after ${MCP_CONNECT_TIMEOUT_MS / 1000}s`)), - MCP_CONNECT_TIMEOUT_MS - ); - }), - ]).finally(() => clearTimeout(timeoutHandle)); + try { + await Promise.race([ + client.connect(transport), + new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => + reject(new Error(`Connection timed out after ${MCP_CONNECT_TIMEOUT_MS / 1000}s`)), + MCP_CONNECT_TIMEOUT_MS + ); + }), + ]).finally(() => clearTimeout(timeoutHandle)); + } catch (err) { + // If Streamable HTTP failed on a URL server, retry with SSE + if (serverConfig.url && transport instanceof StreamableHTTPClientTransport) { + await client.close().catch(() => {}); + log.info({ server: name }, "Streamable HTTP failed, falling back to SSE"); + transport = new SSEClientTransport(new URL(serverConfig.url)); + const fallbackClient = new Client({ name: `teleton-${name}`, version: "1.0.0" }); + await Promise.race([ + fallbackClient.connect(transport), + new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => + reject( + new Error(`SSE fallback timed out after ${MCP_CONNECT_TIMEOUT_MS / 1000}s`) + ), + MCP_CONNECT_TIMEOUT_MS + ); + }), + ]).finally(() => clearTimeout(timeoutHandle)); + return { + serverName: name, + client: fallbackClient, + scope: serverConfig.scope ?? "always", + }; + } + throw err; + } return { serverName: name, client, scope: serverConfig.scope ?? "always" }; }) @@ -125,9 +155,11 @@ export async function loadMcpServers(config: McpConfig): Promise { + const servers = typeof deps.mcpServers === "function" ? deps.mcpServers() : deps.mcpServers; const response: APIResponse = { success: true, - data: deps.mcpServers, + data: servers, }; return c.json(response); }); diff --git a/src/webui/types.ts b/src/webui/types.ts index d100949..e5e1354 100644 --- a/src/webui/types.ts +++ b/src/webui/types.ts @@ -15,7 +15,7 @@ export interface LoadedPlugin { export interface McpServerInfo { name: string; - type: "stdio" | "sse"; + type: "stdio" | "sse" | "streamable-http"; target: string; scope: string; enabled: boolean; @@ -35,7 +35,7 @@ export interface WebUIServerDeps { }; toolRegistry: ToolRegistry; plugins: LoadedPlugin[]; - mcpServers: McpServerInfo[]; + mcpServers: McpServerInfo[] | (() => McpServerInfo[]); config: WebUIConfig; configPath: string; lifecycle?: AgentLifecycle; From b207bba2d8044c8df33463b327c289e948a1d2c3 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 03:05:04 +0100 Subject: [PATCH 04/41] feat(mcp): live mcpServers getter + streamable-http type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcpServers passed as lazy function () => [...] for live status (not a snapshot frozen at startup) - Add streamable-http detection in type guard (command → stdio, url → streamable-http, else → sse) - McpServerInfo.type extended with 'streamable-http' in API types - Unused params prefixed with _ to satisfy noUnusedParameters --- src/index.ts | 43 ++++++++++++++++++++++++------------------- web/src/lib/api.ts | 30 ++++++++++++++---------------- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/index.ts b/src/index.ts index eb1ab1f..d681d06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -164,25 +164,30 @@ ${blue} ┌────────────────────── if (this.config.webui.enabled) { try { const { WebUIServer } = await import("./webui/server.js"); - // Build MCP server info for WebUI - const mcpServers = Object.entries(this.config.mcp.servers).map(([name, serverConfig]) => { - const type = serverConfig.command ? ("stdio" as const) : ("sse" as const); - const target = serverConfig.command ?? serverConfig.url ?? ""; - const connected = this.mcpConnections.some((c) => c.serverName === name); - const moduleName = `mcp_${name}`; - const moduleTools = this.toolRegistry.getModuleTools(moduleName); - return { - name, - type, - target, - scope: serverConfig.scope ?? "always", - enabled: serverConfig.enabled ?? true, - connected, - toolCount: moduleTools.length, - tools: moduleTools.map((t) => t.name), - envKeys: Object.keys(serverConfig.env ?? {}), - }; - }); + // Build MCP server info getter for WebUI (live status, not a snapshot) + const mcpServers = () => + Object.entries(this.config.mcp.servers).map(([name, serverConfig]) => { + const type = serverConfig.command + ? ("stdio" as const) + : serverConfig.url + ? ("streamable-http" as const) + : ("sse" as const); + const target = serverConfig.command ?? serverConfig.url ?? ""; + const connected = this.mcpConnections.some((c) => c.serverName === name); + const moduleName = `mcp_${name}`; + const moduleTools = this.toolRegistry.getModuleTools(moduleName); + return { + name, + type, + target, + scope: serverConfig.scope ?? "always", + enabled: serverConfig.enabled ?? true, + connected, + toolCount: moduleTools.length, + tools: moduleTools.map((t) => t.name), + envKeys: Object.keys(serverConfig.env ?? {}), + }; + }); const builtinNames = this.modules.map((m) => m.name); const pluginContext: PluginContext = { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b5299ea..2be1417 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -185,7 +185,7 @@ export interface ToolRagStatus { export interface McpServerInfo { name: string; - type: 'stdio' | 'sse'; + type: 'stdio' | 'sse' | 'streamable-http'; target: string; scope: string; enabled: boolean; @@ -391,10 +391,10 @@ export const api = { }); }, - async workspaceList(path = '', recursive = false) { + async workspaceList(_path = '', _recursive = false) { const params = new URLSearchParams(); - if (path) params.set('path', path); - if (recursive) params.set('recursive', 'true'); + if (_path) params.set('path', _path); + if (_recursive) params.set('recursive', 'true'); const qs = params.toString(); return fetchAPI>(`/workspace${qs ? `?${qs}` : ''}`); }, @@ -435,8 +435,8 @@ export const api = { return fetchAPI>('/workspace/info'); }, - async tasksList(status?: string) { - const qs = status ? `?status=${status}` : ''; + async tasksList(_status?: string) { + const qs = _status ? `?status=${_status}` : ''; return fetchAPI>(`/tasks${qs}`); }, @@ -444,12 +444,12 @@ export const api = { return fetchAPI>(`/tasks/${id}`); }, - async tasksDelete(id: string) { - return fetchAPI>(`/tasks/${id}`, { method: 'DELETE' }); + async tasksDelete(_id: string) { + return fetchAPI>(`/tasks/${_id}`, { method: 'DELETE' }); }, - async tasksCancel(id: string) { - return fetchAPI>(`/tasks/${id}/cancel`, { method: 'POST' }); + async tasksCancel(_id: string) { + return fetchAPI>(`/tasks/${_id}/cancel`, { method: 'POST' }); }, async tasksCleanDone() { @@ -473,8 +473,8 @@ export const api = { }); }, - async getMarketplace(refresh = false) { - const qs = refresh ? '?refresh=true' : ''; + async getMarketplace(_refresh = false) { + const qs = _refresh ? '?refresh=true' : ''; return fetchAPI>(`/marketplace${qs}`); }, @@ -517,9 +517,7 @@ export const api = { }, connectLogs(onLog: (entry: LogEntry) => void, onError?: (error: Event) => void) { - // No token needed — HttpOnly cookie is sent automatically by the browser const url = `${API_BASE}/logs/stream`; - const eventSource = new EventSource(url); eventSource.addEventListener('log', (event) => { @@ -548,8 +546,8 @@ export const setup = { getProviders: () => fetchSetupAPI('/setup/providers'), - getModels: (provider: string) => - fetchSetupAPI(`/setup/models/${encodeURIComponent(provider)}`), + getModels: (_provider: string) => + fetchSetupAPI(`/setup/models/${encodeURIComponent(_provider)}`), validateApiKey: (provider: string, apiKey: string) => fetchSetupAPI<{ valid: boolean; error?: string }>('/setup/validate/api-key', { From cc02d47a531766277299caafc06b651b49121351 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 03:05:17 +0100 Subject: [PATCH 05/41] fix(sdk): sendJetton robustness + decimals safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap entire sendJetton flow in try/catch for consistent PluginSDKError propagation — raw errors no longer bubble up untyped - Remove SendMode.IGNORE_ERRORS: transaction errors are now surfaced instead of silently swallowed - Fix || → ?? on jetton decimals: prevents 0-decimal tokens from incorrectly falling back to 9 decimals --- src/sdk/ton.ts | 170 ++++++++++++++++++++++++++----------------------- 1 file changed, 89 insertions(+), 81 deletions(-) diff --git a/src/sdk/ton.ts b/src/sdk/ton.ts index 6a9fd6a..10a8a82 100644 --- a/src/sdk/ton.ts +++ b/src/sdk/ton.ts @@ -246,7 +246,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T const { balance, wallet_address, jetton } = item; if (jetton.verification === "blacklist") continue; - const decimals = jetton.decimals || 9; + const decimals = jetton.decimals ?? 9; const rawBalance = BigInt(balance); const divisor = BigInt(10 ** decimals); const wholePart = rawBalance / divisor; @@ -332,95 +332,103 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T throw new PluginSDKError("Invalid recipient address", "INVALID_ADDRESS"); } - // Get sender's jetton wallet from balances - const jettonsResponse = await tonapiFetch(`/accounts/${walletData.address}/jettons`); - if (!jettonsResponse.ok) { - throw new PluginSDKError( - `Failed to fetch jetton balances: ${jettonsResponse.status}`, - "OPERATION_FAILED" + try { + // Get sender's jetton wallet from balances + const jettonsResponse = await tonapiFetch(`/accounts/${walletData.address}/jettons`); + if (!jettonsResponse.ok) { + throw new PluginSDKError( + `Failed to fetch jetton balances: ${jettonsResponse.status}`, + "OPERATION_FAILED" + ); + } + + const jettonsData = await jettonsResponse.json(); + const jettonBalance = jettonsData.balances?.find( + (b: any) => + b.jetton.address.toLowerCase() === jettonAddress.toLowerCase() || + Address.parse(b.jetton.address).toString() === Address.parse(jettonAddress).toString() ); - } - const jettonsData = await jettonsResponse.json(); - const jettonBalance = jettonsData.balances?.find( - (b: any) => - b.jetton.address.toLowerCase() === jettonAddress.toLowerCase() || - Address.parse(b.jetton.address).toString() === Address.parse(jettonAddress).toString() - ); + if (!jettonBalance) { + throw new PluginSDKError( + `You don't own any of this jetton: ${jettonAddress}`, + "OPERATION_FAILED" + ); + } - if (!jettonBalance) { - throw new PluginSDKError( - `You don't own any of this jetton: ${jettonAddress}`, - "OPERATION_FAILED" - ); - } + const senderJettonWallet = jettonBalance.wallet_address.address; + const decimals = jettonBalance.jetton.decimals ?? 9; + const currentBalance = BigInt(jettonBalance.balance); + const amountStr = amount.toFixed(decimals); + const [whole, frac = ""] = amountStr.split("."); + const amountInUnits = BigInt(whole + (frac + "0".repeat(decimals)).slice(0, decimals)); + + if (amountInUnits > currentBalance) { + throw new PluginSDKError( + `Insufficient balance. Have ${Number(currentBalance) / 10 ** decimals}, need ${amount}`, + "OPERATION_FAILED" + ); + } - const senderJettonWallet = jettonBalance.wallet_address.address; - const decimals = jettonBalance.jetton.decimals || 9; - const currentBalance = BigInt(jettonBalance.balance); - const amountStr = amount.toFixed(decimals); - const [whole, frac = ""] = amountStr.split("."); - const amountInUnits = BigInt(whole + (frac + "0".repeat(decimals)).slice(0, decimals)); + const comment = opts?.comment; - if (amountInUnits > currentBalance) { - throw new PluginSDKError( - `Insufficient balance. Have ${Number(currentBalance) / 10 ** decimals}, need ${amount}`, - "OPERATION_FAILED" - ); - } + // Build forward payload (comment) + let forwardPayload = beginCell().endCell(); + if (comment) { + forwardPayload = beginCell().storeUint(0, 32).storeStringTail(comment).endCell(); + } - const comment = opts?.comment; + // TEP-74 transfer message body + const JETTON_TRANSFER_OP = 0xf8a7ea5; + const messageBody = beginCell() + .storeUint(JETTON_TRANSFER_OP, 32) + .storeUint(0, 64) // query_id + .storeCoins(amountInUnits) + .storeAddress(Address.parse(to)) + .storeAddress(Address.parse(walletData.address)) // response_destination + .storeBit(false) // no custom_payload + .storeCoins(comment ? toNano("0.01") : BigInt(1)) // forward_ton_amount + .storeBit(comment ? true : false) + .storeMaybeRef(comment ? forwardPayload : null) + .endCell(); + + const keyPair = await getKeyPair(); + if (!keyPair) { + throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); + } - // Build forward payload (comment) - let forwardPayload = beginCell().endCell(); - if (comment) { - forwardPayload = beginCell().storeUint(0, 32).storeStringTail(comment).endCell(); - } + const wallet = WalletContractV5R1.create({ + workchain: 0, + publicKey: keyPair.publicKey, + }); - // TEP-74 transfer message body - const JETTON_TRANSFER_OP = 0xf8a7ea5; - const messageBody = beginCell() - .storeUint(JETTON_TRANSFER_OP, 32) - .storeUint(0, 64) // query_id - .storeCoins(amountInUnits) - .storeAddress(Address.parse(to)) - .storeAddress(Address.parse(walletData.address)) // response_destination - .storeBit(false) // no custom_payload - .storeCoins(comment ? toNano("0.01") : BigInt(1)) // forward_ton_amount - .storeBit(comment ? true : false) - .storeMaybeRef(comment ? forwardPayload : null) - .endCell(); - - const keyPair = await getKeyPair(); - if (!keyPair) { - throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); - } + const endpoint = await getCachedHttpEndpoint(); + const client = new TonClient({ endpoint }); + const walletContract = client.open(wallet); + const seqno = await walletContract.getSeqno(); + + await walletContract.sendTransfer({ + seqno, + secretKey: keyPair.secretKey, + sendMode: SendMode.PAY_GAS_SEPARATELY, + messages: [ + internal({ + to: Address.parse(senderJettonWallet), + value: toNano("0.05"), + body: messageBody, + bounce: true, + }), + ], + }); - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - - const endpoint = await getCachedHttpEndpoint(); - const client = new TonClient({ endpoint }); - const walletContract = client.open(wallet); - const seqno = await walletContract.getSeqno(); - - await walletContract.sendTransfer({ - seqno, - secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS, - messages: [ - internal({ - to: Address.parse(senderJettonWallet), - value: toNano("0.05"), - body: messageBody, - bounce: true, - }), - ], - }); - - return { success: true, seqno }; + return { success: true, seqno }; + } catch (err) { + if (err instanceof PluginSDKError) throw err; + throw new PluginSDKError( + `Failed to send jetton: ${err instanceof Error ? err.message : String(err)}`, + "OPERATION_FAILED" + ); + } }, async getJettonWalletAddress( From 187747d3a34c748c91264326ab554c1e1ff59453 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 03:05:29 +0100 Subject: [PATCH 06/41] fix(webui): neutralize btn-danger and alert colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - btn-danger: red accent → neutral surface/border (glass style) - alert.success / alert.error: green/red backgrounds → uniform rgba(255,255,255,0.04) with subtle border --- web/src/index.css | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/index.css b/web/src/index.css index d92cd24..754276c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -497,13 +497,13 @@ button.btn-ghost:hover { } button.btn-danger { - background: var(--red-dim); - color: var(--red); - border: 1px solid rgba(255, 69, 58, 0.15); + background: var(--surface); + color: var(--text); + border: 1px solid var(--glass-border); } button.btn-danger:hover { - background: rgba(255, 69, 58, 0.2); + background: var(--surface-hover); opacity: 1; } @@ -565,15 +565,15 @@ button.btn-sm { } .alert.success { - background: var(--green-dim); - color: var(--green); - border: 1px solid rgba(48, 209, 88, 0.2); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + border: 1px solid rgba(255, 255, 255, 0.08); } .alert.error { - background: var(--red-dim); - color: var(--red); - border: 1px solid rgba(255, 69, 58, 0.2); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + border: 1px solid rgba(255, 255, 255, 0.08); } /* ---- File rows (table rows in Workspace, Tasks) ---- */ From 51e738100defafb0f1ede2c728e997e8abd81bc5 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 03:05:36 +0100 Subject: [PATCH 07/41] docs: update changelog [Unreleased] --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 271c696..481e550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Agent Run/Stop control**: Separate agent lifecycle from WebUI — start/stop the agent at runtime without killing the server. New `AgentLifecycle` state machine (`stopped/starting/running/stopping`), REST endpoints (`POST /api/agent/start`, `/stop`, `GET /api/agent/status`), SSE endpoint (`GET /api/agent/events`) for real-time state push, `useAgentStatus` hook (SSE + polling fallback), and `AgentControl` sidebar component with confirmation dialog +- **MCP Streamable HTTP transport**: `StreamableHTTPClientTransport` as primary transport for URL-based MCP servers, with automatic fallback to `SSEClientTransport` on failure. `mcpServers` list is now a lazy function for live status. Resource cleanup (AbortController, sockets) on fallback. Improved error logging with stack traces + +### Fixed +- **WebUI setup wizard**: Neutralize color accent overuse — selection states, warning cards, tag pills, step dots all moved to neutral white/grey palette; security notice collapsed into `
`; "Optional Integrations" renamed to "Optional API Keys"; bot token marked as "(recommended)" +- **Jetton send**: Wrap entire `sendJetton` flow in try/catch for consistent `PluginSDKError` propagation; remove `SendMode.IGNORE_ERRORS` (errors are no longer silently swallowed); fix `||` → `??` on jetton decimals (prevents `0` decimals being replaced by `9`) + ## [0.7.0] - 2026-02-21 ### Added From c268ab2d654e2de644f8f21592a7bfbd9ed184d4 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 03:11:29 +0100 Subject: [PATCH 08/41] docs: add logo as banner above title in README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index afe12b1..e6ffdc2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -

Teleton Agent

+

+ Teleton Agent +

Autonomous AI agent platform for Telegram with native TON blockchain integration

From 516a620ad992ebf223d54d5bb07fb1b2ec14824d Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 03:16:53 +0100 Subject: [PATCH 09/41] docs: add TON badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e6ffdc2..a73f0fd 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ TypeScript Website Ask DeepWiki + Built on TON

--- From 77fb97fec4ebf870374f343c725e7371518af5bb Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 03:38:44 +0100 Subject: [PATCH 10/41] docs: replace DeepWiki badge with official docs + update provider/tool counts - Replace DeepWiki badge with docs badge linking to docs.teletonagent.dev - Update LLM provider count from 6 to 10 (add Moonshot, Mistral, Cocoon, Local) - Update built-in tools count from 114 to 100+ --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a73f0fd..f51ce35 100644 --- a/README.md +++ b/README.md @@ -9,22 +9,22 @@ Node.js TypeScript Website - Ask DeepWiki + Documentation Built on TON

--- -

Teleton is an autonomous AI agent platform that operates as a real Telegram user account (not a bot). It thinks through an agentic loop with tool calling, remembers conversations across sessions with hybrid RAG, and natively integrates the TON blockchain: send crypto, swap on DEXs, bid on domains, verify payments - all from a chat message. It can schedule tasks to run autonomously at any time. It ships with 114 built-in tools, supports 6 LLM providers, and exposes a Plugin SDK so you can build your own tools on top of the platform.

+

Teleton is an autonomous AI agent platform that operates as a real Telegram user account (not a bot). It thinks through an agentic loop with tool calling, remembers conversations across sessions with hybrid RAG, and natively integrates the TON blockchain: send crypto, swap on DEXs, bid on domains, verify payments - all from a chat message. It can schedule tasks to run autonomously at any time. It ships with 100+ built-in tools, supports 10 LLM providers, and exposes a Plugin SDK so you can build your own tools on top of the platform.

### Key Highlights - **Full Telegram access** - Operates as a real user via MTProto (GramJS), not a limited bot - **Agentic loop** - Up to 5 iterations of tool calling per message, the agent thinks, acts, observes, and repeats -- **Multi-Provider LLM** - Anthropic, OpenAI, Google Gemini, xAI Grok, Groq, OpenRouter +- **Multi-Provider LLM** - Anthropic, OpenAI, Google Gemini, xAI Grok, Groq, OpenRouter, Moonshot, Mistral, Cocoon, Local - **TON Blockchain** - Built-in W5R1 wallet, send/receive TON & jettons, swap on STON.fi and DeDust, NFTs, DNS domains - **Persistent memory** - Hybrid RAG (sqlite-vec + FTS5), auto-compaction with AI summarization, daily logs -- **114 built-in tools** - Messaging, media, blockchain, DEX trading, deals, DNS, journaling, and more +- **100+ built-in tools** - Messaging, media, blockchain, DEX trading, deals, DNS, journaling, and more - **Plugin SDK** - Extend the agent with custom tools, frozen SDK with isolated databases, secrets management, lifecycle hooks - **MCP Client** - Connect external tool servers (stdio/SSE) with 2 lines of YAML, no code, no rebuild - **Secure by design** - Prompt injection defense, sandboxed workspace, plugin isolation, wallet encryption @@ -51,7 +51,7 @@ | Capability | Description | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| **Multi-Provider LLM** | Switch between Anthropic, OpenAI, Google, xAI, Groq, OpenRouter with one config change | +| **Multi-Provider LLM** | Switch between Anthropic, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cocoon, or Local with one config change | | **RAG + Hybrid Search** | Local ONNX embeddings (384d) or Voyage AI (512d/1024d) with FTS5 keyword + sqlite-vec cosine similarity, fused via RRF | | **Auto-Compaction** | AI-summarized context management prevents overflow, preserves key information in `memory/*.md` files | | **Observation Masking** | Compresses old tool results to one-line summaries, saving ~90% context window | @@ -408,7 +408,7 @@ src/ ├── agent/ # Core agent runtime │ ├── runtime.ts # Agentic loop (5 iterations, tool calling, masking, compaction) │ ├── client.ts # Multi-provider LLM client -│ └── tools/ # 114 built-in tools +│ └── tools/ # 100+ built-in tools │ ├── register-all.ts # Central tool registration (8 categories, 109 tools) │ ├── registry.ts # Tool registry, scope filtering, provider limits │ ├── module-loader.ts # Built-in module loading (deals → +5 tools) @@ -463,7 +463,7 @@ src/ │ └── loader.ts # 10 sections: soul + security + strategy + memory + context + ... ├── config/ # Configuration │ ├── schema.ts # Zod schemas + validation -│ └── providers.ts # Multi-provider LLM registry (6 providers) +│ └── providers.ts # Multi-provider LLM registry (10 providers) ├── webui/ # Optional web dashboard │ ├── server.ts # Hono server, auth middleware, static serving │ └── routes/ # 11 API route groups (status, tools, logs, memory, soul, plugins, mcp, tasks, workspace, config, marketplace) From 442d3a5e738d5d815aef078a22e26d0699154c4a Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 03:41:23 +0100 Subject: [PATCH 11/41] chore: bump to v0.7.1 --- CHANGELOG.md | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 481e550..31d47c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.1] - 2026-02-23 + ### Added - **Agent Run/Stop control**: Separate agent lifecycle from WebUI — start/stop the agent at runtime without killing the server. New `AgentLifecycle` state machine (`stopped/starting/running/stopping`), REST endpoints (`POST /api/agent/start`, `/stop`, `GET /api/agent/status`), SSE endpoint (`GET /api/agent/events`) for real-time state push, `useAgentStatus` hook (SSE + polling fallback), and `AgentControl` sidebar component with confirmation dialog - **MCP Streamable HTTP transport**: `StreamableHTTPClientTransport` as primary transport for URL-based MCP servers, with automatic fallback to `SSEClientTransport` on failure. `mcpServers` list is now a lazy function for live status. Resource cleanup (AbortController, sockets) on fallback. Improved error logging with stack traces @@ -276,7 +278,8 @@ Git history rewritten to fix commit attribution (email update from `tonresistor@ - Professional distribution (npm, Docker, CI/CD) - Pre-commit hooks and linting infrastructure -[Unreleased]: https://github.com/TONresistor/teleton-agent/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/TONresistor/teleton-agent/compare/v0.7.1...HEAD +[0.7.1]: https://github.com/TONresistor/teleton-agent/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/TONresistor/teleton-agent/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/TONresistor/teleton-agent/compare/v0.5.2...v0.6.0 [0.5.2]: https://github.com/TONresistor/teleton-agent/compare/v0.5.1...v0.5.2 diff --git a/package.json b/package.json index d187acc..fffd497 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teleton", - "version": "0.7.0", + "version": "0.7.1", "workspaces": [ "packages/*" ], From 8a71e2abc06bb7c8b932a65e02daa1694cac77b4 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 16:10:50 +0100 Subject: [PATCH 12/41] fix(webui): plugins route now reflects runtime-loaded plugins GET /api/plugins was returning a stale snapshot created at WebUI init, before startAgent() loaded external plugins into this.modules. Now computes dynamically from deps.marketplace.modules (live reference) using the same isPluginModule filter already used in marketplace routes. Falls back to deps.plugins if marketplace is not configured. --- src/webui/routes/plugins.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/webui/routes/plugins.ts b/src/webui/routes/plugins.ts index 6915620..fdcc602 100644 --- a/src/webui/routes/plugins.ts +++ b/src/webui/routes/plugins.ts @@ -4,13 +4,15 @@ import type { WebUIServerDeps, APIResponse, LoadedPlugin } from "../types.js"; export function createPluginsRoutes(deps: WebUIServerDeps) { const app = new Hono(); - // List all loaded plugins + // List all loaded plugins — computed dynamically so plugins loaded after + // WebUI startup (via startAgent) are always reflected in the response. app.get("/", (c) => { - const response: APIResponse = { - success: true, - data: deps.plugins, - }; - return c.json(response); + const data = deps.marketplace + ? deps.marketplace.modules + .filter((m) => deps.toolRegistry.isPluginModule(m.name)) + .map((m) => ({ name: m.name, version: m.version ?? "0.0.0" })) + : deps.plugins; + return c.json>({ success: true, data }); }); return app; From 6f4fa10f702cc23b83af67b832c12b6fa1d146bc Mon Sep 17 00:00:00 2001 From: TONresistor Date: Mon, 23 Feb 2026 19:56:58 +0100 Subject: [PATCH 13/41] chore: bump to v0.7.2 --- package.json | 2 +- src/sdk/__tests__/ton.test.ts | 24 +++++++-- src/sdk/ton.ts | 58 +++++++++++---------- src/ton/transfer.ts | 95 ++++++++++++++++++----------------- src/ton/tx-lock.ts | 15 ++++++ 5 files changed, 116 insertions(+), 78 deletions(-) create mode 100644 src/ton/tx-lock.ts diff --git a/package.json b/package.json index fffd497..7c42622 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teleton", - "version": "0.7.1", + "version": "0.7.2", "workspaces": [ "packages/*" ], diff --git a/src/sdk/__tests__/ton.test.ts b/src/sdk/__tests__/ton.test.ts index 3c4c3f8..b1972e0 100644 --- a/src/sdk/__tests__/ton.test.ts +++ b/src/sdk/__tests__/ton.test.ts @@ -963,57 +963,73 @@ describe("createTonSDK", () => { // UTILITY METHODS // ═══════════════════════════════════════════════════════════════ - // Note: toNano/fromNano/validateAddress use require() at runtime, - // which loads the real @ton/ton and @ton/core modules (not mocked). - // We test them against the real implementations. + // These now use top-level ESM imports (mocked by vi.mock). + // We configure the mock return values to match the real behaviour. describe("Utility methods", () => { describe("toNano()", () => { it("converts a number to nanoTON", () => { + mocks.toNano.mockReturnValue(BigInt("1500000000")); const result = sdk.toNano(1.5); + expect(mocks.toNano).toHaveBeenCalledWith("1.5"); expect(result).toBe(BigInt("1500000000")); }); it("converts a string to nanoTON", () => { + mocks.toNano.mockReturnValue(BigInt("2000000000")); const result = sdk.toNano("2"); + expect(mocks.toNano).toHaveBeenCalledWith("2"); expect(result).toBe(BigInt("2000000000")); }); it("converts zero", () => { + mocks.toNano.mockReturnValue(BigInt(0)); expect(sdk.toNano(0)).toBe(BigInt(0)); }); it("throws PluginSDKError on invalid input", () => { + mocks.toNano.mockImplementation(() => { + throw new Error("Invalid number"); + }); expect(() => sdk.toNano("not_a_number")).toThrow(PluginSDKError); }); }); describe("fromNano()", () => { it("converts nanoTON bigint to string", () => { + mocks.fromNano.mockReturnValue("1.5"); const result = sdk.fromNano(BigInt("1500000000")); expect(result).toBe("1.5"); }); it("converts nanoTON string to string", () => { + mocks.fromNano.mockReturnValue("3"); const result = sdk.fromNano("3000000000"); expect(result).toBe("3"); }); it("converts zero", () => { + mocks.fromNano.mockReturnValue("0"); expect(sdk.fromNano(BigInt(0))).toBe("0"); }); }); describe("validateAddress()", () => { it("returns true for a valid TON address", () => { - // Use the real @ton/core Address.parse + mocks.addressParse.mockReturnValue({}); expect(sdk.validateAddress(VALID_ADDRESS)).toBe(true); }); it("returns false for an invalid address", () => { + mocks.addressParse.mockImplementation(() => { + throw new Error("Invalid"); + }); expect(sdk.validateAddress("not-an-address")).toBe(false); }); it("returns false for empty string", () => { + mocks.addressParse.mockImplementation(() => { + throw new Error("Invalid"); + }); expect(sdk.validateAddress("")).toBe(false); }); }); diff --git a/src/sdk/ton.ts b/src/sdk/ton.ts index 10a8a82..295f46d 100644 --- a/src/sdk/ton.ts +++ b/src/sdk/ton.ts @@ -25,6 +25,9 @@ import { sendTon } from "../ton/transfer.js"; import { PAYMENT_TOLERANCE_RATIO } from "../constants/limits.js"; import { withBlockchainRetry } from "../utils/retry.js"; import { tonapiFetch } from "../constants/api-endpoints.js"; +import { toNano as tonToNano, fromNano as tonFromNano } from "@ton/ton"; +import { Address as TonAddress } from "@ton/core"; +import { withTxLock } from "../ton/tx-lock.js"; const DEFAULT_MAX_AGE_MINUTES = 10; @@ -397,28 +400,32 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); } - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); + const seqno = await withTxLock(async () => { + const wallet = WalletContractV5R1.create({ + workchain: 0, + publicKey: keyPair.publicKey, + }); - const endpoint = await getCachedHttpEndpoint(); - const client = new TonClient({ endpoint }); - const walletContract = client.open(wallet); - const seqno = await walletContract.getSeqno(); - - await walletContract.sendTransfer({ - seqno, - secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, - messages: [ - internal({ - to: Address.parse(senderJettonWallet), - value: toNano("0.05"), - body: messageBody, - bounce: true, - }), - ], + const endpoint = await getCachedHttpEndpoint(); + const client = new TonClient({ endpoint }); + const walletContract = client.open(wallet); + const seq = await walletContract.getSeqno(); + + await walletContract.sendTransfer({ + seqno: seq, + secretKey: keyPair.secretKey, + sendMode: SendMode.PAY_GAS_SEPARATELY, + messages: [ + internal({ + to: Address.parse(senderJettonWallet), + value: toNano("0.05"), + body: messageBody, + bounce: true, + }), + ], + }); + + return seq; }); return { success: true, seqno }; @@ -506,8 +513,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T toNano(amount: number | string): bigint { try { - const { toNano: convert } = require("@ton/ton"); - return convert(String(amount)); + return tonToNano(String(amount)); } catch (err) { throw new PluginSDKError( `toNano conversion failed: ${err instanceof Error ? err.message : String(err)}`, @@ -517,14 +523,12 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T }, fromNano(nano: bigint | string): string { - const { fromNano: convert } = require("@ton/ton"); - return convert(nano); + return tonFromNano(nano); }, validateAddress(address: string): boolean { try { - const { Address } = require("@ton/core"); - Address.parse(address); + TonAddress.parse(address); return true; } catch { return false; diff --git a/src/ton/transfer.ts b/src/ton/transfer.ts index af59ddd..d13e55a 100644 --- a/src/ton/transfer.ts +++ b/src/ton/transfer.ts @@ -3,6 +3,7 @@ import { Address, SendMode } from "@ton/core"; import { getCachedHttpEndpoint } from "./endpoint.js"; import { getKeyPair } from "./wallet-service.js"; import { createLogger } from "../utils/logger.js"; +import { withTxLock } from "./tx-lock.js"; const log = createLogger("TON"); @@ -14,60 +15,62 @@ export interface SendTonParams { } export async function sendTon(params: SendTonParams): Promise { - try { - const { toAddress, amount, comment = "", bounce = false } = params; + return withTxLock(async () => { + try { + const { toAddress, amount, comment = "", bounce = false } = params; - if (!Number.isFinite(amount) || amount <= 0) { - log.error({ amount }, "Invalid transfer amount"); - return null; - } + if (!Number.isFinite(amount) || amount <= 0) { + log.error({ amount }, "Invalid transfer amount"); + return null; + } - let recipientAddress: Address; - try { - recipientAddress = Address.parse(toAddress); - } catch (e) { - log.error({ err: e }, `Invalid recipient address: ${toAddress}`); - return null; - } + let recipientAddress: Address; + try { + recipientAddress = Address.parse(toAddress); + } catch (e) { + log.error({ err: e }, `Invalid recipient address: ${toAddress}`); + return null; + } - const keyPair = await getKeyPair(); - if (!keyPair) { - log.error("Wallet not initialized"); - return null; - } + const keyPair = await getKeyPair(); + if (!keyPair) { + log.error("Wallet not initialized"); + return null; + } - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); + const wallet = WalletContractV5R1.create({ + workchain: 0, + publicKey: keyPair.publicKey, + }); - const endpoint = await getCachedHttpEndpoint(); - const client = new TonClient({ endpoint }); - const contract = client.open(wallet); + const endpoint = await getCachedHttpEndpoint(); + const client = new TonClient({ endpoint }); + const contract = client.open(wallet); - const seqno = await contract.getSeqno(); + const seqno = await contract.getSeqno(); - await contract.sendTransfer({ - seqno, - secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, - messages: [ - internal({ - to: recipientAddress, - value: toNano(amount), - body: comment, - bounce, - }), - ], - }); + await contract.sendTransfer({ + seqno, + secretKey: keyPair.secretKey, + sendMode: SendMode.PAY_GAS_SEPARATELY, + messages: [ + internal({ + to: recipientAddress, + value: toNano(amount), + body: comment, + bounce, + }), + ], + }); - const pseudoHash = `${seqno}_${Date.now()}_${amount.toFixed(2)}`; + const pseudoHash = `${seqno}_${Date.now()}_${amount.toFixed(2)}`; - log.info(`Sent ${amount} TON to ${toAddress.slice(0, 8)}... - seqno: ${seqno}`); + log.info(`Sent ${amount} TON to ${toAddress.slice(0, 8)}... - seqno: ${seqno}`); - return pseudoHash; - } catch (error) { - log.error({ err: error }, "Error sending TON"); - return null; - } + return pseudoHash; + } catch (error) { + log.error({ err: error }, "Error sending TON"); + return null; + } + }); // withTxLock } diff --git a/src/ton/tx-lock.ts b/src/ton/tx-lock.ts new file mode 100644 index 0000000..d85a75d --- /dev/null +++ b/src/ton/tx-lock.ts @@ -0,0 +1,15 @@ +/** + * Simple async mutex for TON wallet transactions. + * Ensures the seqno read → sendTransfer sequence is atomic, + * preventing two concurrent calls from getting the same seqno. + */ +let pending: Promise = Promise.resolve(); + +export function withTxLock(fn: () => Promise): Promise { + const execute = pending.then(fn, fn); + pending = execute.then( + () => {}, + () => {} + ); + return execute; +} From c8a9bb57a986f0a286f56c181e30cc6d1332834d Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 03:08:07 +0100 Subject: [PATCH 14/41] fix(ton): TEP-74 encoding, infra robustness and tests --- src/agent/tools/deals/cancel.ts | 14 +- src/agent/tools/deals/list.ts | 9 +- src/agent/tools/deals/propose.ts | 24 +-- src/agent/tools/deals/status.ts | 11 +- src/agent/tools/deals/verify-payment.ts | 11 +- src/agent/tools/dedust/pools.ts | 3 +- src/agent/tools/dedust/prices.ts | 3 +- src/agent/tools/dedust/quote.ts | 2 +- src/agent/tools/dedust/swap.ts | 2 +- src/agent/tools/dedust/token-info.ts | 2 +- src/agent/tools/dns/auctions.ts | 3 +- src/agent/tools/dns/bid.ts | 2 +- src/agent/tools/dns/check.ts | 3 +- src/agent/tools/dns/link.ts | 3 +- src/agent/tools/dns/resolve.ts | 3 +- src/agent/tools/dns/start-auction.ts | 2 +- src/agent/tools/dns/unlink.ts | 3 +- src/agent/tools/journal/log.ts | 17 +- src/agent/tools/journal/query.ts | 15 +- src/agent/tools/journal/update.ts | 18 +- src/agent/tools/stonfi/pools.ts | 3 +- src/agent/tools/stonfi/quote.ts | 3 +- src/agent/tools/stonfi/search.ts | 2 +- src/agent/tools/stonfi/swap.ts | 2 +- src/agent/tools/stonfi/trending.ts | 3 +- .../tools/telegram/chats/create-channel.ts | 2 +- .../tools/telegram/chats/edit-channel-info.ts | 14 +- .../tools/telegram/chats/get-chat-info.ts | 2 +- src/agent/tools/telegram/chats/get-dialogs.ts | 2 +- .../tools/telegram/chats/invite-to-channel.ts | 14 +- .../tools/telegram/chats/join-channel.ts | 3 +- .../tools/telegram/chats/leave-channel.ts | 3 +- .../tools/telegram/chats/mark-as-read.ts | 2 +- .../tools/telegram/contacts/block-user.ts | 2 +- .../tools/telegram/contacts/check-username.ts | 16 +- .../tools/telegram/contacts/get-blocked.ts | 3 +- .../telegram/contacts/get-common-chats.ts | 3 +- .../tools/telegram/contacts/get-user-info.ts | 16 +- .../telegram/folders/add-chat-to-folder.ts | 2 +- .../tools/telegram/folders/create-folder.ts | 2 +- .../tools/telegram/folders/get-folders.ts | 3 +- .../tools/telegram/gifts/buy-resale-gift.ts | 2 +- .../telegram/gifts/get-available-gifts.ts | 2 +- .../tools/telegram/gifts/get-my-gifts.ts | 20 +- .../tools/telegram/gifts/get-resale-gifts.ts | 2 +- src/agent/tools/telegram/gifts/send-gift.ts | 2 +- .../telegram/gifts/set-collectible-price.ts | 2 +- .../tools/telegram/gifts/set-gift-status.ts | 14 +- .../telegram/gifts/transfer-collectible.ts | 9 +- src/agent/tools/telegram/groups/get-me.ts | 3 +- .../tools/telegram/groups/get-participants.ts | 2 +- .../tools/telegram/groups/set-chat-photo.ts | 2 +- .../tools/telegram/interactive/create-poll.ts | 2 +- .../tools/telegram/interactive/create-quiz.ts | 2 +- src/agent/tools/telegram/interactive/react.ts | 3 +- .../telegram/interactive/reply-keyboard.ts | 2 +- .../tools/telegram/interactive/send-dice.ts | 12 +- .../tools/telegram/media/download-media.ts | 2 +- src/agent/tools/telegram/media/send-gif.ts | 2 +- src/agent/tools/telegram/media/send-photo.ts | 3 +- .../tools/telegram/media/send-sticker.ts | 2 +- src/agent/tools/telegram/media/send-voice.ts | 26 +-- .../tools/telegram/media/vision-analyze.ts | 2 +- .../tools/telegram/memory/memory-read.ts | 2 +- .../tools/telegram/memory/memory-write.ts | 2 +- .../telegram/messaging/delete-message.ts | 2 +- .../tools/telegram/messaging/edit-message.ts | 3 +- .../telegram/messaging/forward-message.ts | 2 +- .../tools/telegram/messaging/get-replies.ts | 2 +- .../tools/telegram/messaging/quote-reply.ts | 2 +- .../telegram/messaging/schedule-message.ts | 2 +- .../telegram/messaging/search-messages.ts | 2 +- .../tools/telegram/messaging/send-message.ts | 2 +- src/agent/tools/telegram/profile/set-bio.ts | 2 +- .../tools/telegram/profile/set-username.ts | 2 +- .../tools/telegram/profile/update-profile.ts | 2 +- src/agent/tools/telegram/stars/get-balance.ts | 3 +- .../tools/telegram/stars/get-transactions.ts | 3 +- .../telegram/stickers/add-sticker-set.ts | 3 +- .../telegram/stickers/get-my-stickers.ts | 2 +- .../tools/telegram/stickers/search-gifs.ts | 2 +- .../telegram/stickers/search-stickers.ts | 2 +- .../tools/telegram/stories/send-story.ts | 3 +- .../telegram/tasks/create-scheduled-task.ts | 2 +- src/agent/tools/ton/chart.ts | 3 +- src/agent/tools/ton/dex-quote.ts | 2 +- src/agent/tools/ton/get-address.ts | 3 +- src/agent/tools/ton/get-balance.ts | 2 +- src/agent/tools/ton/get-price.ts | 2 +- src/agent/tools/ton/get-transactions.ts | 3 +- src/agent/tools/ton/jetton-balances.ts | 3 +- src/agent/tools/ton/jetton-history.ts | 3 +- src/agent/tools/ton/jetton-holders.ts | 3 +- src/agent/tools/ton/jetton-info.ts | 2 +- src/agent/tools/ton/jetton-price.ts | 3 +- src/agent/tools/ton/jetton-send.ts | 35 +-- src/agent/tools/ton/my-transactions.ts | 3 +- src/agent/tools/ton/send.ts | 50 +---- src/agent/tools/web/fetch.ts | 9 +- src/agent/tools/web/search.ts | 13 +- src/agent/tools/workspace/delete.ts | 12 +- src/agent/tools/workspace/info.ts | 8 +- src/agent/tools/workspace/list.ts | 11 +- src/agent/tools/workspace/read.ts | 14 +- src/agent/tools/workspace/rename.ts | 14 +- src/agent/tools/workspace/write.ts | 14 +- src/deals/executor.ts | 9 +- src/sdk/__tests__/ton-utils.real.test.ts | 199 ++++++++++++++++++ src/sdk/__tests__/ton.test.ts | 25 +-- src/sdk/ton.ts | 87 ++++---- src/ton/endpoint.ts | 5 + src/ton/payment-verifier.ts | 7 +- src/ton/transfer.ts | 16 +- src/ton/wallet-service.ts | 33 ++- 114 files changed, 447 insertions(+), 563 deletions(-) create mode 100644 src/sdk/__tests__/ton-utils.real.test.ts diff --git a/src/agent/tools/deals/cancel.ts b/src/agent/tools/deals/cancel.ts index 0eb47d8..a64b981 100644 --- a/src/agent/tools/deals/cancel.ts +++ b/src/agent/tools/deals/cancel.ts @@ -13,19 +13,7 @@ interface DealCancelParams { export const dealCancelTool: Tool = { name: "deal_cancel", - description: `Cancel an active deal (proposed or accepted status only). - -IMPORTANT: Cannot cancel deals that are: -- Already verified (payment received) -- Already completed -- Already declined, expired, or failed - -Use this when: -- User explicitly asks to cancel -- Deal terms change before verification -- External circumstances make deal impossible - -The deal status will be set to 'cancelled' and cannot be resumed.`, + description: "Cancel a deal. Only works for 'proposed' or 'accepted' status. Irreversible.", parameters: Type.Object({ dealId: Type.String({ description: "Deal ID to cancel" }), reason: Type.Optional(Type.String({ description: "Reason for cancellation (optional)" })), diff --git a/src/agent/tools/deals/list.ts b/src/agent/tools/deals/list.ts index 5d4da15..e41ee91 100644 --- a/src/agent/tools/deals/list.ts +++ b/src/agent/tools/deals/list.ts @@ -15,14 +15,7 @@ interface DealListParams { export const dealListTool: Tool = { name: "deal_list", - description: `List recent deals with optional filters. - -Filters: -- status: Filter by status (proposed, accepted, verified, completed, declined, expired, cancelled, failed) -- userId: Filter by user's Telegram ID -- limit: Max results (default 20) - -Returns summary of each deal with ID, status, parties, trade details, timestamps.`, + description: "List recent deals. Filter by status or user. Non-admins see only their own deals.", category: "data-bearing", parameters: Type.Object({ status: Type.Optional( diff --git a/src/agent/tools/deals/propose.ts b/src/agent/tools/deals/propose.ts index 435a8c3..5647c20 100644 --- a/src/agent/tools/deals/propose.ts +++ b/src/agent/tools/deals/propose.ts @@ -30,28 +30,8 @@ interface DealProposeParams { export const dealProposeTool: Tool = { name: "deal_propose", - description: `Create a trade deal proposal with interactive Accept/Decline buttons. - -Automatically sends an inline bot message with buttons in the chat. -The user can Accept or Decline directly from the message. - -IMPORTANT - MESSAGE FLOW: -- Send your message BEFORE calling this tool (e.g. "I'll create a deal for you") -- Do NOT send any message after this tool returns — the deal card already contains all info -- The inline bot message IS the proposal, no need to repeat deal details - -CRITICAL - STRATEGY.md ENFORCEMENT: -- When BUYING (you buy their gift): Pay max 80% of floor price -- When SELLING (you sell your gift): Charge min 115% of floor price -- Gift swaps: Must receive equal or more value -- User ALWAYS sends first (TON or gift) - -BEFORE proposing: -1. Check gift floor price if market plugin is available -2. Calculate values in TON -3. This tool will REJECT deals that violate strategy - -Deal expires in 2 minutes if not accepted.`, + description: + "Create a trade deal with Accept/Decline buttons. Sends an inline bot message — do NOT send another message after. Strategy compliance is enforced automatically (will reject bad deals). User always sends first. Expires in 2 minutes.", parameters: Type.Object({ chatId: Type.String({ description: "Chat ID where to send proposal" }), userId: Type.Number({ description: "Telegram user ID" }), diff --git a/src/agent/tools/deals/status.ts b/src/agent/tools/deals/status.ts index 9d57107..ff4bed5 100644 --- a/src/agent/tools/deals/status.ts +++ b/src/agent/tools/deals/status.ts @@ -13,15 +13,8 @@ interface DealStatusParams { export const dealStatusTool: Tool = { name: "deal_status", - description: `Check the status and details of a deal by ID. - -Shows: -- Deal parties (user, agent) -- What each side gives/receives -- Current status (proposed, accepted, verified, completed, etc.) -- Timestamps (created, expires, verified, completed) -- Payment/transfer tracking info (TX hashes, msgIds) -- Profit calculation`, + description: + "Get full details of a deal by ID: status, parties, assets, payment tracking, profit.", category: "data-bearing", parameters: Type.Object({ dealId: Type.String({ description: "Deal ID to check status for" }), diff --git a/src/agent/tools/deals/verify-payment.ts b/src/agent/tools/deals/verify-payment.ts index 7244a85..4c7da85 100644 --- a/src/agent/tools/deals/verify-payment.ts +++ b/src/agent/tools/deals/verify-payment.ts @@ -16,15 +16,8 @@ interface DealVerifyPaymentParams { export const dealVerifyPaymentTool: Tool = { name: "deal_verify_payment", - description: `Verify payment/gift for an ACCEPTED deal. - -For TON payments: Checks blockchain for transaction with memo = dealId -For gift transfers: Polls telegram_get_my_gifts for newly received gift - -Updates deal status to 'verified' if successful. -Auto-triggers executor after verification. - -IMPORTANT: Only call this for deals with status = 'accepted'.`, + description: + "Verify payment/gift for an accepted deal. Checks blockchain (TON) or gift inbox. Auto-executes on success. Only for status='accepted'.", parameters: Type.Object({ dealId: Type.String({ description: "Deal ID to verify payment for" }), }), diff --git a/src/agent/tools/dedust/pools.ts b/src/agent/tools/dedust/pools.ts index 1b0b876..d6c540b 100644 --- a/src/agent/tools/dedust/pools.ts +++ b/src/agent/tools/dedust/pools.ts @@ -44,8 +44,7 @@ interface DedustPoolResponse { } export const dedustPoolsTool: Tool = { name: "dedust_pools", - description: - "List liquidity pools on DeDust DEX. Can filter by jetton address or pool type. Shows reserves, fees, and trading volume.", + description: "List DeDust liquidity pools. Filter by jetton address or pool type.", category: "data-bearing", parameters: Type.Object({ jetton_address: Type.Optional( diff --git a/src/agent/tools/dedust/prices.ts b/src/agent/tools/dedust/prices.ts index f5b45ec..fb9201d 100644 --- a/src/agent/tools/dedust/prices.ts +++ b/src/agent/tools/dedust/prices.ts @@ -20,8 +20,7 @@ interface PriceEntry { } export const dedustPricesTool: Tool = { name: "dedust_prices", - description: - "Get real-time token prices from DeDust DEX. Returns USD prices for TON, BTC, ETH, USDT, and other listed tokens. Optionally filter by symbol(s).", + description: "Get real-time token prices from DeDust. Optionally filter by symbol(s).", category: "data-bearing", parameters: Type.Object({ symbols: Type.Optional( diff --git a/src/agent/tools/dedust/quote.ts b/src/agent/tools/dedust/quote.ts index fd77c70..f4d1a9f 100644 --- a/src/agent/tools/dedust/quote.ts +++ b/src/agent/tools/dedust/quote.ts @@ -20,7 +20,7 @@ interface DedustQuoteParams { export const dedustQuoteTool: Tool = { name: "dedust_quote", description: - "Get a price quote for a token swap on DeDust DEX WITHOUT executing it. Shows expected output, minimum output, and pool info. Use 'ton' as from_asset for TON, or jetton master address. Pool types: 'volatile' (default) or 'stable' (for stablecoins).", + "Get a price quote for a token swap on DeDust DEX without executing it. Use 'ton' for TON or jetton master address.", category: "data-bearing", parameters: Type.Object({ from_asset: Type.String({ diff --git a/src/agent/tools/dedust/swap.ts b/src/agent/tools/dedust/swap.ts index 7564640..336b9cf 100644 --- a/src/agent/tools/dedust/swap.ts +++ b/src/agent/tools/dedust/swap.ts @@ -21,7 +21,7 @@ interface DedustSwapParams { export const dedustSwapTool: Tool = { name: "dedust_swap", description: - "Execute a token swap on DeDust DEX. Supports TON->Jetton and Jetton->TON/Jetton swaps. Use 'ton' as from_asset or to_asset for TON. Pool types: 'volatile' (default) or 'stable' (for stablecoins like USDT/USDC). Use dedust_quote first to preview the swap.", + "Execute a token swap on DeDust. Supports TON<->jetton and jetton<->jetton. Use dedust_quote first to preview.", parameters: Type.Object({ from_asset: Type.String({ description: "Source asset: 'ton' for TON, or jetton master address (EQ... format)", diff --git a/src/agent/tools/dedust/token-info.ts b/src/agent/tools/dedust/token-info.ts index 0ad7c6d..13da5ad 100644 --- a/src/agent/tools/dedust/token-info.ts +++ b/src/agent/tools/dedust/token-info.ts @@ -13,7 +13,7 @@ interface DedustTokenInfoParams { export const dedustTokenInfoTool: Tool = { name: "dedust_token_info", description: - "Get detailed information about a jetton on DeDust: on-chain metadata (name, symbol, decimals, image), top holders, top traders by volume, and largest recent buys. Accepts a jetton master address (EQ...) or a symbol like 'USDT'.", + "Get jetton info from DeDust: metadata, top holders, top traders, largest buys. Accepts address or symbol.", category: "data-bearing", parameters: Type.Object({ token: Type.String({ diff --git a/src/agent/tools/dns/auctions.ts b/src/agent/tools/dns/auctions.ts index df19490..b47e0f5 100644 --- a/src/agent/tools/dns/auctions.ts +++ b/src/agent/tools/dns/auctions.ts @@ -10,8 +10,7 @@ interface DnsAuctionsParams { } export const dnsAuctionsTool: Tool = { name: "dns_auctions", - description: - "List all active .ton domain auctions. Returns domains currently in auction with current bid prices, number of bids, and end times.", + description: "List active .ton domain auctions with current bids and end times.", category: "data-bearing", parameters: Type.Object({ limit: Type.Optional( diff --git a/src/agent/tools/dns/bid.ts b/src/agent/tools/dns/bid.ts index 35d31b1..f3ff310 100644 --- a/src/agent/tools/dns/bid.ts +++ b/src/agent/tools/dns/bid.ts @@ -16,7 +16,7 @@ interface DnsBidParams { export const dnsBidTool: Tool = { name: "dns_bid", description: - "Place a bid on an existing .ton domain auction. Bid must be at least 5% higher than current bid. The domain must already be in auction (use dns_check first to verify status and get current bid).", + "Place a bid on a .ton domain auction. Bid must be >= 105% of current bid. Use dns_check first.", parameters: Type.Object({ domain: Type.String({ description: "Domain name (with or without .ton extension)", diff --git a/src/agent/tools/dns/check.ts b/src/agent/tools/dns/check.ts index 92c9eb4..49d6176 100644 --- a/src/agent/tools/dns/check.ts +++ b/src/agent/tools/dns/check.ts @@ -10,8 +10,7 @@ interface DnsCheckParams { } export const dnsCheckTool: Tool = { name: "dns_check", - description: - "Check if a .ton domain is available, in auction, or already owned. Returns status with relevant details (price estimates, current bids, owner info).", + description: "Check .ton domain status: available, in auction, or owned.", category: "data-bearing", parameters: Type.Object({ domain: Type.String({ diff --git a/src/agent/tools/dns/link.ts b/src/agent/tools/dns/link.ts index da68dca..4f32f0d 100644 --- a/src/agent/tools/dns/link.ts +++ b/src/agent/tools/dns/link.ts @@ -26,8 +26,7 @@ interface DnsLinkParams { } export const dnsLinkTool: Tool = { name: "dns_link", - description: - "Link a wallet address to a .ton domain you own. This sets the wallet record so the domain resolves to the specified address. If no wallet_address is provided, links to your own wallet.", + description: "Link a wallet address to a .ton domain you own. Defaults to your own wallet.", parameters: Type.Object({ domain: Type.String({ description: "Domain name (with or without .ton extension)", diff --git a/src/agent/tools/dns/resolve.ts b/src/agent/tools/dns/resolve.ts index 605a8bc..dc4215f 100644 --- a/src/agent/tools/dns/resolve.ts +++ b/src/agent/tools/dns/resolve.ts @@ -10,8 +10,7 @@ interface DnsResolveParams { } export const dnsResolveTool: Tool = { name: "dns_resolve", - description: - "Resolve a .ton domain to its associated wallet address. Only works for domains that are already owned (not available or in auction).", + description: "Resolve a .ton domain to its wallet address. Only works for owned domains.", category: "data-bearing", parameters: Type.Object({ domain: Type.String({ diff --git a/src/agent/tools/dns/start-auction.ts b/src/agent/tools/dns/start-auction.ts index 9a19ef2..3df752f 100644 --- a/src/agent/tools/dns/start-auction.ts +++ b/src/agent/tools/dns/start-auction.ts @@ -17,7 +17,7 @@ interface DnsStartAuctionParams { export const dnsStartAuctionTool: Tool = { name: "dns_start_auction", description: - "Start an auction for an unminted .ton domain. Sends TON to the DNS collection contract to mint a new domain NFT. Domain must be 4-126 characters, available (not minted), and amount must meet minimum price.", + "Start an auction for an unminted .ton domain. Amount must meet minimum price for domain length.", parameters: Type.Object({ domain: Type.String({ description: "Domain name to mint (without .ton extension, 4-126 chars)", diff --git a/src/agent/tools/dns/unlink.ts b/src/agent/tools/dns/unlink.ts index 9e4316f..443f9c1 100644 --- a/src/agent/tools/dns/unlink.ts +++ b/src/agent/tools/dns/unlink.ts @@ -22,8 +22,7 @@ interface DnsUnlinkParams { } export const dnsUnlinkTool: Tool = { name: "dns_unlink", - description: - "Remove the wallet link from a .ton domain you own. This deletes the wallet record so the domain no longer resolves to any address.", + description: "Remove the wallet link from a .ton domain you own.", parameters: Type.Object({ domain: Type.String({ description: "Domain name (with or without .ton extension)", diff --git a/src/agent/tools/journal/log.ts b/src/agent/tools/journal/log.ts index 56ac090..8f23713 100644 --- a/src/agent/tools/journal/log.ts +++ b/src/agent/tools/journal/log.ts @@ -25,21 +25,8 @@ interface JournalLogParams { export const journalLogTool: Tool = { name: "journal_log", - description: `Log a business operation to the trading journal. - -Use this to record: -- **trade**: Crypto swaps, buy/sell operations -- **gift**: Gift purchases, sales, or exchanges -- **middleman**: Escrow services (record both sides if applicable) -- **kol**: Posts, moderation, bullposting services - -ALWAYS include 'reasoning' to explain WHY you took this action. - -Examples: -- trade: "Bought TON dip (-28% in 72h), community active, narrative fits ecosystem" -- gift: "Sold Deluxe Heart at 120% floor - buyer was eager" -- middleman: "Escrow for 150 TON gift trade - 3% fee" -- kol: "Posted project review in channel - 75 TON fee"`, + description: + "Log a business operation (trade, gift, middleman, kol) to the journal. Always include reasoning.", parameters: Type.Object({ type: Type.Union( diff --git a/src/agent/tools/journal/query.ts b/src/agent/tools/journal/query.ts index fc5d8dc..a4f697d 100644 --- a/src/agent/tools/journal/query.ts +++ b/src/agent/tools/journal/query.ts @@ -18,19 +18,8 @@ interface JournalQueryParams { export const journalQueryTool: Tool = { name: "journal_query", - description: `Query the trading journal to analyze past operations. - -Use this to: -- Review recent trades, gifts, or services -- Analyze performance (win rate, P&L) -- Find specific operations by asset or outcome -- Learn from past decisions (read the 'reasoning' field!) - -Examples: -- "Show me my last 10 trades" -- "What gifts did I sell this week?" -- "Show all profitable TON trades" -- "What's my win rate on crypto trades?"`, + description: + "Query the trading journal. Filter by type/asset/outcome/period. Includes P&L summary.", category: "data-bearing", parameters: Type.Object({ type: Type.Optional( diff --git a/src/agent/tools/journal/update.ts b/src/agent/tools/journal/update.ts index a762b37..e869f21 100644 --- a/src/agent/tools/journal/update.ts +++ b/src/agent/tools/journal/update.ts @@ -18,22 +18,8 @@ interface JournalUpdateParams { export const journalUpdateTool: Tool = { name: "journal_update", - description: `Update a journal entry with outcome and P&L. - -Use this to: -- Close pending operations with final results -- Record profit/loss after selling or closing a position -- Update transaction hash after confirmation -- Mark operations as cancelled - -ALWAYS calculate P&L when closing trades: -- pnl_ton = final value in TON - initial cost in TON -- pnl_pct = (pnl_ton / initial_cost) * 100 - -Examples: -- "Close trade #42 - sold at profit" -- "Mark gift sale #38 as complete" -- "Update escrow #55 with tx hash"`, + description: + "Update a journal entry with outcome, P&L, or tx_hash. Auto-sets closed_at when outcome changes from pending.", parameters: Type.Object({ id: Type.Number({ description: "Journal entry ID to update" }), diff --git a/src/agent/tools/stonfi/pools.ts b/src/agent/tools/stonfi/pools.ts index 80b4968..74a0cd3 100644 --- a/src/agent/tools/stonfi/pools.ts +++ b/src/agent/tools/stonfi/pools.ts @@ -12,8 +12,7 @@ interface JettonPoolsParams { } export const stonfiPoolsTool: Tool = { name: "stonfi_pools", - description: - "Get liquidity pools for a Jetton or list top pools by volume. Shows pool addresses, liquidity, volume, APY, and trading pairs. Useful for finding where to trade a token or analyzing DeFi opportunities.", + description: "List STON.fi liquidity pools. Filter by jetton or get top pools by volume.", category: "data-bearing", parameters: Type.Object({ jetton_address: Type.Optional( diff --git a/src/agent/tools/stonfi/quote.ts b/src/agent/tools/stonfi/quote.ts index 8fce379..c53a5cb 100644 --- a/src/agent/tools/stonfi/quote.ts +++ b/src/agent/tools/stonfi/quote.ts @@ -16,8 +16,7 @@ interface JettonQuoteParams { } export const stonfiQuoteTool: Tool = { name: "stonfi_quote", - description: - "Get a price quote for a token swap WITHOUT executing it. Shows expected output, minimum output, price impact, and fees. Use this to preview a swap before committing. Use 'ton' as from_asset for TON, or jetton master address.", + description: "Get a swap price quote on STON.fi without executing. Use stonfi_swap to execute.", category: "data-bearing", parameters: Type.Object({ from_asset: Type.String({ diff --git a/src/agent/tools/stonfi/search.ts b/src/agent/tools/stonfi/search.ts index ee23236..0b6e92e 100644 --- a/src/agent/tools/stonfi/search.ts +++ b/src/agent/tools/stonfi/search.ts @@ -26,7 +26,7 @@ interface SearchResult { export const stonfiSearchTool: Tool = { name: "stonfi_search", description: - "Search for Jettons (tokens) by name or symbol. Returns a list of matching tokens with their addresses, useful for finding a token's address before swapping or checking prices. Search is case-insensitive.", + "Search for jettons by name or symbol. Returns addresses for use in swap/price tools.", category: "data-bearing", parameters: Type.Object({ query: Type.String({ diff --git a/src/agent/tools/stonfi/swap.ts b/src/agent/tools/stonfi/swap.ts index e07e6f2..d3a1913 100644 --- a/src/agent/tools/stonfi/swap.ts +++ b/src/agent/tools/stonfi/swap.ts @@ -22,7 +22,7 @@ interface JettonSwapParams { export const stonfiSwapTool: Tool = { name: "stonfi_swap", description: - "Swap tokens on STON.fi DEX. Supports TON↔Jetton and Jetton↔Jetton swaps. Use 'ton' as from_asset to buy jettons with TON, or provide jetton master address. Amount is in human-readable units (will be converted based on decimals). Example: swap 10 TON for USDT, or swap USDT for SCALE.", + "Execute a token swap on STON.fi. Supports TON<->jetton and jetton<->jetton. Use stonfi_quote first to preview.", parameters: Type.Object({ from_asset: Type.String({ description: "Source asset: 'ton' for TON, or jetton master address (EQ... format)", diff --git a/src/agent/tools/stonfi/trending.ts b/src/agent/tools/stonfi/trending.ts index a211a6c..a14488f 100644 --- a/src/agent/tools/stonfi/trending.ts +++ b/src/agent/tools/stonfi/trending.ts @@ -11,8 +11,7 @@ interface JettonTrendingParams { } export const stonfiTrendingTool: Tool = { name: "stonfi_trending", - description: - "Get trending/popular Jettons on the TON blockchain. Shows tokens ranked by trading volume and liquidity. Useful for discovering popular tokens.", + description: "Get trending jettons ranked by popularity on STON.fi.", category: "data-bearing", parameters: Type.Object({ limit: Type.Optional( diff --git a/src/agent/tools/telegram/chats/create-channel.ts b/src/agent/tools/telegram/chats/create-channel.ts index b691543..269ecaf 100644 --- a/src/agent/tools/telegram/chats/create-channel.ts +++ b/src/agent/tools/telegram/chats/create-channel.ts @@ -21,7 +21,7 @@ interface CreateChannelParams { export const telegramCreateChannelTool: Tool = { name: "telegram_create_channel", description: - "Create a new Telegram channel or megagroup. Channels are one-way broadcast tools where only admins can post, ideal for announcements or content distribution. Megagroups are large groups supporting up to 200k members with admin controls. Use this to establish a new communication platform for announcements, communities, or projects.", + "Create a new Telegram channel (broadcast) or megagroup (chat). Set megagroup=true for group mode.", parameters: Type.Object({ title: Type.String({ description: "Name of the channel/megagroup (max 128 characters)", diff --git a/src/agent/tools/telegram/chats/edit-channel-info.ts b/src/agent/tools/telegram/chats/edit-channel-info.ts index 97d734e..5da03d7 100644 --- a/src/agent/tools/telegram/chats/edit-channel-info.ts +++ b/src/agent/tools/telegram/chats/edit-channel-info.ts @@ -20,19 +20,7 @@ interface EditChannelInfoParams { */ export const telegramEditChannelInfoTool: Tool = { name: "telegram_edit_channel_info", - description: `Edit a channel or group's information. - -USAGE: -- Pass the channelId and any fields to update -- You must be an admin with the appropriate rights - -FIELDS: -- title: Channel/group name (1-255 characters) -- about: Description/bio (0-255 characters) - -NOTE: To change the photo, use a separate photo upload tool. - -Example: Update your channel @my_channel with a new description.`, + description: "Edit a channel or group's title and/or description. Requires admin rights.", parameters: Type.Object({ channelId: Type.String({ description: "Channel or group ID to edit", diff --git a/src/agent/tools/telegram/chats/get-chat-info.ts b/src/agent/tools/telegram/chats/get-chat-info.ts index 87f9379..f75b796 100644 --- a/src/agent/tools/telegram/chats/get-chat-info.ts +++ b/src/agent/tools/telegram/chats/get-chat-info.ts @@ -19,7 +19,7 @@ interface GetChatInfoParams { export const telegramGetChatInfoTool: Tool = { name: "telegram_get_chat_info", description: - "Get detailed information about a Telegram chat, group, or channel. Returns title, description, member count, and other metadata. Use this to understand the context of a conversation.", + "Get detailed info about a chat, group, channel, or user. Returns title, description, member count, and metadata.", category: "data-bearing", parameters: Type.Object({ chatId: Type.String({ diff --git a/src/agent/tools/telegram/chats/get-dialogs.ts b/src/agent/tools/telegram/chats/get-dialogs.ts index 4ff0308..1aff19d 100644 --- a/src/agent/tools/telegram/chats/get-dialogs.ts +++ b/src/agent/tools/telegram/chats/get-dialogs.ts @@ -20,7 +20,7 @@ interface GetDialogsParams { export const telegramGetDialogsTool: Tool = { name: "telegram_get_dialogs", description: - "Get the list of all your Telegram conversations (DMs, groups, channels). Returns chat info with unread message counts. Use this to see your inbox and find chats that need attention.", + "List all conversations (DMs, groups, channels) with unread counts. Use to find chat IDs and check inbox.", category: "data-bearing", parameters: Type.Object({ limit: Type.Optional( diff --git a/src/agent/tools/telegram/chats/invite-to-channel.ts b/src/agent/tools/telegram/chats/invite-to-channel.ts index b9bb498..bff9f14 100644 --- a/src/agent/tools/telegram/chats/invite-to-channel.ts +++ b/src/agent/tools/telegram/chats/invite-to-channel.ts @@ -21,18 +21,8 @@ interface InviteToChannelParams { */ export const telegramInviteToChannelTool: Tool = { name: "telegram_invite_to_channel", - description: `Invite users to a channel or group. - -USAGE: -- Pass channelId and either userIds or usernames (or both) -- You must be an admin with invite rights -- Users must allow being added to groups (privacy settings) - -LIMITS: -- Can invite up to 200 users at once -- Some users may have privacy settings preventing invites - -For public channels, you can also share the invite link instead.`, + description: + "Invite users to a channel or group by userIds or usernames. Requires admin invite rights.", parameters: Type.Object({ channelId: Type.String({ description: "Channel or group ID to invite users to", diff --git a/src/agent/tools/telegram/chats/join-channel.ts b/src/agent/tools/telegram/chats/join-channel.ts index 9da4889..1df3e4e 100644 --- a/src/agent/tools/telegram/chats/join-channel.ts +++ b/src/agent/tools/telegram/chats/join-channel.ts @@ -28,8 +28,7 @@ interface JoinChannelParams { */ export const telegramJoinChannelTool: Tool = { name: "telegram_join_channel", - description: - "Join a Telegram channel or group. Supports public channels (username/@channelname), channel IDs, and private invite links (t.me/+XXXX, t.me/joinchat/XXXX).", + description: "Join a channel or group. Accepts username, channel ID, or private invite link.", parameters: Type.Object({ channel: Type.String({ description: diff --git a/src/agent/tools/telegram/chats/leave-channel.ts b/src/agent/tools/telegram/chats/leave-channel.ts index 177c46b..ffd6a0f 100644 --- a/src/agent/tools/telegram/chats/leave-channel.ts +++ b/src/agent/tools/telegram/chats/leave-channel.ts @@ -18,8 +18,7 @@ interface LeaveChannelParams { */ export const telegramLeaveChannelTool: Tool = { name: "telegram_leave_channel", - description: - "Leave a Telegram channel or group that you're currently a member of. Use this to unsubscribe from channels or exit groups you no longer wish to participate in. Accepts username or channel ID.", + description: "Leave a channel or group you are a member of.", parameters: Type.Object({ channel: Type.String({ description: diff --git a/src/agent/tools/telegram/chats/mark-as-read.ts b/src/agent/tools/telegram/chats/mark-as-read.ts index 9955115..68c528d 100644 --- a/src/agent/tools/telegram/chats/mark-as-read.ts +++ b/src/agent/tools/telegram/chats/mark-as-read.ts @@ -20,7 +20,7 @@ interface MarkAsReadParams { export const telegramMarkAsReadTool: Tool = { name: "telegram_mark_as_read", description: - "Mark messages as read in a Telegram chat. Can mark up to a specific message or clear all unread. Use this to manage your inbox and acknowledge messages.", + "Mark messages as read in a chat. Can mark up to a specific message or clear all unread.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID to mark as read", diff --git a/src/agent/tools/telegram/contacts/block-user.ts b/src/agent/tools/telegram/contacts/block-user.ts index 65f7c2a..7c24db2 100644 --- a/src/agent/tools/telegram/contacts/block-user.ts +++ b/src/agent/tools/telegram/contacts/block-user.ts @@ -19,7 +19,7 @@ interface BlockUserParams { export const telegramBlockUserTool: Tool = { name: "telegram_block_user", description: - "Block a Telegram user to prevent them from sending you messages or adding you to groups. Use this for spam protection, harassment prevention, or managing unwanted contacts. The blocked user will not be notified.", + "Block a user. They won't be able to message you or add you to groups. Not notified.", parameters: Type.Object({ userId: Type.String({ description: "The user ID or username to block (e.g., '123456789' or '@username')", diff --git a/src/agent/tools/telegram/contacts/check-username.ts b/src/agent/tools/telegram/contacts/check-username.ts index 59680a4..0425899 100644 --- a/src/agent/tools/telegram/contacts/check-username.ts +++ b/src/agent/tools/telegram/contacts/check-username.ts @@ -18,20 +18,8 @@ interface CheckUsernameParams { */ export const telegramCheckUsernameTool: Tool = { name: "telegram_check_username", - description: `Check if a Telegram username exists and get basic info about it. - -USAGE: -- Pass a username (with or without @) - -RETURNS: -- exists: whether the username is taken -- type: "user", "channel", "group", or null if not found -- Basic info about the entity if it exists - -Use this to: -- Check if a trader's username is valid -- Verify channel/group names -- See if a username is available`, + description: + "Check if a username exists and get basic info (type, ID). Also reveals if a username is available.", category: "data-bearing", parameters: Type.Object({ username: Type.String({ diff --git a/src/agent/tools/telegram/contacts/get-blocked.ts b/src/agent/tools/telegram/contacts/get-blocked.ts index 5d3f127..fcf8b28 100644 --- a/src/agent/tools/telegram/contacts/get-blocked.ts +++ b/src/agent/tools/telegram/contacts/get-blocked.ts @@ -18,8 +18,7 @@ interface GetBlockedParams { */ export const telegramGetBlockedTool: Tool = { name: "telegram_get_blocked", - description: - "Get list of users you have blocked on Telegram. Use this to see who's blocked, manage your block list, or identify users to unblock. Returns user information for each blocked contact.", + description: "Get your list of blocked users.", category: "data-bearing", parameters: Type.Object({ limit: Type.Optional( diff --git a/src/agent/tools/telegram/contacts/get-common-chats.ts b/src/agent/tools/telegram/contacts/get-common-chats.ts index 2e6f02c..705b5df 100644 --- a/src/agent/tools/telegram/contacts/get-common-chats.ts +++ b/src/agent/tools/telegram/contacts/get-common-chats.ts @@ -20,8 +20,7 @@ interface GetCommonChatsParams { */ export const telegramGetCommonChatsTool: Tool = { name: "telegram_get_common_chats", - description: - "Find groups and channels that you share with another Telegram user. Use this to understand mutual connections, verify relationships, or discover shared communities. Returns list of common chats with their names and IDs.", + description: "Find groups and channels you share with another user.", category: "data-bearing", parameters: Type.Object({ userId: Type.String({ diff --git a/src/agent/tools/telegram/contacts/get-user-info.ts b/src/agent/tools/telegram/contacts/get-user-info.ts index 906c3d3..25ed4f4 100644 --- a/src/agent/tools/telegram/contacts/get-user-info.ts +++ b/src/agent/tools/telegram/contacts/get-user-info.ts @@ -19,20 +19,8 @@ interface GetUserInfoParams { */ export const telegramGetUserInfoTool: Tool = { name: "telegram_get_user_info", - description: `Get detailed information about a Telegram user. - -USAGE: -- By username: pass username (with or without @) -- By ID: pass userId - -RETURNS: -- Basic info: id, username, firstName, lastName, phone (if visible) -- Status: isBot, isPremium, isVerified, isScam, isFake -- Bio/about (if public) -- Photo info (if available) -- Common chats count - -Use this to learn about traders, verify users, or gather intel.`, + description: + "Get detailed info about a Telegram user by username or userId. Returns profile, status, bio, and common chats.", category: "data-bearing", parameters: Type.Object({ userId: Type.Optional( diff --git a/src/agent/tools/telegram/folders/add-chat-to-folder.ts b/src/agent/tools/telegram/folders/add-chat-to-folder.ts index bc00042..751fd5f 100644 --- a/src/agent/tools/telegram/folders/add-chat-to-folder.ts +++ b/src/agent/tools/telegram/folders/add-chat-to-folder.ts @@ -20,7 +20,7 @@ interface AddChatToFolderParams { export const telegramAddChatToFolderTool: Tool = { name: "telegram_add_chat_to_folder", description: - "Add a specific chat to an existing folder. The chat will appear in that folder's view for easy access. Use telegram_get_folders first to see available folder IDs. This helps organize important or related conversations together. Example: Add a project group to your 'Work' folder.", + "Add a chat to an existing folder. Use telegram_get_folders first to get folder IDs.", parameters: Type.Object({ folderId: Type.Number({ description: diff --git a/src/agent/tools/telegram/folders/create-folder.ts b/src/agent/tools/telegram/folders/create-folder.ts index 3c00c6f..225b1d0 100644 --- a/src/agent/tools/telegram/folders/create-folder.ts +++ b/src/agent/tools/telegram/folders/create-folder.ts @@ -25,7 +25,7 @@ interface CreateFolderParams { export const telegramCreateFolderTool: Tool = { name: "telegram_create_folder", description: - "Create a new chat folder to organize your conversations. Folders can auto-include chat types (contacts, groups, bots, etc.) or specific chats added later with telegram_add_chat_to_folder. Use this to categorize chats by topic, importance, or type. Examples: 'Work', 'Family', 'Projects', 'Crypto'.", + "Create a new chat folder. Can auto-include chat types or add specific chats later with telegram_add_chat_to_folder.", parameters: Type.Object({ title: Type.String({ description: "Name of the folder (e.g., 'Work', 'Family', 'Projects'). Max 12 characters.", diff --git a/src/agent/tools/telegram/folders/get-folders.ts b/src/agent/tools/telegram/folders/get-folders.ts index c4f6236..ba486dc 100644 --- a/src/agent/tools/telegram/folders/get-folders.ts +++ b/src/agent/tools/telegram/folders/get-folders.ts @@ -11,8 +11,7 @@ const log = createLogger("Tools"); */ export const telegramGetFoldersTool: Tool = { name: "telegram_get_folders", - description: - "List all your chat folders (also called 'filters' in Telegram). Folders organize chats into categories like 'Work', 'Personal', 'Groups', etc. Returns folder IDs, names, and included chat types. Use this to see your organization structure before adding chats to folders with telegram_add_chat_to_folder.", + description: "List all your chat folders with IDs, names, and included chat types.", category: "data-bearing", parameters: Type.Object({}), // No parameters needed }; diff --git a/src/agent/tools/telegram/gifts/buy-resale-gift.ts b/src/agent/tools/telegram/gifts/buy-resale-gift.ts index 6968a56..74ca058 100644 --- a/src/agent/tools/telegram/gifts/buy-resale-gift.ts +++ b/src/agent/tools/telegram/gifts/buy-resale-gift.ts @@ -19,7 +19,7 @@ interface BuyResaleGiftParams { export const telegramBuyResaleGiftTool: Tool = { name: "telegram_buy_resale_gift", description: - "Purchase a collectible gift from the resale marketplace. Uses Stars from your balance to buy at the listed price. After purchase, the collectible becomes yours. Use telegram_get_resale_gifts to browse available listings and find odayId.", + "Buy a collectible from the resale marketplace using Stars. Get odayId from telegram_get_resale_gifts.", parameters: Type.Object({ odayId: Type.String({ description: "The odayId of the listing to purchase (from telegram_get_resale_gifts)", diff --git a/src/agent/tools/telegram/gifts/get-available-gifts.ts b/src/agent/tools/telegram/gifts/get-available-gifts.ts index e262058..1ee8fdf 100644 --- a/src/agent/tools/telegram/gifts/get-available-gifts.ts +++ b/src/agent/tools/telegram/gifts/get-available-gifts.ts @@ -20,7 +20,7 @@ interface GetAvailableGiftsParams { export const telegramGetAvailableGiftsTool: Tool = { name: "telegram_get_available_gifts", description: - "Get all Star Gifts available for purchase. There are two types: LIMITED gifts (rare, can become collectibles, may sell out) and UNLIMITED gifts (always available). Use filter to see specific types. Returns gift ID, name, stars cost, and availability. Use the gift ID with telegram_send_gift to send one.", + "Get Star Gifts available for purchase. Filterable by limited/unlimited. Use gift ID with telegram_send_gift.", category: "data-bearing", parameters: Type.Object({ filter: Type.Optional( diff --git a/src/agent/tools/telegram/gifts/get-my-gifts.ts b/src/agent/tools/telegram/gifts/get-my-gifts.ts index a00786a..156d934 100644 --- a/src/agent/tools/telegram/gifts/get-my-gifts.ts +++ b/src/agent/tools/telegram/gifts/get-my-gifts.ts @@ -43,24 +43,8 @@ interface GetMyGiftsParams { */ export const telegramGetMyGiftsTool: Tool = { name: "telegram_get_my_gifts", - description: `Get Star Gifts you or another user has received. - -USAGE: -- To view YOUR OWN gifts: omit both userId and viewSender -- To view the SENDER's gifts (when user says "show me MY gifts"): set viewSender=true -- To view a specific user's gifts: pass their userId - -PRESENTATION GUIDE: -- For collectibles: Use "title + model" as display name (e.g., "Hypno Lollipop Telegram") -- NFT link: t.me/nft/{slug} (e.g., t.me/nft/HypnoLollipop-63414) -- Respond concisely: "You have a Hypno Lollipop Telegram 🍭" -- Only give details (rarity, backdrop, pattern) when specifically asked -- attributes.model.name = model, attributes.pattern.name = pattern, attributes.backdrop.name = backdrop -- rarityPermille: divide by 10 to get percentage (7 = 0.7%) - -TRANSFER: Use msgId (for your own gifts) to transfer collectibles via telegram_transfer_collectible. - -NEVER dump all raw data. Keep responses natural and concise.`, + description: + "Get Star Gifts received by you or another user. Set viewSender=true when sender says 'show MY gifts'. For collectibles: display as 'title + model', link as t.me/nft/{slug}. rarityPermille / 10 = %. Use msgId for transfers.", parameters: Type.Object({ userId: Type.Optional( Type.String({ diff --git a/src/agent/tools/telegram/gifts/get-resale-gifts.ts b/src/agent/tools/telegram/gifts/get-resale-gifts.ts index 8a61aeb..b5ccd05 100644 --- a/src/agent/tools/telegram/gifts/get-resale-gifts.ts +++ b/src/agent/tools/telegram/gifts/get-resale-gifts.ts @@ -21,7 +21,7 @@ interface GetResaleGiftsParams { export const telegramGetResaleGiftsTool: Tool = { name: "telegram_get_resale_gifts", description: - "Browse the collectible gifts marketplace. Shows all collectibles currently listed for sale by other users. Can filter by specific gift type or browse all. Returns prices in Stars and seller info. Use telegram_buy_resale_gift to purchase.", + "Browse collectible gifts listed for resale. Filterable by gift type. Use telegram_buy_resale_gift to purchase.", category: "data-bearing", parameters: Type.Object({ giftId: Type.Optional( diff --git a/src/agent/tools/telegram/gifts/send-gift.ts b/src/agent/tools/telegram/gifts/send-gift.ts index d3b286b..b62701a 100644 --- a/src/agent/tools/telegram/gifts/send-gift.ts +++ b/src/agent/tools/telegram/gifts/send-gift.ts @@ -23,7 +23,7 @@ interface SendGiftParams { export const telegramSendGiftTool: Tool = { name: "telegram_send_gift", description: - "Send a Star Gift to another user. First use telegram_get_available_gifts to see available gifts and their IDs. Limited gifts are rare and can become collectibles. The gift will appear on the recipient's profile unless they hide it. Costs Stars from your balance.", + "Send a Star Gift to a user. Costs Stars. Requires a verified deal (use deal_propose first).", parameters: Type.Object({ userId: Type.String({ description: "User ID or @username to send the gift to", diff --git a/src/agent/tools/telegram/gifts/set-collectible-price.ts b/src/agent/tools/telegram/gifts/set-collectible-price.ts index 56ce959..0129d42 100644 --- a/src/agent/tools/telegram/gifts/set-collectible-price.ts +++ b/src/agent/tools/telegram/gifts/set-collectible-price.ts @@ -20,7 +20,7 @@ interface SetCollectiblePriceParams { export const telegramSetCollectiblePriceTool: Tool = { name: "telegram_set_collectible_price", description: - "List or unlist a collectible gift for sale on the Telegram marketplace. Set a price in Stars to list it for sale. Omit price or set to 0 to remove from sale. Only works with upgraded collectible gifts you own.", + "List/unlist a collectible for sale. Set price in Stars to list, omit or 0 to unlist. Collectibles only.", parameters: Type.Object({ odayId: Type.String({ description: "The odayId of the collectible to list/unlist (from telegram_get_my_gifts)", diff --git a/src/agent/tools/telegram/gifts/set-gift-status.ts b/src/agent/tools/telegram/gifts/set-gift-status.ts index 9995112..4c1b16b 100644 --- a/src/agent/tools/telegram/gifts/set-gift-status.ts +++ b/src/agent/tools/telegram/gifts/set-gift-status.ts @@ -20,18 +20,8 @@ interface SetGiftStatusParams { */ export const telegramSetGiftStatusTool: Tool = { name: "telegram_set_gift_status", - description: `Set a Collectible Gift as your Emoji Status (the icon next to your name). - -USAGE: -- Set status: telegram_set_gift_status({ collectibleId: "123456789" }) -- Clear status: telegram_set_gift_status({ clear: true }) - -IMPORTANT: -- Only COLLECTIBLE gifts (isCollectible: true) can be used as emoji status -- Use the "collectibleId" field from telegram_get_my_gifts (NOT the slug!) -- collectibleId is a numeric string like "6219780841349758977" - -The emoji status appears next to your name in chats and your profile.`, + description: + "Set a collectible gift as your emoji status (icon next to your name). Use collectibleId from telegram_get_my_gifts (not slug). Set clear=true to remove.", parameters: Type.Object({ collectibleId: Type.Optional( Type.String({ diff --git a/src/agent/tools/telegram/gifts/transfer-collectible.ts b/src/agent/tools/telegram/gifts/transfer-collectible.ts index d021af9..ea52450 100644 --- a/src/agent/tools/telegram/gifts/transfer-collectible.ts +++ b/src/agent/tools/telegram/gifts/transfer-collectible.ts @@ -20,13 +20,8 @@ interface TransferCollectibleParams { */ export const telegramTransferCollectibleTool: Tool = { name: "telegram_transfer_collectible", - description: `Transfer a collectible gift you own to another user. Only works with upgraded collectible gifts (starGiftUnique), not regular gifts. The recipient will become the new owner. - -IMPORTANT: Some collectibles require a Star fee to transfer (shown as transferStars in telegram_get_my_gifts). -- If transferStars is null/0: Transfer is FREE -- If transferStars has a value: That amount of Stars will be deducted from your balance - -Use telegram_get_my_gifts to find your collectibles and their msgId.`, + description: + "Transfer a collectible gift to another user. Requires verified deal. May cost Stars (see transferStars in telegram_get_my_gifts). Collectibles only.", parameters: Type.Object({ msgId: Type.Number({ description: diff --git a/src/agent/tools/telegram/groups/get-me.ts b/src/agent/tools/telegram/groups/get-me.ts index a35bddf..5050075 100644 --- a/src/agent/tools/telegram/groups/get-me.ts +++ b/src/agent/tools/telegram/groups/get-me.ts @@ -11,8 +11,7 @@ const log = createLogger("Tools"); */ export const telegramGetMeTool: Tool = { name: "telegram_get_me", - description: - "Get information about yourself (the currently authenticated Telegram account). Returns your user ID, username, name, phone number, and whether you're a bot. Use this for self-awareness and to understand your own account details.", + description: "Get your own Telegram account info (user ID, username, name, phone).", category: "data-bearing", parameters: Type.Object({}), // No parameters needed }; diff --git a/src/agent/tools/telegram/groups/get-participants.ts b/src/agent/tools/telegram/groups/get-participants.ts index 0807cfa..f673630 100644 --- a/src/agent/tools/telegram/groups/get-participants.ts +++ b/src/agent/tools/telegram/groups/get-participants.ts @@ -22,7 +22,7 @@ interface GetParticipantsParams { export const telegramGetParticipantsTool: Tool = { name: "telegram_get_participants", description: - "Get list of participants (members) in a Telegram group or channel. Use this to see who's in a chat, identify admins, check banned users, or find bots. Useful for moderation, member management, and group analytics.", + "Get participants of a group or channel. Filterable by all, admins, banned, or bots.", category: "data-bearing", parameters: Type.Object({ chatId: Type.String({ diff --git a/src/agent/tools/telegram/groups/set-chat-photo.ts b/src/agent/tools/telegram/groups/set-chat-photo.ts index 3680038..4339014 100644 --- a/src/agent/tools/telegram/groups/set-chat-photo.ts +++ b/src/agent/tools/telegram/groups/set-chat-photo.ts @@ -21,7 +21,7 @@ interface SetChatPhotoParams { export const telegramSetChatPhotoTool: Tool = { name: "telegram_set_chat_photo", - description: `Set or delete a group/channel profile photo. You need admin rights with change info permission. Provide a local image path to set, or use delete_photo to remove.`, + description: `Set or delete a group/channel profile photo. Requires admin rights with change-info permission.`, parameters: Type.Object({ chat_id: Type.String({ description: "Group/channel ID or username", diff --git a/src/agent/tools/telegram/interactive/create-poll.ts b/src/agent/tools/telegram/interactive/create-poll.ts index e89a871..4154039 100644 --- a/src/agent/tools/telegram/interactive/create-poll.ts +++ b/src/agent/tools/telegram/interactive/create-poll.ts @@ -28,7 +28,7 @@ interface CreatePollParams { export const telegramCreatePollTool: Tool = { name: "telegram_create_poll", description: - "Create a poll in a Telegram chat to gather opinions or votes from users. Polls can be anonymous or public, allow single or multiple answers. Use this to make group decisions, conduct surveys, or engage users with questions. For quizzes with correct answers, use telegram_create_quiz instead.", + "Create a poll in a chat. For quizzes with a correct answer, use telegram_create_quiz instead.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID where the poll will be created", diff --git a/src/agent/tools/telegram/interactive/create-quiz.ts b/src/agent/tools/telegram/interactive/create-quiz.ts index 6f7445c..0531c08 100644 --- a/src/agent/tools/telegram/interactive/create-quiz.ts +++ b/src/agent/tools/telegram/interactive/create-quiz.ts @@ -26,7 +26,7 @@ interface CreateQuizParams { export const telegramCreateQuizTool: Tool = { name: "telegram_create_quiz", description: - "Create a quiz (poll with a correct answer) in a Telegram chat. Unlike regular polls, quizzes have one correct answer that gets revealed when users vote. Optionally add an explanation. Use this for educational content, trivia games, or testing knowledge. For opinion polls without correct answers, use telegram_create_poll instead.", + "Create a quiz (poll with one correct answer revealed on vote). For opinion polls, use telegram_create_poll.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID where the quiz will be created", diff --git a/src/agent/tools/telegram/interactive/react.ts b/src/agent/tools/telegram/interactive/react.ts index e1c44d6..fdda1b1 100644 --- a/src/agent/tools/telegram/interactive/react.ts +++ b/src/agent/tools/telegram/interactive/react.ts @@ -19,8 +19,7 @@ interface ReactParams { */ export const telegramReactTool: Tool = { name: "telegram_react", - description: - "Add an emoji reaction to a Telegram message. Use this to quickly acknowledge, approve, or express emotions without sending a full message. Common reactions: 👍 (like/approve), ❤️ (love), 🔥 (fire/hot), 😂 (funny), 😢 (sad), 🎉 (celebrate), 👎 (dislike), 🤔 (thinking). The message ID comes from the current conversation context or from telegram_get_history.", + description: "Add an emoji reaction to a message.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID where the message is located", diff --git a/src/agent/tools/telegram/interactive/reply-keyboard.ts b/src/agent/tools/telegram/interactive/reply-keyboard.ts index 23c8d17..8dddeb7 100644 --- a/src/agent/tools/telegram/interactive/reply-keyboard.ts +++ b/src/agent/tools/telegram/interactive/reply-keyboard.ts @@ -25,7 +25,7 @@ interface ReplyKeyboardParams { export const telegramReplyKeyboardTool: Tool = { name: "telegram_reply_keyboard", description: - "Send a message with a custom reply keyboard that replaces the user's regular keyboard. Users can tap buttons to quickly send predefined responses. Each button sends its text as a message. Use this to create menus, quick replies, or guided conversations. Buttons are arranged in rows. Example: [['Yes', 'No'], ['Maybe']] creates 2 rows.", + "Send a message with a custom reply keyboard. Buttons are arranged in rows; each button sends its label as a message.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID to send the message with keyboard to", diff --git a/src/agent/tools/telegram/interactive/send-dice.ts b/src/agent/tools/telegram/interactive/send-dice.ts index d587ce2..1dd9866 100644 --- a/src/agent/tools/telegram/interactive/send-dice.ts +++ b/src/agent/tools/telegram/interactive/send-dice.ts @@ -19,17 +19,7 @@ interface SendDiceParams { export const telegramSendDiceTool: Tool = { name: "telegram_send_dice", - description: `Send an animated dice/game message. The result is random and determined by Telegram servers. - -Available games: -- 🎲 Dice (1-6) -- 🎯 Darts (1-6, 6 = bullseye) -- 🏀 Basketball (1-5, 4-5 = score) -- ⚽ Football (1-5, 4-5 = goal) -- 🎰 Slot machine (1-64, 64 = jackpot 777) -- 🎳 Bowling (1-6, 6 = strike) - -Use for games, decisions, or fun interactions.`, + description: `Send an animated dice/game message. Result is random, determined by Telegram servers.`, parameters: Type.Object({ chat_id: Type.String({ diff --git a/src/agent/tools/telegram/media/download-media.ts b/src/agent/tools/telegram/media/download-media.ts index 4e3c55c..0a088cd 100644 --- a/src/agent/tools/telegram/media/download-media.ts +++ b/src/agent/tools/telegram/media/download-media.ts @@ -28,7 +28,7 @@ interface DownloadMediaParams { export const telegramDownloadMediaTool: Tool = { name: "telegram_download_media", description: - "Download media (photo, video, document, voice, sticker, etc.) from a Telegram message. The file will be saved to ~/.teleton/downloads/. Use this to retrieve images, documents, or other files sent in conversations. Returns the local file path after download.", + "Download media from a Telegram message to ~/.teleton/downloads/. Returns the local file path.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID where the message with media is located", diff --git a/src/agent/tools/telegram/media/send-gif.ts b/src/agent/tools/telegram/media/send-gif.ts index 799ef4e..8dccd85 100644 --- a/src/agent/tools/telegram/media/send-gif.ts +++ b/src/agent/tools/telegram/media/send-gif.ts @@ -26,7 +26,7 @@ interface SendGifParams { export const telegramSendGifTool: Tool = { name: "telegram_send_gif", description: - "Send an animated GIF to a Telegram chat. You can either: 1) Use queryId + resultId from telegram_search_gifs to send a GIF from Telegram's library, or 2) Provide a local file path to a GIF/MP4. For online GIFs, always use the search + send workflow.", + "Send a GIF via queryId+resultId (from telegram_search_gifs) or a local GIF/MP4 file path.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID to send the GIF to", diff --git a/src/agent/tools/telegram/media/send-photo.ts b/src/agent/tools/telegram/media/send-photo.ts index f1c00ea..4304ce6 100644 --- a/src/agent/tools/telegram/media/send-photo.ts +++ b/src/agent/tools/telegram/media/send-photo.ts @@ -21,8 +21,7 @@ interface SendPhotoParams { */ export const telegramSendPhotoTool: Tool = { name: "telegram_send_photo", - description: - "Send a photo/image to a Telegram chat. Provide the local file path to the image. Supports JPG, PNG, WEBP formats. Use this to share visual content, screenshots, or images with users.", + description: "Send a photo from a local file path to a Telegram chat. Supports JPG, PNG, WEBP.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID to send the photo to", diff --git a/src/agent/tools/telegram/media/send-sticker.ts b/src/agent/tools/telegram/media/send-sticker.ts index 30387e5..1ea19b6 100644 --- a/src/agent/tools/telegram/media/send-sticker.ts +++ b/src/agent/tools/telegram/media/send-sticker.ts @@ -25,7 +25,7 @@ interface SendStickerParams { export const telegramSendStickerTool: Tool = { name: "telegram_send_sticker", description: - "Send a sticker to a Telegram chat. You can either provide a sticker set's short name + index (position in the pack, starting from 0), or a local path to a WEBP/TGS file. To find sticker sets, use telegram_search_stickers first to get the shortName, then choose which sticker (by index 0-N) to send.", + "Send a sticker via stickerSetShortName+stickerIndex (from telegram_search_stickers) or a local WEBP/TGS file path.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID to send the sticker to", diff --git a/src/agent/tools/telegram/media/send-voice.ts b/src/agent/tools/telegram/media/send-voice.ts index d9b4b4d..1b53f56 100644 --- a/src/agent/tools/telegram/media/send-voice.ts +++ b/src/agent/tools/telegram/media/send-voice.ts @@ -37,30 +37,8 @@ interface SendVoiceParams { export const telegramSendVoiceTool: Tool = { name: "telegram_send_voice", - description: `Send a voice message to a Telegram chat. - -**Two modes:** -1. **File mode**: Provide \`voicePath\` to send an existing audio file -2. **TTS mode**: Provide \`text\` to generate speech and send as voice note - -**TTS Providers:** -- \`piper\` (default): Offline neural TTS with Trump voice -- \`edge\`: Free Microsoft Edge TTS - cloud, many voices -- \`openai\`: OpenAI TTS API (requires OPENAI_API_KEY) -- \`elevenlabs\`: ElevenLabs API (requires ELEVENLABS_API_KEY) - -**Piper voices (default):** -- \`trump\`: Trump voice (default, en-US) -- \`dmitri\` / \`ru-ru\`: Russian male - -**Edge voices (fallback):** -- English: en-us-male, en-us-female, en-gb-male -- Russian: ru-ru-male, ru-ru-female - -**Examples:** -- Default Trump voice: text="This is tremendous, believe me!" -- Russian: text="Привет!", voice="dmitri", ttsProvider="piper" -- Edge fallback: text="Hello!", ttsProvider="edge"`, + description: + "Send a voice message. Either provide voicePath for an existing file, or text for TTS generation. Default TTS: piper (Trump voice). Available providers: piper, edge, openai, elevenlabs.", parameters: Type.Object({ chatId: Type.String({ diff --git a/src/agent/tools/telegram/media/vision-analyze.ts b/src/agent/tools/telegram/media/vision-analyze.ts index fcd1fa4..b8fb84e 100644 --- a/src/agent/tools/telegram/media/vision-analyze.ts +++ b/src/agent/tools/telegram/media/vision-analyze.ts @@ -33,7 +33,7 @@ interface VisionAnalyzeParams { export const visionAnalyzeTool: Tool = { name: "vision_analyze", description: - "Analyze an image using Claude's vision capabilities. Can analyze images from Telegram messages OR from local workspace files. Use this when a user sends an image and asks you to describe, analyze, or understand its content. Returns Claude's analysis of the image.", + "Analyze an image using vision LLM. Provide chatId+messageId for Telegram images or filePath for local files.", category: "data-bearing", parameters: Type.Object({ chatId: Type.Optional( diff --git a/src/agent/tools/telegram/memory/memory-read.ts b/src/agent/tools/telegram/memory/memory-read.ts index b7cbee0..d33750c 100644 --- a/src/agent/tools/telegram/memory/memory-read.ts +++ b/src/agent/tools/telegram/memory/memory-read.ts @@ -25,7 +25,7 @@ interface MemoryReadParams { export const memoryReadTool: Tool = { name: "memory_read", description: - "Read your memory files. Use 'persistent' for MEMORY.md, 'daily' for today's log, 'recent' for today+yesterday, or 'list' to see all available memory files.", + "Read your memory files: persistent (MEMORY.md), daily (today's log), recent (today+yesterday), or list all.", category: "data-bearing", parameters: Type.Object({ target: Type.String({ diff --git a/src/agent/tools/telegram/memory/memory-write.ts b/src/agent/tools/telegram/memory/memory-write.ts index 416e999..38d49cc 100644 --- a/src/agent/tools/telegram/memory/memory-write.ts +++ b/src/agent/tools/telegram/memory/memory-write.ts @@ -37,7 +37,7 @@ interface MemoryWriteParams { export const memoryWriteTool: Tool = { name: "memory_write", description: - "Write important information to your persistent memory. Use this to remember facts, lessons learned, decisions, preferences, or anything you want to recall in future sessions. 'persistent' writes to MEMORY.md (long-term), 'daily' writes to today's log (short-term notes).", + "Save to agent memory. Use 'persistent' for long-term facts, preferences, contacts, rules → MEMORY.md. Use 'daily' for session notes, events, temporary context → today's log. Disabled in group chats.", parameters: Type.Object({ content: Type.String({ description: "The content to write to memory. Be concise but complete.", diff --git a/src/agent/tools/telegram/messaging/delete-message.ts b/src/agent/tools/telegram/messaging/delete-message.ts index 90b4112..286029a 100644 --- a/src/agent/tools/telegram/messaging/delete-message.ts +++ b/src/agent/tools/telegram/messaging/delete-message.ts @@ -21,7 +21,7 @@ interface DeleteMessageParams { export const telegramDeleteMessageTool: Tool = { name: "telegram_delete_message", description: - "Delete one or more messages from a chat. Can delete your own messages in any chat, or any message in groups where you have admin rights. Use 'revoke: true' to delete for everyone (not just yourself). Be careful - deletion is permanent!", + "Delete messages from a chat. Own messages in any chat, or any message with admin rights. Deletion is permanent.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID where the messages are located", diff --git a/src/agent/tools/telegram/messaging/edit-message.ts b/src/agent/tools/telegram/messaging/edit-message.ts index 1dfcdaa..5b4bdc6 100644 --- a/src/agent/tools/telegram/messaging/edit-message.ts +++ b/src/agent/tools/telegram/messaging/edit-message.ts @@ -21,8 +21,7 @@ interface EditMessageParams { */ export const telegramEditMessageTool: Tool = { name: "telegram_edit_message", - description: - "Edit a previously sent message in a Telegram chat. Use this to correct errors, update information, or modify content without deleting and resending. Only messages sent by the bot can be edited. Text messages can be edited within 48 hours of being sent.", + description: "Edit a previously sent message. Only your own messages can be edited, within 48h.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID where the message was sent", diff --git a/src/agent/tools/telegram/messaging/forward-message.ts b/src/agent/tools/telegram/messaging/forward-message.ts index 153dab0..5c97adf 100644 --- a/src/agent/tools/telegram/messaging/forward-message.ts +++ b/src/agent/tools/telegram/messaging/forward-message.ts @@ -24,7 +24,7 @@ interface ForwardMessageParams { export const telegramForwardMessageTool: Tool = { name: "telegram_forward_message", description: - "Forward one or more messages from one chat to another. Useful for sharing messages, quotes, or content between conversations. The forwarded messages will show their original sender unless sent silently.", + "Forward one or more messages from one chat to another. Shows original sender attribution.", parameters: Type.Object({ fromChatId: Type.String({ description: "The chat ID where the original message(s) are located", diff --git a/src/agent/tools/telegram/messaging/get-replies.ts b/src/agent/tools/telegram/messaging/get-replies.ts index 163e801..289e216 100644 --- a/src/agent/tools/telegram/messaging/get-replies.ts +++ b/src/agent/tools/telegram/messaging/get-replies.ts @@ -22,7 +22,7 @@ interface GetRepliesParams { export const telegramGetRepliesTool: Tool = { name: "telegram_get_replies", description: - "Get all replies to a specific message (reply thread/chain). Useful for reading conversation threads, forum discussions, or comment sections under channel posts. Returns messages sorted from oldest to newest.", + "Get all replies to a specific message (thread/chain). Returns messages oldest-first.", category: "data-bearing", parameters: Type.Object({ chatId: Type.String({ diff --git a/src/agent/tools/telegram/messaging/quote-reply.ts b/src/agent/tools/telegram/messaging/quote-reply.ts index c53bb1c..809f442 100644 --- a/src/agent/tools/telegram/messaging/quote-reply.ts +++ b/src/agent/tools/telegram/messaging/quote-reply.ts @@ -24,7 +24,7 @@ interface QuoteReplyParams { export const telegramQuoteReplyTool: Tool = { name: "telegram_quote_reply", description: - "Reply to a message while quoting a specific part of it. The quoted text will be highlighted in the reply. Use this when you want to respond to a specific part of someone's message.", + "Reply to a message while quoting a specific part of it. quoteText must match the original exactly.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID where the message is", diff --git a/src/agent/tools/telegram/messaging/schedule-message.ts b/src/agent/tools/telegram/messaging/schedule-message.ts index a9a5b88..4be0ba5 100644 --- a/src/agent/tools/telegram/messaging/schedule-message.ts +++ b/src/agent/tools/telegram/messaging/schedule-message.ts @@ -23,7 +23,7 @@ interface ScheduleMessageParams { export const telegramScheduleMessageTool: Tool = { name: "telegram_schedule_message", description: - "Schedule a message to be sent at a specific future time in a Telegram chat. Useful for reminders, delayed announcements, or time-sensitive messages. The message will be sent automatically at the scheduled time.", + "Schedule a message to be sent at a future time. scheduleDate must be in the future.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID to send the scheduled message to", diff --git a/src/agent/tools/telegram/messaging/search-messages.ts b/src/agent/tools/telegram/messaging/search-messages.ts index c9f5cef..00a44f2 100644 --- a/src/agent/tools/telegram/messaging/search-messages.ts +++ b/src/agent/tools/telegram/messaging/search-messages.ts @@ -21,7 +21,7 @@ interface SearchMessagesParams { export const telegramSearchMessagesTool: Tool = { name: "telegram_search_messages", description: - "Search for messages in a Telegram chat by text query. Use this to find past conversations, retrieve specific information, or locate messages containing keywords. Returns matching messages with their content and metadata.", + "Search for messages in a chat by text query. Returns matching messages with content and metadata.", category: "data-bearing", parameters: Type.Object({ chatId: Type.String({ diff --git a/src/agent/tools/telegram/messaging/send-message.ts b/src/agent/tools/telegram/messaging/send-message.ts index e721796..5858010 100644 --- a/src/agent/tools/telegram/messaging/send-message.ts +++ b/src/agent/tools/telegram/messaging/send-message.ts @@ -21,7 +21,7 @@ interface SendMessageParams { export const telegramSendMessageTool: Tool = { name: "telegram_send_message", description: - "Send a text message to a Telegram chat. Supports up to 4096 characters. Use this for standard text responses in DMs or groups. For messages with custom keyboards, use telegram_reply_keyboard. For media, use specific media tools (telegram_send_photo, etc.).", + "Send a text message to a Telegram chat. For custom keyboards use telegram_reply_keyboard; for media use telegram_send_photo/gif/sticker.", parameters: Type.Object({ chatId: Type.String({ description: "The chat ID to send the message to", diff --git a/src/agent/tools/telegram/profile/set-bio.ts b/src/agent/tools/telegram/profile/set-bio.ts index 5004bdc..2f13859 100644 --- a/src/agent/tools/telegram/profile/set-bio.ts +++ b/src/agent/tools/telegram/profile/set-bio.ts @@ -19,7 +19,7 @@ interface SetBioParams { export const telegramSetBioTool: Tool = { name: "telegram_set_bio", description: - "Set or update your Telegram bio (the 'About' section in your profile). This short text describes who you are or what you do, visible to anyone who views your profile. Max 70 characters. Use this to share a tagline, status, or brief description. Leave empty to remove bio entirely.", + "Set or update your Telegram bio (About section). Max 70 chars. Empty string to remove.", parameters: Type.Object({ bio: Type.String({ description: diff --git a/src/agent/tools/telegram/profile/set-username.ts b/src/agent/tools/telegram/profile/set-username.ts index acba4f8..662ca66 100644 --- a/src/agent/tools/telegram/profile/set-username.ts +++ b/src/agent/tools/telegram/profile/set-username.ts @@ -19,7 +19,7 @@ interface SetUsernameParams { export const telegramSetUsernameTool: Tool = { name: "telegram_set_username", description: - "Set or change your Telegram username (the @handle people use to find you). Usernames are unique across Telegram - must be 5-32 characters, alphanumeric plus underscores, and available. Use this to claim a memorable handle or update your public identifier. Empty string removes username. Warning: Changing username breaks existing t.me/username links.", + "Set or change your Telegram @username. Must be 5-32 chars, alphanumeric + underscores. Empty string removes it. Warning: breaks existing t.me/ links.", parameters: Type.Object({ username: Type.String({ description: diff --git a/src/agent/tools/telegram/profile/update-profile.ts b/src/agent/tools/telegram/profile/update-profile.ts index 0a05537..df582b7 100644 --- a/src/agent/tools/telegram/profile/update-profile.ts +++ b/src/agent/tools/telegram/profile/update-profile.ts @@ -21,7 +21,7 @@ interface UpdateProfileParams { export const telegramUpdateProfileTool: Tool = { name: "telegram_update_profile", description: - "Update your Telegram profile information including first name, last name, and bio (about text). Changes are visible to all users who view your profile. Use this to keep your public identity current, reflect life changes, or update your description. Leave fields undefined to keep current values.", + "Update your profile (first name, last name, bio). Omit fields to keep current values.", parameters: Type.Object({ firstName: Type.Optional( Type.String({ diff --git a/src/agent/tools/telegram/stars/get-balance.ts b/src/agent/tools/telegram/stars/get-balance.ts index ab2d189..87c769c 100644 --- a/src/agent/tools/telegram/stars/get-balance.ts +++ b/src/agent/tools/telegram/stars/get-balance.ts @@ -11,8 +11,7 @@ const log = createLogger("Tools"); */ export const telegramGetStarsBalanceTool: Tool = { name: "telegram_get_stars_balance", - description: - "Get your current Telegram Stars balance. Stars are Telegram's virtual currency used to buy gifts, tip creators, and purchase digital goods. Returns your total balance and any pending/withdrawable amounts.", + description: "Get your current Telegram Stars balance.", category: "data-bearing", parameters: Type.Object({}), }; diff --git a/src/agent/tools/telegram/stars/get-transactions.ts b/src/agent/tools/telegram/stars/get-transactions.ts index 4787569..fb506c5 100644 --- a/src/agent/tools/telegram/stars/get-transactions.ts +++ b/src/agent/tools/telegram/stars/get-transactions.ts @@ -20,8 +20,7 @@ interface GetTransactionsParams { */ export const telegramGetStarsTransactionsTool: Tool = { name: "telegram_get_stars_transactions", - description: - "Get your Telegram Stars transaction history. Shows all purchases, gifts sent/received, and other Star movements. Can filter by inbound (received) or outbound (spent) transactions.", + description: "Get your Stars transaction history. Filterable by inbound/outbound.", category: "data-bearing", parameters: Type.Object({ limit: Type.Optional( diff --git a/src/agent/tools/telegram/stickers/add-sticker-set.ts b/src/agent/tools/telegram/stickers/add-sticker-set.ts index e1f7198..4ef07b4 100644 --- a/src/agent/tools/telegram/stickers/add-sticker-set.ts +++ b/src/agent/tools/telegram/stickers/add-sticker-set.ts @@ -18,8 +18,7 @@ interface AddStickerSetParams { */ export const telegramAddStickerSetTool: Tool = { name: "telegram_add_sticker_set", - description: - "Add/install a sticker pack to your account by its short name. Once added, you can use the stickers from this pack in conversations. The short name is the part after t.me/addstickers/ in a sticker pack link, or can be found via telegram_search_stickers. Use this to build your sticker collection.", + description: "Install a sticker pack to your account by its short name.", parameters: Type.Object({ shortName: Type.String({ description: diff --git a/src/agent/tools/telegram/stickers/get-my-stickers.ts b/src/agent/tools/telegram/stickers/get-my-stickers.ts index 5bd7105..a61a92a 100644 --- a/src/agent/tools/telegram/stickers/get-my-stickers.ts +++ b/src/agent/tools/telegram/stickers/get-my-stickers.ts @@ -20,7 +20,7 @@ interface GetMyStickersParams { export const telegramGetMyStickersTool: Tool = { name: "telegram_get_my_stickers", description: - "List all sticker packs that are installed/saved to your account. Returns your personal sticker collection with shortName, title, and count for each pack. Use this to see what stickers you already have before sending. To send a sticker from your collection: use telegram_send_sticker with the shortName + stickerIndex.", + "List all sticker packs installed on your account. Returns shortName, title, and count per pack.", category: "data-bearing", parameters: Type.Object({ limit: Type.Optional( diff --git a/src/agent/tools/telegram/stickers/search-gifs.ts b/src/agent/tools/telegram/stickers/search-gifs.ts index e6cc0de..10c18ce 100644 --- a/src/agent/tools/telegram/stickers/search-gifs.ts +++ b/src/agent/tools/telegram/stickers/search-gifs.ts @@ -20,7 +20,7 @@ interface SearchGifsParams { export const telegramSearchGifsTool: Tool = { name: "telegram_search_gifs", description: - "Search for GIF animations using Telegram's built-in GIF search (@gif bot). Returns GIF results with IDs and query_id. To send a GIF: 1) Use this tool to search, 2) Note the queryId and a result's id, 3) Use telegram_send_gif with queryId + resultId. This ensures proper sending via Telegram's inline bot system.", + "Search for GIFs via @gif bot. Returns queryId + result IDs needed by telegram_send_gif.", parameters: Type.Object({ query: Type.String({ description: "Search query for GIFs. Example: 'happy', 'dancing', 'thumbs up', 'laughing'", diff --git a/src/agent/tools/telegram/stickers/search-stickers.ts b/src/agent/tools/telegram/stickers/search-stickers.ts index b2c240a..54a6768 100644 --- a/src/agent/tools/telegram/stickers/search-stickers.ts +++ b/src/agent/tools/telegram/stickers/search-stickers.ts @@ -20,7 +20,7 @@ interface SearchStickersParams { export const telegramSearchStickersTool: Tool = { name: "telegram_search_stickers", description: - "Search for sticker packs globally in Telegram's catalog by keyword or emoji. Returns both installed and uninstalled packs with their installation status. Use this to discover new packs or find specific ones. For a focused view of ONLY your installed packs, use telegram_get_my_stickers instead. Results include shortName and count. To send: telegram_send_sticker(chatId, stickerSetShortName, stickerIndex 0 to count-1).", + "Search sticker packs globally by keyword or emoji. Returns packs with shortName, count, and install status. For installed-only, use telegram_get_my_stickers.", parameters: Type.Object({ query: Type.String({ description: diff --git a/src/agent/tools/telegram/stories/send-story.ts b/src/agent/tools/telegram/stories/send-story.ts index 219a5dc..f7f4c5f 100644 --- a/src/agent/tools/telegram/stories/send-story.ts +++ b/src/agent/tools/telegram/stories/send-story.ts @@ -25,8 +25,7 @@ interface SendStoryParams { */ export const telegramSendStoryTool: Tool = { name: "telegram_send_story", - description: - "Post a story (ephemeral photo/video) to Telegram that disappears after 24 hours. Stories are displayed at the top of chats and provide high visibility. Use this for announcements, updates, or time-sensitive visual content. Supports photos (JPG, PNG) and videos (MP4).", + description: "Post a story (photo/video) that disappears after 24h. Supports JPG, PNG, MP4.", parameters: Type.Object({ mediaPath: Type.String({ description: diff --git a/src/agent/tools/telegram/tasks/create-scheduled-task.ts b/src/agent/tools/telegram/tasks/create-scheduled-task.ts index 3ad5cf0..9f553c3 100644 --- a/src/agent/tools/telegram/tasks/create-scheduled-task.ts +++ b/src/agent/tools/telegram/tasks/create-scheduled-task.ts @@ -51,7 +51,7 @@ interface CreateScheduledTaskParams { export const telegramCreateScheduledTaskTool: Tool = { name: "telegram_create_scheduled_task", description: - "Create a scheduled task that will be executed at a specific time. The task will be stored in the database and a reminder message will be scheduled in Saved Messages. When the time comes, you'll receive the task context and can execute it with full agent capabilities. Supports both simple tool calls and complex multi-step tasks.", + "Schedule a task for future execution. Stores in DB and schedules a reminder in Saved Messages. Supports tool_call, agent_task payloads, or simple reminders. Can depend on other tasks.", parameters: Type.Object({ description: Type.String({ description: "What the task is about (e.g., 'Check TON price and alert if > $5')", diff --git a/src/agent/tools/ton/chart.ts b/src/agent/tools/ton/chart.ts index 2ae757f..edece88 100644 --- a/src/agent/tools/ton/chart.ts +++ b/src/agent/tools/ton/chart.ts @@ -13,8 +13,7 @@ interface ChartParams { export const tonChartTool: Tool = { name: "ton_chart", - description: - "Get price history chart for TON or any jetton. Returns price points over a time period with stats (min, max, change %). Use token param for jettons (master contract address).", + description: "Get price history chart for TON or any jetton over a configurable time period.", parameters: Type.Object({ token: Type.Optional( Type.String({ diff --git a/src/agent/tools/ton/dex-quote.ts b/src/agent/tools/ton/dex-quote.ts index 5a17ef7..1403ab4 100644 --- a/src/agent/tools/ton/dex-quote.ts +++ b/src/agent/tools/ton/dex-quote.ts @@ -35,7 +35,7 @@ interface DexQuoteResult { export const dexQuoteTool: Tool = { name: "dex_quote", description: - "Smart router that compares quotes from STON.fi and DeDust DEX to find the best price. Returns comparison table with expected outputs, fees, and recommends the best DEX for your swap. Use 'ton' for TON or jetton master address.", + "Compare swap quotes from STON.fi and DeDust to find the best price. Does not execute.", category: "data-bearing", parameters: Type.Object({ from_asset: Type.String({ diff --git a/src/agent/tools/ton/get-address.ts b/src/agent/tools/ton/get-address.ts index 151b5b0..438759f 100644 --- a/src/agent/tools/ton/get-address.ts +++ b/src/agent/tools/ton/get-address.ts @@ -7,8 +7,7 @@ import { createLogger } from "../../../utils/logger.js"; const log = createLogger("Tools"); export const tonGetAddressTool: Tool = { name: "ton_get_address", - description: - "Get your TON wallet address. Returns the address where you can receive TON cryptocurrency.", + description: "Get your TON wallet address.", parameters: Type.Object({}), }; export const tonGetAddressExecutor: ToolExecutor<{}> = async ( diff --git a/src/agent/tools/ton/get-balance.ts b/src/agent/tools/ton/get-balance.ts index abde5ad..8146a04 100644 --- a/src/agent/tools/ton/get-balance.ts +++ b/src/agent/tools/ton/get-balance.ts @@ -7,7 +7,7 @@ import { createLogger } from "../../../utils/logger.js"; const log = createLogger("Tools"); export const tonGetBalanceTool: Tool = { name: "ton_get_balance", - description: "Get your current TON wallet balance. Returns the balance in TON and nanoTON.", + description: "Get your current TON wallet balance.", parameters: Type.Object({}), category: "data-bearing", }; diff --git a/src/agent/tools/ton/get-price.ts b/src/agent/tools/ton/get-price.ts index 9e69a10..ef4fd99 100644 --- a/src/agent/tools/ton/get-price.ts +++ b/src/agent/tools/ton/get-price.ts @@ -7,7 +7,7 @@ import { createLogger } from "../../../utils/logger.js"; const log = createLogger("Tools"); export const tonPriceTool: Tool = { name: "ton_price", - description: "Get current TON cryptocurrency price in USD. Returns real-time market price.", + description: "Get current TON price in USD.", category: "data-bearing", parameters: Type.Object({}), }; diff --git a/src/agent/tools/ton/get-transactions.ts b/src/agent/tools/ton/get-transactions.ts index 01c3ddb..dde61a4 100644 --- a/src/agent/tools/ton/get-transactions.ts +++ b/src/agent/tools/ton/get-transactions.ts @@ -14,8 +14,7 @@ interface GetTransactionsParams { } export const tonGetTransactionsTool: Tool = { name: "ton_get_transactions", - description: - "Get transaction history for any TON address. Returns transactions with type (ton_received, ton_sent, jetton_received, jetton_sent, nft_received, nft_sent, gas_refund), amount, counterparty, and explorer link.", + description: "Get transaction history for any TON address.", category: "data-bearing", parameters: Type.Object({ address: Type.String({ diff --git a/src/agent/tools/ton/jetton-balances.ts b/src/agent/tools/ton/jetton-balances.ts index 73a0bad..7bd3ff6 100644 --- a/src/agent/tools/ton/jetton-balances.ts +++ b/src/agent/tools/ton/jetton-balances.ts @@ -27,8 +27,7 @@ interface JettonBalance { } export const jettonBalancesTool: Tool = { name: "jetton_balances", - description: - "Get all Jetton (token) balances owned by the agent. Returns a list of all tokens with their balances, names, symbols, and verification status. Useful to check what tokens you currently hold.", + description: "Get all jetton balances owned by the agent. Filters out blacklisted tokens.", parameters: Type.Object({}), category: "data-bearing", }; diff --git a/src/agent/tools/ton/jetton-history.ts b/src/agent/tools/ton/jetton-history.ts index 3c1d575..8d03181 100644 --- a/src/agent/tools/ton/jetton-history.ts +++ b/src/agent/tools/ton/jetton-history.ts @@ -13,8 +13,7 @@ interface JettonHistoryParams { export const jettonHistoryTool: Tool = { name: "jetton_history", - description: - "Get price history and performance data for a Jetton. Shows price changes over 24h, 7d, 30d periods, along with volume and market data. Useful for analyzing token trends.", + description: "Get jetton price history: 24h/7d/30d changes, volume, FDV, and holder count.", category: "data-bearing", parameters: Type.Object({ jetton_address: Type.String({ diff --git a/src/agent/tools/ton/jetton-holders.ts b/src/agent/tools/ton/jetton-holders.ts index e0792bb..52161a0 100644 --- a/src/agent/tools/ton/jetton-holders.ts +++ b/src/agent/tools/ton/jetton-holders.ts @@ -11,8 +11,7 @@ interface JettonHoldersParams { } export const jettonHoldersTool: Tool = { name: "jetton_holders", - description: - "Get the top holders of a Jetton (token). Shows wallet addresses and their balances. Useful to analyze token distribution and identify whale wallets.", + description: "Get top holders of a jetton with their balances.", category: "data-bearing", parameters: Type.Object({ jetton_address: Type.String({ diff --git a/src/agent/tools/ton/jetton-info.ts b/src/agent/tools/ton/jetton-info.ts index e633f86..6172c9c 100644 --- a/src/agent/tools/ton/jetton-info.ts +++ b/src/agent/tools/ton/jetton-info.ts @@ -13,7 +13,7 @@ interface JettonInfoParams { export const jettonInfoTool: Tool = { name: "jetton_info", description: - "Get detailed information about a Jetton (token) by its master contract address. Returns name, symbol, decimals, total supply, holders count, and verification status. Useful to research a token before buying or sending.", + "Get jetton metadata: name, symbol, decimals, total supply, holders, verification status.", category: "data-bearing", parameters: Type.Object({ jetton_address: Type.String({ diff --git a/src/agent/tools/ton/jetton-price.ts b/src/agent/tools/ton/jetton-price.ts index 2bfdaf8..e8885d9 100644 --- a/src/agent/tools/ton/jetton-price.ts +++ b/src/agent/tools/ton/jetton-price.ts @@ -12,8 +12,7 @@ interface JettonPriceParams { export const jettonPriceTool: Tool = { name: "jetton_price", - description: - "Get the current price of a Jetton (token) in USD and TON, along with 24h, 7d, and 30d price changes. Useful to check token value before swapping or to monitor investments.", + description: "Get current jetton price in USD/TON with 24h, 7d, 30d changes.", category: "data-bearing", parameters: Type.Object({ jetton_address: Type.String({ diff --git a/src/agent/tools/ton/jetton-send.ts b/src/agent/tools/ton/jetton-send.ts index f4ed98e..e79b96c 100644 --- a/src/agent/tools/ton/jetton-send.ts +++ b/src/agent/tools/ton/jetton-send.ts @@ -1,9 +1,8 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, TonClient, toNano, internal } from "@ton/ton"; +import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js"; +import { WalletContractV5R1, toNano, internal } from "@ton/ton"; import { Address, SendMode, beginCell } from "@ton/core"; -import { getCachedHttpEndpoint } from "../../../ton/endpoint.js"; import { tonapiFetch } from "../../../constants/api-endpoints.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; @@ -21,7 +20,7 @@ interface JettonSendParams { export const jettonSendTool: Tool = { name: "jetton_send", description: - "Send Jettons (tokens) to another address. Requires the jetton master address, recipient address, and amount. Amount is in human-readable units (e.g., 10 for 10 USDT). Use jetton_balances first to see what tokens you own and their addresses.", + "Send jettons to another address. Amount in human-readable units. Use jetton_balances first to find addresses.", parameters: Type.Object({ jetton_address: Type.String({ description: "Jetton master contract address (EQ... or 0:... format)", @@ -65,7 +64,9 @@ export const jettonSendExecutor: ToolExecutor = async ( } // Get sender's jetton wallet address from TonAPI - const jettonsResponse = await tonapiFetch(`/accounts/${walletData.address}/jettons`); + const jettonsResponse = await tonapiFetch( + `/accounts/${encodeURIComponent(walletData.address)}/jettons` + ); if (!jettonsResponse.ok) { return { @@ -76,12 +77,17 @@ export const jettonSendExecutor: ToolExecutor = async ( const jettonsData = await jettonsResponse.json(); - // Find the jetton in our balances - const jettonBalance = jettonsData.balances?.find( - (b: any) => - b.jetton.address.toLowerCase() === jetton_address.toLowerCase() || - Address.parse(b.jetton.address).toString() === Address.parse(jetton_address).toString() - ); + // Find the jetton in our balances (safe: skip entries with malformed addresses) + const jettonBalance = jettonsData.balances?.find((b: any) => { + if (b.jetton.address.toLowerCase() === jetton_address.toLowerCase()) return true; + try { + return ( + Address.parse(b.jetton.address).toString() === Address.parse(jetton_address).toString() + ); + } catch { + return false; + } + }); if (!jettonBalance) { return { @@ -127,8 +133,8 @@ export const jettonSendExecutor: ToolExecutor = async ( .storeAddress(Address.parse(walletData.address)) // response_destination (excess returns here) .storeBit(false) // no custom_payload .storeCoins(comment ? toNano("0.01") : BigInt(1)) // forward_ton_amount (for notification) - .storeBit(comment ? true : false) // forward_payload flag - .storeMaybeRef(comment ? forwardPayload : null) // forward_payload + .storeBit(comment ? 1 : 0) // forward_payload: Either tag (0=inline, 1=ref) + .storeRef(comment ? forwardPayload : beginCell().endCell()) // forward_payload .endCell(); const keyPair = await getKeyPair(); @@ -140,8 +146,7 @@ export const jettonSendExecutor: ToolExecutor = async ( publicKey: keyPair.publicKey, }); - const endpoint = await getCachedHttpEndpoint(); - const client = new TonClient({ endpoint }); + const client = await getCachedTonClient(); const walletContract = client.open(wallet); const seqno = await walletContract.getSeqno(); diff --git a/src/agent/tools/ton/my-transactions.ts b/src/agent/tools/ton/my-transactions.ts index e0ec943..08a0a14 100644 --- a/src/agent/tools/ton/my-transactions.ts +++ b/src/agent/tools/ton/my-transactions.ts @@ -16,8 +16,7 @@ interface MyTransactionsParams { export const tonMyTransactionsTool: Tool = { name: "ton_my_transactions", - description: - "Get your own wallet's transaction history. Returns transactions with type (ton_received, ton_sent, jetton_received, jetton_sent, nft_received, nft_sent, gas_refund), amount, counterparty, and explorer link.", + description: "Get your own wallet's transaction history.", category: "data-bearing", parameters: Type.Object({ limit: Type.Optional( diff --git a/src/agent/tools/ton/send.ts b/src/agent/tools/ton/send.ts index bbb81a8..9008deb 100644 --- a/src/agent/tools/ton/send.ts +++ b/src/agent/tools/ton/send.ts @@ -1,9 +1,7 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, TonClient, toNano, internal } from "@ton/ton"; -import { Address, SendMode } from "@ton/core"; -import { getCachedHttpEndpoint } from "../../../ton/endpoint.js"; +import { loadWallet } from "../../../ton/wallet-service.js"; +import { sendTon } from "../../../ton/transfer.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; @@ -16,7 +14,7 @@ interface SendParams { export const tonSendTool: Tool = { name: "ton_send", description: - "Send TON cryptocurrency to an address. Requires wallet to be initialized. Amount is in TON (not nanoTON). Example: amount 1.5 = 1.5 TON. Always confirm the transaction details before sending.", + "Send TON to an address. Amount in TON (not nanoTON). Confirm details before sending.", parameters: Type.Object({ to: Type.String({ description: "Recipient TON address (EQ... or UQ... format)", @@ -34,7 +32,7 @@ export const tonSendTool: Tool = { }; export const tonSendExecutor: ToolExecutor = async ( params, - context + _context ): Promise => { try { const { to, amount, comment } = params; @@ -47,47 +45,15 @@ export const tonSendExecutor: ToolExecutor = async ( }; } - try { - Address.parse(to); - } catch (e) { + const txRef = await sendTon({ toAddress: to, amount, comment }); + + if (!txRef) { return { success: false, - error: `Invalid recipient address: ${to}`, + error: "TON transfer failed (wallet not initialized or invalid parameters)", }; } - const keyPair = await getKeyPair(); - if (!keyPair) { - return { success: false, error: "Wallet key derivation failed." }; - } - - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - - // Get decentralized endpoint from orbs network (no rate limits) - const endpoint = await getCachedHttpEndpoint(); - const client = new TonClient({ endpoint }); - - const contract = client.open(wallet); - - const seqno = await contract.getSeqno(); - - await contract.sendTransfer({ - seqno, - secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, - messages: [ - internal({ - to: Address.parse(to), - value: toNano(amount), - body: comment || "", - bounce: false, - }), - ], - }); - return { success: true, data: { diff --git a/src/agent/tools/web/fetch.ts b/src/agent/tools/web/fetch.ts index 965e7b8..554c991 100644 --- a/src/agent/tools/web/fetch.ts +++ b/src/agent/tools/web/fetch.ts @@ -16,14 +16,7 @@ const ALLOWED_SCHEMES = new Set(["http:", "https:"]); export const webFetchTool: Tool = { name: "web_fetch", - description: `Fetch a web page and extract its readable text content using Tavily Extract. - -Returns clean, readable text extracted from the page — ideal for reading articles, docs, or links shared by users. -Only http/https URLs are allowed. Content is truncated to max_length characters. - -Examples: -- url="https://docs.ton.org/develop/overview" -- url="https://example.com/article", max_length=10000`, + description: "Fetch a web page and extract readable text. HTTP/HTTPS only.", category: "data-bearing", parameters: Type.Object({ url: Type.String({ description: "URL to fetch (http or https only)" }), diff --git a/src/agent/tools/web/search.ts b/src/agent/tools/web/search.ts index 83c1b67..3a172f6 100644 --- a/src/agent/tools/web/search.ts +++ b/src/agent/tools/web/search.ts @@ -15,18 +15,7 @@ interface WebSearchParams { export const webSearchTool: Tool = { name: "web_search", - description: `Search the web using Tavily. Returns results with title, URL, content snippet, and relevance score. - -Use this to find up-to-date information, verify facts, research topics, or get news. - -Parameters: -- query: search query string -- count: number of results (default 5, max ${WEB_SEARCH_MAX_RESULTS}) -- topic: "general" (default), "news", or "finance" - -Examples: -- query="TON blockchain latest news", topic="news" -- query="bitcoin price today", count=3, topic="finance"`, + description: "Search the web. Returns results with title, URL, and content snippet.", category: "data-bearing", parameters: Type.Object({ query: Type.String({ description: "Search query" }), diff --git a/src/agent/tools/workspace/delete.ts b/src/agent/tools/workspace/delete.ts index 7297ac8..cffd011 100644 --- a/src/agent/tools/workspace/delete.ts +++ b/src/agent/tools/workspace/delete.ts @@ -23,16 +23,8 @@ const PROTECTED_WORKSPACE_FILES = [ export const workspaceDeleteTool: Tool = { name: "workspace_delete", - description: `Delete a file or directory from your workspace. - -PROTECTED FILES (cannot delete): -- SOUL.md, MEMORY.md, IDENTITY.md, USER.md - -You CAN delete: -- Files in memory/, downloads/, uploads/, temp/ -- Custom files you've created - -Use recursive=true to delete non-empty directories.`, + description: + "Delete a file or directory from workspace. Cannot delete SOUL.md, MEMORY.md, IDENTITY.md, USER.md.", parameters: Type.Object({ path: Type.String({ diff --git a/src/agent/tools/workspace/info.ts b/src/agent/tools/workspace/info.ts index 13e5fc5..17a22b9 100644 --- a/src/agent/tools/workspace/info.ts +++ b/src/agent/tools/workspace/info.ts @@ -15,13 +15,7 @@ interface WorkspaceInfoParams { export const workspaceInfoTool: Tool = { name: "workspace_info", - description: `Get information about your workspace structure and usage. - -Returns: -- Workspace root path -- Directory structure -- File counts and sizes -- Usage limits`, + description: "Get workspace structure, file counts, sizes, and usage limits.", category: "data-bearing", parameters: Type.Object({ detailed: Type.Optional( diff --git a/src/agent/tools/workspace/list.ts b/src/agent/tools/workspace/list.ts index 35cb922..5d74c6c 100644 --- a/src/agent/tools/workspace/list.ts +++ b/src/agent/tools/workspace/list.ts @@ -19,16 +19,7 @@ interface WorkspaceListParams { export const workspaceListTool: Tool = { name: "workspace_list", - description: `List files and directories in your workspace. - -Your workspace is at ~/.teleton/workspace/ and contains: -- SOUL.md, MEMORY.md, IDENTITY.md (config files) -- memory/ (daily logs) -- downloads/ (downloaded media) -- uploads/ (files to send) -- temp/ (temporary files) - -You can ONLY access files within this workspace. Files outside (config.yaml, wallet.json, etc.) are protected.`, + description: "List files and directories in the workspace.", category: "data-bearing", parameters: Type.Object({ path: Type.Optional( diff --git a/src/agent/tools/workspace/read.ts b/src/agent/tools/workspace/read.ts index 27df65d..e6a67f9 100644 --- a/src/agent/tools/workspace/read.ts +++ b/src/agent/tools/workspace/read.ts @@ -14,18 +14,8 @@ interface WorkspaceReadParams { export const workspaceReadTool: Tool = { name: "workspace_read", - description: `Read a file from your workspace. - -You can ONLY read files within ~/.teleton/workspace/. Protected files like config.yaml, wallet.json, and telegram_session.txt are NOT accessible. - -Supported files: -- Text files (.md, .txt, .json, .csv) -- Use encoding="base64" for binary files - -Examples: -- Read your memory: path="MEMORY.md" -- Read today's log: path="memory/2024-01-15.md" -- Read downloaded image info: path="downloads/image.jpg" (will return metadata only)`, + description: + "Read a file from workspace. Only ~/.teleton/workspace/ is accessible. Use encoding='base64' for binary files.", category: "data-bearing", parameters: Type.Object({ path: Type.String({ diff --git a/src/agent/tools/workspace/rename.ts b/src/agent/tools/workspace/rename.ts index 8a34c33..b9a18c9 100644 --- a/src/agent/tools/workspace/rename.ts +++ b/src/agent/tools/workspace/rename.ts @@ -16,19 +16,7 @@ interface WorkspaceRenameParams { export const workspaceRenameTool: Tool = { name: "workspace_rename", - description: `Rename or move a file within your workspace. - -Use this to: -- Give meaningful names to downloaded files -- Organize files into subdirectories -- Rename Telegram downloads (default names like "123_456_789.jpg" are hard to track) - -Examples: -- Rename: from="downloads/123_456_789.jpg", to="downloads/alice_profile.jpg" -- Move: from="downloads/photo.jpg", to="uploads/photo.jpg" -- Organize: from="downloads/doc.pdf", to="downloads/contracts/2026/lease.pdf" - -CANNOT move/rename files outside workspace or to protected locations.`, + description: "Rename or move a file within workspace. Creates parent directories as needed.", parameters: Type.Object({ from: Type.String({ diff --git a/src/agent/tools/workspace/write.ts b/src/agent/tools/workspace/write.ts index 9a125a6..e06d4c1 100644 --- a/src/agent/tools/workspace/write.ts +++ b/src/agent/tools/workspace/write.ts @@ -18,18 +18,8 @@ interface WorkspaceWriteParams { export const workspaceWriteTool: Tool = { name: "workspace_write", - description: `Write a file to your workspace. - -You can ONLY write files within ~/.teleton/workspace/. This includes: -- memory/ - Daily logs and notes -- uploads/ - Files to send -- temp/ - Temporary files - -You CANNOT write to protected locations like config.yaml, wallet.json, etc. - -Examples: -- Save a note: path="memory/note.md", content="..." -- Prepare upload: path="uploads/message.txt", content="..."`, + description: + "Write a file to workspace. Only ~/.teleton/workspace/ is writable. Cannot write to protected locations.", parameters: Type.Object({ path: Type.String({ diff --git a/src/deals/executor.ts b/src/deals/executor.ts index d33e002..9bb85b4 100644 --- a/src/deals/executor.ts +++ b/src/deals/executor.ts @@ -95,14 +95,7 @@ export async function executeDeal( }); if (!txHash) { - // Release lock since send failed - db.prepare( - `UPDATE deals SET agent_sent_at = NULL, status = 'failed', notes = 'TON transfer returned no tx hash' WHERE id = ?` - ).run(dealId); - return { - success: false, - error: "TON transfer failed (no tx hash returned)", - }; + throw new Error("TON transfer failed (wallet not initialized or invalid parameters)"); } // Update deal: mark as completed (agent_sent_at already set by lock) diff --git a/src/sdk/__tests__/ton-utils.real.test.ts b/src/sdk/__tests__/ton-utils.real.test.ts new file mode 100644 index 0000000..dcdb2b7 --- /dev/null +++ b/src/sdk/__tests__/ton-utils.real.test.ts @@ -0,0 +1,199 @@ +/** + * ton-utils.real.test.ts + * + * Tests for toNano / fromNano / validateAddress using the REAL @ton/ton and + * @ton/core implementations — no mock for those two packages. + * + * Only infrastructure modules (wallet-service, transfer, http clients…) are + * mocked so we can instantiate createTonSDK without side-effects. + */ + +import { describe, it, expect, vi } from "vitest"; +import { PluginSDKError } from "@teleton-agent/sdk"; + +// ─── Mock infrastructure — NOT @ton/ton or @ton/core ───────────────────────── + +vi.mock("../../ton/wallet-service.js", () => ({ + getWalletAddress: vi.fn(), + getWalletBalance: vi.fn(), + getTonPrice: vi.fn(), + loadWallet: vi.fn(), + getKeyPair: vi.fn(), +})); + +vi.mock("../../ton/transfer.js", () => ({ + sendTon: vi.fn(), +})); + +vi.mock("../../constants/limits.js", () => ({ + PAYMENT_TOLERANCE_RATIO: 0.99, +})); + +vi.mock("../../utils/retry.js", () => ({ + withBlockchainRetry: vi.fn(), +})); + +vi.mock("../../constants/api-endpoints.js", () => ({ + tonapiFetch: vi.fn(), +})); + +vi.mock("../../ton/endpoint.js", () => ({ + getCachedHttpEndpoint: vi.fn().mockResolvedValue("https://toncenter.test"), +})); + +vi.mock("../../ton/format-transactions.js", () => ({ + formatTransactions: vi.fn((txs: any[]) => txs), +})); + +// withTxLock is a passthrough in this context (no real transactions sent) +vi.mock("../../ton/tx-lock.js", () => ({ + withTxLock: vi.fn((fn: () => Promise) => fn()), +})); + +// ─── Subject under test ─────────────────────────────────────────────────────── + +import { createTonSDK } from "../ton.js"; + +const mockLog = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +// SDK instance — db is null (utilities do not require a database) +const sdk = createTonSDK(mockLog as any, null); + +// ─── Known-valid TON address (EQ bounceable, verified on mainnet) ───────────── +const VALID_BOUNCEABLE = "EQDtFpEwcFAEcRe5mLVh2N6C0x-_hJEM7W61_JLnSF74p4q2"; +// Same address in raw hex format (both should parse correctly) +const VALID_RAW_HEX = "0:ed169130705004711b99c35615c6fd41a16e7b52bea6dcb87f6f84d3e6b57f7e"; + +// ───────────────────────────────────────────────────────────────────────────── + +describe("TonSDK utility methods — real @ton/ton + @ton/core", () => { + // ═══════════════════════════════════════════════════════════════════════════ + // toNano() + // ═══════════════════════════════════════════════════════════════════════════ + + describe("toNano()", () => { + it("converts 1.5 (number) → 1_500_000_000n", () => { + expect(sdk.toNano(1.5)).toBe(BigInt("1500000000")); + }); + + it("converts '1.5' (string) → 1_500_000_000n", () => { + expect(sdk.toNano("1.5")).toBe(BigInt("1500000000")); + }); + + it("converts integer 2 → 2_000_000_000n", () => { + expect(sdk.toNano(2)).toBe(BigInt("2000000000")); + }); + + it("converts 0 → 0n", () => { + expect(sdk.toNano(0)).toBe(BigInt(0)); + }); + + it("converts sub-nano precision '0.5' → 500_000_000n", () => { + expect(sdk.toNano("0.5")).toBe(BigInt("500000000")); + }); + + it("converts large amount 1_000_000 → 1_000_000_000_000_000n", () => { + expect(sdk.toNano(1_000_000)).toBe(BigInt("1000000000000000")); + }); + + // The library allows negative values — SDK utility does not add extra guard + // (amount validation is the responsibility of sendTON / sendJetton) + it("accepts negative values (library behaviour) → negative bigint", () => { + expect(sdk.toNano(-1)).toBe(BigInt("-1000000000")); + }); + + it("throws PluginSDKError on non-numeric string 'not_a_number'", () => { + expect(() => sdk.toNano("not_a_number")).toThrow(PluginSDKError); + }); + + it("throws PluginSDKError on NaN", () => { + expect(() => sdk.toNano(NaN)).toThrow(PluginSDKError); + }); + + it("throws PluginSDKError on Infinity", () => { + expect(() => sdk.toNano(Infinity)).toThrow(PluginSDKError); + }); + + it("throws PluginSDKError on scientific notation string '1e9'", () => { + // @ton/core's parser does not support 'e' notation + expect(() => sdk.toNano("1e9")).toThrow(PluginSDKError); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // fromNano() + // ═══════════════════════════════════════════════════════════════════════════ + + describe("fromNano()", () => { + it("converts 1_500_000_000n → '1.5'", () => { + expect(sdk.fromNano(BigInt("1500000000"))).toBe("1.5"); + }); + + it("converts 3_000_000_000n → '3'", () => { + expect(sdk.fromNano(BigInt("3000000000"))).toBe("3"); + }); + + it("converts 0n → '0'", () => { + expect(sdk.fromNano(BigInt(0))).toBe("0"); + }); + + it("accepts string input '1500000000' → '1.5'", () => { + expect(sdk.fromNano("1500000000")).toBe("1.5"); + }); + + it("preserves precision: 1n → '0.000000001'", () => { + expect(sdk.fromNano(BigInt(1))).toBe("0.000000001"); + }); + + it("preserves precision: 999_999_999n → '0.999999999'", () => { + expect(sdk.fromNano(BigInt("999999999"))).toBe("0.999999999"); + }); + + it("round-trips with toNano for 2.5", () => { + expect(sdk.fromNano(sdk.toNano(2.5))).toBe("2.5"); + }); + + it("round-trips with toNano for 0.1", () => { + expect(sdk.fromNano(sdk.toNano("0.1"))).toBe("0.1"); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // validateAddress() + // ═══════════════════════════════════════════════════════════════════════════ + + describe("validateAddress()", () => { + it("returns true for a valid bounceable address (EQ…)", () => { + expect(sdk.validateAddress(VALID_BOUNCEABLE)).toBe(true); + }); + + it("returns true for a valid raw hex address (0:…)", () => { + expect(sdk.validateAddress(VALID_RAW_HEX)).toBe(true); + }); + + it("returns false for an empty string", () => { + expect(sdk.validateAddress("")).toBe(false); + }); + + it("returns false for a random alphanumeric string", () => { + expect(sdk.validateAddress("not-an-address")).toBe(false); + }); + + it("returns false for a truncated address", () => { + expect(sdk.validateAddress("EQDtFpEwcFAEc")).toBe(false); + }); + + it("returns false for a raw address with short hex payload", () => { + expect(sdk.validateAddress("0:abc123")).toBe(false); + }); + + it("returns false for a URL that looks like an address", () => { + expect(sdk.validateAddress("https://ton.org/wallet")).toBe(false); + }); + }); +}); diff --git a/src/sdk/__tests__/ton.test.ts b/src/sdk/__tests__/ton.test.ts index b1972e0..e5e8e3b 100644 --- a/src/sdk/__tests__/ton.test.ts +++ b/src/sdk/__tests__/ton.test.ts @@ -10,6 +10,7 @@ vi.mock("../../ton/wallet-service.js", () => ({ getTonPrice: vi.fn(), loadWallet: vi.fn(), getKeyPair: vi.fn(), + getCachedTonClient: vi.fn(), })); vi.mock("../../ton/transfer.js", () => ({ @@ -73,6 +74,7 @@ import { getTonPrice, loadWallet, getKeyPair, + getCachedTonClient, } from "../../ton/wallet-service.js"; import { sendTon } from "../../ton/transfer.js"; import { tonapiFetch } from "../../constants/api-endpoints.js"; @@ -295,9 +297,7 @@ describe("createTonSDK", () => { it("returns formatted transactions", async () => { const mockTxs = [{ hash: "abc", type: "ton_received" }]; const mockGetTx = vi.fn().mockResolvedValue(mockTxs); - mocks.tonClient.mockImplementation(function (this: any) { - this.getTransactions = mockGetTx; - }); + (getCachedTonClient as Mock).mockResolvedValue({ getTransactions: mockGetTx }); mocks.formatTransactions.mockReturnValue(mockTxs); const result = await sdk.getTransactions(VALID_ADDRESS, 5); @@ -306,9 +306,7 @@ describe("createTonSDK", () => { it("caps limit at 50", async () => { const mockGetTx = vi.fn().mockResolvedValue([]); - mocks.tonClient.mockImplementation(function (this: any) { - this.getTransactions = mockGetTx; - }); + (getCachedTonClient as Mock).mockResolvedValue({ getTransactions: mockGetTx }); mocks.formatTransactions.mockReturnValue([]); await sdk.getTransactions(VALID_ADDRESS, 999); @@ -320,9 +318,7 @@ describe("createTonSDK", () => { it("defaults limit to 10 when not specified", async () => { const mockGetTx = vi.fn().mockResolvedValue([]); - mocks.tonClient.mockImplementation(function (this: any) { - this.getTransactions = mockGetTx; - }); + (getCachedTonClient as Mock).mockResolvedValue({ getTransactions: mockGetTx }); mocks.formatTransactions.mockReturnValue([]); await sdk.getTransactions(VALID_ADDRESS); @@ -333,9 +329,7 @@ describe("createTonSDK", () => { }); it("returns empty array on error", async () => { - mocks.tonClient.mockImplementation(function () { - throw new Error("connection failed"); - }); + (getCachedTonClient as Mock).mockRejectedValue(new Error("connection failed")); const result = await sdk.getTransactions(VALID_ADDRESS); expect(result).toEqual([]); @@ -601,6 +595,7 @@ describe("createTonSDK", () => { storeCoins: vi.fn().mockReturnThis(), storeAddress: vi.fn().mockReturnThis(), storeBit: vi.fn().mockReturnThis(), + storeRef: vi.fn().mockReturnThis(), storeMaybeRef: vi.fn().mockReturnThis(), storeStringTail: vi.fn().mockReturnThis(), endCell: vi.fn().mockReturnValue(cellMock), @@ -613,13 +608,13 @@ describe("createTonSDK", () => { // Mock toNano (used in TEP-74 transfer body) mocks.toNano.mockReturnValue(BigInt(1)); - // Mock TonClient (must use regular function for `new` constructor) + // Mock getCachedTonClient — returns a client with an open() method const mockWalletContract = { getSeqno: vi.fn().mockResolvedValue(42), sendTransfer: vi.fn().mockResolvedValue(undefined), }; - mocks.tonClient.mockImplementation(function (this: any) { - this.open = vi.fn().mockReturnValue(mockWalletContract); + (getCachedTonClient as Mock).mockResolvedValue({ + open: vi.fn().mockReturnValue(mockWalletContract), }); }); diff --git a/src/sdk/ton.ts b/src/sdk/ton.ts index 295f46d..f5ac7a8 100644 --- a/src/sdk/ton.ts +++ b/src/sdk/ton.ts @@ -20,14 +20,21 @@ import { getTonPrice, loadWallet, getKeyPair, + getCachedTonClient, } from "../ton/wallet-service.js"; import { sendTon } from "../ton/transfer.js"; import { PAYMENT_TOLERANCE_RATIO } from "../constants/limits.js"; import { withBlockchainRetry } from "../utils/retry.js"; import { tonapiFetch } from "../constants/api-endpoints.js"; -import { toNano as tonToNano, fromNano as tonFromNano } from "@ton/ton"; -import { Address as TonAddress } from "@ton/core"; +import { + toNano as tonToNano, + fromNano as tonFromNano, + WalletContractV5R1, + internal, +} from "@ton/ton"; +import { Address as TonAddress, beginCell, SendMode } from "@ton/core"; import { withTxLock } from "../ton/tx-lock.js"; +import { formatTransactions } from "../ton/format-transactions.js"; const DEFAULT_MAX_AGE_MINUTES = 10; @@ -35,6 +42,20 @@ const DEFAULT_TX_RETENTION_DAYS = 30; const CLEANUP_PROBABILITY = 0.1; +/** Match a jetton in a balances array by raw address or parsed canonical form. */ +function findJettonBalance(balances: any[], jettonAddress: string): any | undefined { + return balances.find((b: any) => { + if (b.jetton.address.toLowerCase() === jettonAddress.toLowerCase()) return true; + try { + return ( + TonAddress.parse(b.jetton.address).toString() === TonAddress.parse(jettonAddress).toString() + ); + } catch { + return false; + } + }); +} + function cleanupOldTransactions( db: Database.Database, retentionDays: number, @@ -97,8 +118,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T } try { - const { Address } = await import("@ton/core"); - Address.parse(to); + TonAddress.parse(to); } catch { throw new PluginSDKError("Invalid TON address format", "INVALID_ADDRESS"); } @@ -130,14 +150,8 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T async getTransactions(address: string, limit?: number): Promise { try { - const { TonClient } = await import("@ton/ton"); - const { Address } = await import("@ton/core"); - const { getCachedHttpEndpoint } = await import("../ton/endpoint.js"); - const { formatTransactions } = await import("../ton/format-transactions.js"); - - const addressObj = Address.parse(address); - const endpoint = await getCachedHttpEndpoint(); - const client = new TonClient({ endpoint }); + const addressObj = TonAddress.parse(address); + const client = await getCachedTonClient(); const transactions = await withBlockchainRetry( () => @@ -236,7 +250,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T const addr = ownerAddress ?? getWalletAddress(); if (!addr) return []; - const response = await tonapiFetch(`/accounts/${addr}/jettons`); + const response = await tonapiFetch(`/accounts/${encodeURIComponent(addr)}/jettons`); if (!response.ok) { log.error(`ton.getJettonBalances() TonAPI error: ${response.status}`); return []; @@ -282,7 +296,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T async getJettonInfo(jettonAddress: string): Promise { try { - const response = await tonapiFetch(`/jettons/${jettonAddress}`); + const response = await tonapiFetch(`/jettons/${encodeURIComponent(jettonAddress)}`); if (response.status === 404) return null; if (!response.ok) { log.error(`ton.getJettonInfo() TonAPI error: ${response.status}`); @@ -316,10 +330,6 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T amount: number, opts?: { comment?: string } ): Promise { - const { Address, beginCell, SendMode } = await import("@ton/core"); - const { WalletContractV5R1, TonClient, toNano, internal } = await import("@ton/ton"); - const { getCachedHttpEndpoint } = await import("../ton/endpoint.js"); - const walletData = loadWallet(); if (!walletData) { throw new PluginSDKError("Wallet not initialized", "WALLET_NOT_INITIALIZED"); @@ -330,14 +340,16 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T } try { - Address.parse(to); + TonAddress.parse(to); } catch { throw new PluginSDKError("Invalid recipient address", "INVALID_ADDRESS"); } try { // Get sender's jetton wallet from balances - const jettonsResponse = await tonapiFetch(`/accounts/${walletData.address}/jettons`); + const jettonsResponse = await tonapiFetch( + `/accounts/${encodeURIComponent(walletData.address)}/jettons` + ); if (!jettonsResponse.ok) { throw new PluginSDKError( `Failed to fetch jetton balances: ${jettonsResponse.status}`, @@ -346,11 +358,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T } const jettonsData = await jettonsResponse.json(); - const jettonBalance = jettonsData.balances?.find( - (b: any) => - b.jetton.address.toLowerCase() === jettonAddress.toLowerCase() || - Address.parse(b.jetton.address).toString() === Address.parse(jettonAddress).toString() - ); + const jettonBalance = findJettonBalance(jettonsData.balances ?? [], jettonAddress); if (!jettonBalance) { throw new PluginSDKError( @@ -387,12 +395,12 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T .storeUint(JETTON_TRANSFER_OP, 32) .storeUint(0, 64) // query_id .storeCoins(amountInUnits) - .storeAddress(Address.parse(to)) - .storeAddress(Address.parse(walletData.address)) // response_destination + .storeAddress(TonAddress.parse(to)) + .storeAddress(TonAddress.parse(walletData.address)) // response_destination .storeBit(false) // no custom_payload - .storeCoins(comment ? toNano("0.01") : BigInt(1)) // forward_ton_amount - .storeBit(comment ? true : false) - .storeMaybeRef(comment ? forwardPayload : null) + .storeCoins(comment ? tonToNano("0.01") : BigInt(1)) // forward_ton_amount + .storeBit(comment ? 1 : 0) + .storeRef(comment ? forwardPayload : beginCell().endCell()) .endCell(); const keyPair = await getKeyPair(); @@ -406,8 +414,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T publicKey: keyPair.publicKey, }); - const endpoint = await getCachedHttpEndpoint(); - const client = new TonClient({ endpoint }); + const client = await getCachedTonClient(); const walletContract = client.open(wallet); const seq = await walletContract.getSeqno(); @@ -417,8 +424,8 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [ internal({ - to: Address.parse(senderJettonWallet), - value: toNano("0.05"), + to: TonAddress.parse(senderJettonWallet), + value: tonToNano("0.05"), body: messageBody, bounce: true, }), @@ -443,21 +450,15 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T jettonAddress: string ): Promise { try { - const response = await tonapiFetch(`/accounts/${ownerAddress}/jettons`); + const response = await tonapiFetch(`/accounts/${encodeURIComponent(ownerAddress)}/jettons`); if (!response.ok) { log.error(`ton.getJettonWalletAddress() TonAPI error: ${response.status}`); return null; } - const { Address } = await import("@ton/core"); const data = await response.json(); - const match = (data.balances || []).find( - (b: any) => - b.jetton.address.toLowerCase() === jettonAddress.toLowerCase() || - Address.parse(b.jetton.address).toString() === Address.parse(jettonAddress).toString() - ); - + const match = findJettonBalance(data.balances ?? [], jettonAddress); return match ? match.wallet_address.address : null; } catch (err) { log.error("ton.getJettonWalletAddress() failed:", err); @@ -494,7 +495,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T async getNftInfo(nftAddress: string): Promise { try { - const response = await tonapiFetch(`/nfts/${nftAddress}`); + const response = await tonapiFetch(`/nfts/${encodeURIComponent(nftAddress)}`); if (response.status === 404) return null; if (!response.ok) { log.error(`ton.getNftInfo() TonAPI error: ${response.status}`); diff --git a/src/ton/endpoint.ts b/src/ton/endpoint.ts index 5364e90..ef485ad 100644 --- a/src/ton/endpoint.ts +++ b/src/ton/endpoint.ts @@ -49,3 +49,8 @@ export async function getCachedHttpEndpoint(): Promise { _cache = { url, ts: Date.now() }; return url; } + +/** Call this when a node returns a 5xx error — forces re-discovery on next call. */ +export function invalidateEndpointCache(): void { + _cache = null; +} diff --git a/src/ton/payment-verifier.ts b/src/ton/payment-verifier.ts index acadf31..031a61a 100644 --- a/src/ton/payment-verifier.ts +++ b/src/ton/payment-verifier.ts @@ -1,7 +1,7 @@ import type Database from "better-sqlite3"; -import { TonClient, fromNano } from "@ton/ton"; +import { fromNano } from "@ton/ton"; import { Address } from "@ton/core"; -import { getCachedHttpEndpoint } from "./endpoint.js"; +import { getCachedTonClient } from "./wallet-service.js"; import { withBlockchainRetry } from "../utils/retry.js"; import { PAYMENT_TOLERANCE_RATIO } from "../constants/limits.js"; import { getErrorMessage } from "../utils/errors.js"; @@ -71,8 +71,7 @@ export async function verifyPayment( maxPaymentAgeMinutes = DEFAULT_MAX_PAYMENT_AGE_MINUTES, } = params; - const endpoint = await getCachedHttpEndpoint(); - const client = new TonClient({ endpoint }); + const client = await getCachedTonClient(); const botAddress = Address.parse(botWalletAddress); const transactions = await withBlockchainRetry( diff --git a/src/ton/transfer.ts b/src/ton/transfer.ts index d13e55a..f745d9a 100644 --- a/src/ton/transfer.ts +++ b/src/ton/transfer.ts @@ -1,7 +1,6 @@ -import { WalletContractV5R1, TonClient, toNano, internal } from "@ton/ton"; +import { WalletContractV5R1, toNano, internal } from "@ton/ton"; import { Address, SendMode } from "@ton/core"; -import { getCachedHttpEndpoint } from "./endpoint.js"; -import { getKeyPair } from "./wallet-service.js"; +import { getKeyPair, getCachedTonClient, invalidateTonClientCache } from "./wallet-service.js"; import { createLogger } from "../utils/logger.js"; import { withTxLock } from "./tx-lock.js"; @@ -43,8 +42,7 @@ export async function sendTon(params: SendTonParams): Promise { publicKey: keyPair.publicKey, }); - const endpoint = await getCachedHttpEndpoint(); - const client = new TonClient({ endpoint }); + const client = await getCachedTonClient(); const contract = client.open(wallet); const seqno = await contract.getSeqno(); @@ -68,9 +66,13 @@ export async function sendTon(params: SendTonParams): Promise { log.info(`Sent ${amount} TON to ${toAddress.slice(0, 8)}... - seqno: ${seqno}`); return pseudoHash; - } catch (error) { + } catch (error: any) { + // Invalidate node cache on 5xx so next attempt picks a fresh node + if (error?.status >= 500 || error?.response?.status >= 500) { + invalidateTonClientCache(); + } log.error({ err: error }, "Error sending TON"); - return null; + throw error; } }); // withTxLock } diff --git a/src/ton/wallet-service.ts b/src/ton/wallet-service.ts index 10c742e..fe40276 100644 --- a/src/ton/wallet-service.ts +++ b/src/ton/wallet-service.ts @@ -2,7 +2,7 @@ import { mnemonicNew, mnemonicToPrivateKey, mnemonicValidate } from "@ton/crypto import { WalletContractV5R1, TonClient, fromNano } from "@ton/ton"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; import { join, dirname } from "path"; -import { getCachedHttpEndpoint } from "./endpoint.js"; +import { getCachedHttpEndpoint, invalidateEndpointCache } from "./endpoint.js"; import { fetchWithTimeout } from "../utils/fetch.js"; import { TELETON_ROOT } from "../workspace/paths.js"; import { tonapiFetch, COINGECKO_API_URL } from "../constants/api-endpoints.js"; @@ -19,6 +19,9 @@ let _walletCache: WalletData | null | undefined; // undefined = not yet loaded /** Cached key pair derived from mnemonic */ let _keyPairCache: { publicKey: Buffer; secretKey: Buffer } | null = null; +/** Cached TonClient — invalidated when endpoint rotates */ +let _tonClientCache: { client: TonClient; endpoint: string } | null = null; + export interface WalletData { version: "w5r1"; address: string; @@ -138,6 +141,29 @@ export function getWalletAddress(): string | null { return wallet?.address || null; } +/** + * Get (or create) a cached TonClient. + * Re-creates only when the endpoint URL rotates (60s TTL on endpoint). + */ +export async function getCachedTonClient(): Promise { + const endpoint = await getCachedHttpEndpoint(); + if (_tonClientCache && _tonClientCache.endpoint === endpoint) { + return _tonClientCache.client; + } + const client = new TonClient({ endpoint }); + _tonClientCache = { client, endpoint }; + return client; +} + +/** + * Invalidate the TonClient cache and the endpoint cache. + * Call this when a node returns a 5xx error so the next call picks a fresh node. + */ +export function invalidateTonClientCache(): void { + _tonClientCache = null; + invalidateEndpointCache(); +} + /** * Get cached KeyPair (derives from mnemonic once, then reuses). * Returns null if no wallet is configured. @@ -160,10 +186,7 @@ export async function getWalletBalance(address: string): Promise<{ balanceNano: string; } | null> { try { - // Get decentralized endpoint from orbs network (no rate limits) - const endpoint = await getCachedHttpEndpoint(); - - const client = new TonClient({ endpoint }); + const client = await getCachedTonClient(); // Import Address from @ton/core const { Address } = await import("@ton/core"); From b9b27ccc6d619cdadcfc646a369366d6f8c64270 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 13:39:33 +0100 Subject: [PATCH 15/41] feat(provider): add claude-code provider with auto-detected credentials Add a new "Claude Code (Auto)" provider that reuses the Anthropic API but auto-reads OAuth tokens from the local Claude Code installation (~/.claude/.credentials.json on Linux/Windows, macOS Keychain). - Credential reader with intelligent caching (re-read on expiration) - 401 retry in chatWithContext for expired tokens - CLI onboard: auto-detect + confirm, fallback to manual key - WebUI setup: detection panel with status, fallback button - 20 tests (unit + integration + registration) --- src/agent/client.ts | 25 ++- src/cli/commands/onboard.ts | 66 +++++- src/config/__tests__/loader.test.ts | 2 +- src/config/configurable-keys.ts | 2 + src/config/loader.ts | 6 +- src/config/providers.ts | 17 +- src/config/schema.ts | 3 +- .../__tests__/claude-code-credentials.test.ts | 197 ++++++++++++++++++ .../__tests__/claude-code-provider.test.ts | 56 +++++ .../__tests__/claude-code-retry.test.ts | 152 ++++++++++++++ src/providers/claude-code-credentials.ts | 177 ++++++++++++++++ src/webui/__tests__/setup-routes.test.ts | 12 +- src/webui/routes/setup.ts | 40 +++- web/src/components/setup/ProviderStep.tsx | 88 +++++++- web/src/components/setup/SetupContext.tsx | 3 + web/src/lib/api.ts | 10 + web/src/pages/Setup.tsx | 2 +- 17 files changed, 838 insertions(+), 20 deletions(-) create mode 100644 src/providers/__tests__/claude-code-credentials.test.ts create mode 100644 src/providers/__tests__/claude-code-provider.test.ts create mode 100644 src/providers/__tests__/claude-code-retry.test.ts create mode 100644 src/providers/claude-code-credentials.ts diff --git a/src/agent/client.ts b/src/agent/client.ts index 3469deb..1a5f454 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -17,11 +17,15 @@ import { getProviderMetadata, type SupportedProvider } from "../config/providers import { sanitizeToolsForGemini } from "./schema-sanitizer.js"; import { createLogger } from "../utils/logger.js"; import { fetchWithTimeout } from "../utils/fetch.js"; +import { + getClaudeCodeApiKey, + refreshClaudeCodeApiKey, +} from "../providers/claude-code-credentials.js"; const log = createLogger("LLM"); export function isOAuthToken(apiKey: string, provider?: string): boolean { - if (provider && provider !== "anthropic") return false; + if (provider && provider !== "anthropic" && provider !== "claude-code") return false; return apiKey.startsWith("sk-ant-oat01-"); } @@ -29,6 +33,7 @@ export function isOAuthToken(apiKey: string, provider?: string): boolean { export function getEffectiveApiKey(provider: string, rawKey: string): string { if (provider === "local") return "local"; if (provider === "cocoon") return ""; + if (provider === "claude-code") return getClaudeCodeApiKey(rawKey); return rawKey; } @@ -292,7 +297,23 @@ export async function chatWithContext( completeOptions.onPayload = stripCocoonPayload; } - const response = await complete(model, context, completeOptions as ProviderStreamOptions); + let response = await complete(model, context, completeOptions as ProviderStreamOptions); + + // Claude Code provider: retry once on 401/Unauthorized by refreshing credentials + if ( + provider === "claude-code" && + response.stopReason === "error" && + response.errorMessage && + (response.errorMessage.includes("401") || + response.errorMessage.toLowerCase().includes("unauthorized")) + ) { + log.warn("Claude Code token rejected (401), refreshing credentials and retrying..."); + const refreshedKey = refreshClaudeCodeApiKey(); + if (refreshedKey) { + completeOptions.apiKey = refreshedKey; + response = await complete(model, context, completeOptions as ProviderStreamOptions); + } + } // Cocoon: parse from text response if (isCocoon) { diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index c4aac2d..5b76eee 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -52,6 +52,10 @@ import { import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../../constants/limits.js"; import { fetchWithTimeout } from "../../utils/fetch.js"; import ora from "ora"; +import { + getClaudeCodeApiKey, + isClaudeCodeTokenValid, +} from "../../providers/claude-code-credentials.js"; export interface OnboardOptions { workspace?: string; @@ -97,10 +101,15 @@ function sleep(ms: number): Promise { const MODEL_OPTIONS: Record> = { anthropic: [ + { + value: "claude-opus-4-6", + name: "Claude Opus 4.6", + description: "Most capable, 1M ctx, $5/M", + }, { value: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", - description: "Most capable, $5/M", + description: "Previous gen, 200K ctx, $5/M", }, { value: "claude-sonnet-4-0", name: "Claude Sonnet 4", description: "Balanced, $3/M" }, { @@ -469,6 +478,58 @@ async function runInteractiveOnboarding( ); STEPS[1].value = `${providerMeta.displayName} ${DIM(localBaseUrl)}`; + } else if (selectedProvider === "claude-code") { + // Claude Code — auto-detect credentials, fallback to manual key + let detected = false; + try { + const key = getClaudeCodeApiKey(); + const valid = isClaudeCodeTokenValid(); + apiKey = ""; // Don't store in config — auto-detected at runtime + detected = true; + const masked = key.length > 16 ? key.slice(0, 12) + "..." + key.slice(-4) : "***"; + noteBox( + `Credentials auto-detected from Claude Code\n` + + `Key: ${masked}\n` + + `Status: ${valid ? GREEN("valid ✓") : "expired (will refresh on use)"}\n` + + `Token will auto-refresh when it expires.`, + "Claude Code", + TON + ); + await confirm({ + message: "Continue with auto-detected credentials?", + default: true, + theme, + }); + } catch (err) { + if (err instanceof CancelledError) throw err; + prompter.warn( + "Claude Code credentials not found. Make sure Claude Code is installed and authenticated (claude login)." + ); + const useFallback = await confirm({ + message: "Enter an API key manually instead?", + default: true, + theme, + }); + if (useFallback) { + apiKey = await password({ + message: `Anthropic API Key (fallback)`, + theme, + validate: (value = "") => { + if (!value || value.trim().length === 0) return "API key is required"; + return true; + }, + }); + } else { + throw new CancelledError(); + } + } + + if (detected) { + STEPS[1].value = `${providerMeta.displayName} ${DIM("auto-detected ✓")}`; + } else { + const maskedKey = apiKey.length > 10 ? apiKey.slice(0, 6) + "..." + apiKey.slice(-4) : "***"; + STEPS[1].value = `${providerMeta.displayName} ${DIM(maskedKey)}`; + } } else { // Standard providers — API key required const envApiKey = process.env.TELETON_API_KEY; @@ -593,7 +654,8 @@ async function runInteractiveOnboarding( selectedProvider !== "cocoon" && selectedProvider !== "local" ) { - const providerModels = MODEL_OPTIONS[selectedProvider] || []; + const modelKey = selectedProvider === "claude-code" ? "anthropic" : selectedProvider; + const providerModels = MODEL_OPTIONS[modelKey] || []; const modelChoices = [ ...providerModels, { value: "__custom__", name: "Custom", description: "Enter a model ID manually" }, diff --git a/src/config/__tests__/loader.test.ts b/src/config/__tests__/loader.test.ts index 07a68d0..701dc10 100644 --- a/src/config/__tests__/loader.test.ts +++ b/src/config/__tests__/loader.test.ts @@ -350,7 +350,7 @@ describe("Config Loader", () => { const config = loadConfig(TEST_CONFIG_PATH); // Agent defaults - expect(config.agent.model).toBe("claude-opus-4-5-20251101"); + expect(config.agent.model).toBe("claude-opus-4-6"); expect(config.agent.max_tokens).toBe(4096); expect(config.agent.temperature).toBe(0.7); expect(config.agent.max_agentic_iterations).toBe(5); diff --git a/src/config/configurable-keys.ts b/src/config/configurable-keys.ts index fc0087b..91c720a 100644 --- a/src/config/configurable-keys.ts +++ b/src/config/configurable-keys.ts @@ -96,6 +96,7 @@ export const CONFIGURABLE_KEYS: Record = { sensitive: false, options: [ "anthropic", + "claude-code", "openai", "google", "xai", @@ -108,6 +109,7 @@ export const CONFIGURABLE_KEYS: Record = { ], validate: enumValidator([ "anthropic", + "claude-code", "openai", "google", "xai", diff --git a/src/config/loader.ts b/src/config/loader.ts index a70c9c3..174c1d6 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -52,7 +52,11 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config { const config = result.data; const provider = config.agent.provider as SupportedProvider; - if (provider !== "anthropic" && !(raw as Record>).agent?.model) { + if ( + provider !== "anthropic" && + provider !== "claude-code" && + !(raw as Record>).agent?.model + ) { const meta = getProviderMetadata(provider); config.agent.model = meta.defaultModel; } diff --git a/src/config/providers.ts b/src/config/providers.ts index e0c530d..894fdfa 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -1,5 +1,6 @@ export type SupportedProvider = | "anthropic" + | "claude-code" | "openai" | "google" | "xai" @@ -31,7 +32,19 @@ const PROVIDER_REGISTRY: Record = { keyPrefix: "sk-ant-", keyHint: "sk-ant-api03-...", consoleUrl: "https://console.anthropic.com/", - defaultModel: "claude-opus-4-5-20251101", + defaultModel: "claude-opus-4-6", + utilityModel: "claude-3-5-haiku-20241022", + toolLimit: null, + piAiProvider: "anthropic", + }, + "claude-code": { + id: "claude-code", + displayName: "Claude Code (Auto)", + envVar: "ANTHROPIC_API_KEY", + keyPrefix: "sk-ant-", + keyHint: "Auto-detected from Claude Code", + consoleUrl: "https://console.anthropic.com/", + defaultModel: "claude-opus-4-6", utilityModel: "claude-3-5-haiku-20241022", toolLimit: null, piAiProvider: "anthropic", @@ -161,7 +174,7 @@ export function getSupportedProviders(): ProviderMetadata[] { export function validateApiKeyFormat(provider: SupportedProvider, key: string): string | undefined { const meta = PROVIDER_REGISTRY[provider]; if (!meta) return `Unknown provider: ${provider}`; - if (provider === "cocoon" || provider === "local") return undefined; // No API key needed + if (provider === "cocoon" || provider === "local" || provider === "claude-code") return undefined; // No API key needed (claude-code auto-detects) if (!key || key.trim().length === 0) return "API key is required"; if (meta.keyPrefix && !key.startsWith(meta.keyPrefix)) { return `Invalid format (should start with ${meta.keyPrefix})`; diff --git a/src/config/schema.ts b/src/config/schema.ts index 926380f..bcb9104 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -23,6 +23,7 @@ export const AgentConfigSchema = z.object({ provider: z .enum([ "anthropic", + "claude-code", "openai", "google", "xai", @@ -40,7 +41,7 @@ export const AgentConfigSchema = z.object({ .url() .optional() .describe("Base URL for local LLM server (e.g. http://localhost:11434/v1)"), - model: z.string().default("claude-opus-4-5-20251101"), + model: z.string().default("claude-opus-4-6"), utility_model: z .string() .optional() diff --git a/src/providers/__tests__/claude-code-credentials.test.ts b/src/providers/__tests__/claude-code-credentials.test.ts new file mode 100644 index 0000000..2f6d6b5 --- /dev/null +++ b/src/providers/__tests__/claude-code-credentials.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { join } from "path"; +import { mkdirSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { randomBytes } from "crypto"; + +// ── Test fixtures ─────────────────────────────────────────────────────── + +const TEST_DIR = join(tmpdir(), `claude-creds-test-${randomBytes(8).toString("hex")}`); +const CREDS_FILE = join(TEST_DIR, ".credentials.json"); + +function validCredentials(overrides: Record = {}) { + return { + claudeAiOauth: { + accessToken: "sk-ant-oat01-test-token-abc123", + refreshToken: "sk-ant-ort01-refresh-xyz", + expiresAt: Date.now() + 3_600_000, // 1h from now + scopes: ["user:inference", "user:profile"], + ...overrides, + }, + }; +} + +function expiredCredentials() { + return validCredentials({ expiresAt: Date.now() - 60_000 }); // 1min ago +} + +function writeCredsFile(data: unknown) { + writeFileSync(CREDS_FILE, JSON.stringify(data), "utf-8"); +} + +// ── Env management ────────────────────────────────────────────────────── + +const envKeysToClean: string[] = []; + +function setEnv(key: string, value: string) { + process.env[key] = value; + envKeysToClean.push(key); +} + +// ── Setup / Teardown ──────────────────────────────────────────────────── + +beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + setEnv("CLAUDE_CONFIG_DIR", TEST_DIR); +}); + +afterEach(async () => { + for (const key of envKeysToClean) delete process.env[key]; + envKeysToClean.length = 0; + try { + rmSync(TEST_DIR, { recursive: true, force: true }); + } catch {} + // Reset module to clear cached state + vi.resetModules(); +}); + +// ── Helper: fresh import ──────────────────────────────────────────────── + +async function importModule() { + return import("../claude-code-credentials.js"); +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +describe("claude-code-credentials", () => { + // T1 + it("reads valid credentials from .credentials.json", async () => { + writeCredsFile(validCredentials()); + const mod = await importModule(); + const key = mod.getClaudeCodeApiKey(); + expect(key).toBe("sk-ant-oat01-test-token-abc123"); + }); + + // T2 + it("throws when no credentials file and no fallback", async () => { + const mod = await importModule(); + expect(() => mod.getClaudeCodeApiKey()).toThrow(/No Claude Code credentials found/); + }); + + // T3 + it("falls back to manual key on malformed JSON", async () => { + writeFileSync(CREDS_FILE, "NOT VALID JSON{{{", "utf-8"); + const mod = await importModule(); + const key = mod.getClaudeCodeApiKey("sk-ant-api03-fallback"); + expect(key).toBe("sk-ant-api03-fallback"); + }); + + // T4 + it("falls back when claudeAiOauth field is missing", async () => { + writeCredsFile({ someOtherKey: "value" }); + const mod = await importModule(); + const key = mod.getClaudeCodeApiKey("sk-ant-api03-fallback"); + expect(key).toBe("sk-ant-api03-fallback"); + }); + + // T5 + it("caches token and does not re-read on second call", async () => { + writeCredsFile(validCredentials()); + const mod = await importModule(); + + const key1 = mod.getClaudeCodeApiKey(); + // Overwrite file with different token + writeCredsFile(validCredentials({ accessToken: "sk-ant-oat01-different" })); + const key2 = mod.getClaudeCodeApiKey(); + + // Should still return cached token + expect(key1).toBe(key2); + expect(key2).toBe("sk-ant-oat01-test-token-abc123"); + }); + + // T6 + it("re-reads file when cached token is expired", async () => { + writeCredsFile(expiredCredentials()); + const mod = await importModule(); + + // First call reads expired token — it still returns it (just read) + const key1 = mod.getClaudeCodeApiKey(); + expect(key1).toBe("sk-ant-oat01-test-token-abc123"); + + // Now token is cached but expired, write new token + writeCredsFile(validCredentials({ accessToken: "sk-ant-oat01-refreshed" })); + const key2 = mod.getClaudeCodeApiKey(); + expect(key2).toBe("sk-ant-oat01-refreshed"); + }); + + // T7 + it("refreshClaudeCodeApiKey clears cache and re-reads", async () => { + writeCredsFile(validCredentials()); + const mod = await importModule(); + + const key1 = mod.getClaudeCodeApiKey(); + expect(key1).toBe("sk-ant-oat01-test-token-abc123"); + + // Write new token and force refresh + writeCredsFile(validCredentials({ accessToken: "sk-ant-oat01-new-token" })); + const key2 = mod.refreshClaudeCodeApiKey(); + expect(key2).toBe("sk-ant-oat01-new-token"); + }); + + // T8 + it("respects CLAUDE_CONFIG_DIR override", async () => { + const customDir = join(tmpdir(), `claude-custom-${randomBytes(4).toString("hex")}`); + mkdirSync(customDir, { recursive: true }); + writeFileSync( + join(customDir, ".credentials.json"), + JSON.stringify(validCredentials({ accessToken: "sk-ant-oat01-custom-dir" })), + "utf-8" + ); + + // Override the env + setEnv("CLAUDE_CONFIG_DIR", customDir); + const mod = await importModule(); + const key = mod.getClaudeCodeApiKey(); + expect(key).toBe("sk-ant-oat01-custom-dir"); + + rmSync(customDir, { recursive: true, force: true }); + }); + + // T9 + it("falls back to manual api_key when no credentials", async () => { + const mod = await importModule(); + const key = mod.getClaudeCodeApiKey("sk-ant-api03-manual-key"); + expect(key).toBe("sk-ant-api03-manual-key"); + }); + + // T10 + it("isClaudeCodeTokenValid returns true for valid cached token", async () => { + writeCredsFile(validCredentials()); + const mod = await importModule(); + mod.getClaudeCodeApiKey(); // populate cache + expect(mod.isClaudeCodeTokenValid()).toBe(true); + }); + + // T11 + it("isClaudeCodeTokenValid returns false when no cached token", async () => { + const mod = await importModule(); + expect(mod.isClaudeCodeTokenValid()).toBe(false); + }); + + // T12 + it("refreshClaudeCodeApiKey returns null when no credentials available", async () => { + const mod = await importModule(); + const result = mod.refreshClaudeCodeApiKey(); + expect(result).toBeNull(); + }); + + // T13 + it("_resetCache clears the cache", async () => { + writeCredsFile(validCredentials()); + const mod = await importModule(); + mod.getClaudeCodeApiKey(); + expect(mod.isClaudeCodeTokenValid()).toBe(true); + mod._resetCache(); + expect(mod.isClaudeCodeTokenValid()).toBe(false); + }); +}); diff --git a/src/providers/__tests__/claude-code-provider.test.ts b/src/providers/__tests__/claude-code-provider.test.ts new file mode 100644 index 0000000..29d7f80 --- /dev/null +++ b/src/providers/__tests__/claude-code-provider.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { + getProviderMetadata, + validateApiKeyFormat, + getSupportedProviders, +} from "../../config/providers.js"; +import { AgentConfigSchema } from "../../config/schema.js"; + +describe("claude-code provider registration", () => { + // T14 + it("is registered with correct metadata", () => { + const meta = getProviderMetadata("claude-code"); + expect(meta.id).toBe("claude-code"); + expect(meta.displayName).toBe("Claude Code (Auto)"); + expect(meta.piAiProvider).toBe("anthropic"); + expect(meta.toolLimit).toBeNull(); + expect(meta.defaultModel).toBe("claude-opus-4-6"); + expect(meta.utilityModel).toBe("claude-3-5-haiku-20241022"); + expect(meta.keyPrefix).toBe("sk-ant-"); + }); + + it("appears in getSupportedProviders()", () => { + const providers = getSupportedProviders(); + const ids = providers.map((p) => p.id); + expect(ids).toContain("claude-code"); + }); + + it("has identical API config to anthropic except display", () => { + const anthropic = getProviderMetadata("anthropic"); + const claudeCode = getProviderMetadata("claude-code"); + + expect(claudeCode.piAiProvider).toBe(anthropic.piAiProvider); + expect(claudeCode.toolLimit).toBe(anthropic.toolLimit); + expect(claudeCode.defaultModel).toBe(anthropic.defaultModel); + expect(claudeCode.utilityModel).toBe(anthropic.utilityModel); + expect(claudeCode.envVar).toBe(anthropic.envVar); + expect(claudeCode.keyPrefix).toBe(anthropic.keyPrefix); + }); + + // T15 + it("is accepted by AgentConfigSchema", () => { + const result = AgentConfigSchema.safeParse({ provider: "claude-code" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.provider).toBe("claude-code"); + } + }); + + it("skips api key validation for claude-code (auto-detects)", () => { + // claude-code is exempt from key validation — credentials are auto-detected + expect(validateApiKeyFormat("claude-code", "sk-ant-api03-valid")).toBeUndefined(); + expect(validateApiKeyFormat("claude-code", "sk-ant-oat01-oauth")).toBeUndefined(); + expect(validateApiKeyFormat("claude-code", "invalid-key")).toBeUndefined(); + expect(validateApiKeyFormat("claude-code", "")).toBeUndefined(); + }); +}); diff --git a/src/providers/__tests__/claude-code-retry.test.ts b/src/providers/__tests__/claude-code-retry.test.ts new file mode 100644 index 0000000..20f0c0d --- /dev/null +++ b/src/providers/__tests__/claude-code-retry.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { join } from "path"; +import { mkdirSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { randomBytes } from "crypto"; + +// ── Test fixtures ─────────────────────────────────────────────────────── + +const TEST_DIR = join(tmpdir(), `claude-retry-test-${randomBytes(8).toString("hex")}`); +const CREDS_FILE = join(TEST_DIR, ".credentials.json"); + +function validCredentials(token = "sk-ant-oat01-test-token") { + return { + claudeAiOauth: { + accessToken: token, + refreshToken: "sk-ant-ort01-refresh", + expiresAt: Date.now() + 3_600_000, + scopes: ["user:inference"], + }, + }; +} + +function writeCredsFile(data: unknown) { + writeFileSync(CREDS_FILE, JSON.stringify(data), "utf-8"); +} + +// ── Mock pi-ai complete ───────────────────────────────────────────────── + +const mockComplete = vi.fn(); +vi.mock("@mariozechner/pi-ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + complete: (...args: unknown[]) => mockComplete(...args), + }; +}); + +// ── Env management ────────────────────────────────────────────────────── + +const envKeysToClean: string[] = []; + +function setEnv(key: string, value: string) { + process.env[key] = value; + envKeysToClean.push(key); +} + +beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + setEnv("CLAUDE_CONFIG_DIR", TEST_DIR); + vi.clearAllMocks(); +}); + +afterEach(() => { + for (const key of envKeysToClean) delete process.env[key]; + envKeysToClean.length = 0; + try { + rmSync(TEST_DIR, { recursive: true, force: true }); + } catch {} +}); + +// ── Helpers ───────────────────────────────────────────────────────────── + +function makeAssistantMessage(text: string, stopReason = "endTurn", errorMessage?: string) { + return { + role: "assistant", + content: [{ type: "text", text }], + stopReason, + errorMessage, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +describe("claude-code 401 retry", () => { + // T12 + it("retries once on 401 and succeeds", async () => { + writeCredsFile(validCredentials("sk-ant-oat01-first-token")); + + // First call: 401 error + mockComplete.mockResolvedValueOnce(makeAssistantMessage("", "error", "401 Unauthorized")); + // After refresh: write new credentials and return success + writeCredsFile(validCredentials("sk-ant-oat01-refreshed-token")); + mockComplete.mockResolvedValueOnce(makeAssistantMessage("Hello!", "endTurn")); + + const { chatWithContext } = await import("../../agent/client.js"); + const { _resetCache } = await import("../claude-code-credentials.js"); + _resetCache(); + + const response = await chatWithContext( + { + provider: "claude-code", + api_key: "", + model: "claude-opus-4-6", + max_tokens: 1024, + temperature: 0.7, + system_prompt: null, + max_agentic_iterations: 5, + session_reset_policy: { + daily_reset_enabled: false, + daily_reset_hour: 4, + idle_expiry_enabled: false, + idle_expiry_minutes: 1440, + }, + }, + { + context: { messages: [], systemPrompt: "test" }, + } + ); + + expect(mockComplete).toHaveBeenCalledTimes(2); + expect(response.text).toBe("Hello!"); + }); + + // T13 + it("does not retry more than once on persistent 401", async () => { + writeCredsFile(validCredentials()); + + // Both calls return 401 + mockComplete.mockResolvedValue(makeAssistantMessage("", "error", "401 Unauthorized")); + + const { chatWithContext } = await import("../../agent/client.js"); + const { _resetCache } = await import("../claude-code-credentials.js"); + _resetCache(); + + const response = await chatWithContext( + { + provider: "claude-code", + api_key: "", + model: "claude-opus-4-6", + max_tokens: 1024, + temperature: 0.7, + system_prompt: null, + max_agentic_iterations: 5, + session_reset_policy: { + daily_reset_enabled: false, + daily_reset_hour: 4, + idle_expiry_enabled: false, + idle_expiry_minutes: 1440, + }, + }, + { + context: { messages: [], systemPrompt: "test" }, + } + ); + + // Should have retried exactly once (2 calls total) + expect(mockComplete).toHaveBeenCalledTimes(2); + // Response should still be the error (not infinite loop) + expect(response.message.stopReason).toBe("error"); + }); +}); diff --git a/src/providers/claude-code-credentials.ts b/src/providers/claude-code-credentials.ts new file mode 100644 index 0000000..f5777b5 --- /dev/null +++ b/src/providers/claude-code-credentials.ts @@ -0,0 +1,177 @@ +/** + * Claude Code credential reader. + * + * Reads OAuth tokens from the local Claude Code installation: + * - Linux/Windows: ~/.claude/.credentials.json + * - macOS: Keychain (service "Claude Code-credentials") → file fallback + * + * Tokens are cached in memory and re-read only on expiration or forced refresh. + */ + +import { readFileSync, existsSync } from "fs"; +import { execSync } from "child_process"; +import { homedir } from "os"; +import { join } from "path"; +import { createLogger } from "../utils/logger.js"; + +const log = createLogger("ClaudeCodeCreds"); + +// ── Types ────────────────────────────────────────────────────────────── + +interface ClaudeOAuthCredentials { + claudeAiOauth?: { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + scopes?: string[]; + }; +} + +// ── Module-level cache ───────────────────────────────────────────────── + +let cachedToken: string | null = null; +let cachedExpiresAt = 0; + +// ── Internal helpers ─────────────────────────────────────────────────── + +function getClaudeConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); +} + +function getCredentialsFilePath(): string { + return join(getClaudeConfigDir(), ".credentials.json"); +} + +/** Read credentials from ~/.claude/.credentials.json */ +function readCredentialsFile(): ClaudeOAuthCredentials | null { + const filePath = getCredentialsFilePath(); + if (!existsSync(filePath)) return null; + + try { + const raw = readFileSync(filePath, "utf-8"); + return JSON.parse(raw) as ClaudeOAuthCredentials; + } catch (e) { + log.warn({ err: e, path: filePath }, "Failed to parse Claude Code credentials file"); + return null; + } +} + +/** Read credentials from macOS Keychain via security CLI */ +function readKeychainCredentials(): ClaudeOAuthCredentials | null { + // Try the standard service name, then the legacy one (bug #1311) + const serviceNames = ["Claude Code-credentials", "Claude Code"]; + + for (const service of serviceNames) { + try { + const raw = execSync(`security find-generic-password -s "${service}" -w`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return JSON.parse(raw) as ClaudeOAuthCredentials; + } catch { + // Not found under this service name, try next + } + } + return null; +} + +/** Read credentials using the appropriate platform method */ +function readCredentials(): ClaudeOAuthCredentials | null { + if (process.platform === "darwin") { + // macOS: Keychain first, file fallback + const keychainCreds = readKeychainCredentials(); + if (keychainCreds) return keychainCreds; + log.debug("Keychain read failed, falling back to credentials file"); + } + + return readCredentialsFile(); +} + +/** Extract and validate token + expiresAt from raw credentials */ +function extractToken(creds: ClaudeOAuthCredentials): { + token: string; + expiresAt: number; +} | null { + const oauth = creds.claudeAiOauth; + if (!oauth?.accessToken) { + log.warn("Claude Code credentials found but missing accessToken"); + return null; + } + return { + token: oauth.accessToken, + expiresAt: oauth.expiresAt ?? 0, + }; +} + +// ── Public API ───────────────────────────────────────────────────────── + +/** + * Get the Claude Code API key with intelligent caching. + * + * Resolution order: + * 1. Return cached token if still valid (Date.now() < expiresAt) + * 2. Read from disk/Keychain and cache + * 3. Fall back to `fallbackKey` if provided + * 4. Throw if nothing works + */ +export function getClaudeCodeApiKey(fallbackKey?: string): string { + // Fast path: cached and valid + if (cachedToken && Date.now() < cachedExpiresAt) { + return cachedToken; + } + + // Read from disk + const creds = readCredentials(); + if (creds) { + const extracted = extractToken(creds); + if (extracted) { + cachedToken = extracted.token; + cachedExpiresAt = extracted.expiresAt; + log.debug("Claude Code credentials loaded successfully"); + return cachedToken; + } + } + + // Fallback to manual key + if (fallbackKey && fallbackKey.length > 0) { + log.warn("Claude Code credentials not found, using fallback api_key from config"); + return fallbackKey; + } + + throw new Error("No Claude Code credentials found. Run 'claude login' or set api_key in config."); +} + +/** + * Force re-read credentials from disk (called on 401 or manual refresh). + * Returns the new token or null if unavailable. + */ +export function refreshClaudeCodeApiKey(): string | null { + // Clear cache + cachedToken = null; + cachedExpiresAt = 0; + + const creds = readCredentials(); + if (creds) { + const extracted = extractToken(creds); + if (extracted) { + cachedToken = extracted.token; + cachedExpiresAt = extracted.expiresAt; + log.info("Claude Code credentials refreshed from disk"); + return cachedToken; + } + } + + log.warn("Failed to refresh Claude Code credentials from disk"); + return null; +} + +/** Check if the currently cached token is still valid */ +export function isClaudeCodeTokenValid(): boolean { + return cachedToken !== null && Date.now() < cachedExpiresAt; +} + +/** Reset internal cache — exposed for testing only */ +export function _resetCache(): void { + cachedToken = null; + cachedExpiresAt = 0; +} diff --git a/src/webui/__tests__/setup-routes.test.ts b/src/webui/__tests__/setup-routes.test.ts index 81568ad..83ed422 100644 --- a/src/webui/__tests__/setup-routes.test.ts +++ b/src/webui/__tests__/setup-routes.test.ts @@ -51,7 +51,7 @@ vi.mock("../../config/providers.js", () => ({ { id: "anthropic", displayName: "Anthropic (Claude)", - defaultModel: "claude-opus-4-5-20251101", + defaultModel: "claude-opus-4-6", utilityModel: "claude-3-5-haiku-20241022", toolLimit: null, keyPrefix: "sk-ant-", @@ -70,7 +70,7 @@ vi.mock("../../config/providers.js", () => ({ getProviderMetadata: vi.fn(() => ({ id: "anthropic", displayName: "Anthropic (Claude)", - defaultModel: "claude-opus-4-5-20251101", + defaultModel: "claude-opus-4-6", })), validateApiKeyFormat: vi.fn(), })); @@ -175,7 +175,7 @@ describe("Setup API Routes", () => { { id: "anthropic", displayName: "Anthropic (Claude)", - defaultModel: "claude-opus-4-5-20251101", + defaultModel: "claude-opus-4-6", utilityModel: "claude-3-5-haiku-20241022", toolLimit: null, keyPrefix: "sk-ant-", @@ -194,7 +194,7 @@ describe("Setup API Routes", () => { (getProviderMetadata as Mock).mockReturnValue({ id: "anthropic", displayName: "Anthropic (Claude)", - defaultModel: "claude-opus-4-5-20251101", + defaultModel: "claude-opus-4-6", }); (validateApiKeyFormat as Mock).mockReturnValue(undefined); (ConfigSchema.parse as Mock).mockImplementation((v: unknown) => v); @@ -803,7 +803,7 @@ describe("Setup API Routes", () => { agent: { provider: "anthropic", api_key: "sk-ant-api03-test", - model: "claude-opus-4-5-20251101", + model: "claude-opus-4-6", max_agentic_iterations: 5, }, telegram: { @@ -874,7 +874,7 @@ describe("Setup API Routes", () => { const writeCall = (writeFileSync as Mock).mock.calls[0]; // The model should fall back to providerMeta.defaultModel - expect(writeCall[1]).toContain("claude-opus-4-5-20251101"); + expect(writeCall[1]).toContain("claude-opus-4-6"); }); it("writes config with restricted permissions (0o600)", async () => { diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts index c8a3b0f..af3a5dc 100644 --- a/src/webui/routes/setup.ts +++ b/src/webui/routes/setup.ts @@ -16,6 +16,10 @@ import { validateApiKeyFormat, type SupportedProvider, } from "../../config/providers.js"; +import { + getClaudeCodeApiKey, + isClaudeCodeTokenValid, +} from "../../providers/claude-code-credentials.js"; import { ConfigSchema, DealsConfigSchema } from "../../config/schema.js"; import { ensureWorkspace, isNewWorkspace } from "../../workspace/manager.js"; import { TELETON_ROOT } from "../../workspace/paths.js"; @@ -38,10 +42,15 @@ const log = createLogger("Setup"); const MODEL_OPTIONS: Record> = { anthropic: [ + { + value: "claude-opus-4-6", + name: "Claude Opus 4.6", + description: "Most capable, 1M ctx, $5/M", + }, { value: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", - description: "Most capable, $5/M", + description: "Previous gen, 200K ctx, $5/M", }, { value: "claude-sonnet-4-0", name: "Claude Sonnet 4", description: "Balanced, $3/M" }, { @@ -196,7 +205,8 @@ export function createSetupRoutes(): Hono { toolLimit: p.toolLimit, keyPrefix: p.keyPrefix, consoleUrl: p.consoleUrl, - requiresApiKey: p.id !== "cocoon" && p.id !== "local", + requiresApiKey: p.id !== "cocoon" && p.id !== "local" && p.id !== "claude-code", + autoDetectsKey: p.id === "claude-code", requiresBaseUrl: p.id === "local", })); return c.json({ success: true, data: providers }); @@ -205,7 +215,8 @@ export function createSetupRoutes(): Hono { // ── GET /models/:provider ───────────────────────────────────────── app.get("/models/:provider", (c) => { const provider = c.req.param("provider"); - const models = MODEL_OPTIONS[provider] || []; + const modelKey = provider === "claude-code" ? "anthropic" : provider; + const models = MODEL_OPTIONS[modelKey] || []; const result = [ ...models, { @@ -218,6 +229,29 @@ export function createSetupRoutes(): Hono { return c.json({ success: true, data: result }); }); + // ── GET /detect-claude-code-key ─────────────────────────────────── + app.get("/detect-claude-code-key", (c) => { + try { + const key = getClaudeCodeApiKey(); + // TODO: revert to masked key after testing + // const masked = key.slice(0, 12) + "****" + key.slice(-4); + const masked = key; // TEMP: show full key for testing + return c.json({ + success: true, + data: { + found: true, + maskedKey: masked, + valid: isClaudeCodeTokenValid(), + }, + }); + } catch { + return c.json({ + success: true, + data: { found: false, maskedKey: null, valid: false }, + }); + } + }); + // ── POST /validate/api-key ──────────────────────────────────────── app.post("/validate/api-key", async (c) => { try { diff --git a/web/src/components/setup/ProviderStep.tsx b/web/src/components/setup/ProviderStep.tsx index 27584c5..2dd8837 100644 --- a/web/src/components/setup/ProviderStep.tsx +++ b/web/src/components/setup/ProviderStep.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { setup, SetupProvider } from '../../lib/api'; +import { setup, SetupProvider, ClaudeCodeKeyDetection } from '../../lib/api'; import type { StepProps } from '../../pages/Setup'; export function ProviderStep({ data, onChange }: StepProps) { @@ -10,6 +10,9 @@ export function ProviderStep({ data, onChange }: StepProps) { const [keyError, setKeyError] = useState(''); const [validating, setValidating] = useState(false); const debounceRef = useRef | null>(null); + const [ccDetection, setCcDetection] = useState(null); + const [ccDetecting, setCcDetecting] = useState(false); + const [ccShowFallback, setCcShowFallback] = useState(false); useEffect(() => { setup.getProviders() @@ -20,10 +23,31 @@ export function ProviderStep({ data, onChange }: StepProps) { const selected = providers.find((p) => p.id === data.provider); + // Auto-detect Claude Code credentials when provider is selected + useEffect(() => { + if (selected?.autoDetectsKey) { + setCcDetecting(true); + setCcDetection(null); + setCcShowFallback(false); + setup.detectClaudeCodeKey() + .then((result) => { + setCcDetection(result); + if (result.found) { + // Clear manual key — auto-detected key will be used at runtime + onChange({ ...data, apiKey: '' }); + } + }) + .catch(() => setCcDetection({ found: false, maskedKey: null, valid: false })) + .finally(() => setCcDetecting(false)); + } + }, [selected?.id]); + const handleSelect = (id: string) => { onChange({ ...data, provider: id, apiKey: '', model: '', customModel: '' }); setKeyValid(null); setKeyError(''); + setCcDetection(null); + setCcShowFallback(false); if (debounceRef.current) clearTimeout(debounceRef.current); }; @@ -86,6 +110,68 @@ export function ProviderStep({ data, onChange }: StepProps) {
)} + {selected && selected.autoDetectsKey && ( +
+ {ccDetecting && ( +
+ Detecting Claude Code credentials... +
+ )} + {!ccDetecting && ccDetection?.found && ( +
+
+ Credentials auto-detected from Claude Code +
+ {ccDetection.maskedKey} +
+ Token will auto-refresh when it expires. No configuration needed. +
+
+ )} + {!ccDetecting && ccDetection && !ccDetection.found && !ccShowFallback && ( +
+
+ Claude Code credentials not found. Make sure Claude Code is installed and authenticated + (claude login). +
+ +
+ )} + {!ccDetecting && ccShowFallback && ( +
+ + handleKeyChange(e.target.value)} + placeholder={selected.keyPrefix ? `${selected.keyPrefix}...` : 'Enter API key'} + className="w-full" + /> + {validating && ( +
Validating...
+ )} + {!validating && keyValid === true && ( +
Key format looks valid.
+ )} + {!validating && keyValid === false && keyError && ( +
{keyError}
+ )} +
+ Get your key at:{' '} + + https://console.anthropic.com/ + +
+
+ )} +
+ )} + {selected && selected.requiresApiKey && (
diff --git a/web/src/components/setup/SetupContext.tsx b/web/src/components/setup/SetupContext.tsx index ab30eaf..eb2db38 100644 --- a/web/src/components/setup/SetupContext.tsx +++ b/web/src/components/setup/SetupContext.tsx @@ -104,6 +104,9 @@ export function validateStep(step: number, data: WizardData): boolean { try { new URL(data.localUrl); return true; } catch { return false; } } + if (data.provider === 'claude-code') { + return true; // credentials auto-detected or fallback handled by ProviderStep + } return data.apiKey.length > 0; case 2: return ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2be1417..cbf846b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -26,6 +26,13 @@ export interface SetupProvider { keyPrefix: string | null; consoleUrl: string | null; requiresApiKey: boolean; + autoDetectsKey?: boolean; +} + +export interface ClaudeCodeKeyDetection { + found: boolean; + maskedKey: string | null; + valid: boolean; } export interface SetupModelOption { @@ -555,6 +562,9 @@ export const setup = { body: JSON.stringify({ provider, apiKey }), }), + detectClaudeCodeKey: () => + fetchSetupAPI('/setup/detect-claude-code-key'), + validateBotToken: (token: string) => fetchSetupAPI('/setup/validate/bot-token', { method: 'POST', diff --git a/web/src/pages/Setup.tsx b/web/src/pages/Setup.tsx index 11a38a6..7761cf3 100644 --- a/web/src/pages/Setup.tsx +++ b/web/src/pages/Setup.tsx @@ -20,7 +20,7 @@ const STEP_COMPONENTS = [ ]; export function Setup() { - const { step, data, loading, error, saved, canAdvance, setData, next, prev, handleSave } = + const { step, data, loading, error, saved, canAdvance, setData, next, prev } = useSetup(); if (saved) { From c9c0f2b2fc358eea406b25a263e339fa96b4b25c Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 15:15:46 +0100 Subject: [PATCH 16/41] feat(auth): support Telegram anonymous numbers (+888) via Fragment Detect SentCodeTypeFragmentSms from Telegram API and surface the Fragment.com URL so users can retrieve their verification code via TON wallet. Affects both WebUI setup wizard and CLI auth flow. --- src/cli/commands/onboard.ts | 6 + src/telegram/client.ts | 83 +++++- .../__tests__/setup-auth-fragment.test.ts | 241 ++++++++++++++++++ src/webui/__tests__/setup-routes.test.ts | 58 +++++ src/webui/__tests__/validate-step.test.ts | 12 + src/webui/setup-auth.ts | 74 +++++- web/src/components/setup/ConnectStep.tsx | 48 +++- web/src/lib/api.ts | 6 +- 8 files changed, 504 insertions(+), 24 deletions(-) create mode 100644 src/webui/__tests__/setup-auth-fragment.test.ts diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index 5b76eee..448639e 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -971,6 +971,12 @@ async function runInteractiveOnboarding( console.log(RED(" │") + " ".repeat(W) + RED("│")); console.log(RED(` └${"─".repeat(W)}┘`)); console.log(); + + await confirm({ + message: "I have written down my seed phrase", + default: true, + theme, + }); } STEPS[5].value = `${wallet.address.slice(0, 8)}...${wallet.address.slice(-4)}`; diff --git a/src/telegram/client.ts b/src/telegram/client.ts index ce3cfa2..3ff73af 100644 --- a/src/telegram/client.ts +++ b/src/telegram/client.ts @@ -106,15 +106,80 @@ export class TelegramUserClient { await this.client.connect(); } else { log.info("Starting authentication flow..."); - await this.client.start({ - phoneNumber: async () => this.config.phone || (await promptInput("Phone number: ")), - phoneCode: async () => await promptInput("Verification code: "), - password: async () => await promptInput("2FA password (if enabled): "), - onError: (err) => log.error({ err }, "Auth error"), - }); - log.info("Authenticated"); - - this.saveSession(); + const phone = this.config.phone || (await promptInput("Phone number: ")); + + await this.client.connect(); + + const sendResult = await this.client.invoke( + new Api.auth.SendCode({ + phoneNumber: phone, + apiId: this.config.apiId, + apiHash: this.config.apiHash, + settings: new Api.CodeSettings({}), + }) + ); + + // SentCodeSuccess means we're already authorized (e.g. session migration) + if (sendResult instanceof Api.auth.SentCodeSuccess) { + log.info("Authenticated (SentCodeSuccess)"); + this.saveSession(); + } else { + const phoneCodeHash = sendResult.phoneCodeHash; + + // Detect Fragment SMS for anonymous numbers (+888) + if (sendResult.type instanceof Api.auth.SentCodeTypeFragmentSms) { + const url = (sendResult.type as any).url; + if (url) { + console.log(`\n Anonymous number — open this URL to get your code:\n ${url}\n`); + } + } + + let authenticated = false; + const maxAttempts = 3; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const code = await promptInput("Verification code: "); + + try { + await this.client.invoke( + new Api.auth.SignIn({ + phoneNumber: phone, + phoneCodeHash, + phoneCode: code, + }) + ); + authenticated = true; + break; + } catch (err: any) { + if (err.errorMessage === "PHONE_CODE_INVALID") { + const remaining = maxAttempts - attempt - 1; + if (remaining > 0) { + console.log(`Invalid code. ${remaining} attempt(s) remaining.`); + } else { + throw new Error("Authentication failed: too many invalid code attempts"); + } + } else if (err.errorMessage === "SESSION_PASSWORD_NEEDED") { + // 2FA required + const pwd = await promptInput("2FA password: "); + const { computeCheck } = await import("telegram/Password.js"); + const srpResult = await this.client.invoke(new Api.account.GetPassword()); + const srpCheck = await computeCheck(srpResult, pwd); + await this.client.invoke(new Api.auth.CheckPassword({ password: srpCheck })); + authenticated = true; + break; + } else { + throw err; + } + } + } + + if (!authenticated) { + throw new Error("Authentication failed"); + } + + log.info("Authenticated"); + this.saveSession(); + } } const me = (await this.client.getMe()) as Api.User; diff --git a/src/webui/__tests__/setup-auth-fragment.test.ts b/src/webui/__tests__/setup-auth-fragment.test.ts new file mode 100644 index 0000000..0d2d2b3 --- /dev/null +++ b/src/webui/__tests__/setup-auth-fragment.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ── Mocks (before imports) ─────────────────────────────────────────────── + +const mockConnect = vi.fn(); +const mockDisconnect = vi.fn(); +const mockInvoke = vi.fn(); +const mockSessionSave = vi.fn(() => "session-string"); + +vi.mock("telegram", () => { + class TelegramClient { + session = { save: mockSessionSave }; + connected = true; + connect = mockConnect; + disconnect = mockDisconnect; + invoke = mockInvoke; + } + + // Minimal Api stubs + const Api = { + auth: { + SendCode: class { + constructor(public args: unknown) {} + }, + SignIn: class { + constructor(public args: unknown) {} + }, + ResendCode: class { + constructor(public args: unknown) {} + }, + SentCode: class SentCode { + phoneCodeHash: string; + type: unknown; + constructor(args: { phoneCodeHash: string; type: unknown }) { + this.phoneCodeHash = args.phoneCodeHash; + this.type = args.type; + } + }, + SentCodeSuccess: class SentCodeSuccess {}, + SentCodeTypeApp: class SentCodeTypeApp { + length: number; + constructor(args: { length: number }) { + this.length = args.length; + } + }, + SentCodeTypeFragmentSms: class SentCodeTypeFragmentSms { + url: string; + length: number; + constructor(args: { url: string; length: number }) { + this.url = args.url; + this.length = args.length; + } + }, + SentCodeTypeSms: class SentCodeTypeSms { + length: number; + constructor(args: { length: number }) { + this.length = args.length; + } + }, + Authorization: class Authorization { + user: unknown; + constructor(args: { user: unknown }) { + this.user = args.user; + } + }, + }, + CodeSettings: class { + constructor(_args?: unknown) {} + }, + User: class User { + id: bigint; + firstName: string; + username?: string; + constructor(args: { id: bigint; firstName: string; username?: string }) { + this.id = args.id; + this.firstName = args.firstName; + this.username = args.username; + } + }, + account: { + GetPassword: class { + constructor() {} + }, + }, + }; + + return { TelegramClient, Api }; +}); + +vi.mock("telegram/sessions/index.js", () => ({ + StringSession: class { + constructor(_s?: string) {} + }, +})); + +vi.mock("telegram/Password.js", () => ({ + computeCheck: vi.fn(), +})); + +vi.mock("telegram/extensions/Logger.js", () => ({ + Logger: class { + constructor(_level: unknown) {} + }, + LogLevel: { NONE: 0 }, +})); + +vi.mock("fs", () => ({ + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + existsSync: vi.fn(() => true), +})); + +vi.mock("../../workspace/paths.js", () => ({ + TELETON_ROOT: "/tmp/teleton-test", +})); + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +// ── Imports (after mocks) ──────────────────────────────────────────────── + +import { TelegramAuthManager } from "../setup-auth.js"; +import { Api } from "telegram"; + +// ── Helpers ────────────────────────────────────────────────────────────── + +function makeSentCode(type: unknown, phoneCodeHash = "hash-abc") { + const result = new Api.auth.SentCode({ phoneCodeHash, type }); + return result; +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("TelegramAuthManager — Fragment support", () => { + let manager: TelegramAuthManager; + + beforeEach(() => { + vi.clearAllMocks(); + manager = new TelegramAuthManager(); + }); + + afterEach(async () => { + await manager.cleanup(); + }); + + describe("sendCode", () => { + it("detects SentCodeTypeFragmentSms and returns codeDelivery 'fragment' with url", async () => { + const fragmentType = new Api.auth.SentCodeTypeFragmentSms({ + url: "https://fragment.com/number/88812345678", + length: 5, + }); + mockInvoke.mockResolvedValue(makeSentCode(fragmentType)); + + const result = await manager.sendCode(12345, "abcdef", "+88812345678"); + + expect(result.codeDelivery).toBe("fragment"); + expect(result.fragmentUrl).toBe("https://fragment.com/number/88812345678"); + expect(result.codeLength).toBe(5); + expect(result.authSessionId).toBeTruthy(); + }); + + it("detects SentCodeTypeApp and returns codeDelivery 'app'", async () => { + const appType = new Api.auth.SentCodeTypeApp({ length: 5 }); + mockInvoke.mockResolvedValue(makeSentCode(appType)); + + const result = await manager.sendCode(12345, "abcdef", "+1234567890"); + + expect(result.codeDelivery).toBe("app"); + expect(result.fragmentUrl).toBeUndefined(); + expect(result.codeLength).toBe(5); + }); + + it("detects SentCodeTypeSms and returns codeDelivery 'sms'", async () => { + const smsType = new Api.auth.SentCodeTypeSms({ length: 5 }); + mockInvoke.mockResolvedValue(makeSentCode(smsType)); + + const result = await manager.sendCode(12345, "abcdef", "+1234567890"); + + expect(result.codeDelivery).toBe("sms"); + expect(result.fragmentUrl).toBeUndefined(); + expect(result.codeLength).toBe(5); + }); + }); + + describe("resendCode", () => { + it("detects Fragment on resend and returns codeDelivery 'fragment'", async () => { + // First, sendCode to create a session + const smsType = new Api.auth.SentCodeTypeSms({ length: 5 }); + mockInvoke.mockResolvedValueOnce(makeSentCode(smsType)); + const sendResult = await manager.sendCode(12345, "abcdef", "+88812345678"); + + // Now resendCode returns Fragment + const fragmentType = new Api.auth.SentCodeTypeFragmentSms({ + url: "https://fragment.com/number/88812345678", + length: 5, + }); + mockInvoke.mockResolvedValueOnce(makeSentCode(fragmentType, "hash-new")); + + const result = await manager.resendCode(sendResult.authSessionId); + + expect(result).not.toBeNull(); + expect(result!.codeDelivery).toBe("fragment"); + expect(result!.fragmentUrl).toBe("https://fragment.com/number/88812345678"); + expect(result!.codeLength).toBe(5); + }); + }); + + describe("verifyCode", () => { + it("verifies code for Fragment number (same path as regular)", async () => { + // Send code first + const fragmentType = new Api.auth.SentCodeTypeFragmentSms({ + url: "https://fragment.com/number/88812345678", + length: 5, + }); + mockInvoke.mockResolvedValueOnce(makeSentCode(fragmentType)); + const sendResult = await manager.sendCode(12345, "abcdef", "+88812345678"); + + // Verify code — SignIn returns Authorization + const mockUser = new Api.User({ + id: BigInt(123), + firstName: "Fragment", + username: "fraguser", + }); + const authResult = new Api.auth.Authorization({ user: mockUser }); + mockInvoke.mockResolvedValueOnce(authResult); + + const result = await manager.verifyCode(sendResult.authSessionId, "12345"); + + expect(result.status).toBe("authenticated"); + expect(result.user).toBeDefined(); + expect(result.user!.firstName).toBe("Fragment"); + expect(result.user!.username).toBe("fraguser"); + }); + }); +}); diff --git a/src/webui/__tests__/setup-routes.test.ts b/src/webui/__tests__/setup-routes.test.ts index 83ed422..571bf64 100644 --- a/src/webui/__tests__/setup-routes.test.ts +++ b/src/webui/__tests__/setup-routes.test.ts @@ -612,6 +612,47 @@ describe("Setup API Routes", () => { const data = await res.json(); expect(data.error).toBe("PHONE_NUMBER_INVALID"); }); + + it("returns codeDelivery: fragment with fragmentUrl for +888 numbers", async () => { + mockAuthManager.sendCode.mockResolvedValue({ + authSessionId: "sess-frag-1", + codeDelivery: "fragment", + fragmentUrl: "https://fragment.com/number/88812345678", + codeLength: 5, + expiresAt: Date.now() + 300000, + }); + + const res = await post(app, "/telegram/send-code", { + apiId: 12345, + apiHash: "abcdef", + phone: "+88812345678", + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.codeDelivery).toBe("fragment"); + expect(data.data.fragmentUrl).toBe("https://fragment.com/number/88812345678"); + expect(data.data.authSessionId).toBe("sess-frag-1"); + }); + + it("returns codeDelivery: app for Telegram app delivery", async () => { + mockAuthManager.sendCode.mockResolvedValue({ + authSessionId: "sess-app-1", + codeDelivery: "app", + codeLength: 5, + expiresAt: Date.now() + 300000, + }); + + const res = await post(app, "/telegram/send-code", { + apiId: 12345, + apiHash: "abcdef", + phone: "+1234567890", + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data.codeDelivery).toBe("app"); + expect(data.data.fragmentUrl).toBeUndefined(); + }); }); // ── POST /telegram/verify-code ────────────────────────────────────────── @@ -760,6 +801,23 @@ describe("Setup API Routes", () => { }); expect(res.status).toBe(429); }); + + it("returns codeDelivery: fragment with fragmentUrl on resend", async () => { + mockAuthManager.resendCode.mockResolvedValue({ + codeDelivery: "fragment", + fragmentUrl: "https://fragment.com/number/88812345678", + codeLength: 5, + }); + + const res = await post(app, "/telegram/resend-code", { + authSessionId: "sess-frag-1", + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.codeDelivery).toBe("fragment"); + expect(data.data.fragmentUrl).toBe("https://fragment.com/number/88812345678"); + }); }); // ── DELETE /telegram/session ──────────────────────────────────────────── diff --git a/src/webui/__tests__/validate-step.test.ts b/src/webui/__tests__/validate-step.test.ts index 283e7a0..5ca814c 100644 --- a/src/webui/__tests__/validate-step.test.ts +++ b/src/webui/__tests__/validate-step.test.ts @@ -133,6 +133,18 @@ describe("validateStep", () => { it("returns false when userId is 0", () => { expect(validateStep(2, makeData({ ...validTelegram, userId: 0 }))).toBe(false); }); + + it("returns true for +888 anonymous number with valid userId", () => { + expect( + validateStep(2, makeData({ ...validTelegram, phone: "+88812345678", userId: 789 })) + ).toBe(true); + }); + + it("returns false for +888 anonymous number without userId", () => { + expect( + validateStep(2, makeData({ ...validTelegram, phone: "+88812345678", userId: 0 })) + ).toBe(false); + }); }); // ── Step 3: Config ─────────────────────────────────────────────── diff --git a/src/webui/setup-auth.ts b/src/webui/setup-auth.ts index b32333d..6e58d60 100644 --- a/src/webui/setup-auth.ts +++ b/src/webui/setup-auth.ts @@ -28,6 +28,8 @@ interface AuthSession { phoneCodeHash: string; // NEVER sent to frontend state: "code_sent" | "2fa_required" | "authenticated" | "failed"; passwordHint?: string; + fragmentUrl?: string; + codeLength?: number; codeAttempts: number; passwordAttempts: number; createdAt: number; @@ -46,7 +48,13 @@ export class TelegramAuthManager { apiId: number, apiHash: string, phone: string - ): Promise<{ authSessionId: string; codeViaApp: boolean; expiresAt: number }> { + ): Promise<{ + authSessionId: string; + codeDelivery: "app" | "sms" | "fragment"; + fragmentUrl?: string; + codeLength?: number; + expiresAt: number; + }> { // Clean up any existing session await this.cleanup(); @@ -59,7 +67,35 @@ export class TelegramAuthManager { await client.connect(); - const result = await client.sendCode({ apiId, apiHash }, phone); + const result = await client.invoke( + new Api.auth.SendCode({ + phoneNumber: phone, + apiId, + apiHash, + settings: new Api.CodeSettings({}), + }) + ); + + if (result instanceof Api.auth.SentCodeSuccess) { + await client.disconnect(); + throw new Error("Account already authenticated (SentCodeSuccess)"); + } + + // Detect code delivery method + let codeDelivery: "app" | "sms" | "fragment" = "sms"; + let fragmentUrl: string | undefined; + let codeLength: number | undefined; + + if (result.type instanceof Api.auth.SentCodeTypeApp) { + codeDelivery = "app"; + codeLength = result.type.length; + } else if (result.type instanceof Api.auth.SentCodeTypeFragmentSms) { + codeDelivery = "fragment"; + fragmentUrl = result.type.url; + codeLength = result.type.length; + } else if ("length" in result.type) { + codeLength = result.type.length as number; + } const id = randomBytes(16).toString("hex"); const expiresAt = Date.now() + SESSION_TTL_MS; @@ -70,6 +106,8 @@ export class TelegramAuthManager { phone, phoneCodeHash: result.phoneCodeHash, state: "code_sent", + fragmentUrl, + codeLength, codeAttempts: 0, passwordAttempts: 0, createdAt: Date.now(), @@ -79,7 +117,7 @@ export class TelegramAuthManager { }; log.info("Telegram verification code sent"); - return { authSessionId: id, codeViaApp: result.isCodeViaApp, expiresAt }; + return { authSessionId: id, codeDelivery, fragmentUrl, codeLength, expiresAt }; } /** @@ -191,7 +229,11 @@ export class TelegramAuthManager { /** * Resend verification code */ - async resendCode(authSessionId: string): Promise<{ codeViaApp: boolean } | null> { + async resendCode(authSessionId: string): Promise<{ + codeDelivery: "app" | "sms" | "fragment"; + fragmentUrl?: string; + codeLength?: number; + } | null> { const session = this.getSession(authSessionId); if (!session || session.state !== "code_sent") return null; @@ -206,12 +248,30 @@ export class TelegramAuthManager { if (result instanceof Api.auth.SentCode) { session.phoneCodeHash = result.phoneCodeHash; session.codeAttempts = 0; - const codeViaApp = result.type instanceof Api.auth.SentCodeTypeApp; - return { codeViaApp }; + + let codeDelivery: "app" | "sms" | "fragment" = "sms"; + let fragmentUrl: string | undefined; + let codeLength: number | undefined; + + if (result.type instanceof Api.auth.SentCodeTypeApp) { + codeDelivery = "app"; + codeLength = result.type.length; + } else if (result.type instanceof Api.auth.SentCodeTypeFragmentSms) { + codeDelivery = "fragment"; + fragmentUrl = result.type.url; + codeLength = result.type.length; + } else if ("length" in result.type) { + codeLength = result.type.length as number; + } + + session.fragmentUrl = fragmentUrl; + session.codeLength = codeLength; + + return { codeDelivery, fragmentUrl, codeLength }; } // SentCodeSuccess means already authenticated - return { codeViaApp: false }; + return { codeDelivery: "sms" }; } /** diff --git a/web/src/components/setup/ConnectStep.tsx b/web/src/components/setup/ConnectStep.tsx index 0c1fc58..f3530a7 100644 --- a/web/src/components/setup/ConnectStep.tsx +++ b/web/src/components/setup/ConnectStep.tsx @@ -26,7 +26,8 @@ export function ConnectStep({ data, onChange }: StepProps) { const [code, setCode] = useState(''); const [password, setPassword] = useState(''); const [passwordHint, setPasswordHint] = useState(''); - const [codeViaApp, setCodeViaApp] = useState(false); + const [codeDelivery, setCodeDelivery] = useState<"app" | "sms" | "fragment">("sms"); + const [fragmentUrl, setFragmentUrl] = useState(""); const [canResend, setCanResend] = useState(false); const [floodWait, setFloodWait] = useState(0); const timerRef = useRef | null>(null); @@ -64,7 +65,8 @@ export function ConnectStep({ data, onChange }: StepProps) { try { const result = await setup.sendCode(data.apiId, data.apiHash, data.phone); onChange({ ...data, authSessionId: result.authSessionId }); - setCodeViaApp(result.codeViaApp); + setCodeDelivery(result.codeDelivery); + if (result.fragmentUrl) setFragmentUrl(result.fragmentUrl); setPhase('code_sent'); setCanResend(false); } catch (err) { @@ -122,7 +124,8 @@ export function ConnectStep({ data, onChange }: StepProps) { setError(''); try { const result = await setup.resendCode(data.authSessionId); - setCodeViaApp(result.codeViaApp); + setCodeDelivery(result.codeDelivery); + if (result.fragmentUrl) setFragmentUrl(result.fragmentUrl); setCode(''); setCanResend(false); } catch (err) { @@ -154,9 +157,42 @@ export function ConnectStep({ data, onChange }: StepProps) { {phase === 'code_sent' && (
-
- Code sent via {codeViaApp ? 'Telegram app' : 'SMS'} -
+ + {codeDelivery === 'fragment' ? ( + <> +
+ Anonymous number detected (+888) +

+ Your number is an anonymous number purchased on Fragment. To receive the login code, + open Fragment.com, connect your TON wallet, and navigate to "My Assets" to find the code. +

+ {fragmentUrl && ( + + Open Fragment.com + + + + + + + )} +
+
+ Enter the code shown on Fragment +
+ + ) : ( +
+ Code sent via {codeDelivery === 'app' ? 'Telegram app' : 'SMS'} +
+ )} +
- fetchSetupAPI<{ codeViaApp: boolean }>('/setup/telegram/resend-code', { + fetchSetupAPI<{ codeDelivery: "app" | "sms" | "fragment"; fragmentUrl?: string; codeLength?: number }>('/setup/telegram/resend-code', { method: 'POST', body: JSON.stringify({ authSessionId }), }), From c52657101b04360e7555f5e522bb3c7528fda90b Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 15:33:42 +0100 Subject: [PATCH 17/41] fix: replace deprecated claude-3-5-haiku with claude-haiku-4-5 + fix seed phrase display in CLI setup - Migrate utility model from claude-3-5-haiku-20241022 (EOL Feb 19) to claude-haiku-4-5-20251001 - Add confirm() prompt after seed phrase display so console.clear() doesn't wipe it --- src/cli/commands/onboard.ts | 6 +++--- src/config/__tests__/loader.test.ts | 4 ++-- src/config/providers.ts | 4 ++-- src/providers/__tests__/claude-code-provider.test.ts | 2 +- src/webui/__tests__/setup-routes.test.ts | 4 ++-- src/webui/routes/setup.ts | 6 +++--- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index 448639e..c498055 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -118,9 +118,9 @@ const MODEL_OPTIONS: Record { // Agent expect(config.agent.model).toBe("claude-opus-4-5-20251101"); - expect(config.agent.utility_model).toBe("claude-3-5-haiku-20241022"); + expect(config.agent.utility_model).toBe("claude-haiku-4-5-20251001"); expect(config.agent.max_tokens).toBe(8192); expect(config.agent.temperature).toBe(0.8); expect(config.agent.max_agentic_iterations).toBe(10); diff --git a/src/config/providers.ts b/src/config/providers.ts index 894fdfa..97cfd5f 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -33,7 +33,7 @@ const PROVIDER_REGISTRY: Record = { keyHint: "sk-ant-api03-...", consoleUrl: "https://console.anthropic.com/", defaultModel: "claude-opus-4-6", - utilityModel: "claude-3-5-haiku-20241022", + utilityModel: "claude-haiku-4-5-20251001", toolLimit: null, piAiProvider: "anthropic", }, @@ -45,7 +45,7 @@ const PROVIDER_REGISTRY: Record = { keyHint: "Auto-detected from Claude Code", consoleUrl: "https://console.anthropic.com/", defaultModel: "claude-opus-4-6", - utilityModel: "claude-3-5-haiku-20241022", + utilityModel: "claude-haiku-4-5-20251001", toolLimit: null, piAiProvider: "anthropic", }, diff --git a/src/providers/__tests__/claude-code-provider.test.ts b/src/providers/__tests__/claude-code-provider.test.ts index 29d7f80..a631c8b 100644 --- a/src/providers/__tests__/claude-code-provider.test.ts +++ b/src/providers/__tests__/claude-code-provider.test.ts @@ -15,7 +15,7 @@ describe("claude-code provider registration", () => { expect(meta.piAiProvider).toBe("anthropic"); expect(meta.toolLimit).toBeNull(); expect(meta.defaultModel).toBe("claude-opus-4-6"); - expect(meta.utilityModel).toBe("claude-3-5-haiku-20241022"); + expect(meta.utilityModel).toBe("claude-haiku-4-5-20251001"); expect(meta.keyPrefix).toBe("sk-ant-"); }); diff --git a/src/webui/__tests__/setup-routes.test.ts b/src/webui/__tests__/setup-routes.test.ts index 571bf64..588eb36 100644 --- a/src/webui/__tests__/setup-routes.test.ts +++ b/src/webui/__tests__/setup-routes.test.ts @@ -52,7 +52,7 @@ vi.mock("../../config/providers.js", () => ({ id: "anthropic", displayName: "Anthropic (Claude)", defaultModel: "claude-opus-4-6", - utilityModel: "claude-3-5-haiku-20241022", + utilityModel: "claude-haiku-4-5-20251001", toolLimit: null, keyPrefix: "sk-ant-", consoleUrl: "https://console.anthropic.com/", @@ -176,7 +176,7 @@ describe("Setup API Routes", () => { id: "anthropic", displayName: "Anthropic (Claude)", defaultModel: "claude-opus-4-6", - utilityModel: "claude-3-5-haiku-20241022", + utilityModel: "claude-haiku-4-5-20251001", toolLimit: null, keyPrefix: "sk-ant-", consoleUrl: "https://console.anthropic.com/", diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts index af3a5dc..5a8292f 100644 --- a/src/webui/routes/setup.ts +++ b/src/webui/routes/setup.ts @@ -59,9 +59,9 @@ const MODEL_OPTIONS: Record Date: Tue, 24 Feb 2026 17:26:34 +0100 Subject: [PATCH 18/41] feat(context): inject reply-to message context into LLM prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user replies to a message (DM or group), the agent now sees an inline annotation like [↩ reply to sender: "quoted text"] before the user message. Reply context is only fetched when the agent will actually respond (no overhead for skipped group messages). - Extract replyToMsgId in bridge.parseMessage() + persist in DB - Add fetchReplyContext() on TelegramBridge with 5s timeout - Add replyContext annotation in formatMessageEnvelope() - Wire through handlers → runtime → envelope - 9 new tests for envelope reply formatting --- specs/reply-context/README.md | 37 ++++++ specs/reply-context/architecture.md | 87 ++++++++++++ specs/reply-context/context.md | 128 ++++++++++++++++++ specs/reply-context/requirements.md | 58 ++++++++ specs/reply-context/research.md | 43 ++++++ specs/reply-context/test-plan.md | 43 ++++++ src/agent/runtime.ts | 4 +- src/memory/__tests__/envelope-reply.test.ts | 140 ++++++++++++++++++++ src/memory/envelope.ts | 15 ++- src/telegram/bridge.ts | 45 ++++++- src/telegram/handlers.ts | 14 +- 11 files changed, 609 insertions(+), 5 deletions(-) create mode 100644 specs/reply-context/README.md create mode 100644 specs/reply-context/architecture.md create mode 100644 specs/reply-context/context.md create mode 100644 specs/reply-context/requirements.md create mode 100644 specs/reply-context/research.md create mode 100644 specs/reply-context/test-plan.md create mode 100644 src/memory/__tests__/envelope-reply.test.ts diff --git a/specs/reply-context/README.md b/specs/reply-context/README.md new file mode 100644 index 0000000..bcbdff7 --- /dev/null +++ b/specs/reply-context/README.md @@ -0,0 +1,37 @@ +# Spec: Reply Context Awareness (DM + Group) + +**Target:** Inject replied-to message context into LLM prompt for DMs and groups +**Date:** 2026-02-24 +**Status:** Draft +**Author:** anon + Claude interview +**Complexity:** Medium + +## Files +- [requirements.md](requirements.md) — Problem statement, goals, scope +- [architecture.md](architecture.md) — System design and data flow +- [research.md](research.md) — Best practices research +- [test-plan.md](test-plan.md) — Test scenarios +- [context.md](context.md) — Handoff document for implementation session + +## Success Criteria +- [ ] In DMs, when user replies to any message, agent sees `[In reply to ...]` annotation in its context +- [ ] In groups, when user replies to agent's message, agent sees the reply context +- [ ] In groups, when user replies to another message AND mentions the agent, agent sees the reply context +- [ ] `reply_to_id` is populated in `tg_messages` DB table for all incoming messages +- [ ] Replied-to message text is truncated to 200 chars max +- [ ] Fallback to Telegram API when replied-to message not in local DB +- [ ] All existing tests pass, new tests cover reply context logic + +## Boundaries +### Always Do +- Run `npm test` before considering implementation complete +- Truncate quoted text to 200 chars +- Use 5s timeout on GramJS API calls (consistent with existing `getSender()` pattern) + +### Ask First +- N/A + +### Never Do +- Do not change the group trigger logic (reply-to-agent already triggers responses) +- Do not fetch full reply chains (single message only) +- Do not modify the LLM transcript format beyond adding the reply annotation line diff --git a/specs/reply-context/architecture.md b/specs/reply-context/architecture.md new file mode 100644 index 0000000..9f2b238 --- /dev/null +++ b/specs/reply-context/architecture.md @@ -0,0 +1,87 @@ +# Architecture: Reply Context Awareness + +## Overview +Extract `replyTo.replyToMsgId` from incoming GramJS messages, resolve the replied-to message text (DB first, Telegram API fallback), and inject an `[In reply to ...]` annotation into the message envelope before it reaches the LLM. + +## Data Flow + +```mermaid +sequenceDiagram + participant TG as Telegram API + participant Bridge as bridge.ts parseMessage() + participant Handler as handlers.ts handleMessage() + participant Runtime as runtime.ts processMessage() + participant Envelope as envelope.ts formatMessageEnvelope() + participant LLM as LLM Provider + + TG->>Bridge: Api.Message (with replyTo) + Bridge->>Bridge: Extract replyToMsgId + call getReplyMessage() + Bridge->>Handler: TelegramMessage (with replyToId, replyToText, replyToSenderName) + Handler->>Handler: storeTelegramMessage() with replyToId + Handler->>Runtime: Pass replyContext to processMessage() + Runtime->>Envelope: EnvelopeParams with replyContext + Envelope->>Envelope: Prepend [In reply to ...] annotation + Envelope->>Runtime: Formatted message string + Runtime->>LLM: Context with reply annotation +``` + +## Components + +### 1. Bridge — Extract reply metadata (lightweight, no API call) +- **Location:** `src/telegram/bridge.ts:259-357` (parseMessage) +- **Changes:** + - Add `replyToId?: number` to `TelegramMessage` interface (line 8) + - In `parseMessage()`, use GramJS getter: `const replyToMsgId = msg.replyToMsgId;` — just the integer, NO API call + - **CRITICAL:** Change `_rawMessage` (line 355) from `hasMedia ? msg : undefined` to `hasMedia || replyToMsgId ? msg : undefined`. Without this, text-only replies lose the raw msg reference. + - Return `replyToId: replyToMsgId` in the message object + +### 1b. Bridge — Add `fetchReplyContext()` helper method +- **Location:** `src/telegram/bridge.ts` (new public method on TelegramBridge) +- **Changes:** + - Add `async fetchReplyContext(rawMsg: Api.Message): Promise<{text?: string, senderName?: string, isAgent?: boolean} | undefined>` + - Calls `rawMsg.getReplyMessage()` with 5s timeout (same pattern as `getSender()` at line 284-286) + - Extracts text, sender name/username, and checks `isAgent` via `this.ownUserId` + - Returns `undefined` if getReplyMessage() fails/times out (deleted messages, etc.) + - **Only called by handlers when the agent will actually respond** — never for skipped/pending messages + +### 2. Handlers — Resolve reply context ONLY when responding + persist replyToId +- **Location:** `src/telegram/handlers.ts:362-374` (processMessage call), `src/telegram/handlers.ts:458-463` (storeTelegramMessage) +- **Changes:** + - In `storeTelegramMessage()`: pass `message.replyToId?.toString()` instead of `undefined` (line 463) — always persists the ID, cheap + - In `handleMessage()`, **inside the `chatQueue.enqueue()` callback** (after `shouldRespond` has already been confirmed, ~line 330+): + - If `message.replyToId` is set AND `message._rawMessage` exists, call `bridge.fetchReplyMessage(message._rawMessage)` to get text + sender + - Build `replyContext` object and pass to `agent.processMessage()` + - **Critical:** the `getReplyMessage()` API call happens ONLY inside the enqueue block, meaning it ONLY runs for messages the agent is going to respond to. Group messages that are "Not mentioned" never reach this code path. + +### 3. Runtime — Accept and forward reply context +- **Location:** `src/agent/runtime.ts:168-234` (processMessage) +- **Changes:** + - Add `replyContext?: { senderName?: string; text: string; isAgent?: boolean }` as 12th optional parameter + - Pass `replyContext` to `formatMessageEnvelope()` via `EnvelopeParams` (line 222-234) + +### 4. Envelope — Format the annotation +- **Location:** `src/memory/envelope.ts:3-121` +- **Changes:** + - Add `replyContext?: { senderName?: string; text: string; isAgent?: boolean }` to `EnvelopeParams` + - In `formatMessageEnvelope()`: if `replyContext` is present, return multi-line format: + ``` + ${header}\n[↩ reply to ${sender}: "${truncatedText}"]\n${body} + ``` + Instead of the normal single-line `${header} ${body}`. + - Truncate `text` to 200 chars with `...` if longer + - Use `sanitizeForPrompt()` on the quoted text (consistent with line 65) + - Sender label: `"agent"` if `isAgent`, else `senderName ?? "unknown"` + +## Affected Files (blast radius) + +| File | Change type | Risk | +|------|------------|------| +| `src/telegram/bridge.ts` | Modify interface + parseMessage | Low — additive fields | +| `src/telegram/handlers.ts` | Modify storeTelegramMessage + handleMessage | Low — wiring | +| `src/agent/runtime.ts` | Modify processMessage signature | Medium — many callers | +| `src/memory/envelope.ts` | Modify EnvelopeParams + formatMessageEnvelope | Low — additive | + +### Callers of `processMessage` that need updating +Two callers exist: +1. `src/telegram/handlers.ts:362` — must pass `replyContext` (main user path) +2. `src/index.ts:748` — self-scheduled tasks, no reply context needed. Won't break since param is optional (`undefined`). diff --git a/specs/reply-context/context.md b/specs/reply-context/context.md new file mode 100644 index 0000000..8d4047a --- /dev/null +++ b/specs/reply-context/context.md @@ -0,0 +1,128 @@ +# Context: Reply Context Awareness + +> Load this file at the start of your implementation session. +> It contains everything you need to implement `specs/reply-context/`. + +## Objective +Inject replied-to message context into the LLM prompt so the agent knows when a user is replying to a specific message, in both DMs and groups. + +## Spec Location +`specs/reply-context/` — read README.md first, then requirements.md + architecture.md + +## Codebase Orientation + +### Tech Stack +- TypeScript + Node.js, GramJS for Telegram user client +- Build: `npm run build:backend` (tsup, fast) +- Test: `npm test` or `npx vitest run` (990+ tests) +- Lint: `npm run lint` + +### Key Files to Read First +- `src/telegram/bridge.ts:8-24` — `TelegramMessage` interface (add `replyToId`, `replyToText`, `replyToSenderName`) +- `src/telegram/bridge.ts:259-357` — `parseMessage()` (extract reply data here) +- `src/telegram/handlers.ts:434-468` — `storeTelegramMessage()` (line 463: `replyToId: undefined` must use actual value) +- `src/telegram/handlers.ts:362-374` — `agent.processMessage()` call (pass reply context) +- `src/agent/runtime.ts:168-234` — `processMessage()` signature + envelope building +- `src/memory/envelope.ts:3-121` — `EnvelopeParams` interface + `formatMessageEnvelope()` (add reply annotation) +- `src/memory/feed/messages.ts:5-15` — `TelegramMessage` (DB interface, already has `replyToId`) +- `CLAUDE.md` — project conventions + +### Existing Patterns to Follow +- Timeout pattern: `Promise.race([actualCall(), new Promise(resolve => setTimeout(() => resolve(undefined), 5000))])` — used in `bridge.ts:284-286` for `getSender()` +- Media annotation pattern: `[emoji type msg_id=X] body` — used in `envelope.ts:106-117` +- Optional additive fields: new interface fields are optional (`?:`) to avoid breaking existing callers +- `sanitizeForPrompt()` used on all user-provided text before injection into prompt +- Tests in `src/**/__tests__/` directories + +## Key Decisions +- **Single message depth:** Only fetch the one replied-to message, not a reply chain. Keeps it simple and token-efficient. +- **Inline annotation format:** `[In reply to : ""]` — matches existing envelope annotation patterns (media, etc.) +- **GramJS `getReplyMessage()` as primary source:** Fetches text + sender in one call. DB `tg_messages` is NOT used as source because the sender name isn't stored there (only `sender_id`). The bridge extracts everything from the GramJS reply message object. +- **200 char truncation:** Quoted text capped at 200 chars with `...` — ~50 tokens overhead max. +- **Graceful degradation:** If `getReplyMessage()` fails/times out, still set `replyToId` on the message but skip the text annotation. If the replied-to message is deleted, show `[In reply to msg #1234]` (ID only). +- **CRITICAL — Fetch only when responding:** The `getReplyMessage()` API call (expensive, 100-500ms) happens ONLY inside `handleMessage()` AFTER `shouldRespond: true` is confirmed. `parseMessage()` only extracts the lightweight `replyToMsgId` integer. Group messages that are "Not mentioned" never trigger the API call — they just get `replyToId` persisted in DB (cheap) and go into `pendingHistory`. + +## Lore (Gotchas & Non-Obvious Knowledge) +- **USE `msg.replyToMsgId` GETTER** — GramJS custom Message class exposes a convenience getter (message.d.ts:397). Do NOT dig into `msg.replyTo.replyToMsgId` manually. +- `msg.replyTo` is typed as `TypeMessageReplyHeader = MessageReplyHeader | MessageReplyStoryHeader`. The getter handles both types. +- `getReplyMessage()` returns `Promise` — undefined for deleted messages. Handle gracefully. +- **CRITICAL: `_rawMessage` is only set for media messages** (bridge.ts:355: `hasMedia ? msg : undefined`). Must change to `hasMedia || replyToMsgId ? msg : undefined`, otherwise text-only replies lose the raw msg reference and we can't call `getReplyMessage()` from the handler. +- The `TelegramMessage` interface in `bridge.ts:8` is DIFFERENT from the one in `messages.ts:5` — the bridge one is the runtime type, the messages.ts one is the DB type. Both already have `replyToId` (messages.ts) / need `replyToId` (bridge.ts). +- `storeTelegramMessage()` at line 463 hardcodes `replyToId: undefined` — this is the bug to fix +- `processMessage()` has 11 positional parameters. Add `replyContext` as the 12th optional param. **Second caller at `src/index.ts:748`** (self-scheduled tasks) won't break since param is optional. +- **Envelope format**: currently returns single-line `${header} ${body}`. With reply context, use multi-line: `${header}\n[↩ reply to sender: "text"]\n${body}`. Without reply, keep the current single-line format. +- `sanitizeForPrompt()` from `src/utils/sanitize.ts` must be called on quoted text to prevent prompt injection (consistent with existing usage in envelope.ts:65) +- Group messages already include sender label in body (`senderLabel: text`) — the reply annotation goes on its own line between header and body +- GramJS `msg.date` is in seconds, `msg.timestamp` in the bridge is a `Date` object + +## Implementation Order (suggested) + +1. **Bridge: Extract replyToId (lightweight) + add fetchReplyContext helper** — `src/telegram/bridge.ts` + - Add `replyToId?: number` to `TelegramMessage` interface (line 8) + - In `parseMessage()`, after the media detection block (~line 338), extract the ID using the GramJS getter: + ``` + const replyToMsgId = msg.replyToMsgId; // GramJS getter, returns number | undefined + ``` + **NOTE:** Do NOT use `msg.replyTo.replyToMsgId` — use the convenience getter `msg.replyToMsgId` directly. + - **CRITICAL FIX:** Change `_rawMessage` assignment (line 355) from: + ``` + _rawMessage: hasMedia ? msg : undefined + ``` + to: + ``` + _rawMessage: hasMedia || replyToMsgId ? msg : undefined + ``` + Without this, text-only reply messages won't have the raw msg reference needed for `getReplyMessage()`. + - Return `replyToId: replyToMsgId` in the message object. NO API call here. + - Add a new public method on `TelegramBridge`: + ``` + async fetchReplyContext(rawMsg: Api.Message): Promise<{text?: string, senderName?: string, isAgent?: boolean} | undefined> + ``` + This calls `rawMsg.getReplyMessage()` with 5s timeout (same pattern as `getSender()` at line 284-286). + Uses `this.ownUserId` to determine `isAgent`. Only called by handlers when responding. + +2. **Envelope: Add reply annotation** — `src/memory/envelope.ts` + - Add `replyContext?: { senderName?: string; text: string; isAgent?: boolean }` to `EnvelopeParams` + - In `formatMessageEnvelope()`, after building `header` and `body`, change the return: + ``` + if (params.replyContext) { + const sender = params.replyContext.isAgent ? "agent" : sanitizeForPrompt(params.replyContext.senderName ?? "unknown"); + let quotedText = sanitizeForPrompt(params.replyContext.text); + if (quotedText.length > 200) quotedText = quotedText.slice(0, 200) + "..."; + return `${header}\n[↩ reply to ${sender}: "${quotedText}"]\n${body}`; + } + return `${header} ${body}`; // existing single-line format + ``` + - **Note:** The current return at line 120 is `${header} ${body}` (single-line). With reply context, we switch to multi-line to keep the annotation visually separate. Without reply context, format is unchanged. + +3. **Handlers: Wire replyToId storage + resolve context ONLY when responding** — `src/telegram/handlers.ts` + - In `storeTelegramMessage()` line 463: change `replyToId: undefined` to `replyToId: message.replyToId?.toString()` — always, cheap + - In `handleMessage()` **inside the `chatQueue.enqueue()` callback** (~line 345, after shouldRespond is confirmed): + ``` + let replyContext; + if (message.replyToId && message._rawMessage) { + replyContext = await this.bridge.fetchReplyMessage(message._rawMessage); + } + ``` + - Pass `replyContext` to `agent.processMessage()` call + - **This code path is ONLY reached for messages the agent will respond to** — group messages that are "Not mentioned" exit at line 305 before ever reaching the enqueue block + +4. **Runtime: Accept and forward reply context** — `src/agent/runtime.ts` + - Add `replyContext?` parameter to `processMessage()` (optional, after `messageId`) + - Pass to `formatMessageEnvelope()` in the `EnvelopeParams` + +5. **Tests** — Write tests for envelope, bridge parsing, and handler wiring + +## Verification Commands +- `npm test` — all 990+ tests must pass +- `npx vitest run src/memory/__tests__/envelope` — envelope tests +- `npx vitest run src/telegram/__tests__/` — bridge + handler tests +- `npm run build:backend` — no type errors +- `npm run lint` — no lint warnings + +## Warnings +- Do NOT change group trigger logic in `handlers.ts:224-258` — reply-to-agent already triggers via `msg.mentioned` +- Do NOT fetch reply chains (only single message) +- Do NOT use `msg.replyTo` without checking it exists and has `replyToMsgId` — some message types have different reply header shapes +- The `processMessage()` function has 11+ positional params — adding one more is acceptable but consider the readability tradeoff +- Call `sanitizeForPrompt()` on ALL quoted text to prevent prompt injection via crafted reply messages diff --git a/specs/reply-context/requirements.md b/specs/reply-context/requirements.md new file mode 100644 index 0000000..abb8348 --- /dev/null +++ b/specs/reply-context/requirements.md @@ -0,0 +1,58 @@ +# Requirements: Reply Context Awareness + +## Problem Statement +When a user replies to a message (their own or the agent's) in DMs or groups, the agent has zero visibility into which message was replied to. The LLM receives the new message text but has no idea it references a specific prior message. This breaks conversational coherence when the user says things like "yes" or "I meant the other one" in reply to a specific message. + +## Goals +- Agent sees replied-to message content in its LLM context, formatted as an inline annotation +- `reply_to_id` is persisted in the database for all incoming messages (currently hardcoded to `undefined`) +- Works in both DM and group contexts with appropriate scoping + +## Non-Goals (explicitly out of scope) +- Thread chain reconstruction (only single replied-to message, not the full chain) +- Changing group trigger logic (reply-to-agent already works via `msg.mentioned`) +- Fetching reply context for messages the agent doesn't process (non-mentioned group messages) +- Reply context for outgoing agent messages (only incoming user messages) + +## User Stories +- As a DM user, I want to reply to a specific message so that the agent understands what I'm referring to +- As a group user, I want to reply to the agent's message so the agent knows which of its messages I'm responding to +- As a group user, I want to reply to someone else's message and @mention the agent so it understands the full context + +## Acceptance Criteria (Given/When/Then) + +### DM: User replies to agent's message +- **Given** a DM conversation where the agent previously sent "Your balance is 100 TON" +- **When** the user replies to that specific message with "convert it to USD" +- **Then** the agent's context contains `[In reply to agent: "Your balance is 100 TON"]` before the user message + +### DM: User replies to their own message +- **Given** a DM conversation where the user previously sent "check wallet A" +- **When** the user replies to their own message with "actually check wallet B instead" +- **Then** the agent's context contains `[In reply to user: "check wallet A"]` before the user message + +### Group: User replies to agent's message +- **Given** a group where the agent said "Done, transaction sent" +- **When** a user replies to that message (triggering via `msg.mentioned`) +- **Then** the agent's context contains `[In reply to agent: "Done, transaction sent"]` before the user message + +### Group: User replies to another message and @mentions agent +- **Given** a group where Alice said "I need help with my wallet" +- **When** Bob replies to Alice's message with "@agent help her" +- **Then** the agent's context contains `[In reply to Alice: "I need help with my wallet"]` before Bob's message + +### Long quoted text is truncated +- **Given** a replied-to message with 500+ characters +- **When** the reply context is built +- **Then** the quoted text is truncated to 200 characters with "..." appended + +### Replied-to message not in DB +- **Given** a reply to a message that predates the agent's database +- **When** the reply context is being resolved +- **Then** the system fetches the message from Telegram API via `getReplyMessage()` with a 5s timeout +- **And** if that also fails, the reply annotation shows `[In reply to msg #1234]` (ID only, no text) + +## Constraints +- Max 5s timeout for Telegram API fallback (consistent with existing `getSender()` pattern) +- Reply annotation must not break existing envelope XML tag structure (`` tags) +- Zero impact on messages that are NOT replies (no extra DB queries, no latency) diff --git a/specs/reply-context/research.md b/specs/reply-context/research.md new file mode 100644 index 0000000..08e3e51 --- /dev/null +++ b/specs/reply-context/research.md @@ -0,0 +1,43 @@ +# Research Notes + +## Technology Research + +### GramJS Reply Message API +**Question:** How to access replied-to message data from incoming GramJS events? +**Finding:** `Api.Message` has a `replyTo` field of type `MessageReplyHeader` containing `replyToMsgId: number`. The full replied-to message can be fetched via `msg.getReplyMessage()` which returns `Api.Message | undefined`. This is an async call that hits the Telegram API. +**Impact on spec:** Use `getReplyMessage()` as primary source (gets text + sender in one call), with the same 5s timeout pattern used for `getSender()` in the codebase. + +### Telegram Bot API TextQuote +**Question:** Does Telegram provide the quoted text directly? +**Finding:** Telegram Bot API (not User API / GramJS) has a `TextQuote` object for manually selected quotes. GramJS user client does not expose this — only `replyToMsgId`. The full message must be fetched separately. +**Sources:** https://core.telegram.org/bots/api#textquote +**Impact on spec:** Cannot avoid the `getReplyMessage()` call; no inline quote text available in user client events. + +## Best Practices & Standards + +### Reply Context in LLM-Powered Chat Agents +**Industry standard:** The consensus from context engineering literature is that a reply is a "semantic anchor" — the user explicitly signals a prior message is relevant to their current utterance. Production bots use one of three patterns: +1. **Inline quote prepend** (most common, simplest) — `[In reply to: "text..."]` before user message +2. **Thread context block** — reconstruct reply chain as ordered messages +3. **XML-tagged context block** — `` tags (Anthropic-recommended for Claude) + +**Pattern chosen:** Inline quote prepend — matches existing Teleton envelope patterns (media annotations use `[emoji type msg_id=X]` format) and keeps tokens minimal. +**Reference implementations:** +- [n3d1117/chatgpt-telegram-bot PR #351](https://github.com/n3d1117/chatgpt-telegram-bot/issues/348) — inline prepend +- [ExposedCat/tg-local-llm](https://github.com/ExposedCat/tg-local-llm) — DB-based thread grouping +- [dolmario/chatbot_context_understanding](https://github.com/dolmario/-chatbot_context_understanding) — context-aware bot + +**Gotchas:** +- `getReplyMessage()` can return `undefined` for deleted messages +- In channels, `replyTo` may reference a different channel's message (cross-chat replies) — ignore these +- Token overhead: 200 chars ~ 50 tokens, acceptable + +**Sources:** +- https://www.comet.com/site/blog/context-engineering/ +- https://docs.langchain.com/oss/python/langchain/context-engineering +- https://docs.slack.dev/ai/ai-apps-best-practices + +### XML Tag Structure for Context +**Industry standard:** Anthropic recommends descriptive XML tags for structured context injection. Tags create clear boundaries preventing prompt contamination. +**Our approach:** The codebase already uses `` tags. The reply annotation sits OUTSIDE the `` tags (as a header line), consistent with how media annotations work. +**Sources:** https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/use-xml-tags diff --git a/specs/reply-context/test-plan.md b/specs/reply-context/test-plan.md new file mode 100644 index 0000000..1e12745 --- /dev/null +++ b/specs/reply-context/test-plan.md @@ -0,0 +1,43 @@ +# Test Plan: Reply Context Awareness + +## Strategy +- Unit tests: Vitest, co-located in `src/**/__tests__/` +- Focus on envelope formatting (pure function) + bridge parsing (mock GramJS) + +## Coverage Target +All new code must have tests. Key areas: envelope formatting, reply resolution, DB persistence. + +## Test Scenarios + +### Envelope Formatting (`src/memory/__tests__/envelope-reply.test.ts`) +| # | Scenario | Type | Priority | Verification | +|---|----------|------|----------|-------------| +| 1 | Reply context renders `[In reply to sender: "text"]` before body | Unit | P0 | `npx vitest run src/memory/__tests__/envelope-reply.test.ts` | +| 2 | Reply text truncated to 200 chars with `...` | Unit | P0 | same | +| 3 | Reply text exactly 200 chars — no `...` | Unit | P1 | same | +| 4 | Reply context with no sender name shows `[In reply to unknown: "text"]` | Unit | P1 | same | +| 5 | Reply context for agent's message shows `[In reply to agent: "text"]` | Unit | P0 | same | +| 6 | No reply context — envelope unchanged (regression) | Unit | P0 | same | +| 7 | Reply context with special chars in text (XML-like, quotes) — properly escaped | Unit | P1 | same | +| 8 | Reply context + media annotation — both rendered correctly | Unit | P1 | same | + +### Bridge Parsing (`src/telegram/__tests__/bridge-reply.test.ts`) +| # | Scenario | Type | Priority | Verification | +|---|----------|------|----------|-------------| +| 1 | Message with `replyTo.replyToMsgId` extracts `replyToId` | Unit | P0 | `npx vitest run src/telegram/__tests__/bridge-reply.test.ts` | +| 2 | Message without `replyTo` — `replyToId` is `undefined` | Unit | P0 | same | +| 3 | `getReplyMessage()` returns message — text + sender extracted | Unit | P0 | same | +| 4 | `getReplyMessage()` times out (>5s) — `replyToId` set, text undefined | Unit | P1 | same | +| 5 | `getReplyMessage()` throws — graceful fallback, no crash | Unit | P1 | same | + +### Handler Integration (`src/telegram/__tests__/handler-reply.test.ts`) +| # | Scenario | Type | Priority | Verification | +|---|----------|------|----------|-------------| +| 1 | `storeTelegramMessage` persists `replyToId` in DB | Unit | P0 | `npx vitest run src/telegram/__tests__/handler-reply.test.ts` | +| 2 | Reply context passed to `agent.processMessage()` | Unit | P0 | same | +| 3 | Non-reply message — no reply context passed | Unit | P0 | same | + +### Full Pipeline (existing test suite) +| # | Scenario | Type | Priority | Verification | +|---|----------|------|----------|-------------| +| 1 | All existing 990+ tests still pass | Regression | P0 | `npm test` | diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts index e9fc47e..3b45481 100644 --- a/src/agent/runtime.ts +++ b/src/agent/runtime.ts @@ -176,7 +176,8 @@ export class AgentRuntime { senderUsername?: string, hasMedia?: boolean, mediaType?: string, - messageId?: number + messageId?: number, + replyContext?: { senderName?: string; text: string; isAgent?: boolean } ): Promise { try { let session = getOrCreateSession(chatId); @@ -231,6 +232,7 @@ export class AgentRuntime { hasMedia, mediaType, messageId, + replyContext, }); if (pendingContext) { diff --git a/src/memory/__tests__/envelope-reply.test.ts b/src/memory/__tests__/envelope-reply.test.ts new file mode 100644 index 0000000..02eaa2b --- /dev/null +++ b/src/memory/__tests__/envelope-reply.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { formatMessageEnvelope } from "../envelope.js"; + +const BASE_PARAMS = { + channel: "Telegram", + senderId: "12345", + senderName: "Alice", + senderUsername: "alice", + timestamp: new Date("2026-02-24T14:30:00Z").getTime(), + body: "Hello world", + isGroup: false, +} as const; + +describe("formatMessageEnvelope — reply context", () => { + it("renders reply annotation for DM", () => { + const result = formatMessageEnvelope({ + ...BASE_PARAMS, + replyContext: { + senderName: "Bob", + text: "Hey, did you check the logs?", + isAgent: false, + }, + }); + + expect(result).toContain("[↩ reply to Bob:"); + expect(result).toContain('"Hey, did you check the logs?"'); + expect(result).toContain("Hello world"); + // Multi-line format + expect(result).toMatch(/\]\n\[↩ reply to/); + }); + + it("renders reply annotation for group message", () => { + const result = formatMessageEnvelope({ + ...BASE_PARAMS, + isGroup: true, + replyContext: { + senderName: "Bob", + text: "Original message", + isAgent: false, + }, + }); + + expect(result).toContain("[↩ reply to Bob:"); + expect(result).toContain("Alice (@alice, id:12345): "); + }); + + it("shows 'agent' as sender when isAgent is true", () => { + const result = formatMessageEnvelope({ + ...BASE_PARAMS, + replyContext: { + senderName: "BotName", + text: "Your balance is 100 TON", + isAgent: true, + }, + }); + + expect(result).toContain("[↩ reply to agent:"); + expect(result).not.toContain("BotName"); + }); + + it("shows 'unknown' when senderName is missing", () => { + const result = formatMessageEnvelope({ + ...BASE_PARAMS, + replyContext: { + text: "Some message", + }, + }); + + expect(result).toContain("[↩ reply to unknown:"); + }); + + it("truncates quoted text to 200 chars with ellipsis", () => { + const longText = "A".repeat(300); + const result = formatMessageEnvelope({ + ...BASE_PARAMS, + replyContext: { + senderName: "Bob", + text: longText, + }, + }); + + // After sanitize, text is truncated to 200 + "..." + expect(result).toContain("..." + '"'); + // Should NOT contain the full 300-char string + expect(result).not.toContain("A".repeat(300)); + }); + + it("does not truncate text exactly 200 chars", () => { + const exactText = "B".repeat(200); + const result = formatMessageEnvelope({ + ...BASE_PARAMS, + replyContext: { + senderName: "Bob", + text: exactText, + }, + }); + + expect(result).toContain("B".repeat(200)); + expect(result).not.toContain("..."); + }); + + it("keeps single-line format when no reply context", () => { + const result = formatMessageEnvelope(BASE_PARAMS); + + // No newlines in the output (single line) + expect(result).not.toContain("\n"); + expect(result).not.toContain("↩"); + }); + + it("renders reply context + media annotation together", () => { + const result = formatMessageEnvelope({ + ...BASE_PARAMS, + hasMedia: true, + mediaType: "photo", + messageId: 999, + replyContext: { + senderName: "Bob", + text: "Check this out", + isAgent: false, + }, + }); + + expect(result).toContain("[↩ reply to Bob:"); + expect(result).toContain("[📷 photo msg_id=999]"); + expect(result).toContain("Hello world"); + }); + + it("sanitizes special characters in quoted text", () => { + const result = formatMessageEnvelope({ + ...BASE_PARAMS, + replyContext: { + senderName: "Bob", + text: "Test injected text", + }, + }); + + // sanitizeForPrompt should strip or escape the tags + expect(result).not.toContain("injected"); + }); +}); diff --git a/src/memory/envelope.ts b/src/memory/envelope.ts index 4d37b34..40c562a 100644 --- a/src/memory/envelope.ts +++ b/src/memory/envelope.ts @@ -1,4 +1,4 @@ -import { sanitizeForPrompt } from "../utils/sanitize.js"; +import { sanitizeForPrompt, sanitizeForContext } from "../utils/sanitize.js"; export interface EnvelopeParams { channel: string; @@ -14,6 +14,11 @@ export interface EnvelopeParams { hasMedia?: boolean; mediaType?: string; messageId?: number; // For media download reference + replyContext?: { + senderName?: string; + text: string; + isAgent?: boolean; + }; } function formatElapsed(elapsedMs: number): string { @@ -117,6 +122,14 @@ export function formatMessageEnvelope(params: EnvelopeParams): string { body = `[${mediaEmoji} ${params.mediaType}${msgIdHint}] ${body}`; } + if (params.replyContext) { + const sender = params.replyContext.isAgent + ? "agent" + : sanitizeForPrompt(params.replyContext.senderName ?? "unknown"); + let quotedText = sanitizeForContext(params.replyContext.text); + if (quotedText.length > 200) quotedText = quotedText.slice(0, 200) + "..."; + return `${header}\n[↩ reply to ${sender}: "${quotedText}"]\n${body}`; + } return `${header} ${body}`; } diff --git a/src/telegram/bridge.ts b/src/telegram/bridge.ts index 63aa864..ae4239a 100644 --- a/src/telegram/bridge.ts +++ b/src/telegram/bridge.ts @@ -20,6 +20,7 @@ export interface TelegramMessage { _rawPeer?: Api.TypePeer; hasMedia: boolean; mediaType?: "photo" | "document" | "video" | "audio" | "voice" | "sticker"; + replyToId?: number; _rawMessage?: Api.Message; } @@ -315,6 +316,8 @@ export class TelegramBridge { else if (msg.sticker) mediaType = "sticker"; else if (msg.document) mediaType = "document"; + const replyToMsgId = msg.replyToMsgId; // GramJS getter, returns number | undefined + let text = msg.message ?? ""; if (!text && msg.media) { if (msg.media.className === "MessageMediaDice") { @@ -352,7 +355,8 @@ export class TelegramBridge { _rawPeer: msg.peerId, hasMedia, mediaType, - _rawMessage: hasMedia ? msg : undefined, + replyToId: replyToMsgId, + _rawMessage: hasMedia || !!replyToMsgId ? msg : undefined, }; } @@ -360,6 +364,45 @@ export class TelegramBridge { return this.peerCache.get(chatId); } + async fetchReplyContext( + rawMsg: Api.Message + ): Promise<{ text?: string; senderName?: string; isAgent?: boolean } | undefined> { + try { + const replyMsg = await Promise.race([ + rawMsg.getReplyMessage(), + new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)), + ]); + if (!replyMsg) return undefined; + + let senderName: string | undefined; + try { + const sender = await Promise.race([ + replyMsg.getSender(), + new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)), + ]); + if (sender && "firstName" in sender) { + senderName = (sender.firstName as string) ?? undefined; + } + if (sender && "username" in sender && !senderName) { + senderName = (sender.username as string) ?? undefined; + } + } catch { + // Non-critical + } + + const replyMsgSenderId = replyMsg.senderId ? BigInt(replyMsg.senderId.toString()) : undefined; + const isAgent = this.ownUserId !== undefined && replyMsgSenderId === this.ownUserId; + + return { + text: replyMsg.message || undefined, + senderName, + isAgent, + }; + } catch { + return undefined; + } + } + getClient(): TelegramUserClient { return this.client; } diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index bf5185b..05bb27a 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -348,6 +348,15 @@ export class MessageHandler { pendingContext = this.pendingHistory.getAndClearPending(message.chatId); } + // 5b. Resolve reply context (only for messages we're responding to) + let replyContext: { text: string; senderName?: string; isAgent?: boolean } | undefined; + if (message.replyToId && message._rawMessage) { + const raw = await this.bridge.fetchReplyContext(message._rawMessage); + if (raw?.text) { + replyContext = { text: raw.text, senderName: raw.senderName, isAgent: raw.isAgent }; + } + } + // 6. Build tool context const toolContext: Omit = { bridge: this.bridge, @@ -370,7 +379,8 @@ export class MessageHandler { message.senderUsername, message.hasMedia, message.mediaType, - message.id + message.id, + replyContext ); // 8. Handle response based on whether tools were used @@ -460,7 +470,7 @@ export class MessageHandler { chatId: message.chatId, senderId: message.senderId?.toString() ?? null, text: message.text, - replyToId: undefined, + replyToId: message.replyToId?.toString(), isFromAgent, hasMedia: message.hasMedia, mediaType: message.mediaType, From 95bc8cb30b3b2920aa11727a8a854019fe549536 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 17:31:29 +0100 Subject: [PATCH 19/41] chore: bump to v0.7.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c42622..6e98349 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teleton", - "version": "0.7.2", + "version": "0.7.3", "workspaces": [ "packages/*" ], From 526ce2c9782e11d4c631b9c20e94bd8483679825 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 18:20:54 +0100 Subject: [PATCH 20/41] fix(webui): render Select dropdown via portal to fix z-index stacking Dropdown menus were clipped by parent containers with overflow-y: auto (e.g. modals). Render menu via createPortal to document.body with position: fixed and z-index: 10000 to ensure dropdowns always appear above all other UI elements. --- web/src/components/Select.tsx | 22 +++++++++++++++++++--- web/src/index.css | 11 +---------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/web/src/components/Select.tsx b/web/src/components/Select.tsx index b358350..889178b 100644 --- a/web/src/components/Select.tsx +++ b/web/src/components/Select.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; interface SelectProps { value: string; @@ -11,6 +12,7 @@ interface SelectProps { export function Select({ value, options, labels, onChange, style }: SelectProps) { const [open, setOpen] = useState(false); const [focusIdx, setFocusIdx] = useState(-1); + const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null); const ref = useRef(null); const menuRef = useRef(null); const idRef = useRef(Math.random().toString(36).slice(2)); @@ -19,7 +21,9 @@ export function Select({ value, options, labels, onChange, style }: SelectProps) useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { + const target = e.target as Node; + const clickedInside = ref.current?.contains(target) || menuRef.current?.contains(target); + if (!clickedInside) { setOpen(false); } }; @@ -27,6 +31,16 @@ export function Select({ value, options, labels, onChange, style }: SelectProps) return () => document.removeEventListener('mousedown', handler); }, [open]); + // Calculate menu position when opening + useEffect(() => { + if (open && ref.current) { + const rect = ref.current.getBoundingClientRect(); + setMenuPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); + } else { + setMenuPos(null); + } + }, [open]); + // Reset focus index when opening useEffect(() => { if (open) { @@ -101,13 +115,14 @@ export function Select({ value, options, labels, onChange, style }: SelectProps) - {open && ( + {open && menuPos && createPortal(
= 0 ? `${menuId}-opt-${focusIdx}` : undefined} + style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, width: menuPos.width }} > {options.map((opt, idx) => (
))} -
+
, + document.body )}
); diff --git a/web/src/index.css b/web/src/index.css index 754276c..90cf27d 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -297,11 +297,6 @@ select { .custom-select { position: relative; - z-index: 1; -} - -.custom-select:has(.custom-select-menu) { - z-index: 200; } .custom-select-trigger { @@ -335,15 +330,11 @@ select { } .custom-select-menu { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; background: var(--bg); border: 1px solid var(--glass-border-strong); border-radius: var(--radius-sm); padding: 4px; - z-index: 50; + z-index: 10000; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); max-height: 200px; overflow-y: auto; From 853d975833b1adc2c58756df2da25cf1886ba50e Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 18:23:53 +0100 Subject: [PATCH 21/41] =?UTF-8?q?fix(deps):=20update=20hono=204.11.9?= =?UTF-8?q?=E2=86=924.12.2=20and=20ajv=208.17.1=E2=86=928.18.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patches Dependabot alerts: - hono: timing comparison hardening in basicAuth/bearerAuth - ajv: ReDoS when using $data option --- package-lock.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index bd61a9c..190908f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "teleton", - "version": "0.7.0", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "teleton", - "version": "0.7.0", + "version": "0.7.3", "license": "MIT", "workspaces": [ "packages/*" @@ -4497,9 +4497,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5845,9 +5845,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -7068,9 +7068,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", + "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", "license": "MIT", "engines": { "node": ">=16.9.0" From c7bffcc384d40a2bc52ce17c6a5b611927968257 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 21:33:01 +0100 Subject: [PATCH 22/41] feat(telegram): add 7 new tools + voice auto-transcription + bump pi-ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tools (66 → 73): - transcribe-audio: voice/audio transcription via TranscribeAudio API - get-scheduled-messages, delete-scheduled-message, send-scheduled-now - get-collectible-info: Fragment collectible lookup - get-admined-channels: list admin public channels - set-personal-channel: set/remove personal channel Auto-transcription integrated in message handler for voice/audio messages. Bump @mariozechner/pi-ai 0.52 → 0.54. --- package-lock.json | 8 +- package.json | 2 +- .../telegram/chats/get-admined-channels.ts | 69 ++++++++++ src/agent/tools/telegram/chats/index.ts | 10 ++ .../telegram/gifts/get-collectible-info.ts | 77 +++++++++++ src/agent/tools/telegram/gifts/index.ts | 6 + src/agent/tools/telegram/media/index.ts | 6 + .../tools/telegram/media/transcribe-audio.ts | 124 ++++++++++++++++++ .../messaging/delete-scheduled-message.ts | 68 ++++++++++ .../messaging/get-scheduled-messages.ts | 67 ++++++++++ src/agent/tools/telegram/messaging/index.ts | 18 +++ .../telegram/messaging/send-scheduled-now.ts | 69 ++++++++++ src/agent/tools/telegram/profile/index.ts | 10 ++ .../telegram/profile/set-personal-channel.ts | 75 +++++++++++ src/telegram/handlers.ts | 33 ++++- 15 files changed, 636 insertions(+), 6 deletions(-) create mode 100644 src/agent/tools/telegram/chats/get-admined-channels.ts create mode 100644 src/agent/tools/telegram/gifts/get-collectible-info.ts create mode 100644 src/agent/tools/telegram/media/transcribe-audio.ts create mode 100644 src/agent/tools/telegram/messaging/delete-scheduled-message.ts create mode 100644 src/agent/tools/telegram/messaging/get-scheduled-messages.ts create mode 100644 src/agent/tools/telegram/messaging/send-scheduled-now.ts create mode 100644 src/agent/tools/telegram/profile/set-personal-channel.ts diff --git a/package-lock.json b/package-lock.json index 190908f..f5c0c2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@hono/node-server": "^1.19.9", "@huggingface/transformers": "^3.8.1", "@inquirer/prompts": "^8.2.1", - "@mariozechner/pi-ai": "^0.52.12", + "@mariozechner/pi-ai": "^0.54.2", "@modelcontextprotocol/sdk": "^1.26.0", "@sinclair/typebox": "^0.34.48", "@tavily/core": "^0.7.1", @@ -2530,9 +2530,9 @@ } }, "node_modules/@mariozechner/pi-ai": { - "version": "0.52.12", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.52.12.tgz", - "integrity": "sha512-oF7OMJu1aUx7MXJeJoJ/3JDXzD2a5SqK9nHVK3mCA8DRQaykv9g+wcFZaANcCl0vAR2QSDr5KN3ZMARlFNWiVg==", + "version": "0.54.2", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.54.2.tgz", + "integrity": "sha512-QKQV8iT7afwdaOiLDPTPyQcsGw4ulxBjAI0GvgvowAuqy9UbDeKFSdQYLmjVt7CtnJD1Z8zMjQQ4SLigdZ6dRQ==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", diff --git a/package.json b/package.json index 6e98349..b0de0a7 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@hono/node-server": "^1.19.9", "@huggingface/transformers": "^3.8.1", "@inquirer/prompts": "^8.2.1", - "@mariozechner/pi-ai": "^0.52.12", + "@mariozechner/pi-ai": "^0.54.2", "@modelcontextprotocol/sdk": "^1.26.0", "@sinclair/typebox": "^0.34.48", "@tavily/core": "^0.7.1", diff --git a/src/agent/tools/telegram/chats/get-admined-channels.ts b/src/agent/tools/telegram/chats/get-admined-channels.ts new file mode 100644 index 0000000..cab9981 --- /dev/null +++ b/src/agent/tools/telegram/chats/get-admined-channels.ts @@ -0,0 +1,69 @@ +import { Type } from "@sinclair/typebox"; +import { Api } from "telegram"; +import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; +import { getErrorMessage } from "../../../../utils/errors.js"; +import { createLogger } from "../../../../utils/logger.js"; + +const log = createLogger("Tools"); + +interface GetAdminedChannelsParams { + forPersonal?: boolean; +} + +export const telegramGetAdminedChannelsTool: Tool = { + name: "telegram_get_admined_channels", + description: + "List public channels where the current account has admin rights. Returns channel IDs, titles, usernames, and participant counts.", + category: "data-bearing", + parameters: Type.Object({ + forPersonal: Type.Optional( + Type.Boolean({ + description: "If true, filter for channels suitable as personal channel", + }) + ), + }), +}; + +export const telegramGetAdminedChannelsExecutor: ToolExecutor = async ( + params, + context +): Promise => { + try { + const gramJsClient = context.bridge.getClient().getClient(); + + const result = await gramJsClient.invoke( + new Api.channels.GetAdminedPublicChannels({ + byLocation: false, + checkLimit: false, + forPersonal: params.forPersonal, + }) + ); + + const chats = ("chats" in result ? result.chats : []) as Api.Channel[]; + + const channels = chats.map((ch) => ({ + id: ch.id?.toString(), + title: ch.title, + username: ch.username || null, + participantsCount: ch.participantsCount || null, + isMegagroup: ch.megagroup || false, + isBroadcast: ch.broadcast || false, + })); + + log.info(`📡 get_admined_channels: ${channels.length} channels found`); + + return { + success: true, + data: { + count: channels.length, + channels, + }, + }; + } catch (error) { + log.error({ err: error }, "Error getting admined channels"); + return { + success: false, + error: getErrorMessage(error), + }; + } +}; diff --git a/src/agent/tools/telegram/chats/index.ts b/src/agent/tools/telegram/chats/index.ts index cb3b32a..05bcf8c 100644 --- a/src/agent/tools/telegram/chats/index.ts +++ b/src/agent/tools/telegram/chats/index.ts @@ -13,6 +13,10 @@ import { telegramInviteToChannelTool, telegramInviteToChannelExecutor, } from "./invite-to-channel.js"; +import { + telegramGetAdminedChannelsTool, + telegramGetAdminedChannelsExecutor, +} from "./get-admined-channels.js"; import type { ToolEntry } from "../../types.js"; export { telegramGetDialogsTool, telegramGetDialogsExecutor }; @@ -24,6 +28,7 @@ export { telegramLeaveChannelTool, telegramLeaveChannelExecutor }; export { telegramCreateChannelTool, telegramCreateChannelExecutor }; export { telegramEditChannelInfoTool, telegramEditChannelInfoExecutor }; export { telegramInviteToChannelTool, telegramInviteToChannelExecutor }; +export { telegramGetAdminedChannelsTool, telegramGetAdminedChannelsExecutor }; export const tools: ToolEntry[] = [ { tool: telegramGetDialogsTool, executor: telegramGetDialogsExecutor }, @@ -43,4 +48,9 @@ export const tools: ToolEntry[] = [ executor: telegramInviteToChannelExecutor, scope: "dm-only", }, + { + tool: telegramGetAdminedChannelsTool, + executor: telegramGetAdminedChannelsExecutor, + scope: "dm-only", + }, ]; diff --git a/src/agent/tools/telegram/gifts/get-collectible-info.ts b/src/agent/tools/telegram/gifts/get-collectible-info.ts new file mode 100644 index 0000000..784bdec --- /dev/null +++ b/src/agent/tools/telegram/gifts/get-collectible-info.ts @@ -0,0 +1,77 @@ +import { Type } from "@sinclair/typebox"; +import { Api } from "telegram"; +import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; +import { getErrorMessage } from "../../../../utils/errors.js"; +import { createLogger } from "../../../../utils/logger.js"; + +const log = createLogger("Tools"); + +interface GetCollectibleInfoParams { + type: "username" | "phone"; + value: string; +} + +export const telegramGetCollectibleInfoTool: Tool = { + name: "telegram_get_collectible_info", + description: + "Get info about a Fragment collectible (username or phone number). Returns purchase date, price (fiat + crypto), and Fragment URL.", + category: "data-bearing", + parameters: Type.Object({ + type: Type.Union([Type.Literal("username"), Type.Literal("phone")], { + description: + "Type of collectible: 'username' for a Telegram username, 'phone' for a phone number", + }), + value: Type.String({ + description: "The username (without @) or phone number (with country code, e.g. +888...)", + }), + }), +}; + +export const telegramGetCollectibleInfoExecutor: ToolExecutor = async ( + params, + context +): Promise => { + try { + const { type, value } = params; + const gramJsClient = context.bridge.getClient().getClient(); + + const collectible = + type === "username" + ? new Api.InputCollectibleUsername({ username: value.replace("@", "") }) + : new Api.InputCollectiblePhone({ phone: value }); + + const result = await gramJsClient.invoke(new Api.fragment.GetCollectibleInfo({ collectible })); + + log.info(`💎 get_collectible_info: ${type}=${value}`); + + return { + success: true, + data: { + type, + value, + purchaseDate: new Date(result.purchaseDate * 1000).toISOString(), + currency: result.currency, + amount: result.amount?.toString(), + cryptoCurrency: result.cryptoCurrency, + cryptoAmount: result.cryptoAmount?.toString(), + url: result.url, + }, + }; + } catch (error: any) { + if ( + error.errorMessage === "PHONE_NOT_OCCUPIED" || + error.errorMessage === "USERNAME_NOT_OCCUPIED" + ) { + return { + success: false, + error: `Collectible not found: ${params.type} "${params.value}" is not a Fragment collectible.`, + }; + } + + log.error({ err: error }, "Error getting collectible info"); + return { + success: false, + error: getErrorMessage(error), + }; + } +}; diff --git a/src/agent/tools/telegram/gifts/index.ts b/src/agent/tools/telegram/gifts/index.ts index 983f40b..a861a43 100644 --- a/src/agent/tools/telegram/gifts/index.ts +++ b/src/agent/tools/telegram/gifts/index.ts @@ -15,6 +15,10 @@ import { import { telegramGetResaleGiftsTool, telegramGetResaleGiftsExecutor } from "./get-resale-gifts.js"; import { telegramBuyResaleGiftTool, telegramBuyResaleGiftExecutor } from "./buy-resale-gift.js"; import { telegramSetGiftStatusTool, telegramSetGiftStatusExecutor } from "./set-gift-status.js"; +import { + telegramGetCollectibleInfoTool, + telegramGetCollectibleInfoExecutor, +} from "./get-collectible-info.js"; import type { ToolEntry } from "../../types.js"; export { telegramGetAvailableGiftsTool, telegramGetAvailableGiftsExecutor }; @@ -25,6 +29,7 @@ export { telegramSetCollectiblePriceTool, telegramSetCollectiblePriceExecutor }; export { telegramGetResaleGiftsTool, telegramGetResaleGiftsExecutor }; export { telegramBuyResaleGiftTool, telegramBuyResaleGiftExecutor }; export { telegramSetGiftStatusTool, telegramSetGiftStatusExecutor }; +export { telegramGetCollectibleInfoTool, telegramGetCollectibleInfoExecutor }; export const tools: ToolEntry[] = [ { tool: telegramGetAvailableGiftsTool, executor: telegramGetAvailableGiftsExecutor }, @@ -43,4 +48,5 @@ export const tools: ToolEntry[] = [ { tool: telegramGetResaleGiftsTool, executor: telegramGetResaleGiftsExecutor }, { tool: telegramBuyResaleGiftTool, executor: telegramBuyResaleGiftExecutor, scope: "dm-only" }, { tool: telegramSetGiftStatusTool, executor: telegramSetGiftStatusExecutor, scope: "dm-only" }, + { tool: telegramGetCollectibleInfoTool, executor: telegramGetCollectibleInfoExecutor }, ]; diff --git a/src/agent/tools/telegram/media/index.ts b/src/agent/tools/telegram/media/index.ts index c1fb9bf..fefc710 100644 --- a/src/agent/tools/telegram/media/index.ts +++ b/src/agent/tools/telegram/media/index.ts @@ -4,6 +4,10 @@ import { telegramSendStickerTool, telegramSendStickerExecutor } from "./send-sti import { telegramSendGifTool, telegramSendGifExecutor } from "./send-gif.js"; import { telegramDownloadMediaTool, telegramDownloadMediaExecutor } from "./download-media.js"; import { visionAnalyzeTool, visionAnalyzeExecutor } from "./vision-analyze.js"; +import { + telegramTranscribeAudioTool, + telegramTranscribeAudioExecutor, +} from "./transcribe-audio.js"; import type { ToolEntry } from "../../types.js"; export { telegramSendPhotoTool, telegramSendPhotoExecutor }; @@ -12,6 +16,7 @@ export { telegramSendStickerTool, telegramSendStickerExecutor }; export { telegramSendGifTool, telegramSendGifExecutor }; export { telegramDownloadMediaTool, telegramDownloadMediaExecutor }; export { visionAnalyzeTool, visionAnalyzeExecutor }; +export { telegramTranscribeAudioTool, telegramTranscribeAudioExecutor }; export const tools: ToolEntry[] = [ { tool: telegramSendPhotoTool, executor: telegramSendPhotoExecutor }, @@ -20,4 +25,5 @@ export const tools: ToolEntry[] = [ { tool: telegramSendGifTool, executor: telegramSendGifExecutor }, { tool: telegramDownloadMediaTool, executor: telegramDownloadMediaExecutor }, { tool: visionAnalyzeTool, executor: visionAnalyzeExecutor }, + { tool: telegramTranscribeAudioTool, executor: telegramTranscribeAudioExecutor }, ]; diff --git a/src/agent/tools/telegram/media/transcribe-audio.ts b/src/agent/tools/telegram/media/transcribe-audio.ts new file mode 100644 index 0000000..dd5e913 --- /dev/null +++ b/src/agent/tools/telegram/media/transcribe-audio.ts @@ -0,0 +1,124 @@ +import { Type } from "@sinclair/typebox"; +import { Api } from "telegram"; +import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; +import { getErrorMessage } from "../../../../utils/errors.js"; +import { createLogger } from "../../../../utils/logger.js"; + +const log = createLogger("Tools"); + +interface TranscribeAudioParams { + chatId: string; + messageId: number; +} + +export const telegramTranscribeAudioTool: Tool = { + name: "telegram_transcribe_audio", + description: + "Transcribe a voice or audio message to text using Telegram's native transcription. Requires the message to be a voice/audio type. May require Telegram Premium.", + category: "data-bearing", + parameters: Type.Object({ + chatId: Type.String({ + description: "The chat ID where the voice/audio message is", + }), + messageId: Type.Number({ + description: "The message ID of the voice/audio message to transcribe", + }), + }), +}; + +const POLL_INTERVAL_MS = 1500; +const MAX_POLL_RETRIES = 15; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const telegramTranscribeAudioExecutor: ToolExecutor = async ( + params, + context +): Promise => { + try { + const { chatId, messageId } = params; + + const gramJsClient = context.bridge.getClient().getClient(); + const entity = await gramJsClient.getEntity(chatId); + + let result = await gramJsClient.invoke( + new Api.messages.TranscribeAudio({ + peer: entity, + msgId: messageId, + }) + ); + + // Poll if transcription is still pending + let retries = 0; + while (result.pending && retries < MAX_POLL_RETRIES) { + retries++; + log.debug(`⏳ Transcription pending, polling (${retries}/${MAX_POLL_RETRIES})...`); + await sleep(POLL_INTERVAL_MS); + + try { + result = await gramJsClient.invoke( + new Api.messages.TranscribeAudio({ + peer: entity, + msgId: messageId, + }) + ); + } catch (pollError: any) { + // On transient errors (FLOOD_WAIT, network), keep polling + log.warn( + `⚠️ Transcription poll ${retries} failed: ${pollError.errorMessage || pollError.message}` + ); + continue; + } + } + + if (result.pending) { + log.warn(`Transcription still pending after ${MAX_POLL_RETRIES} retries`); + return { + success: true, + data: { + transcriptionId: result.transcriptionId?.toString(), + text: result.text || null, + pending: true, + message: "Transcription is still processing. Try again later.", + }, + }; + } + + log.info(`🎤 transcribe_audio: msg ${messageId} → "${result.text?.substring(0, 50)}..."`); + + return { + success: true, + data: { + transcriptionId: result.transcriptionId?.toString(), + text: result.text, + pending: false, + ...(result.trialRemainsNum !== undefined && { + trialRemainsNum: result.trialRemainsNum, + trialRemainsUntilDate: result.trialRemainsUntilDate, + }), + }, + }; + } catch (error: any) { + // Handle specific Telegram errors + if (error.errorMessage === "PREMIUM_ACCOUNT_REQUIRED") { + return { + success: false, + error: "Telegram Premium is required to transcribe audio messages.", + }; + } + if (error.errorMessage === "MSG_ID_INVALID") { + return { + success: false, + error: "Invalid message ID — the message may not exist or is not a voice/audio message.", + }; + } + + log.error({ err: error }, "Error transcribing audio"); + return { + success: false, + error: getErrorMessage(error), + }; + } +}; diff --git a/src/agent/tools/telegram/messaging/delete-scheduled-message.ts b/src/agent/tools/telegram/messaging/delete-scheduled-message.ts new file mode 100644 index 0000000..e6f7589 --- /dev/null +++ b/src/agent/tools/telegram/messaging/delete-scheduled-message.ts @@ -0,0 +1,68 @@ +import { Type } from "@sinclair/typebox"; +import { Api } from "telegram"; +import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; +import { getErrorMessage } from "../../../../utils/errors.js"; +import { createLogger } from "../../../../utils/logger.js"; + +const log = createLogger("Tools"); + +interface DeleteScheduledMessageParams { + chatId: string; + messageIds: number[]; +} + +export const telegramDeleteScheduledMessageTool: Tool = { + name: "telegram_delete_scheduled_message", + description: + "Cancel one or more scheduled messages by their IDs. Use telegram_get_scheduled_messages first to find message IDs.", + parameters: Type.Object({ + chatId: Type.String({ + description: "The chat ID where the scheduled messages are", + }), + messageIds: Type.Array(Type.Number(), { + description: "Array of scheduled message IDs to cancel", + minItems: 1, + maxItems: 30, + }), + }), +}; + +export const telegramDeleteScheduledMessageExecutor: ToolExecutor< + DeleteScheduledMessageParams +> = async (params, context): Promise => { + try { + const { chatId, messageIds } = params; + const gramJsClient = context.bridge.getClient().getClient(); + const entity = await gramJsClient.getEntity(chatId); + + await gramJsClient.invoke( + new Api.messages.DeleteScheduledMessages({ + peer: entity, + id: messageIds, + }) + ); + + log.info(`🗑️ delete_scheduled: ${messageIds.length} messages cancelled in ${chatId}`); + + return { + success: true, + data: { + chatId, + deletedIds: messageIds, + deletedCount: messageIds.length, + }, + }; + } catch (error: any) { + if (error.errorMessage === "MESSAGE_ID_INVALID") { + return { + success: false, + error: "One or more message IDs are invalid or not scheduled messages.", + }; + } + log.error({ err: error }, "Error deleting scheduled messages"); + return { + success: false, + error: getErrorMessage(error), + }; + } +}; diff --git a/src/agent/tools/telegram/messaging/get-scheduled-messages.ts b/src/agent/tools/telegram/messaging/get-scheduled-messages.ts new file mode 100644 index 0000000..30aea46 --- /dev/null +++ b/src/agent/tools/telegram/messaging/get-scheduled-messages.ts @@ -0,0 +1,67 @@ +import { Type } from "@sinclair/typebox"; +import { Api } from "telegram"; +import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; +import { getErrorMessage } from "../../../../utils/errors.js"; +import { createLogger } from "../../../../utils/logger.js"; +import bigInt from "big-integer"; + +const log = createLogger("Tools"); + +interface GetScheduledMessagesParams { + chatId: string; +} + +export const telegramGetScheduledMessagesTool: Tool = { + name: "telegram_get_scheduled_messages", + description: + "List all scheduled (pending) messages in a chat. Shows message text, scheduled send date, and message IDs for management.", + category: "data-bearing", + parameters: Type.Object({ + chatId: Type.String({ + description: "The chat ID to list scheduled messages for", + }), + }), +}; + +export const telegramGetScheduledMessagesExecutor: ToolExecutor< + GetScheduledMessagesParams +> = async (params, context): Promise => { + try { + const { chatId } = params; + const gramJsClient = context.bridge.getClient().getClient(); + const entity = await gramJsClient.getEntity(chatId); + + const result = await gramJsClient.invoke( + new Api.messages.GetScheduledHistory({ + peer: entity, + hash: bigInt(0), + }) + ); + + const messages = ("messages" in result ? result.messages : []) as Api.Message[]; + + const scheduled = messages.map((msg) => ({ + id: msg.id, + text: msg.message || null, + scheduledFor: msg.date ? new Date(msg.date * 1000).toISOString() : null, + hasMedia: !!msg.media, + })); + + log.info(`📋 get_scheduled_messages: ${scheduled.length} scheduled in ${chatId}`); + + return { + success: true, + data: { + chatId, + count: scheduled.length, + messages: scheduled, + }, + }; + } catch (error) { + log.error({ err: error }, "Error getting scheduled messages"); + return { + success: false, + error: getErrorMessage(error), + }; + } +}; diff --git a/src/agent/tools/telegram/messaging/index.ts b/src/agent/tools/telegram/messaging/index.ts index 7886da6..630c26a 100644 --- a/src/agent/tools/telegram/messaging/index.ts +++ b/src/agent/tools/telegram/messaging/index.ts @@ -15,6 +15,18 @@ import { } from "./pin.js"; import { telegramQuoteReplyTool, telegramQuoteReplyExecutor } from "./quote-reply.js"; import { telegramGetRepliesTool, telegramGetRepliesExecutor } from "./get-replies.js"; +import { + telegramGetScheduledMessagesTool, + telegramGetScheduledMessagesExecutor, +} from "./get-scheduled-messages.js"; +import { + telegramDeleteScheduledMessageTool, + telegramDeleteScheduledMessageExecutor, +} from "./delete-scheduled-message.js"; +import { + telegramSendScheduledNowTool, + telegramSendScheduledNowExecutor, +} from "./send-scheduled-now.js"; import type { ToolEntry } from "../../types.js"; export { telegramSendMessageTool, telegramSendMessageExecutor }; @@ -31,6 +43,9 @@ export { }; export { telegramQuoteReplyTool, telegramQuoteReplyExecutor }; export { telegramGetRepliesTool, telegramGetRepliesExecutor }; +export { telegramGetScheduledMessagesTool, telegramGetScheduledMessagesExecutor }; +export { telegramDeleteScheduledMessageTool, telegramDeleteScheduledMessageExecutor }; +export { telegramSendScheduledNowTool, telegramSendScheduledNowExecutor }; export const tools: ToolEntry[] = [ { tool: telegramSendMessageTool, executor: telegramSendMessageExecutor }, @@ -38,6 +53,9 @@ export const tools: ToolEntry[] = [ { tool: telegramGetRepliesTool, executor: telegramGetRepliesExecutor }, { tool: telegramEditMessageTool, executor: telegramEditMessageExecutor }, { tool: telegramScheduleMessageTool, executor: telegramScheduleMessageExecutor }, + { tool: telegramGetScheduledMessagesTool, executor: telegramGetScheduledMessagesExecutor }, + { tool: telegramDeleteScheduledMessageTool, executor: telegramDeleteScheduledMessageExecutor }, + { tool: telegramSendScheduledNowTool, executor: telegramSendScheduledNowExecutor }, { tool: telegramSearchMessagesTool, executor: telegramSearchMessagesExecutor }, { tool: telegramPinMessageTool, executor: telegramPinMessageExecutor }, { tool: telegramUnpinMessageTool, executor: telegramUnpinMessageExecutor }, diff --git a/src/agent/tools/telegram/messaging/send-scheduled-now.ts b/src/agent/tools/telegram/messaging/send-scheduled-now.ts new file mode 100644 index 0000000..b59d1ad --- /dev/null +++ b/src/agent/tools/telegram/messaging/send-scheduled-now.ts @@ -0,0 +1,69 @@ +import { Type } from "@sinclair/typebox"; +import { Api } from "telegram"; +import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; +import { getErrorMessage } from "../../../../utils/errors.js"; +import { createLogger } from "../../../../utils/logger.js"; + +const log = createLogger("Tools"); + +interface SendScheduledNowParams { + chatId: string; + messageIds: number[]; +} + +export const telegramSendScheduledNowTool: Tool = { + name: "telegram_send_scheduled_now", + description: + "Send one or more scheduled messages immediately instead of waiting for their scheduled time.", + parameters: Type.Object({ + chatId: Type.String({ + description: "The chat ID where the scheduled messages are", + }), + messageIds: Type.Array(Type.Number(), { + description: "Array of scheduled message IDs to send immediately", + minItems: 1, + maxItems: 30, + }), + }), +}; + +export const telegramSendScheduledNowExecutor: ToolExecutor = async ( + params, + context +): Promise => { + try { + const { chatId, messageIds } = params; + const gramJsClient = context.bridge.getClient().getClient(); + const entity = await gramJsClient.getEntity(chatId); + + await gramJsClient.invoke( + new Api.messages.SendScheduledMessages({ + peer: entity, + id: messageIds, + }) + ); + + log.info(`🚀 send_scheduled_now: ${messageIds.length} messages sent in ${chatId}`); + + return { + success: true, + data: { + chatId, + sentIds: messageIds, + sentCount: messageIds.length, + }, + }; + } catch (error: any) { + if (error.errorMessage === "MESSAGE_ID_INVALID") { + return { + success: false, + error: "One or more message IDs are invalid or not scheduled messages.", + }; + } + log.error({ err: error }, "Error sending scheduled messages now"); + return { + success: false, + error: getErrorMessage(error), + }; + } +}; diff --git a/src/agent/tools/telegram/profile/index.ts b/src/agent/tools/telegram/profile/index.ts index bc267d2..91e7c9f 100644 --- a/src/agent/tools/telegram/profile/index.ts +++ b/src/agent/tools/telegram/profile/index.ts @@ -1,14 +1,24 @@ import { telegramUpdateProfileTool, telegramUpdateProfileExecutor } from "./update-profile.js"; import { telegramSetBioTool, telegramSetBioExecutor } from "./set-bio.js"; import { telegramSetUsernameTool, telegramSetUsernameExecutor } from "./set-username.js"; +import { + telegramSetPersonalChannelTool, + telegramSetPersonalChannelExecutor, +} from "./set-personal-channel.js"; import type { ToolEntry } from "../../types.js"; export { telegramUpdateProfileTool, telegramUpdateProfileExecutor }; export { telegramSetBioTool, telegramSetBioExecutor }; export { telegramSetUsernameTool, telegramSetUsernameExecutor }; +export { telegramSetPersonalChannelTool, telegramSetPersonalChannelExecutor }; export const tools: ToolEntry[] = [ { tool: telegramUpdateProfileTool, executor: telegramUpdateProfileExecutor, scope: "dm-only" }, { tool: telegramSetBioTool, executor: telegramSetBioExecutor, scope: "dm-only" }, { tool: telegramSetUsernameTool, executor: telegramSetUsernameExecutor, scope: "dm-only" }, + { + tool: telegramSetPersonalChannelTool, + executor: telegramSetPersonalChannelExecutor, + scope: "dm-only", + }, ]; diff --git a/src/agent/tools/telegram/profile/set-personal-channel.ts b/src/agent/tools/telegram/profile/set-personal-channel.ts new file mode 100644 index 0000000..f77a17a --- /dev/null +++ b/src/agent/tools/telegram/profile/set-personal-channel.ts @@ -0,0 +1,75 @@ +import { Type } from "@sinclair/typebox"; +import { Api } from "telegram"; +import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; +import { getErrorMessage } from "../../../../utils/errors.js"; +import { createLogger } from "../../../../utils/logger.js"; + +const log = createLogger("Tools"); + +interface SetPersonalChannelParams { + channelId?: string; +} + +export const telegramSetPersonalChannelTool: Tool = { + name: "telegram_set_personal_channel", + description: + "Set or remove the personal channel displayed on your Telegram profile. Provide a channel ID to set, or omit to remove.", + parameters: Type.Object({ + channelId: Type.Optional( + Type.String({ + description: "Channel ID or username to set as personal channel. Omit to remove.", + }) + ), + }), +}; + +export const telegramSetPersonalChannelExecutor: ToolExecutor = async ( + params, + context +): Promise => { + try { + const gramJsClient = context.bridge.getClient().getClient(); + + let channel: Api.TypeEntityLike; + let action: "set" | "removed"; + + if (params.channelId) { + channel = await gramJsClient.getEntity(params.channelId); + action = "set"; + } else { + channel = new Api.InputChannelEmpty(); + action = "removed"; + } + + await gramJsClient.invoke(new Api.account.UpdatePersonalChannel({ channel })); + + log.info(`👤 set_personal_channel: ${action} (${params.channelId || "empty"})`); + + return { + success: true, + data: { + action, + channelId: params.channelId || null, + }, + }; + } catch (error: any) { + if (error.errorMessage === "CHANNEL_INVALID") { + return { + success: false, + error: "Invalid channel — make sure you are an admin of this public channel.", + }; + } + if (error.errorMessage === "CHANNELS_ADMIN_PUBLIC_TOO_MUCH") { + return { + success: false, + error: "You administer too many public channels to set a personal channel.", + }; + } + + log.error({ err: error }, "Error setting personal channel"); + return { + success: false, + error: getErrorMessage(error), + }; + } +}; diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index 05bb27a..89e49aa 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -15,6 +15,7 @@ import { } from "../agent/tools/telegram/index.js"; import type { ToolContext } from "../agent/tools/types.js"; import { TELEGRAM_SEND_TOOLS } from "../constants/tools.js"; +import { telegramTranscribeAudioExecutor } from "../agent/tools/telegram/media/transcribe-audio.js"; import { createLogger } from "../utils/logger.js"; const log = createLogger("Telegram"); @@ -357,6 +358,32 @@ export class MessageHandler { } } + // 5c. Auto-transcribe voice/audio messages + let transcriptionText: string | null = null; + if (message.mediaType === "voice" || message.mediaType === "audio") { + try { + const transcribeResult = await telegramTranscribeAudioExecutor( + { chatId: message.chatId, messageId: message.id }, + { + bridge: this.bridge, + db: this.db, + chatId: message.chatId, + senderId: message.senderId, + isGroup: message.isGroup, + config: this.fullConfig, + } + ); + if (transcribeResult.success && (transcribeResult.data as any)?.text) { + transcriptionText = (transcribeResult.data as any).text; + log.info( + `🎤 Auto-transcribed voice msg ${message.id}: "${transcriptionText!.substring(0, 80)}..."` + ); + } + } catch (err) { + log.warn({ err }, `Failed to auto-transcribe voice message ${message.id}`); + } + } + // 6. Build tool context const toolContext: Omit = { bridge: this.bridge, @@ -368,9 +395,13 @@ export class MessageHandler { // 7. Get response from agent (with tools) const userName = message.senderFirstName || message.senderUsername || `user:${message.senderId}`; + // Inject transcription into message text if available + const effectiveText = transcriptionText + ? `🎤 (voice): ${transcriptionText}${message.text ? `\n${message.text}` : ""}` + : message.text; const response = await this.agent.processMessage( message.chatId, - message.text, + effectiveText, userName, message.timestamp.getTime(), message.isGroup, From a6b95bec067879aa56395cec6bb4a5043cf1057a Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 21:33:23 +0100 Subject: [PATCH 23/41] feat(dashboard): gated provider switch with API key validation + shared model catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: changing provider now requires entering and validating the API key before the switch is applied. Providers without keys (claude-code, cocoon, local) switch instantly. Adds provider-meta and validate-api-key endpoints to config routes. Extracts shared model-catalog.ts (60+ models across 11 providers) used by CLI onboard, setup wizard, and dashboard — eliminates ~220 lines of duplicated model options. New/updated models: - Anthropic: +claude-sonnet-4-6 - OpenAI: +gpt-5-pro, +gpt-5-mini, +gpt-5.1, +o4-mini, +codex-mini - Google: +gemini-3-pro-preview, +gemini-3-flash-preview, +gemini-2.5-flash-lite - xAI: +grok-4-1-fast, +grok-code-fast-1 - OpenRouter: +claude-sonnet-4-6, +deepseek-r1-0528, +deepseek-v3.2/v3.1, +qwen3-coder/max/235b, +nemotron-nano-9b, +sonar-pro, +minimax-m2.5 Updates README: tool count 66→73, 11 providers, supported models table, dashboard provider switching, current model names in config example. --- README.md | 43 +++++++--- src/cli/commands/onboard.ts | 112 +----------------------- src/config/model-catalog.ts | 167 ++++++++++++++++++++++++++++++++++++ src/webui/routes/config.ts | 57 ++++++++++++ src/webui/routes/setup.ts | 111 +----------------------- web/src/index.css | 10 +++ web/src/lib/api.ts | 15 ++++ web/src/pages/Dashboard.tsx | 155 +++++++++++++++++++++++++++++++-- 8 files changed, 431 insertions(+), 239 deletions(-) create mode 100644 src/config/model-catalog.ts diff --git a/README.md b/README.md index f51ce35..c338032 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ - **Full Telegram access** - Operates as a real user via MTProto (GramJS), not a limited bot - **Agentic loop** - Up to 5 iterations of tool calling per message, the agent thinks, acts, observes, and repeats -- **Multi-Provider LLM** - Anthropic, OpenAI, Google Gemini, xAI Grok, Groq, OpenRouter, Moonshot, Mistral, Cocoon, Local +- **Multi-Provider LLM** - Anthropic, Claude Code, OpenAI, Google Gemini, xAI Grok, Groq, OpenRouter, Moonshot, Mistral, Cocoon, Local (11 providers) - **TON Blockchain** - Built-in W5R1 wallet, send/receive TON & jettons, swap on STON.fi and DeDust, NFTs, DNS domains - **Persistent memory** - Hybrid RAG (sqlite-vec + FTS5), auto-compaction with AI summarization, daily logs - **100+ built-in tools** - Messaging, media, blockchain, DEX trading, deals, DNS, journaling, and more @@ -37,7 +37,7 @@ | Category | Tools | Description | | ------------- | ----- | ------------------------------------------------------------------------------------------------------------------ | -| Telegram | 66 | Messaging, media, chats, groups, polls, stickers, gifts, stars, stories, contacts, folders, profile, memory, tasks | +| Telegram | 73 | Messaging, media, chats, groups, polls, stickers, gifts, stars, stories, contacts, folders, profile, memory, tasks, voice transcription, scheduled messages | | TON & Jettons | 15 | W5R1 wallet, send/receive TON & jettons, balances, prices, holders, history, charts, NFTs, smart DEX router | | STON.fi DEX | 5 | Swap, quote, search, trending tokens, liquidity pools | | DeDust DEX | 5 | Swap, quote, pools, prices, token analytics (holders, top traders, buy/sell tax) | @@ -51,7 +51,7 @@ | Capability | Description | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| **Multi-Provider LLM** | Switch between Anthropic, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cocoon, or Local with one config change | +| **Multi-Provider LLM** | Switch between Anthropic, Claude Code, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cocoon, or Local — Dashboard validates API key before switching | | **RAG + Hybrid Search** | Local ONNX embeddings (384d) or Voyage AI (512d/1024d) with FTS5 keyword + sqlite-vec cosine similarity, fused via RRF | | **Auto-Compaction** | AI-summarized context management prevents overflow, preserves key information in `memory/*.md` files | | **Observation Masking** | Compresses old tool results to one-line summaries, saving ~90% context window | @@ -71,7 +71,7 @@ ## Prerequisites - **Node.js 20.0.0+** - [Download](https://nodejs.org/) -- **LLM API Key** - One of: [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/) +- **LLM API Key** - One of: [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/), [Moonshot](https://platform.moonshot.ai/), [Mistral](https://console.mistral.ai/) — or keyless: Claude Code (auto-detect), Cocoon (TON), Local (Ollama/vLLM) - **Telegram Account** - Dedicated account recommended for security - **Telegram API Credentials** - From [my.telegram.org/apps](https://my.telegram.org/apps) - **Your Telegram User ID** - Message [@userinfobot](https://t.me/userinfobot) @@ -109,7 +109,7 @@ teleton setup ``` The wizard will configure: -- LLM provider selection (Anthropic, OpenAI, Google, xAI, Groq, OpenRouter) +- LLM provider selection (11 providers: Anthropic, Claude Code, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cocoon, Local) - Telegram authentication (API credentials, phone, login code) - Access policies (DM/group response rules) - Admin user ID @@ -152,10 +152,10 @@ The `teleton setup` wizard generates a fully configured `~/.teleton/config.yaml` ```yaml agent: - provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter + provider: "anthropic" # anthropic | claude-code | openai | google | xai | groq | openrouter | moonshot | mistral | cocoon | local api_key: "sk-ant-api03-..." - model: "claude-opus-4-5-20251101" - utility_model: "claude-3-5-haiku-20241022" # for summarization, compaction, vision + model: "claude-opus-4-6" + utility_model: "claude-haiku-4-5-20251001" # for summarization, compaction, vision max_agentic_iterations: 5 telegram: @@ -183,6 +183,24 @@ webui: # Optional: Web dashboard # auth_token: "..." # Auto-generated if omitted ``` +### Supported Models + +All models are defined in `src/config/model-catalog.ts` and shared across the CLI setup, WebUI setup wizard, and Dashboard. To add a model, add it there — it will appear everywhere automatically. + +| Provider | Models | +|----------|--------| +| **Anthropic** | Claude Opus 4.6, Claude Opus 4.5, Claude Sonnet 4.6, Claude Haiku 4.5 | +| **Claude Code** | Same as Anthropic (auto-detected credentials) | +| **OpenAI** | GPT-5, GPT-5 Pro, GPT-5 Mini, GPT-5.1, GPT-4o, GPT-4.1, GPT-4.1 Mini, o4 Mini, o3, Codex Mini | +| **Google** | Gemini 3 Pro (preview), Gemini 3 Flash (preview), Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash Lite, Gemini 2.0 Flash | +| **xAI** | Grok 4.1 Fast, Grok 4 Fast, Grok 4, Grok Code, Grok 3 | +| **Groq** | Llama 4 Maverick, Qwen3 32B, DeepSeek R1 70B, Llama 3.3 70B | +| **OpenRouter** | Claude Opus/Sonnet, GPT-5, Gemini, DeepSeek R1/V3, Qwen3 Coder/Max/235B, Nemotron, Sonar Pro, MiniMax, Grok 4 | +| **Moonshot** | Kimi K2.5, Kimi K2 Thinking | +| **Mistral** | Devstral Small/Medium, Mistral Large, Magistral Small | +| **Cocoon** | Qwen/Qwen3-32B (decentralized, pays in TON) | +| **Local** | Auto-detected (Ollama, vLLM, LM Studio) | + ### MCP Servers Connect external tool servers via the [Model Context Protocol](https://modelcontextprotocol.io/). No code needed - tools are auto-discovered and registered at startup. @@ -283,7 +301,7 @@ Teleton includes an **optional web dashboard** for monitoring and configuration. ### Features -- **Dashboard**: System status, uptime, model info, session count, memory stats +- **Dashboard**: System status, uptime, model info, session count, memory stats, provider switching with API key validation - **Tools Management**: View all tools grouped by module, toggle enable/disable, change scope per tool - **Plugin Marketplace**: Install, update, and manage plugins from registry with secrets management - **Soul Editor**: Edit SOUL.md, SECURITY.md, STRATEGY.md, MEMORY.md with unsaved changes warning @@ -386,7 +404,7 @@ All admin commands support `/`, `!`, or `.` prefix: | Layer | Technology | |-------|------------| -| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (Anthropic, OpenAI, Google, xAI, Groq, OpenRouter) | +| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (11 providers: Anthropic, Claude Code, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cocoon, Local) | | Telegram Userbot | [GramJS](https://gram.js.org/) (MTProto) | | Inline Bot | [Grammy](https://grammy.dev/) (Bot API, for deals) | | Blockchain | [TON SDK](https://github.com/ton-org/ton) (W5R1 wallet) | @@ -414,7 +432,7 @@ src/ │ ├── module-loader.ts # Built-in module loading (deals → +5 tools) │ ├── plugin-loader.ts # External plugin discovery, validation, hot-reload │ ├── mcp-loader.ts # MCP client (stdio/SSE), tool discovery, lifecycle -│ ├── telegram/ # Telegram operations (66 tools) +│ ├── telegram/ # Telegram operations (73 tools) │ ├── ton/ # TON blockchain + jettons + DEX router (15 tools) │ ├── stonfi/ # STON.fi DEX (5 tools) │ ├── dedust/ # DeDust DEX (5 tools) @@ -463,7 +481,8 @@ src/ │ └── loader.ts # 10 sections: soul + security + strategy + memory + context + ... ├── config/ # Configuration │ ├── schema.ts # Zod schemas + validation -│ └── providers.ts # Multi-provider LLM registry (10 providers) +│ ├── providers.ts # Multi-provider LLM registry (11 providers) +│ └── model-catalog.ts # Shared model catalog (60+ models across all providers) ├── webui/ # Optional web dashboard │ ├── server.ts # Hono server, auth middleware, static serving │ └── routes/ # 11 API route groups (status, tools, logs, memory, soul, plugins, mcp, tasks, workspace, config, marketplace) diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index c498055..f6c28f3 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -36,6 +36,7 @@ import { TELETON_ROOT } from "../../workspace/paths.js"; import { TelegramUserClient } from "../../telegram/client.js"; import YAML from "yaml"; import { type Config, DealsConfigSchema } from "../../config/schema.js"; +import { getModelsForProvider } from "../../config/model-catalog.js"; import { generateWallet, importWallet, @@ -97,113 +98,7 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -// ── Model catalogs (per provider) ───────────────────────────────────── - -const MODEL_OPTIONS: Record> = { - anthropic: [ - { - value: "claude-opus-4-6", - name: "Claude Opus 4.6", - description: "Most capable, 1M ctx, $5/M", - }, - { - value: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", - description: "Previous gen, 200K ctx, $5/M", - }, - { value: "claude-sonnet-4-0", name: "Claude Sonnet 4", description: "Balanced, $3/M" }, - { - value: "claude-haiku-4-5-20251001", - name: "Claude Haiku 4.5", - description: "Fast & cheap, $1/M", - }, - { - value: "claude-haiku-4-5-20251001", - name: "Claude Haiku 4.5", - description: "Fast & cheap, $1/M", - }, - ], - openai: [ - { value: "gpt-5", name: "GPT-5", description: "Most capable, 400K ctx, $1.25/M" }, - { value: "gpt-4o", name: "GPT-4o", description: "Balanced, 128K ctx, $2.50/M" }, - { value: "gpt-4.1", name: "GPT-4.1", description: "1M ctx, $2/M" }, - { value: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "1M ctx, cheap, $0.40/M" }, - { value: "o3", name: "o3", description: "Reasoning, 200K ctx, $2/M" }, - ], - google: [ - { value: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "Fast, 1M ctx, $0.30/M" }, - { - value: "gemini-2.5-pro", - name: "Gemini 2.5 Pro", - description: "Most capable, 1M ctx, $1.25/M", - }, - { value: "gemini-2.0-flash", name: "Gemini 2.0 Flash", description: "Cheap, 1M ctx, $0.10/M" }, - ], - xai: [ - { value: "grok-4-fast", name: "Grok 4 Fast", description: "Vision, 2M ctx, $0.20/M" }, - { value: "grok-4", name: "Grok 4", description: "Reasoning, 256K ctx, $3/M" }, - { value: "grok-3", name: "Grok 3", description: "Stable, 131K ctx, $3/M" }, - ], - groq: [ - { - value: "meta-llama/llama-4-maverick-17b-128e-instruct", - name: "Llama 4 Maverick", - description: "Vision, 131K ctx, $0.20/M", - }, - { value: "qwen/qwen3-32b", name: "Qwen3 32B", description: "Reasoning, 131K ctx, $0.29/M" }, - { - value: "deepseek-r1-distill-llama-70b", - name: "DeepSeek R1 70B", - description: "Reasoning, 131K ctx, $0.75/M", - }, - { - value: "llama-3.3-70b-versatile", - name: "Llama 3.3 70B", - description: "General purpose, 131K ctx, $0.59/M", - }, - ], - openrouter: [ - { value: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", description: "200K ctx, $5/M" }, - { value: "openai/gpt-5", name: "GPT-5", description: "400K ctx, $1.25/M" }, - { value: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "1M ctx, $0.30/M" }, - { - value: "deepseek/deepseek-r1", - name: "DeepSeek R1", - description: "Reasoning, 64K ctx, $0.70/M", - }, - { value: "x-ai/grok-4", name: "Grok 4", description: "256K ctx, $3/M" }, - ], - moonshot: [ - { value: "kimi-k2.5", name: "Kimi K2.5", description: "Free, 256K ctx, multimodal" }, - { - value: "kimi-k2-thinking", - name: "Kimi K2 Thinking", - description: "Free, 256K ctx, reasoning", - }, - ], - mistral: [ - { - value: "devstral-small-2507", - name: "Devstral Small", - description: "Coding, 128K ctx, $0.10/M", - }, - { - value: "devstral-medium-latest", - name: "Devstral Medium", - description: "Coding, 262K ctx, $0.40/M", - }, - { - value: "mistral-large-latest", - name: "Mistral Large", - description: "General, 128K ctx, $2/M", - }, - { - value: "magistral-small", - name: "Magistral Small", - description: "Reasoning, 128K ctx, $0.50/M", - }, - ], -}; +// Model catalog imported from shared source (see src/config/model-catalog.ts) /** * Main onboard command @@ -654,8 +549,7 @@ async function runInteractiveOnboarding( selectedProvider !== "cocoon" && selectedProvider !== "local" ) { - const modelKey = selectedProvider === "claude-code" ? "anthropic" : selectedProvider; - const providerModels = MODEL_OPTIONS[modelKey] || []; + const providerModels = getModelsForProvider(selectedProvider); const modelChoices = [ ...providerModels, { value: "__custom__", name: "Custom", description: "Enter a model ID manually" }, diff --git a/src/config/model-catalog.ts b/src/config/model-catalog.ts new file mode 100644 index 0000000..18cd7f5 --- /dev/null +++ b/src/config/model-catalog.ts @@ -0,0 +1,167 @@ +/** + * Shared model catalog used by WebUI setup, CLI onboard, and config routes. + * To add a model, add it here — it will appear in all UIs automatically. + * Models must exist in pi-ai's registry (or be entered as custom). + */ + +export interface ModelOption { + value: string; + name: string; + description: string; +} + +export const MODEL_OPTIONS: Record = { + anthropic: [ + { + value: "claude-opus-4-6", + name: "Claude Opus 4.6", + description: "Most capable, 1M ctx, $5/M", + }, + { + value: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + description: "Previous gen, 200K ctx, $5/M", + }, + { + value: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + description: "Balanced, 200K ctx, $3/M", + }, + { + value: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5", + description: "Fast & cheap, $1/M", + }, + ], + openai: [ + { value: "gpt-5", name: "GPT-5", description: "Most capable, 400K ctx, $1.25/M" }, + { value: "gpt-5-pro", name: "GPT-5 Pro", description: "Extended thinking, 400K ctx" }, + { value: "gpt-5-mini", name: "GPT-5 Mini", description: "Fast & cheap, 400K ctx" }, + { value: "gpt-5.1", name: "GPT-5.1", description: "Latest gen, 400K ctx" }, + { value: "gpt-4o", name: "GPT-4o", description: "Balanced, 128K ctx, $2.50/M" }, + { value: "gpt-4.1", name: "GPT-4.1", description: "1M ctx, $2/M" }, + { value: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "1M ctx, cheap, $0.40/M" }, + { value: "o4-mini", name: "o4 Mini", description: "Reasoning, fast, 200K ctx" }, + { value: "o3", name: "o3", description: "Reasoning, 200K ctx, $2/M" }, + { value: "codex-mini-latest", name: "Codex Mini", description: "Coding specialist" }, + ], + google: [ + { value: "gemini-3-pro-preview", name: "Gemini 3 Pro", description: "Preview, most capable" }, + { value: "gemini-3-flash-preview", name: "Gemini 3 Flash", description: "Preview, fast" }, + { value: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Stable, 1M ctx, $1.25/M" }, + { value: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "Fast, 1M ctx, $0.30/M" }, + { + value: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash Lite", + description: "Ultra cheap, 1M ctx", + }, + { value: "gemini-2.0-flash", name: "Gemini 2.0 Flash", description: "Cheap, 1M ctx, $0.10/M" }, + ], + xai: [ + { value: "grok-4-1-fast", name: "Grok 4.1 Fast", description: "Latest, vision, 2M ctx" }, + { value: "grok-4-fast", name: "Grok 4 Fast", description: "Vision, 2M ctx, $0.20/M" }, + { value: "grok-4", name: "Grok 4", description: "Reasoning, 256K ctx, $3/M" }, + { value: "grok-code-fast-1", name: "Grok Code", description: "Coding specialist, fast" }, + { value: "grok-3", name: "Grok 3", description: "Stable, 131K ctx, $3/M" }, + ], + groq: [ + { + value: "meta-llama/llama-4-maverick-17b-128e-instruct", + name: "Llama 4 Maverick", + description: "Vision, 131K ctx, $0.20/M", + }, + { value: "qwen/qwen3-32b", name: "Qwen3 32B", description: "Reasoning, 131K ctx, $0.29/M" }, + { + value: "deepseek-r1-distill-llama-70b", + name: "DeepSeek R1 70B", + description: "Reasoning, 131K ctx, $0.75/M", + }, + { + value: "llama-3.3-70b-versatile", + name: "Llama 3.3 70B", + description: "General purpose, 131K ctx, $0.59/M", + }, + ], + openrouter: [ + { value: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", description: "200K ctx, $5/M" }, + { + value: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + description: "200K ctx, $3/M", + }, + { value: "openai/gpt-5", name: "GPT-5", description: "400K ctx, $1.25/M" }, + { value: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "1M ctx, $0.30/M" }, + { + value: "deepseek/deepseek-r1", + name: "DeepSeek R1", + description: "Reasoning, 64K ctx, $0.70/M", + }, + { + value: "deepseek/deepseek-r1-0528", + name: "DeepSeek R1 0528", + description: "Reasoning improved, 64K ctx", + }, + { + value: "deepseek/deepseek-v3.2", + name: "DeepSeek V3.2", + description: "Latest, general, 64K ctx", + }, + { value: "deepseek/deepseek-v3.1", name: "DeepSeek V3.1", description: "General, 64K ctx" }, + { + value: "deepseek/deepseek-v3-0324", + name: "DeepSeek V3", + description: "General, 64K ctx, $0.30/M", + }, + { value: "qwen/qwen3-coder", name: "Qwen3 Coder", description: "Coding specialist" }, + { value: "qwen/qwen3-max", name: "Qwen3 Max", description: "Most capable Qwen" }, + { value: "qwen/qwen3-235b-a22b", name: "Qwen3 235B", description: "235B params, MoE" }, + { + value: "nvidia/nemotron-nano-9b-v2", + name: "Nemotron Nano 9B", + description: "Small & fast, Nvidia", + }, + { + value: "perplexity/sonar-pro", + name: "Perplexity Sonar Pro", + description: "Web search integrated", + }, + { value: "minimax/minimax-m2.5", name: "MiniMax M2.5", description: "Latest MiniMax" }, + { value: "x-ai/grok-4", name: "Grok 4", description: "256K ctx, $3/M" }, + ], + moonshot: [ + { value: "kimi-k2.5", name: "Kimi K2.5", description: "Free, 256K ctx, multimodal" }, + { + value: "kimi-k2-thinking", + name: "Kimi K2 Thinking", + description: "Free, 256K ctx, reasoning", + }, + ], + mistral: [ + { + value: "devstral-small-2507", + name: "Devstral Small", + description: "Coding, 128K ctx, $0.10/M", + }, + { + value: "devstral-medium-latest", + name: "Devstral Medium", + description: "Coding, 262K ctx, $0.40/M", + }, + { + value: "mistral-large-latest", + name: "Mistral Large", + description: "General, 128K ctx, $2/M", + }, + { + value: "magistral-small", + name: "Magistral Small", + description: "Reasoning, 128K ctx, $0.50/M", + }, + ], +}; + +/** Get models for a provider (claude-code maps to anthropic) */ +export function getModelsForProvider(provider: string): ModelOption[] { + const key = provider === "claude-code" ? "anthropic" : provider; + return MODEL_OPTIONS[key] || []; +} diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index da4845f..2d88647 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -9,6 +9,12 @@ import { writeRawConfig, } from "../../config/configurable-keys.js"; import type { ConfigKeyType, ConfigCategory } from "../../config/configurable-keys.js"; +import { getModelsForProvider } from "../../config/model-catalog.js"; +import { + getProviderMetadata, + validateApiKeyFormat, + type SupportedProvider, +} from "../../config/providers.js"; interface ConfigKeyData { key: string; @@ -164,5 +170,56 @@ export function createConfigRoutes(deps: WebUIServerDeps) { } }); + // Get model options for a provider + app.get("/models/:provider", (c) => { + const provider = c.req.param("provider"); + const models = getModelsForProvider(provider); + return c.json({ success: true, data: models } as APIResponse); + }); + + // Get provider metadata (for API key UX) + app.get("/provider-meta/:provider", (c) => { + const provider = c.req.param("provider"); + try { + const meta = getProviderMetadata(provider as SupportedProvider); + const needsKey = provider !== "claude-code" && provider !== "cocoon" && provider !== "local"; + return c.json({ + success: true, + data: { + needsKey, + keyHint: meta.keyHint, + keyPrefix: meta.keyPrefix, + consoleUrl: meta.consoleUrl, + displayName: meta.displayName, + }, + } as APIResponse); + } catch (err) { + return c.json( + { success: false, error: err instanceof Error ? err.message : String(err) } as APIResponse, + 400 + ); + } + }); + + // Validate an API key format for a provider + app.post("/validate-api-key", async (c) => { + try { + const body = await c.req.json<{ provider: string; apiKey: string }>(); + if (!body.provider || !body.apiKey) { + return c.json({ success: false, error: "Missing provider or apiKey" } as APIResponse, 400); + } + const error = validateApiKeyFormat(body.provider as SupportedProvider, body.apiKey); + return c.json({ + success: true, + data: { valid: !error, error: error ?? null }, + } as APIResponse); + } catch (err) { + return c.json( + { success: false, error: err instanceof Error ? err.message : String(err) } as APIResponse, + 400 + ); + } + }); + return app; } diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts index 5a8292f..2e0aac9 100644 --- a/src/webui/routes/setup.ts +++ b/src/webui/routes/setup.ts @@ -38,113 +38,7 @@ import { createLogger } from "../../utils/logger.js"; const log = createLogger("Setup"); -// ── Model catalog (same as CLI onboard.ts) ──────────────────────────── - -const MODEL_OPTIONS: Record> = { - anthropic: [ - { - value: "claude-opus-4-6", - name: "Claude Opus 4.6", - description: "Most capable, 1M ctx, $5/M", - }, - { - value: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", - description: "Previous gen, 200K ctx, $5/M", - }, - { value: "claude-sonnet-4-0", name: "Claude Sonnet 4", description: "Balanced, $3/M" }, - { - value: "claude-haiku-4-5-20251001", - name: "Claude Haiku 4.5", - description: "Fast & cheap, $1/M", - }, - { - value: "claude-haiku-4-5-20251001", - name: "Claude Haiku 4.5", - description: "Fast & cheap, $1/M", - }, - ], - openai: [ - { value: "gpt-5", name: "GPT-5", description: "Most capable, 400K ctx, $1.25/M" }, - { value: "gpt-4o", name: "GPT-4o", description: "Balanced, 128K ctx, $2.50/M" }, - { value: "gpt-4.1", name: "GPT-4.1", description: "1M ctx, $2/M" }, - { value: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "1M ctx, cheap, $0.40/M" }, - { value: "o3", name: "o3", description: "Reasoning, 200K ctx, $2/M" }, - ], - google: [ - { value: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "Fast, 1M ctx, $0.30/M" }, - { - value: "gemini-2.5-pro", - name: "Gemini 2.5 Pro", - description: "Most capable, 1M ctx, $1.25/M", - }, - { value: "gemini-2.0-flash", name: "Gemini 2.0 Flash", description: "Cheap, 1M ctx, $0.10/M" }, - ], - xai: [ - { value: "grok-4-fast", name: "Grok 4 Fast", description: "Vision, 2M ctx, $0.20/M" }, - { value: "grok-4", name: "Grok 4", description: "Reasoning, 256K ctx, $3/M" }, - { value: "grok-3", name: "Grok 3", description: "Stable, 131K ctx, $3/M" }, - ], - groq: [ - { - value: "meta-llama/llama-4-maverick-17b-128e-instruct", - name: "Llama 4 Maverick", - description: "Vision, 131K ctx, $0.20/M", - }, - { value: "qwen/qwen3-32b", name: "Qwen3 32B", description: "Reasoning, 131K ctx, $0.29/M" }, - { - value: "deepseek-r1-distill-llama-70b", - name: "DeepSeek R1 70B", - description: "Reasoning, 131K ctx, $0.75/M", - }, - { - value: "llama-3.3-70b-versatile", - name: "Llama 3.3 70B", - description: "General purpose, 131K ctx, $0.59/M", - }, - ], - openrouter: [ - { value: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", description: "200K ctx, $5/M" }, - { value: "openai/gpt-5", name: "GPT-5", description: "400K ctx, $1.25/M" }, - { value: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "1M ctx, $0.30/M" }, - { - value: "deepseek/deepseek-r1", - name: "DeepSeek R1", - description: "Reasoning, 64K ctx, $0.70/M", - }, - { value: "x-ai/grok-4", name: "Grok 4", description: "256K ctx, $3/M" }, - ], - moonshot: [ - { value: "kimi-k2.5", name: "Kimi K2.5", description: "Free, 256K ctx, multimodal" }, - { - value: "kimi-k2-thinking", - name: "Kimi K2 Thinking", - description: "Free, 256K ctx, reasoning", - }, - ], - mistral: [ - { - value: "devstral-small-2507", - name: "Devstral Small", - description: "Coding, 128K ctx, $0.10/M", - }, - { - value: "devstral-medium-latest", - name: "Devstral Medium", - description: "Coding, 262K ctx, $0.40/M", - }, - { - value: "mistral-large-latest", - name: "Mistral Large", - description: "General, 128K ctx, $2/M", - }, - { - value: "magistral-small", - name: "Magistral Small", - description: "Reasoning, 128K ctx, $0.50/M", - }, - ], -}; +import { getModelsForProvider } from "../../config/model-catalog.js"; // ── Helpers ──────────────────────────────────────────────────────────── @@ -215,8 +109,7 @@ export function createSetupRoutes(): Hono { // ── GET /models/:provider ───────────────────────────────────────── app.get("/models/:provider", (c) => { const provider = c.req.param("provider"); - const modelKey = provider === "claude-code" ? "anthropic" : provider; - const models = MODEL_OPTIONS[modelKey] || []; + const models = getModelsForProvider(provider); const result = [ ...models, { diff --git a/web/src/index.css b/web/src/index.css index 90cf27d..51a0304 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1466,6 +1466,16 @@ button.btn-lg { to { opacity: 1; } } +/* ---- Provider Switch Zone ---- */ + +.provider-switch-zone { + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--glass-border-strong); + border-radius: var(--radius-sm); + animation: step-fade-in 150ms ease-out; +} + /* ---- Loading spinner ---- */ .spinner { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 17119db..fe14ed9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -482,6 +482,21 @@ export const api = { }); }, + async getModelsForProvider(provider: string) { + return fetchAPI>>(`/config/models/${encodeURIComponent(provider)}`); + }, + + async getProviderMeta(provider: string) { + return fetchAPI>(`/config/provider-meta/${encodeURIComponent(provider)}`); + }, + + async validateApiKey(provider: string, apiKey: string) { + return fetchAPI>('/config/validate-api-key', { + method: 'POST', + body: JSON.stringify({ provider, apiKey }), + }); + }, + async getMarketplace(_refresh = false) { const qs = _refresh ? '?refresh=true' : ''; return fetchAPI>(`/marketplace${qs}`); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 1c8000d..c45c809 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -2,6 +2,14 @@ import { useEffect, useState, useCallback } from 'react'; import { api, StatusData, MemoryStats, ToolRagStatus } from '../lib/api'; import { Select } from '../components/Select'; +interface ProviderMeta { + needsKey: boolean; + keyHint: string; + keyPrefix: string | null; + consoleUrl: string; + displayName: string; +} + export function Dashboard() { const [status, setStatus] = useState(null); const [stats, setStats] = useState(null); @@ -9,6 +17,14 @@ export function Dashboard() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [saveSuccess, setSaveSuccess] = useState(null); + const [modelOptions, setModelOptions] = useState>([]); + + // Provider switch gating state + const [pendingProvider, setPendingProvider] = useState(null); + const [pendingMeta, setPendingMeta] = useState(null); + const [pendingApiKey, setPendingApiKey] = useState(''); + const [pendingValidating, setPendingValidating] = useState(false); + const [pendingError, setPendingError] = useState(null); // Local input state — decoupled from server values to avoid sending empty/partial values const [localInputs, setLocalInputs] = useState>({}); @@ -39,6 +55,81 @@ export function Dashboard() { const getLocal = (key: string): string => localInputs[key] ?? ''; + // Load model options when provider changes + const currentProvider = getLocal('agent.provider'); + useEffect(() => { + if (!currentProvider) return; + api.getModelsForProvider(currentProvider).then((res) => { + const models = res.data.map((m) => ({ value: m.value, name: m.name })); + setModelOptions(models); + // Auto-select first model if current model isn't in the new list + const currentModel = localInputs['agent.model'] ?? ''; + if (models.length > 0 && !models.some((m) => m.value === currentModel)) { + saveConfig('agent.model', models[0].value); + } + }).catch(() => setModelOptions([])); + }, [currentProvider]); + + // Handle provider change — gate on API key + const handleProviderChange = async (newProvider: string) => { + if (newProvider === currentProvider) return; + try { + const res = await api.getProviderMeta(newProvider); + const meta = res.data; + if (!meta.needsKey) { + // No key needed — save directly + await saveConfig('agent.provider', newProvider); + setPendingProvider(null); + setPendingMeta(null); + } else { + // Show the gated transition zone + setPendingProvider(newProvider); + setPendingMeta(meta); + setPendingApiKey(''); + setPendingError(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleProviderConfirm = async () => { + if (!pendingProvider || !pendingMeta) return; + if (pendingMeta.needsKey && !pendingApiKey.trim()) { + setPendingError('API key is required'); + return; + } + setPendingValidating(true); + setPendingError(null); + try { + // Validate API key format + const valRes = await api.validateApiKey(pendingProvider, pendingApiKey); + if (!valRes.data.valid) { + setPendingError(valRes.data.error || 'Invalid API key'); + setPendingValidating(false); + return; + } + // Save provider + API key + await api.setConfigKey('agent.api_key', pendingApiKey.trim()); + await saveConfig('agent.provider', pendingProvider); + setPendingProvider(null); + setPendingMeta(null); + setPendingApiKey(''); + showSuccess(`Switched to ${pendingMeta.displayName}`); + } catch (err) { + setPendingError(err instanceof Error ? err.message : String(err)); + } finally { + setPendingValidating(false); + } + }; + + const handleProviderCancel = () => { + setPendingProvider(null); + setPendingMeta(null); + setPendingApiKey(''); + setPendingError(null); + }; + const setLocal = (key: string, value: string) => { setLocalInputs((prev) => ({ ...prev, [key]: value })); }; @@ -139,20 +230,66 @@ export function Dashboard() {
{ setPendingApiKey(e.target.value); setPendingError(null); }} + onKeyDown={(e) => e.key === 'Enter' && handleProviderConfirm()} + style={{ width: '100%' }} + autoFocus + /> + {pendingMeta.consoleUrl && ( + + Get key at {new URL(pendingMeta.consoleUrl).hostname} ↗ + + )} +
+ )} + {pendingError && ( +
+ {pendingError} +
+ )} +
+ + +
+
+ )} +
- setLocal('agent.model', e.target.value)} - onBlur={(e) => saveConfig('agent.model', e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && saveConfig('agent.model', e.currentTarget.value)} - style={{ width: '100%' }} + options={modelOptions.map((m) => m.value)} + labels={modelOptions.map((m) => m.name)} + onChange={(v) => saveConfig('agent.model', v)} />
From 59fd7c0a877d4e4a85d79db71b49435ff96822eb Mon Sep 17 00:00:00 2001 From: TONresistor Date: Tue, 24 Feb 2026 21:49:57 +0100 Subject: [PATCH 24/41] fix(setup): move model selection into provider step + fix async log pollution Model selection was in a separate "Config" step, far from provider/API key selection. Users expect to pick their model right after choosing a provider. Moved it into the Provider step (CLI advanced mode + WebUI). Also fixes a bug where pino-pretty's async worker thread would emit workspace creation logs after the inquirer prompt was already rendered, corrupting the CLI display and requiring an extra Enter press. Added silent option to ensureWorkspace to suppress logs during the interactive wizard. --- .../plans/2026-02-24-setup-model-ux-design.md | 51 ++++++++++++ src/cli/commands/onboard.ts | 80 ++++++++++--------- src/workspace/manager.ts | 14 ++-- web/src/components/setup/ConfigStep.tsx | 55 +------------ web/src/components/setup/ProviderStep.tsx | 50 +++++++++++- web/src/pages/Dashboard.tsx | 4 +- 6 files changed, 157 insertions(+), 97 deletions(-) create mode 100644 docs/plans/2026-02-24-setup-model-ux-design.md diff --git a/docs/plans/2026-02-24-setup-model-ux-design.md b/docs/plans/2026-02-24-setup-model-ux-design.md new file mode 100644 index 0000000..9bebd0a --- /dev/null +++ b/docs/plans/2026-02-24-setup-model-ux-design.md @@ -0,0 +1,51 @@ +# Design: Move Model Selection Into Provider Step + +**Date**: 2026-02-24 +**Status**: Approved +**Scope**: CLI onboard wizard + WebUI setup wizard + +## Problem + +The setup wizard (CLI and WebUI) separates provider selection (Step 1) from model selection (Step 3 "Config"). Users expect to choose the model right after selecting the provider and entering the API key. The current flow forces them through unrelated steps (Telegram credentials) before reaching model selection, which is confusing. + +## Decision + +Move the model selector from Step 3 ("Config") into Step 1 ("Provider"), immediately after API key entry. This applies to advanced mode only; QuickStart continues to use the provider's default model. + +## Changes + +### CLI (`src/cli/commands/onboard.ts`) + +- Move the model selection block (select + custom input) from Step 3 into Step 1, after API key entry +- Guard with `selectedFlow === "advanced"` and `provider !== "cocoon" && provider !== "local"` (unchanged) +- Update STEPS labels: Step 1 desc becomes `"LLM, key & model"`, Step 3 desc becomes `"Policies"` +- Step 3 retains: DM policy, group policy, require mention, max agentic iterations + +### WebUI Frontend + +- **`ProviderStep.tsx`**: Add model selector after API key input. Fetch `GET /models/:provider` when provider changes. Show dropdown with custom option. +- **`ConfigStep.tsx`**: Remove model selector section. +- **`SetupContext.tsx`**: Step 1 validation checks that model is set (or falls back to provider default). + +### Backend (`src/webui/routes/setup.ts`) + +No changes. Existing endpoints `GET /providers` and `GET /models/:provider` are sufficient. + +## Flow After Change + +``` +Step 0: Agent — name, mode (quick/advanced) +Step 1: Provider — provider, API key, model (advanced only) +Step 2: Telegram — API ID, hash, phone, user ID +Step 3: Config — policies (DM, group, mention, max iterations) +Step 4: Modules — deals bot, TonAPI, Tavily +Step 5: Wallet — generate/import TON wallet +Step 6: Connect — Telegram authentication +``` + +## Edge Cases + +- **Providers without model selection** (cocoon, local): No model selector shown — unchanged +- **claude-code provider**: Uses `anthropic` model catalog — unchanged +- **Custom model**: "Custom" option with free text input — unchanged +- **QuickStart mode**: Skips model selection, uses provider default — unchanged diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index f6c28f3..6def71c 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -77,9 +77,9 @@ export interface OnboardOptions { const STEPS: StepDef[] = [ { label: "Agent", desc: "Name & mode" }, - { label: "Provider", desc: "LLM & API key" }, + { label: "Provider", desc: "LLM, key & model" }, { label: "Telegram", desc: "Credentials" }, - { label: "Config", desc: "Model & policies" }, + { label: "Config", desc: "Policies" }, { label: "Modules", desc: "Optional features" }, { label: "Wallet", desc: "TON blockchain" }, { label: "Connect", desc: "Telegram auth" }, @@ -242,6 +242,7 @@ async function runInteractiveOnboarding( const workspace = await ensureWorkspace({ workspaceDir: options.workspace, ensureTemplates: true, + silent: true, }); const isNew = isNewWorkspace(workspace); spinner.succeed(DIM(`Workspace: ${workspace.root}`)); @@ -460,6 +461,42 @@ async function runInteractiveOnboarding( STEPS[1].value = `${providerMeta.displayName} ${DIM(maskedKey)}`; } + // Model selection (advanced mode only, after provider + API key) + selectedModel = providerMeta.defaultModel; + + if ( + selectedFlow === "advanced" && + selectedProvider !== "cocoon" && + selectedProvider !== "local" + ) { + const providerModels = getModelsForProvider(selectedProvider); + const modelChoices = [ + ...providerModels, + { value: "__custom__", name: "Custom", description: "Enter a model ID manually" }, + ]; + + const modelChoice = await select({ + message: "Model", + default: providerMeta.defaultModel, + theme, + choices: modelChoices, + }); + + if (modelChoice === "__custom__") { + const customModel = await input({ + message: "Model ID", + default: providerMeta.defaultModel, + theme, + }); + if (customModel?.trim()) selectedModel = customModel.trim(); + } else { + selectedModel = modelChoice; + } + + const modelLabel = providerModels.find((m) => m.value === selectedModel)?.name ?? selectedModel; + STEPS[1].value = `${STEPS[1].value ?? providerMeta.displayName}, ${modelLabel}`; + } + // ════════════════════════════════════════════════════════════════════ // Step 2: Telegram — credentials // ════════════════════════════════════════════════════════════════════ @@ -538,41 +575,11 @@ async function runInteractiveOnboarding( STEPS[2].value = `${phone} (ID: ${userId})`; // ════════════════════════════════════════════════════════════════════ - // Step 3: Config — model + policies (advanced only) + // Step 3: Config — policies (advanced only) // ════════════════════════════════════════════════════════════════════ redraw(3); - selectedModel = providerMeta.defaultModel; - - if ( - selectedFlow === "advanced" && - selectedProvider !== "cocoon" && - selectedProvider !== "local" - ) { - const providerModels = getModelsForProvider(selectedProvider); - const modelChoices = [ - ...providerModels, - { value: "__custom__", name: "Custom", description: "Enter a model ID manually" }, - ]; - - const modelChoice = await select({ - message: "Model", - default: providerMeta.defaultModel, - theme, - choices: modelChoices, - }); - - if (modelChoice === "__custom__") { - const customModel = await input({ - message: "Model ID", - default: providerMeta.defaultModel, - theme, - }); - if (customModel?.trim()) selectedModel = customModel.trim(); - } else { - selectedModel = modelChoice; - } - + if (selectedFlow === "advanced") { dmPolicy = await select({ message: "DM policy (private messages)", default: "open", @@ -611,10 +618,9 @@ async function runInteractiveOnboarding( }, }); - const modelLabel = providerModels.find((m) => m.value === selectedModel)?.name ?? selectedModel; - STEPS[3].value = `${modelLabel}, ${dmPolicy}/${groupPolicy}`; + STEPS[3].value = `${dmPolicy}/${groupPolicy}`; } else { - STEPS[3].value = `${selectedModel} (defaults)`; + STEPS[3].value = "defaults"; } // ════════════════════════════════════════════════════════════════════ diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 1400a97..53f2d22 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -22,6 +22,8 @@ const TEMPLATES_DIR = join(findPackageRoot(), "src", "templates"); export interface WorkspaceConfig { workspaceDir?: string; ensureTemplates?: boolean; + /** Suppress log.info() output (useful when CLI spinners are active) */ + silent?: boolean; } export interface Workspace { @@ -50,16 +52,18 @@ export interface Workspace { * Ensure workspace directory structure exists and is initialized */ export async function ensureWorkspace(config?: WorkspaceConfig): Promise { + const silent = config?.silent ?? false; + // Create base teleton directory if (!existsSync(TELETON_ROOT)) { mkdirSync(TELETON_ROOT, { recursive: true }); - log.info(`Created Teleton root at ${TELETON_ROOT}`); + if (!silent) log.info(`Created Teleton root at ${TELETON_ROOT}`); } // Create workspace directory if (!existsSync(WORKSPACE_ROOT)) { mkdirSync(WORKSPACE_ROOT, { recursive: true }); - log.info(`Created workspace at ${WORKSPACE_ROOT}`); + if (!silent) log.info(`Created workspace at ${WORKSPACE_ROOT}`); } // Create workspace subdirectories @@ -102,7 +106,7 @@ export async function ensureWorkspace(config?: WorkspaceConfig): Promise { +async function bootstrapTemplates(workspace: Workspace, silent = false): Promise { const templates = [ { name: "SOUL.md", path: workspace.soulPath }, { name: "MEMORY.md", path: workspace.memoryPath }, @@ -126,7 +130,7 @@ async function bootstrapTemplates(workspace: Workspace): Promise { const templateSource = join(TEMPLATES_DIR, template.name); if (existsSync(templateSource)) { copyFileSync(templateSource, template.path); - log.info(`Created ${template.name}`); + if (!silent) log.info(`Created ${template.name}`); } } } diff --git a/web/src/components/setup/ConfigStep.tsx b/web/src/components/setup/ConfigStep.tsx index 9347dfa..3f5cde7 100644 --- a/web/src/components/setup/ConfigStep.tsx +++ b/web/src/components/setup/ConfigStep.tsx @@ -1,12 +1,9 @@ -import { useState, useEffect } from 'react'; -import { setup, SetupModelOption, BotValidation } from '../../lib/api'; +import { useState } from 'react'; +import { setup, BotValidation } from '../../lib/api'; import { Select } from '../Select'; import type { StepProps } from '../../pages/Setup'; export function ConfigStep({ data, onChange }: StepProps) { - const [models, setModels] = useState([]); - const [loadingModels, setLoadingModels] = useState(false); - const [botLoading, setBotLoading] = useState(false); const [botValid, setBotValid] = useState(null); const [botNetworkError, setBotNetworkError] = useState(false); @@ -36,21 +33,6 @@ export function ConfigStep({ data, onChange }: StepProps) { } }; - // Always load models (no quick/advanced gate) - useEffect(() => { - if (data.provider === 'cocoon' || data.provider === 'local' || !data.provider) return; - setLoadingModels(true); - setup.getModels(data.provider) - .then((m) => { - setModels(m); - if (!data.model && m.length > 0) { - onChange({ ...data, model: m[0].value }); - } - }) - .catch(() => setModels([])) - .finally(() => setLoadingModels(false)); - }, [data.provider]); - const policyOptions = ['open', 'allowlist', 'disabled']; const policyLabels = ['Open', 'Allowlist', 'Disabled']; @@ -70,40 +52,9 @@ export function ConfigStep({ data, onChange }: StepProps) {

Configuration

- Configure your agent's model and behavior. Defaults are pre-filled — adjust what you need. + Configure your agent's behavior policies. Defaults are pre-filled — adjust what you need.

- {(data.provider === 'cocoon' || data.provider === 'local') ? ( -
- Model is auto-discovered from the {data.provider === 'local' ? 'local server' : 'Cocoon proxy'} at startup. -
- ) : ( -
- - {loadingModels ? ( -
Loading models...
- ) : ( - onChange({ ...data, customModel: e.target.value })} - placeholder="Enter custom model ID" - className="w-full" - style={{ marginTop: '8px' }} - /> - )} -
- )} -
m.value)} + labels={models.map((m) => m.isCustom ? 'Custom...' : `${m.name} - ${m.description}`)} + onChange={(v) => onChange({ ...data, model: v })} + style={{ width: '100%' }} + /> + )} + {data.model === '__custom__' && ( + onChange({ ...data, customModel: e.target.value })} + placeholder="Enter custom model ID" + className="w-full" + style={{ marginTop: '8px' }} + /> + )} +
+ )}
); } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index c45c809..535a333 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -231,8 +231,8 @@ export function Dashboard() { onChange({ ...data, userId: parseInt(e.target.value) || 0 })} + placeholder="123456789" + className="w-full" + /> +
+ This account will have admin control over the agent in DMs and groups. + Get your ID from @userinfobot on Telegram. +
+
+
onChange({ ...data, userId: parseInt(e.target.value) || 0 })} - placeholder="123456789" - className="w-full" - /> -
- This account will have admin control over the agent in DMs and groups. - Get your ID from @userinfobot on Telegram. -
-
); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fe14ed9..d2c4da6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -88,7 +88,7 @@ export interface SetupConfig { bot_username?: string; }; cocoon?: { port: number }; - deals?: { buy_max_floor_percent?: number; sell_min_floor_percent?: number }; + deals?: { enabled?: boolean; buy_max_floor_percent?: number; sell_min_floor_percent?: number }; tonapi_key?: string; tavily_api_key?: string; webui?: { enabled: boolean }; diff --git a/web/src/pages/Setup.tsx b/web/src/pages/Setup.tsx index 7761cf3..8c32b5d 100644 --- a/web/src/pages/Setup.tsx +++ b/web/src/pages/Setup.tsx @@ -13,9 +13,9 @@ export type { WizardData, StepProps } from '../components/setup/SetupContext'; const STEP_COMPONENTS = [ WelcomeStep, ProviderStep, - TelegramStep, ConfigStep, WalletStep, + TelegramStep, ConnectStep, ]; From a5c78dbc3e6b1c5f91aa955f455f39eda4dcf860 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Wed, 25 Feb 2026 03:21:31 +0100 Subject: [PATCH 28/41] refactor(webui): reorganize Dashboard & Config pages Extract shared hooks and components from the monolithic Dashboard: - useConfigState hook for shared state management - AgentSettingsPanel and TelegramSettingsPanel components - PillBar tab navigation component Dashboard now shows only status, memory, and quick-access settings. Config page uses pill-bar tabs (LLM, Telegram, Session, API Keys, Advanced) for the full settings experience. Also fixes embedding model cache dir creation on fresh install and surfaces the Update All button in the Plugins installed tab. --- src/memory/embeddings/local.ts | 42 +- web/src/components/AgentSettingsPanel.tsx | 145 ++++++ web/src/components/PillBar.tsx | 21 + web/src/components/TelegramSettingsPanel.tsx | 114 +++++ web/src/hooks/useConfigState.ts | 170 +++++++ web/src/index.css | 36 ++ web/src/pages/Config.tsx | 467 ++++++++++++------- web/src/pages/Dashboard.tsx | 452 +----------------- web/src/pages/Logs.tsx | 2 +- web/src/pages/Plugins.tsx | 10 + 10 files changed, 861 insertions(+), 598 deletions(-) create mode 100644 web/src/components/AgentSettingsPanel.tsx create mode 100644 web/src/components/PillBar.tsx create mode 100644 web/src/components/TelegramSettingsPanel.tsx create mode 100644 web/src/hooks/useConfigState.ts diff --git a/src/memory/embeddings/local.ts b/src/memory/embeddings/local.ts index 7c3d61c..5289040 100644 --- a/src/memory/embeddings/local.ts +++ b/src/memory/embeddings/local.ts @@ -1,5 +1,6 @@ import { pipeline, env, type FeatureExtractionPipeline } from "@huggingface/transformers"; import { join } from "node:path"; +import { mkdirSync } from "node:fs"; import type { EmbeddingProvider } from "./provider.js"; import { TELETON_ROOT } from "../../workspace/paths.js"; import { createLogger } from "../../utils/logger.js"; @@ -7,15 +8,23 @@ import { createLogger } from "../../utils/logger.js"; const log = createLogger("Memory"); // Force model cache into ~/.teleton/models/ (writable even with npm install -g) -env.cacheDir = join(TELETON_ROOT, "models"); +const modelCacheDir = join(TELETON_ROOT, "models"); +try { + mkdirSync(modelCacheDir, { recursive: true }); +} catch { + // Will fail later with a clear error during warmup +} +env.cacheDir = modelCacheDir; let extractorPromise: Promise | null = null; function getExtractor(model: string): Promise { if (!extractorPromise) { - log.info(`Loading local embedding model: ${model} (cache: ${env.cacheDir})`); + log.info(`Loading local embedding model: ${model} (cache: ${modelCacheDir})`); extractorPromise = pipeline("feature-extraction", model, { dtype: "fp32", + // Explicit cache_dir to avoid any env race condition + cache_dir: modelCacheDir, }) .then((ext) => { log.info(`Local embedding model ready`); @@ -47,21 +56,30 @@ export class LocalEmbeddingProvider implements EmbeddingProvider { /** * Pre-download and load the model at startup. - * If loading fails, marks this provider as disabled (returns empty embeddings). + * If loading fails, retries once then marks provider as disabled (FTS5-only). * Call this once during app init — avoids retry spam on every message. * @returns true if model loaded successfully, false if fallback to noop */ async warmup(): Promise { - try { - await getExtractor(this.model); - return true; - } catch (err) { - log.warn( - `Local embedding model unavailable — falling back to FTS5-only search (no vector embeddings)` - ); - this._disabled = true; - return false; + for (let attempt = 1; attempt <= 2; attempt++) { + try { + await getExtractor(this.model); + return true; + } catch (err) { + if (attempt === 1) { + log.warn(`Embedding model load failed (attempt 1), retrying...`); + // Small delay before retry + await new Promise((r) => setTimeout(r, 1000)); + } else { + log.warn( + `Local embedding model unavailable — falling back to FTS5-only search (no vector embeddings)` + ); + this._disabled = true; + return false; + } + } } + return false; } async embedQuery(text: string): Promise { diff --git a/web/src/components/AgentSettingsPanel.tsx b/web/src/components/AgentSettingsPanel.tsx new file mode 100644 index 0000000..06baf34 --- /dev/null +++ b/web/src/components/AgentSettingsPanel.tsx @@ -0,0 +1,145 @@ +import { ProviderMeta } from '../hooks/useConfigState'; +import { Select } from './Select'; + +interface AgentSettingsPanelProps { + getLocal: (key: string) => string; + setLocal: (key: string, value: string) => void; + saveConfig: (key: string, value: string) => Promise; + modelOptions: Array<{ value: string; name: string }>; + pendingProvider: string | null; + pendingMeta: ProviderMeta | null; + pendingApiKey: string; + setPendingApiKey: (v: string) => void; + pendingValidating: boolean; + pendingError: string | null; + setPendingError: (v: string | null) => void; + handleProviderChange: (provider: string) => Promise; + handleProviderConfirm: () => Promise; + handleProviderCancel: () => void; + /** Hide temperature/tokens/iterations (Dashboard mode) */ + compact?: boolean; +} + +export function AgentSettingsPanel({ + getLocal, setLocal, saveConfig, modelOptions, + pendingProvider, pendingMeta, pendingApiKey, setPendingApiKey, + pendingValidating, pendingError, setPendingError, + handleProviderChange, handleProviderConfirm, handleProviderCancel, + compact = false, +}: AgentSettingsPanelProps) { + return ( + <> +
Agent
+
+
+ + { setPendingApiKey(e.target.value); setPendingError(null); }} + onKeyDown={(e) => e.key === 'Enter' && handleProviderConfirm()} + style={{ width: '100%' }} + autoFocus + /> + {pendingMeta.consoleUrl && ( + + Get key at {new URL(pendingMeta.consoleUrl).hostname} ↗ + + )} +
+ )} + {pendingError && ( +
+ {pendingError} +
+ )} +
+ + +
+
+ )} + +
+ + setLocal('agent.temperature', e.target.value)} + onBlur={(e) => saveConfig('agent.temperature', e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveConfig('agent.temperature', e.currentTarget.value)} + style={{ width: '120px', textAlign: 'right' }} + /> +
+
+ + setLocal('agent.max_tokens', e.target.value)} + onBlur={(e) => saveConfig('agent.max_tokens', e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveConfig('agent.max_tokens', e.currentTarget.value)} + style={{ width: '120px', textAlign: 'right' }} + /> +
+
+ + setLocal('agent.max_agentic_iterations', e.target.value)} + onBlur={(e) => saveConfig('agent.max_agentic_iterations', e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveConfig('agent.max_agentic_iterations', e.currentTarget.value)} + style={{ width: '120px', textAlign: 'right' }} + /> +
+
+ )} +
+ + ); +} diff --git a/web/src/components/PillBar.tsx b/web/src/components/PillBar.tsx new file mode 100644 index 0000000..541c671 --- /dev/null +++ b/web/src/components/PillBar.tsx @@ -0,0 +1,21 @@ +interface PillBarProps { + tabs: Array<{ id: string; label: string }>; + activeTab: string; + onTabChange: (id: string) => void; +} + +export function PillBar({ tabs, activeTab, onTabChange }: PillBarProps) { + return ( +
+ {tabs.map((t) => ( + + ))} +
+ ); +} diff --git a/web/src/components/TelegramSettingsPanel.tsx b/web/src/components/TelegramSettingsPanel.tsx new file mode 100644 index 0000000..2eca6bb --- /dev/null +++ b/web/src/components/TelegramSettingsPanel.tsx @@ -0,0 +1,114 @@ +import { Select } from './Select'; + +interface TelegramSettingsPanelProps { + getLocal: (key: string) => string; + setLocal: (key: string, value: string) => void; + saveConfig: (key: string, value: string) => Promise; + extended?: boolean; +} + +function TextField({ label, configKey, getLocal, setLocal, saveConfig }: { + label: string; + configKey: string; + getLocal: (key: string) => string; + setLocal: (key: string, value: string) => void; + saveConfig: (key: string, value: string) => Promise; +}) { + return ( +
+ + setLocal(configKey, e.target.value)} + onBlur={(e) => saveConfig(configKey, e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveConfig(configKey, e.currentTarget.value)} + style={{ width: '100%' }} + /> +
+ ); +} + +export function TelegramSettingsPanel({ getLocal, setLocal, saveConfig, extended }: TelegramSettingsPanelProps) { + return ( + <> +
Telegram
+
+ {extended && ( + <> + + + + + )} +
+
+ + saveConfig('telegram.group_policy', v)} + /> +
+
+
+ + +
+ {extended && ( +
+ + +
+ )} + {extended && ( +
+ + setLocal('telegram.debounce_ms', e.target.value)} + onBlur={(e) => saveConfig('telegram.debounce_ms', e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveConfig('telegram.debounce_ms', e.currentTarget.value)} + style={{ width: '100%' }} + /> +
+ )} + {extended && ( + + )} +
+ + ); +} diff --git a/web/src/hooks/useConfigState.ts b/web/src/hooks/useConfigState.ts new file mode 100644 index 0000000..03ad558 --- /dev/null +++ b/web/src/hooks/useConfigState.ts @@ -0,0 +1,170 @@ +import { useEffect, useState, useCallback } from 'react'; +import { api, StatusData, MemoryStats, ToolRagStatus } from '../lib/api'; + +export interface ProviderMeta { + needsKey: boolean; + keyHint: string; + keyPrefix: string | null; + consoleUrl: string; + displayName: string; +} + +export function useConfigState() { + const [status, setStatus] = useState(null); + const [stats, setStats] = useState(null); + const [toolRag, setToolRag] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(null); + const [modelOptions, setModelOptions] = useState>([]); + + // Provider switch gating state + const [pendingProvider, setPendingProvider] = useState(null); + const [pendingMeta, setPendingMeta] = useState(null); + const [pendingApiKey, setPendingApiKey] = useState(''); + const [pendingValidating, setPendingValidating] = useState(false); + const [pendingError, setPendingError] = useState(null); + + // Local input state — decoupled from server values to avoid sending empty/partial values + const [localInputs, setLocalInputs] = useState>({}); + + const loadData = useCallback(() => { + Promise.all([api.getStatus(), api.getMemoryStats(), api.getConfigKeys(), api.getToolRag()]) + .then(([statusRes, statsRes, configRes, ragRes]) => { + setStatus(statusRes.data); + setStats(statsRes.data); + setToolRag(ragRes.data); + // Sync local inputs from server values + const inputs: Record = {}; + for (const c of configRes.data) { + if (c.value != null) inputs[c.key] = c.value; + } + setLocalInputs(inputs); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const getLocal = (key: string): string => localInputs[key] ?? ''; + + const showSuccess = (msg: string) => { + setSaveSuccess(msg); + setTimeout(() => setSaveSuccess(null), 2000); + }; + + const saveConfig = async (key: string, value: string) => { + if (!value.trim()) return; // never send empty values + try { + setError(null); + await api.setConfigKey(key, value.trim()); + await loadData(); + showSuccess(`Saved ${key.split('.').pop()}`); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const saveToolRag = async (update: { enabled?: boolean; topK?: number }) => { + try { + const res = await api.updateToolRag(update); + setToolRag(res.data); + showSuccess('Tool RAG updated'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const setLocal = (key: string, value: string) => { + setLocalInputs((prev) => ({ ...prev, [key]: value })); + }; + + // Load model options when provider changes + const currentProvider = getLocal('agent.provider'); + useEffect(() => { + if (!currentProvider) return; + api.getModelsForProvider(currentProvider).then((res) => { + const models = res.data.map((m) => ({ value: m.value, name: m.name })); + setModelOptions(models); + // Auto-select first model if current model isn't in the new list + const currentModel = localInputs['agent.model'] ?? ''; + if (models.length > 0 && !models.some((m) => m.value === currentModel)) { + saveConfig('agent.model', models[0].value); + } + }).catch(() => setModelOptions([])); + }, [currentProvider]); + + // Handle provider change — gate on API key + const handleProviderChange = async (newProvider: string) => { + if (newProvider === currentProvider) return; + try { + const res = await api.getProviderMeta(newProvider); + const meta = res.data; + if (!meta.needsKey) { + // No key needed — save directly + await saveConfig('agent.provider', newProvider); + setPendingProvider(null); + setPendingMeta(null); + } else { + // Show the gated transition zone + setPendingProvider(newProvider); + setPendingMeta(meta); + setPendingApiKey(''); + setPendingError(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleProviderConfirm = async () => { + if (!pendingProvider || !pendingMeta) return; + if (pendingMeta.needsKey && !pendingApiKey.trim()) { + setPendingError('API key is required'); + return; + } + setPendingValidating(true); + setPendingError(null); + try { + // Validate API key format + const valRes = await api.validateApiKey(pendingProvider, pendingApiKey); + if (!valRes.data.valid) { + setPendingError(valRes.data.error || 'Invalid API key'); + setPendingValidating(false); + return; + } + // Save provider + API key + await api.setConfigKey('agent.api_key', pendingApiKey.trim()); + await saveConfig('agent.provider', pendingProvider); + setPendingProvider(null); + setPendingMeta(null); + setPendingApiKey(''); + showSuccess(`Switched to ${pendingMeta.displayName}`); + } catch (err) { + setPendingError(err instanceof Error ? err.message : String(err)); + } finally { + setPendingValidating(false); + } + }; + + const handleProviderCancel = () => { + setPendingProvider(null); + setPendingMeta(null); + setPendingApiKey(''); + setPendingError(null); + }; + + return { + loading, error, setError, saveSuccess, status, stats, toolRag, + localInputs, getLocal, setLocal, saveConfig, saveToolRag, showSuccess, + modelOptions, pendingProvider, pendingMeta, pendingApiKey, setPendingApiKey, + pendingValidating, pendingError, setPendingError, + handleProviderChange, handleProviderConfirm, handleProviderCancel, loadData, + }; +} diff --git a/web/src/index.css b/web/src/index.css index 51a0304..893b093 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1570,3 +1570,39 @@ button.btn-lg { font-size: 16px; /* prevents iOS zoom */ } } + +/* ── Pill Bar ── */ + +.pill-bar { + display: inline-flex; + gap: var(--space-sm); + padding: var(--space-xs); + background: var(--surface); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + margin-bottom: var(--space-xl); + overflow-x: auto; +} + +.pill-bar button { + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--text-secondary); + font-size: var(--font-md); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s ease; +} + +.pill-bar button:hover { + color: var(--text); + background: var(--surface-hover); +} + +.pill-bar button.active { + background: var(--accent); + color: var(--text-on-accent); +} diff --git a/web/src/pages/Config.tsx b/web/src/pages/Config.tsx index 7f4013d..ee4f27e 100644 --- a/web/src/pages/Config.tsx +++ b/web/src/pages/Config.tsx @@ -1,53 +1,55 @@ import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { api, ConfigKeyData } from '../lib/api'; +import { useConfigState } from '../hooks/useConfigState'; +import { PillBar } from '../components/PillBar'; +import { AgentSettingsPanel } from '../components/AgentSettingsPanel'; +import { TelegramSettingsPanel } from '../components/TelegramSettingsPanel'; import { Select } from '../components/Select'; -const CATEGORY_ORDER = ['API Keys', 'Agent', 'Telegram', 'Embedding', 'WebUI', 'Deals', 'Developer']; - -function groupByCategory(keys: ConfigKeyData[]): Map { - const groups = new Map(); - for (const key of keys) { - const cat = key.category || 'Other'; - if (!groups.has(cat)) groups.set(cat, []); - groups.get(cat)!.push(key); - } - // Sort by CATEGORY_ORDER, unknown categories go last - const sorted = new Map(); - for (const cat of CATEGORY_ORDER) { - if (groups.has(cat)) { - sorted.set(cat, groups.get(cat)!); - groups.delete(cat); - } - } - for (const [cat, items] of groups) { - sorted.set(cat, items); - } - return sorted; -} +const TABS = [ + { id: 'llm', label: 'LLM' }, + { id: 'telegram', label: 'Telegram' }, + { id: 'session', label: 'Session' }, + { id: 'api-keys', label: 'API Keys' }, + { id: 'advanced', label: 'Advanced' }, +]; + +const API_KEY_KEYS = ['agent.api_key', 'telegram.bot_token', 'tavily_api_key', 'tonapi_key']; +const ADVANCED_KEYS = ['embedding.provider', 'webui.port', 'webui.log_requests', 'deals.enabled', 'dev.hot_reload']; export function Config() { - const [keys, setKeys] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get('tab') || 'llm'; + + const config = useConfigState(); + + // Raw config keys state for API Keys / Advanced tabs + const [configKeys, setConfigKeys] = useState([]); + const [keysLoading, setKeysLoading] = useState(false); const [editingKey, setEditingKey] = useState(null); const [editValue, setEditValue] = useState(''); const [saving, setSaving] = useState(false); - const loadKeys = (initial = false) => { - if (initial) setLoading(true); - api.getConfigKeys() - .then((res) => { - setKeys(res.data); - setLoading(false); - }) - .catch((err) => { - setError(err instanceof Error ? err.message : String(err)); - setLoading(false); - }); + const handleTabChange = (id: string) => { + setSearchParams({ tab: id }, { replace: true }); }; - useEffect(() => { loadKeys(true); }, []); + // Load raw config keys when switching to API Keys or Advanced tab + useEffect(() => { + if (activeTab === 'api-keys' || activeTab === 'advanced') { + setKeysLoading(true); + api.getConfigKeys() + .then((res) => { setConfigKeys(res.data); setKeysLoading(false); }) + .catch(() => setKeysLoading(false)); + } + }, [activeTab]); + + const loadKeys = () => { + api.getConfigKeys() + .then((res) => setConfigKeys(res.data)) + .catch(() => {}); + }; const startEdit = (item: ConfigKeyData) => { setEditingKey(item.key); @@ -61,20 +63,19 @@ export function Config() { }; const handleSave = async (key: string) => { - const item = keys.find((k) => k.key === key); + const item = configKeys.find((k) => k.key === key); const isSelectType = item?.type === 'boolean' || item?.type === 'enum'; if (!isSelectType && !editValue.trim()) return; setSaving(true); - setError(null); - setSuccess(null); + config.setError(null); try { await api.setConfigKey(key, editValue.trim()); setEditingKey(null); setEditValue(''); - setSuccess(`${key} updated successfully`); + config.showSuccess(`${key} updated successfully`); loadKeys(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + config.setError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } @@ -82,14 +83,13 @@ export function Config() { const handleUnset = async (key: string) => { setSaving(true); - setError(null); - setSuccess(null); + config.setError(null); try { await api.unsetConfigKey(key); - setSuccess(`${key} removed`); + config.showSuccess(`${key} removed`); loadKeys(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + config.setError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } @@ -100,142 +100,295 @@ export function Config() { setEditValue(''); }; - if (loading) return
Loading...
; + if (config.loading) return
Loading...
; - const groups = groupByCategory(keys); + const renderKeyValueList = (filterKeys: string[]) => { + if (keysLoading) return
Loading...
; + const items = configKeys.filter((k) => filterKeys.includes(k.key)); + if (items.length === 0) return
No keys found
; + + return items.map((item, idx) => ( +
+
+
+ {item.key} + + {item.set ? 'Set' : 'Not set'} + + {item.sensitive && ( + + sensitive + + )} +
+
+ + {item.set && ( + + )} +
+
+ +
+ {item.description} +
+ + {item.set && item.value && editingKey !== item.key && ( +
+ {item.value} +
+ )} + + {editingKey === item.key && ( +
+ {item.type === 'boolean' ? ( + + ) : ( + setEditValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSave(item.key)} + placeholder={`Enter value for ${item.key}...`} + autoFocus + style={{ width: '100%', marginBottom: '8px' }} + /> + )} +
+ + +
+
+ )} +
+ )); + }; return (

Configuration

-

Manage API keys and settings

+

Manage settings and API keys

- {error && ( + {config.error && (
- {error} -
)} - {success && ( -
- {success} - + {config.saveSuccess && ( +
+ {config.saveSuccess}
)} - {Array.from(groups.entries()).map(([category, items]) => ( -
-

{category}

+ - {items.map((item, idx) => ( -
-
+ {/* LLM Tab */} + {activeTab === 'llm' && ( + <> +
+ +
+ + {config.toolRag && ( +
+
- {item.key} - - {item.set ? 'Set' : 'Not set'} - - {item.sensitive && ( - - sensitive - - )} +
Tool RAG
+

+ Semantic tool selection — sends only the most relevant tools to the LLM per message. +

+
+ +
+
+
+ + {config.toolRag.indexed ? 'Yes' : 'No'}
-
- - {item.set && ( - - )} +
+ + - ) : item.type === 'enum' && item.options ? ( - setEditValue(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSave(item.key)} - placeholder={`Enter value for ${item.key}...`} - autoFocus - style={{ width: '100%', marginBottom: '8px' }} - /> - )} -
- - -
-
- )} + {/* Session Tab */} + {activeTab === 'session' && ( +
+
Session
+
+
+ + +
+
+ + + config.saveConfig('agent.session_reset_policy.idle_expiry_enabled', String(e.target.checked)) + } + /> + + + +
+
+ + config.setLocal('agent.session_reset_policy.idle_expiry_minutes', e.target.value)} + onBlur={(e) => config.saveConfig('agent.session_reset_policy.idle_expiry_minutes', e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && config.saveConfig('agent.session_reset_policy.idle_expiry_minutes', e.currentTarget.value)} + style={{ width: '100%' }} + /> +
+
+
+ )} + + {/* API Keys Tab */} + {activeTab === 'api-keys' && ( +
+
API Keys
+ {renderKeyValueList(API_KEY_KEYS)} +
+ )} + + {/* Advanced Tab */} + {activeTab === 'advanced' && ( +
+
Advanced
+ {renderKeyValueList(ADVANCED_KEYS)}
- ))} + )}
); } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 535a333..f1a4185 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,165 +1,16 @@ -import { useEffect, useState, useCallback } from 'react'; -import { api, StatusData, MemoryStats, ToolRagStatus } from '../lib/api'; -import { Select } from '../components/Select'; - -interface ProviderMeta { - needsKey: boolean; - keyHint: string; - keyPrefix: string | null; - consoleUrl: string; - displayName: string; -} +import { useConfigState } from '../hooks/useConfigState'; +import { AgentSettingsPanel } from '../components/AgentSettingsPanel'; +import { TelegramSettingsPanel } from '../components/TelegramSettingsPanel'; export function Dashboard() { - const [status, setStatus] = useState(null); - const [stats, setStats] = useState(null); - const [toolRag, setToolRag] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [saveSuccess, setSaveSuccess] = useState(null); - const [modelOptions, setModelOptions] = useState>([]); - - // Provider switch gating state - const [pendingProvider, setPendingProvider] = useState(null); - const [pendingMeta, setPendingMeta] = useState(null); - const [pendingApiKey, setPendingApiKey] = useState(''); - const [pendingValidating, setPendingValidating] = useState(false); - const [pendingError, setPendingError] = useState(null); - - // Local input state — decoupled from server values to avoid sending empty/partial values - const [localInputs, setLocalInputs] = useState>({}); - - const loadData = useCallback(() => { - Promise.all([api.getStatus(), api.getMemoryStats(), api.getConfigKeys(), api.getToolRag()]) - .then(([statusRes, statsRes, configRes, ragRes]) => { - setStatus(statusRes.data); - setStats(statsRes.data); - setToolRag(ragRes.data); - // Sync local inputs from server values - const inputs: Record = {}; - for (const c of configRes.data) { - if (c.value != null) inputs[c.key] = c.value; - } - setLocalInputs(inputs); - setLoading(false); - }) - .catch((err) => { - setError(err.message); - setLoading(false); - }); - }, []); - - useEffect(() => { - loadData(); - }, [loadData]); - - const getLocal = (key: string): string => localInputs[key] ?? ''; - - // Load model options when provider changes - const currentProvider = getLocal('agent.provider'); - useEffect(() => { - if (!currentProvider) return; - api.getModelsForProvider(currentProvider).then((res) => { - const models = res.data.map((m) => ({ value: m.value, name: m.name })); - setModelOptions(models); - // Auto-select first model if current model isn't in the new list - const currentModel = localInputs['agent.model'] ?? ''; - if (models.length > 0 && !models.some((m) => m.value === currentModel)) { - saveConfig('agent.model', models[0].value); - } - }).catch(() => setModelOptions([])); - }, [currentProvider]); - - // Handle provider change — gate on API key - const handleProviderChange = async (newProvider: string) => { - if (newProvider === currentProvider) return; - try { - const res = await api.getProviderMeta(newProvider); - const meta = res.data; - if (!meta.needsKey) { - // No key needed — save directly - await saveConfig('agent.provider', newProvider); - setPendingProvider(null); - setPendingMeta(null); - } else { - // Show the gated transition zone - setPendingProvider(newProvider); - setPendingMeta(meta); - setPendingApiKey(''); - setPendingError(null); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }; - - const handleProviderConfirm = async () => { - if (!pendingProvider || !pendingMeta) return; - if (pendingMeta.needsKey && !pendingApiKey.trim()) { - setPendingError('API key is required'); - return; - } - setPendingValidating(true); - setPendingError(null); - try { - // Validate API key format - const valRes = await api.validateApiKey(pendingProvider, pendingApiKey); - if (!valRes.data.valid) { - setPendingError(valRes.data.error || 'Invalid API key'); - setPendingValidating(false); - return; - } - // Save provider + API key - await api.setConfigKey('agent.api_key', pendingApiKey.trim()); - await saveConfig('agent.provider', pendingProvider); - setPendingProvider(null); - setPendingMeta(null); - setPendingApiKey(''); - showSuccess(`Switched to ${pendingMeta.displayName}`); - } catch (err) { - setPendingError(err instanceof Error ? err.message : String(err)); - } finally { - setPendingValidating(false); - } - }; - - const handleProviderCancel = () => { - setPendingProvider(null); - setPendingMeta(null); - setPendingApiKey(''); - setPendingError(null); - }; - - const setLocal = (key: string, value: string) => { - setLocalInputs((prev) => ({ ...prev, [key]: value })); - }; - - const saveConfig = async (key: string, value: string) => { - if (!value.trim()) return; // never send empty values - try { - setError(null); - await api.setConfigKey(key, value.trim()); - await loadData(); - showSuccess(`Saved ${key.split('.').pop()}`); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }; - - const saveToolRag = async (update: { enabled?: boolean; topK?: number }) => { - try { - const res = await api.updateToolRag(update); - setToolRag(res.data); - showSuccess('Tool RAG updated'); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }; - - const showSuccess = (msg: string) => { - setSaveSuccess(msg); - setTimeout(() => setSaveSuccess(null), 2000); - }; + const { + loading, error, setError, saveSuccess, status, stats, + getLocal, setLocal, saveConfig, + modelOptions, pendingProvider, pendingMeta, + pendingApiKey, setPendingApiKey, + pendingValidating, pendingError, setPendingError, + handleProviderChange, handleProviderConfirm, handleProviderCancel, + } = useConfigState(); if (loading) return
Loading...
; if (!status || !stats) return
Failed to load dashboard data
; @@ -223,278 +74,23 @@ export function Dashboard() {
- {/* Agent Settings */}
-
Agent
-
-
- - { setPendingApiKey(e.target.value); setPendingError(null); }} - onKeyDown={(e) => e.key === 'Enter' && handleProviderConfirm()} - style={{ width: '100%' }} - autoFocus - /> - {pendingMeta.consoleUrl && ( - - Get key at {new URL(pendingMeta.consoleUrl).hostname} ↗ - - )} -
- )} - {pendingError && ( -
- {pendingError} -
- )} -
- - -
-
- )} - -
- - setLocal('agent.temperature', e.target.value)} - onBlur={(e) => saveConfig('agent.temperature', e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && saveConfig('agent.temperature', e.currentTarget.value)} - /> -
-
- - setLocal('agent.max_tokens', e.target.value)} - onBlur={(e) => saveConfig('agent.max_tokens', e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && saveConfig('agent.max_tokens', e.currentTarget.value)} - /> -
-
- - setLocal('agent.max_agentic_iterations', e.target.value)} - onBlur={(e) => saveConfig('agent.max_agentic_iterations', e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && saveConfig('agent.max_agentic_iterations', e.currentTarget.value)} - /> -
-
-
+
- {/* Tool RAG Settings */} - {toolRag && ( -
-
-
-
Tool RAG
-

- Semantic tool selection — sends only the most relevant tools to the LLM per message. -

-
- -
-
- Indexed: {toolRag.indexed ? 'Yes' : 'No'} - - Top-K: - saveConfig('telegram.dm_policy', v)} - /> -
-
- - saveConfig('telegram.require_mention', String(e.target.checked))} - /> - - - -
-
- - -
-
- - setLocal('telegram.debounce_ms', e.target.value)} - onBlur={(e) => saveConfig('telegram.debounce_ms', e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && saveConfig('telegram.debounce_ms', e.currentTarget.value)} - style={{ width: '100%' }} - /> -
-
-
- - {/* Session Settings */} -
-
Session
-
-
- - -
-
- - - saveConfig('agent.session_reset_policy.idle_expiry_enabled', String(e.target.checked)) - } - /> - - - -
-
- - setLocal('agent.session_reset_policy.idle_expiry_minutes', e.target.value)} - onBlur={(e) => saveConfig('agent.session_reset_policy.idle_expiry_minutes', e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && saveConfig('agent.session_reset_policy.idle_expiry_minutes', e.currentTarget.value)} - style={{ width: '100%' }} - /> -
-
+
); diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index 476779f..36c6fa1 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -35,7 +35,7 @@ export function Logs() {
-
+
{logs.length === 0 ? (
Waiting for logs...
) : ( diff --git a/web/src/pages/Plugins.tsx b/web/src/pages/Plugins.tsx index 786e003..288c3ae 100644 --- a/web/src/pages/Plugins.tsx +++ b/web/src/pages/Plugins.tsx @@ -318,6 +318,16 @@ export function Plugins() { style={{ minWidth: '140px' }} /> )} + {tab === 'installed' && updatableCount > 0 && ( + + )} {tab === 'marketplace' && ( <> {updatableCount > 0 ? ( From 0ca00e06d862ceef5ed9d5e149a3830febdc2731 Mon Sep 17 00:00:00 2001 From: TONresistor Date: Wed, 25 Feb 2026 06:19:40 +0100 Subject: [PATCH 29/41] feat(webui): expand configurable keys + new dashboard features - Add array type support with ArrayInput component - Add labels and option labels to all config keys - Add Telegram keys (admin_ids, allow_from, rate limits, etc.) - Add Deals, Embedding, Cocoon and Agent base_url keys - Auto-sync owner_id to admin_ids - Remove deprecated "pairing" DM policy - Persist GramJS bot session to disk - Memory sources browser with chunk viewer - Workspace raw image preview with MIME detection - Tool RAG persistence to YAML + alwaysInclude/skipUnlimitedProviders - Tasks bulk clean by terminal status --- src/bot/gramjs-bot.ts | 39 ++- src/bot/index.ts | 2 +- src/bot/types.ts | 1 + src/cli/commands/onboard.ts | 4 +- .../__tests__/configurable-keys.test.ts | 261 ++++++++++++++++++ src/config/__tests__/loader.test.ts | 5 +- src/config/configurable-keys.ts | 188 ++++++++++++- src/config/loader.ts | 1 - src/config/schema.ts | 5 +- src/telegram/admin.ts | 2 +- src/telegram/handlers.ts | 10 - .../__tests__/config-array-routes.test.ts | 205 ++++++++++++++ .../__tests__/tools-rag-persistence.test.ts | 202 ++++++++++++++ src/webui/__tests__/workspace-raw.test.ts | 183 ++++++++++++ src/webui/routes/config.ts | 101 ++++++- src/webui/routes/memory.ts | 98 ++++++- src/webui/routes/setup.ts | 1 - src/webui/routes/tasks.ts | 34 ++- src/webui/routes/tools.ts | 37 ++- src/webui/routes/workspace.ts | 62 +++++ src/webui/types.ts | 6 + web/src/components/ArrayInput.tsx | 260 +++++++++++++++++ web/src/components/TelegramSettingsPanel.tsx | 6 +- web/src/hooks/useConfigState.ts | 2 +- web/src/lib/api.ts | 43 ++- web/src/pages/Config.tsx | 231 +++++++++++----- web/src/pages/Memory.tsx | 209 +++++++++++--- web/src/pages/Tasks.tsx | 178 ++++++++++-- web/src/pages/Workspace.tsx | 260 +++++++++++------ 29 files changed, 2368 insertions(+), 268 deletions(-) create mode 100644 src/config/__tests__/configurable-keys.test.ts create mode 100644 src/webui/__tests__/config-array-routes.test.ts create mode 100644 src/webui/__tests__/tools-rag-persistence.test.ts create mode 100644 src/webui/__tests__/workspace-raw.test.ts create mode 100644 web/src/components/ArrayInput.tsx diff --git a/src/bot/gramjs-bot.ts b/src/bot/gramjs-bot.ts index b0cb530..fb47c5a 100644 --- a/src/bot/gramjs-bot.ts +++ b/src/bot/gramjs-bot.ts @@ -14,6 +14,8 @@ import { TelegramClient, Api } from "telegram"; import { StringSession } from "telegram/sessions/index.js"; import { Logger, LogLevel } from "telegram/extensions/Logger.js"; import bigInt from "big-integer"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { dirname } from "path"; import { GRAMJS_RETRY_DELAY_MS } from "../constants/timeouts.js"; import { withFloodRetry } from "../telegram/flood-retry.js"; import { createLogger } from "../utils/logger.js"; @@ -51,10 +53,13 @@ export function decodeInlineMessageId(encoded: string): Api.TypeInputBotInlineMe export class GramJSBotClient { private client: TelegramClient; private connected = false; + private sessionPath: string | undefined; - constructor(apiId: number, apiHash: string) { + constructor(apiId: number, apiHash: string, sessionPath?: string) { + this.sessionPath = sessionPath; + const sessionString = this.loadSession(); const logger = new Logger(LogLevel.NONE); - this.client = new TelegramClient(new StringSession(""), apiId, apiHash, { + this.client = new TelegramClient(new StringSession(sessionString), apiId, apiHash, { connectionRetries: 3, retryDelay: GRAMJS_RETRY_DELAY_MS, autoReconnect: true, @@ -62,6 +67,34 @@ export class GramJSBotClient { }); } + private loadSession(): string { + if (!this.sessionPath) return ""; + try { + if (existsSync(this.sessionPath)) { + return readFileSync(this.sessionPath, "utf-8").trim(); + } + } catch (error) { + log.warn({ err: error }, "[GramJS Bot] Failed to load session"); + } + return ""; + } + + private saveSession(): void { + if (!this.sessionPath) return; + try { + const sessionString = this.client.session.save() as string | undefined; + if (typeof sessionString !== "string" || !sessionString) return; + const dir = dirname(this.sessionPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(this.sessionPath, sessionString, { encoding: "utf-8", mode: 0o600 }); + log.debug("[GramJS Bot] Session saved"); + } catch (error) { + log.error({ err: error }, "[GramJS Bot] Failed to save session"); + } + } + /** * Connect and authenticate as bot via MTProto */ @@ -69,7 +102,7 @@ export class GramJSBotClient { try { await this.client.start({ botAuthToken: botToken }); this.connected = true; - // Styled buttons ready (MTProto connected) + this.saveSession(); } catch (error) { log.error({ err: error }, "[GramJS Bot] Connection failed"); throw error; diff --git a/src/bot/index.ts b/src/bot/index.ts index 10b04c8..cd0bc66 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -54,7 +54,7 @@ export class DealBot { this.bot = new Bot(config.token); if (config.apiId && config.apiHash) { - this.gramjsBot = new GramJSBotClient(config.apiId, config.apiHash); + this.gramjsBot = new GramJSBotClient(config.apiId, config.apiHash, config.gramjsSessionPath); } this.setupHandlers(); diff --git a/src/bot/types.ts b/src/bot/types.ts index a8705e9..e40aac8 100644 --- a/src/bot/types.ts +++ b/src/bot/types.ts @@ -7,6 +7,7 @@ export interface BotConfig { username: string; apiId?: number; apiHash?: string; + gramjsSessionPath?: string; } export interface DealContext { diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index 1fbd6f9..1f0a00e 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -189,7 +189,7 @@ async function runInteractiveOnboarding( let tavilyApiKey: string | undefined; let botToken: string | undefined; let botUsername: string | undefined; - let dmPolicy: "open" | "allowlist" | "pairing" | "disabled" = "open"; + let dmPolicy: "open" | "allowlist" | "disabled" = "open"; let groupPolicy: "open" | "allowlist" | "disabled" = "open"; let requireMention = true; let maxAgenticIterations = "5"; @@ -917,7 +917,6 @@ async function runInteractiveOnboarding( }, storage: { sessions_file: `${workspace.root}/sessions.json`, - pairing_file: `${workspace.root}/pairing.json`, memory_file: `${workspace.root}/memory.json`, history_limit: 100, }, @@ -1088,7 +1087,6 @@ async function runNonInteractiveOnboarding( }, storage: { sessions_file: `${workspace.root}/sessions.json`, - pairing_file: `${workspace.root}/pairing.json`, memory_file: `${workspace.root}/memory.json`, history_limit: 100, }, diff --git a/src/config/__tests__/configurable-keys.test.ts b/src/config/__tests__/configurable-keys.test.ts new file mode 100644 index 0000000..ce41ad2 --- /dev/null +++ b/src/config/__tests__/configurable-keys.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +import { CONFIGURABLE_KEYS } from "../configurable-keys.js"; + +// ── New scalar keys ───────────────────────────────────────────────────── + +describe("CONFIGURABLE_KEYS — new scalar entries", () => { + describe("agent.base_url", () => { + const meta = CONFIGURABLE_KEYS["agent.base_url"]; + + it("accepts valid URL", () => { + expect(meta.validate("https://localhost:11434")).toBeUndefined(); + }); + + it("accepts empty string (reset)", () => { + expect(meta.validate("")).toBeUndefined(); + }); + + it("rejects invalid URL", () => { + expect(meta.validate("not-a-url")).toBeDefined(); + }); + }); + + describe("telegram.owner_id", () => { + const meta = CONFIGURABLE_KEYS["telegram.owner_id"]; + + it("accepts positive integer", () => { + expect(meta.validate("123456789")).toBeUndefined(); + }); + + it("rejects negative number", () => { + expect(meta.validate("-1")).toBeDefined(); + }); + + it("rejects non-numeric", () => { + expect(meta.validate("abc")).toBeDefined(); + }); + + it("parses to number", () => { + expect(meta.parse("123456789")).toBe(123456789); + }); + }); + + describe("telegram.max_message_length", () => { + const meta = CONFIGURABLE_KEYS["telegram.max_message_length"]; + + it("accepts within range 1-32768", () => { + expect(meta.validate("4096")).toBeUndefined(); + }); + + it("rejects zero", () => { + expect(meta.validate("0")).toBeDefined(); + }); + + it("rejects above max", () => { + expect(meta.validate("99999")).toBeDefined(); + }); + }); + + describe("telegram.rate_limit_messages_per_second", () => { + const meta = CONFIGURABLE_KEYS["telegram.rate_limit_messages_per_second"]; + + it("accepts 0.1-10 range", () => { + expect(meta.validate("1.5")).toBeUndefined(); + }); + + it("rejects zero", () => { + expect(meta.validate("0")).toBeDefined(); + }); + + it("description contains 'requires restart'", () => { + expect(meta.description).toContain("requires restart"); + }); + }); + + describe("telegram.rate_limit_groups_per_minute", () => { + const meta = CONFIGURABLE_KEYS["telegram.rate_limit_groups_per_minute"]; + + it("accepts 1-60 range", () => { + expect(meta.validate("20")).toBeUndefined(); + }); + + it("rejects zero", () => { + expect(meta.validate("0")).toBeDefined(); + }); + + it("description contains 'requires restart'", () => { + expect(meta.description).toContain("requires restart"); + }); + }); + + describe("embedding.model", () => { + const meta = CONFIGURABLE_KEYS["embedding.model"]; + + it("accepts any non-empty string", () => { + expect(meta.validate("all-MiniLM-L6-v2")).toBeUndefined(); + }); + + it("accepts empty (reset to default)", () => { + expect(meta.validate("")).toBeUndefined(); + }); + + it("description contains 'requires restart'", () => { + expect(meta.description).toContain("requires restart"); + }); + }); + + describe("deals.expiry_seconds", () => { + const meta = CONFIGURABLE_KEYS["deals.expiry_seconds"]; + + it("accepts 10-3600", () => { + expect(meta.validate("120")).toBeUndefined(); + }); + + it("rejects below min", () => { + expect(meta.validate("5")).toBeDefined(); + }); + }); + + describe("deals.buy_max_floor_percent", () => { + const meta = CONFIGURABLE_KEYS["deals.buy_max_floor_percent"]; + + it("accepts 1-100", () => { + expect(meta.validate("95")).toBeUndefined(); + }); + + it("rejects above 100", () => { + expect(meta.validate("101")).toBeDefined(); + }); + }); + + describe("deals.sell_min_floor_percent", () => { + const meta = CONFIGURABLE_KEYS["deals.sell_min_floor_percent"]; + + it("accepts 100-500", () => { + expect(meta.validate("105")).toBeUndefined(); + }); + + it("rejects below 100", () => { + expect(meta.validate("99")).toBeDefined(); + }); + }); + + describe("cocoon.port", () => { + const meta = CONFIGURABLE_KEYS["cocoon.port"]; + + it("accepts 1-65535", () => { + expect(meta.validate("10000")).toBeUndefined(); + }); + + it("rejects 0", () => { + expect(meta.validate("0")).toBeDefined(); + }); + + it("description contains 'requires restart'", () => { + expect(meta.description).toContain("requires restart"); + }); + }); +}); + +// ── Array keys ────────────────────────────────────────────────────────── + +describe("CONFIGURABLE_KEYS — array entries", () => { + describe("telegram.admin_ids", () => { + const meta = CONFIGURABLE_KEYS["telegram.admin_ids"]; + + it("has type 'array'", () => { + expect(meta.type).toBe("array"); + }); + + it("has itemType 'number'", () => { + expect(meta.itemType).toBe("number"); + }); + + it("validates positive integer per item", () => { + expect(meta.validate("123456")).toBeUndefined(); + }); + + it("rejects non-numeric item", () => { + expect(meta.validate("abc")).toBeDefined(); + }); + + it("rejects negative item", () => { + expect(meta.validate("-5")).toBeDefined(); + }); + + it("parses string to number", () => { + expect(meta.parse("123456")).toBe(123456); + }); + }); + + describe("telegram.allow_from", () => { + const meta = CONFIGURABLE_KEYS["telegram.allow_from"]; + + it("has type 'array' with itemType 'number'", () => { + expect(meta.type).toBe("array"); + expect(meta.itemType).toBe("number"); + }); + + it("validates positive integer per item", () => { + expect(meta.validate("999")).toBeUndefined(); + }); + + it("rejects non-numeric item", () => { + expect(meta.validate("xyz")).toBeDefined(); + }); + + it("parses string to number", () => { + expect(meta.parse("999")).toBe(999); + }); + }); + + describe("telegram.group_allow_from", () => { + const meta = CONFIGURABLE_KEYS["telegram.group_allow_from"]; + + it("has type 'array' with itemType 'number'", () => { + expect(meta.type).toBe("array"); + expect(meta.itemType).toBe("number"); + }); + + it("validates positive integer per item", () => { + expect(meta.validate("777")).toBeUndefined(); + }); + + it("rejects non-numeric item", () => { + expect(meta.validate("bad")).toBeDefined(); + }); + + it("parses string to number", () => { + expect(meta.parse("777")).toBe(777); + }); + }); +}); + +// ── Existing keys not broken ──────────────────────────────────────────── + +describe("existing keys unchanged", () => { + it("all original keys still present (at least 27)", () => { + expect(Object.keys(CONFIGURABLE_KEYS).length).toBeGreaterThanOrEqual(27); + }); + + it("agent.api_key still validates >= 10 chars", () => { + const meta = CONFIGURABLE_KEYS["agent.api_key"]; + expect(meta.validate("short")).toBeDefined(); + expect(meta.validate("long-enough-key-here")).toBeUndefined(); + }); + + it("agent.provider still has all 11 options", () => { + const meta = CONFIGURABLE_KEYS["agent.provider"]; + expect(meta.options).toHaveLength(11); + }); +}); diff --git a/src/config/__tests__/loader.test.ts b/src/config/__tests__/loader.test.ts index 9865e19..34386ab 100644 --- a/src/config/__tests__/loader.test.ts +++ b/src/config/__tests__/loader.test.ts @@ -75,7 +75,6 @@ telegram: storage: sessions_file: "~/custom_sessions.json" - pairing_file: "~/custom_pairing.json" memory_file: "~/custom_memory.json" history_limit: 50 @@ -361,7 +360,7 @@ describe("Config Loader", () => { // Telegram defaults expect(config.telegram.session_name).toBe("teleton_session"); - expect(config.telegram.dm_policy).toBe("pairing"); + expect(config.telegram.dm_policy).toBe("allowlist"); expect(config.telegram.group_policy).toBe("open"); expect(config.telegram.require_mention).toBe(true); expect(config.telegram.typing_simulation).toBe(true); @@ -526,7 +525,6 @@ telegram: const config = loadConfig(TEST_CONFIG_PATH); expect(config.storage.sessions_file).toBe(join(homedir(), ".teleton/sessions.json")); - expect(config.storage.pairing_file).toBe(join(homedir(), ".teleton/pairing.json")); expect(config.storage.memory_file).toBe(join(homedir(), ".teleton/memory.json")); }); @@ -542,7 +540,6 @@ telegram: session_path: "~/custom/session" storage: sessions_file: "~/custom/sessions.json" - pairing_file: "~/custom/pairing.json" memory_file: "~/custom/memory.json" `; writeTestConfig(customPathConfig); diff --git a/src/config/configurable-keys.ts b/src/config/configurable-keys.ts index 91c720a..8372e35 100644 --- a/src/config/configurable-keys.ts +++ b/src/config/configurable-keys.ts @@ -5,7 +5,7 @@ import { ConfigSchema } from "./schema.js"; // ── Types ────────────────────────────────────────────────────────────── -export type ConfigKeyType = "string" | "number" | "boolean" | "enum"; +export type ConfigKeyType = "string" | "number" | "boolean" | "enum" | "array"; export type ConfigCategory = | "API Keys" @@ -20,12 +20,15 @@ export type ConfigCategory = export interface ConfigKeyMeta { type: ConfigKeyType; category: ConfigCategory; + label: string; description: string; sensitive: boolean; validate: (v: string) => string | undefined; mask: (v: string) => string; parse: (v: string) => unknown; options?: string[]; + optionLabels?: Record; + itemType?: "string" | "number"; } // ── Helpers ──────────────────────────────────────────────────────────── @@ -47,6 +50,18 @@ function enumValidator(options: string[]) { return (v: string) => (options.includes(v) ? undefined : `Must be one of: ${options.join(", ")}`); } +function positiveInteger(v: string) { + const n = Number(v); + if (!Number.isInteger(n) || n <= 0) return "Must be a positive integer"; + return undefined; +} + +function validateUrl(v: string) { + if (v === "") return undefined; // empty to reset + if (v.startsWith("http://") || v.startsWith("https://")) return undefined; + return "Must be empty or start with http:// or https://"; +} + // ── Whitelist ────────────────────────────────────────────────────────── export const CONFIGURABLE_KEYS: Record = { @@ -54,6 +69,7 @@ export const CONFIGURABLE_KEYS: Record = { "agent.api_key": { type: "string", category: "API Keys", + label: "LLM API Key", description: "LLM provider API key", sensitive: true, validate: (v) => (v.length >= 10 ? undefined : "Must be at least 10 characters"), @@ -63,6 +79,7 @@ export const CONFIGURABLE_KEYS: Record = { tavily_api_key: { type: "string", category: "API Keys", + label: "Tavily API Key", description: "Tavily API key for web search", sensitive: true, validate: (v) => (v.startsWith("tvly-") ? undefined : "Must start with 'tvly-'"), @@ -72,6 +89,7 @@ export const CONFIGURABLE_KEYS: Record = { tonapi_key: { type: "string", category: "API Keys", + label: "TonAPI Key", description: "TonAPI key for higher rate limits", sensitive: true, validate: (v) => (v.length >= 10 ? undefined : "Must be at least 10 characters"), @@ -81,6 +99,7 @@ export const CONFIGURABLE_KEYS: Record = { "telegram.bot_token": { type: "string", category: "API Keys", + label: "Bot Token", description: "Bot token from @BotFather", sensitive: true, validate: (v) => (v.includes(":") ? undefined : "Must contain ':' (e.g., 123456:ABC...)"), @@ -92,6 +111,7 @@ export const CONFIGURABLE_KEYS: Record = { "agent.provider": { type: "enum", category: "Agent", + label: "Provider", description: "LLM provider", sensitive: false, options: [ @@ -126,6 +146,7 @@ export const CONFIGURABLE_KEYS: Record = { "agent.model": { type: "string", category: "Agent", + label: "Model", description: "Main LLM model ID", sensitive: false, validate: nonEmpty, @@ -135,6 +156,7 @@ export const CONFIGURABLE_KEYS: Record = { "agent.utility_model": { type: "string", category: "Agent", + label: "Utility Model", description: "Cheap model for summarization (auto-detected if empty)", sensitive: false, validate: noValidation, @@ -144,6 +166,7 @@ export const CONFIGURABLE_KEYS: Record = { "agent.temperature": { type: "number", category: "Agent", + label: "Temperature", description: "Response creativity (0.0 = deterministic, 2.0 = max)", sensitive: false, validate: numberInRange(0, 2), @@ -153,6 +176,7 @@ export const CONFIGURABLE_KEYS: Record = { "agent.max_tokens": { type: "number", category: "Agent", + label: "Max Tokens", description: "Maximum response length in tokens", sensitive: false, validate: numberInRange(256, 128000), @@ -162,17 +186,39 @@ export const CONFIGURABLE_KEYS: Record = { "agent.max_agentic_iterations": { type: "number", category: "Agent", + label: "Max Iterations", description: "Max tool-call loop iterations per message", sensitive: false, validate: numberInRange(1, 20), mask: identity, parse: (v) => Number(v), }, + "agent.base_url": { + type: "string", + category: "Agent", + label: "API Base URL", + description: "Base URL for local LLM server (requires restart)", + sensitive: false, + validate: validateUrl, + mask: identity, + parse: identity, + }, + "cocoon.port": { + type: "number", + category: "Agent", + label: "Cocoon Port", + description: "Cocoon proxy port (requires restart)", + sensitive: false, + validate: numberInRange(1, 65535), + mask: identity, + parse: (v) => Number(v), + }, // ─── Session ─────────────────────────────────────────────────── "agent.session_reset_policy.daily_reset_enabled": { type: "boolean", category: "Session", + label: "Daily Reset", description: "Enable daily session reset at specified hour", sensitive: false, validate: enumValidator(["true", "false"]), @@ -182,6 +228,7 @@ export const CONFIGURABLE_KEYS: Record = { "agent.session_reset_policy.daily_reset_hour": { type: "number", category: "Session", + label: "Reset Hour", description: "Hour (0-23 UTC) for daily session reset", sensitive: false, validate: numberInRange(0, 23), @@ -191,6 +238,7 @@ export const CONFIGURABLE_KEYS: Record = { "agent.session_reset_policy.idle_expiry_enabled": { type: "boolean", category: "Session", + label: "Idle Expiry", description: "Enable automatic session expiry after idle period", sensitive: false, validate: enumValidator(["true", "false"]), @@ -200,6 +248,7 @@ export const CONFIGURABLE_KEYS: Record = { "agent.session_reset_policy.idle_expiry_minutes": { type: "number", category: "Session", + label: "Idle Minutes", description: "Idle minutes before session expires (minimum 1)", sensitive: false, validate: numberInRange(1, Number.MAX_SAFE_INTEGER), @@ -211,6 +260,7 @@ export const CONFIGURABLE_KEYS: Record = { "telegram.bot_username": { type: "string", category: "Telegram", + label: "Bot Username", description: "Bot username without @", sensitive: false, validate: (v) => (v.length >= 3 ? undefined : "Must be at least 3 characters"), @@ -220,19 +270,23 @@ export const CONFIGURABLE_KEYS: Record = { "telegram.dm_policy": { type: "enum", category: "Telegram", - description: "DM access policy", + label: "DM Policy", + description: "Who can message the bot in private", sensitive: false, - options: ["pairing", "allowlist", "open", "disabled"], - validate: enumValidator(["pairing", "allowlist", "open", "disabled"]), + options: ["open", "allowlist", "disabled"], + optionLabels: { open: "Open", allowlist: "Allow Users", disabled: "Admin Only" }, + validate: enumValidator(["allowlist", "open", "disabled"]), mask: identity, parse: identity, }, "telegram.group_policy": { type: "enum", category: "Telegram", - description: "Group access policy", + label: "Group Policy", + description: "Which groups the bot can respond in", sensitive: false, options: ["open", "allowlist", "disabled"], + optionLabels: { open: "Open", allowlist: "Allow Groups", disabled: "Disabled" }, validate: enumValidator(["open", "allowlist", "disabled"]), mask: identity, parse: identity, @@ -240,6 +294,7 @@ export const CONFIGURABLE_KEYS: Record = { "telegram.require_mention": { type: "boolean", category: "Telegram", + label: "Require Mention", description: "Require @mention in groups to respond", sensitive: false, validate: enumValidator(["true", "false"]), @@ -249,6 +304,7 @@ export const CONFIGURABLE_KEYS: Record = { "telegram.owner_name": { type: "string", category: "Telegram", + label: "Owner Name", description: "Owner's first name (used in system prompt)", sensitive: false, validate: noValidation, @@ -258,6 +314,7 @@ export const CONFIGURABLE_KEYS: Record = { "telegram.owner_username": { type: "string", category: "Telegram", + label: "Owner Username", description: "Owner's Telegram username (without @)", sensitive: false, validate: noValidation, @@ -267,6 +324,7 @@ export const CONFIGURABLE_KEYS: Record = { "telegram.debounce_ms": { type: "number", category: "Telegram", + label: "Debounce (ms)", description: "Group message debounce delay in ms (0 = disabled)", sensitive: false, validate: numberInRange(0, 10000), @@ -276,6 +334,7 @@ export const CONFIGURABLE_KEYS: Record = { "telegram.agent_channel": { type: "string", category: "Telegram", + label: "Agent Channel", description: "Channel username for auto-publishing", sensitive: false, validate: noValidation, @@ -285,17 +344,92 @@ export const CONFIGURABLE_KEYS: Record = { "telegram.typing_simulation": { type: "boolean", category: "Telegram", + label: "Typing Simulation", description: "Simulate typing indicator before sending replies", sensitive: false, validate: enumValidator(["true", "false"]), mask: identity, parse: (v) => v === "true", }, + "telegram.owner_id": { + type: "number", + category: "Telegram", + label: "Admin ID", + description: "Primary admin Telegram user ID (auto-added to Admin IDs)", + sensitive: false, + validate: positiveInteger, + mask: identity, + parse: (v) => Number(v), + }, + "telegram.max_message_length": { + type: "number", + category: "Telegram", + label: "Max Message Length", + description: "Maximum message length in characters", + sensitive: false, + validate: numberInRange(1, 32768), + mask: identity, + parse: (v) => Number(v), + }, + "telegram.rate_limit_messages_per_second": { + type: "number", + category: "Telegram", + label: "Rate Limit — Messages/sec", + description: "Rate limit: messages per second (requires restart)", + sensitive: false, + validate: numberInRange(0.1, 10), + mask: identity, + parse: (v) => Number(v), + }, + "telegram.rate_limit_groups_per_minute": { + type: "number", + category: "Telegram", + label: "Rate Limit — Groups/min", + description: "Rate limit: groups per minute (requires restart)", + sensitive: false, + validate: numberInRange(1, 60), + mask: identity, + parse: (v) => Number(v), + }, + "telegram.admin_ids": { + type: "array", + itemType: "number", + category: "Telegram", + label: "Admin IDs", + description: "Admin user IDs with elevated access", + sensitive: false, + validate: positiveInteger, + mask: identity, + parse: (v) => Number(v), + }, + "telegram.allow_from": { + type: "array", + itemType: "number", + category: "Telegram", + label: "Allowed Users", + description: "User IDs allowed for DM access", + sensitive: false, + validate: positiveInteger, + mask: identity, + parse: (v) => Number(v), + }, + "telegram.group_allow_from": { + type: "array", + itemType: "number", + category: "Telegram", + label: "Allowed Groups", + description: "Group IDs allowed for group access", + sensitive: false, + validate: positiveInteger, + mask: identity, + parse: (v) => Number(v), + }, // ─── Embedding ───────────────────────────────────────────────────── "embedding.provider": { type: "enum", category: "Embedding", + label: "Embedding Provider", description: "Embedding provider for RAG", sensitive: false, options: ["local", "anthropic", "none"], @@ -303,11 +437,22 @@ export const CONFIGURABLE_KEYS: Record = { mask: identity, parse: identity, }, + "embedding.model": { + type: "string", + category: "Embedding", + label: "Embedding Model", + description: "Embedding model ID (requires restart)", + sensitive: false, + validate: noValidation, + mask: identity, + parse: identity, + }, // ─── WebUI ───────────────────────────────────────────────────────── "webui.port": { type: "number", category: "WebUI", + label: "WebUI Port", description: "HTTP server port (requires restart)", sensitive: false, validate: numberInRange(1024, 65535), @@ -317,6 +462,7 @@ export const CONFIGURABLE_KEYS: Record = { "webui.log_requests": { type: "boolean", category: "WebUI", + label: "Log HTTP Requests", description: "Log all HTTP requests to console", sensitive: false, validate: enumValidator(["true", "false"]), @@ -328,17 +474,49 @@ export const CONFIGURABLE_KEYS: Record = { "deals.enabled": { type: "boolean", category: "Deals", + label: "Deals Enabled", description: "Enable the deals/escrow module", sensitive: false, validate: enumValidator(["true", "false"]), mask: identity, parse: (v) => v === "true", }, + "deals.expiry_seconds": { + type: "number", + category: "Deals", + label: "Deal Expiry", + description: "Deal expiry timeout in seconds", + sensitive: false, + validate: numberInRange(10, 3600), + mask: identity, + parse: (v) => Number(v), + }, + "deals.buy_max_floor_percent": { + type: "number", + category: "Deals", + label: "Buy Max Floor %", + description: "Maximum floor % for buy deals", + sensitive: false, + validate: numberInRange(1, 100), + mask: identity, + parse: (v) => Number(v), + }, + "deals.sell_min_floor_percent": { + type: "number", + category: "Deals", + label: "Sell Min Floor %", + description: "Minimum floor % for sell deals", + sensitive: false, + validate: numberInRange(100, 500), + mask: identity, + parse: (v) => Number(v), + }, // ─── Developer ───────────────────────────────────────────────────── "dev.hot_reload": { type: "boolean", category: "Developer", + label: "Hot Reload", description: "Watch ~/.teleton/plugins/ for live changes", sensitive: false, validate: enumValidator(["true", "false"]), diff --git a/src/config/loader.ts b/src/config/loader.ts index 174c1d6..1415a2b 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -63,7 +63,6 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config { config.telegram.session_path = expandPath(config.telegram.session_path); config.storage.sessions_file = expandPath(config.storage.sessions_file); - config.storage.pairing_file = expandPath(config.storage.pairing_file); config.storage.memory_file = expandPath(config.storage.memory_file); if (process.env.TELETON_API_KEY) { diff --git a/src/config/schema.ts b/src/config/schema.ts index ceadc6f..8624a30 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../constants/limits.js"; -export const DMPolicy = z.enum(["pairing", "allowlist", "open", "disabled"]); +export const DMPolicy = z.enum(["allowlist", "open", "disabled"]); export const GroupPolicy = z.enum(["open", "allowlist", "disabled"]); export const SessionResetPolicySchema = z.object({ @@ -62,7 +62,7 @@ export const TelegramConfigSchema = z.object({ phone: z.string(), session_name: z.string().default("teleton_session"), session_path: z.string().default("~/.teleton"), - dm_policy: DMPolicy.default("pairing"), + dm_policy: DMPolicy.default("allowlist"), allow_from: z.array(z.number()).default([]), group_policy: GroupPolicy.default("open"), group_allow_from: z.array(z.number()).default([]), @@ -92,7 +92,6 @@ export const TelegramConfigSchema = z.object({ export const StorageConfigSchema = z.object({ sessions_file: z.string().default("~/.teleton/sessions.json"), - pairing_file: z.string().default("~/.teleton/pairing.json"), memory_file: z.string().default("~/.teleton/memory.json"), history_limit: z.number().default(100), }); diff --git a/src/telegram/admin.ts b/src/telegram/admin.ts index 10d3994..56401c7 100644 --- a/src/telegram/admin.ts +++ b/src/telegram/admin.ts @@ -20,7 +20,7 @@ export interface AdminCommand { senderId: number; } -const VALID_DM_POLICIES = ["open", "allowlist", "pairing", "disabled"] as const; +const VALID_DM_POLICIES = ["open", "allowlist", "disabled"] as const; const VALID_GROUP_POLICIES = ["open", "allowlist", "disabled"] as const; const VALID_MODULE_LEVELS = ["open", "admin", "disabled"] as const; diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index 89e49aa..ce944fe 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -205,16 +205,6 @@ export class MessageHandler { }; } break; - case "pairing": - if (!this.config.allow_from.includes(message.senderId) && !isAdmin) { - return { - message, - isAdmin, - shouldRespond: false, - reason: "Not paired", - }; - } - break; case "open": break; } diff --git a/src/webui/__tests__/config-array-routes.test.ts b/src/webui/__tests__/config-array-routes.test.ts new file mode 100644 index 0000000..ad544da --- /dev/null +++ b/src/webui/__tests__/config-array-routes.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +// Mock readRawConfig and writeRawConfig, keep everything else real +const mockReadRawConfig = vi.fn(); +const mockWriteRawConfig = vi.fn(); + +vi.mock("../../config/configurable-keys.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readRawConfig: (...args: any[]) => mockReadRawConfig(...args), + writeRawConfig: (...args: any[]) => mockWriteRawConfig(...args), + }; +}); + +import { createConfigRoutes } from "../routes/config.js"; +import type { WebUIServerDeps } from "../types.js"; + +function createTestApp(mockConfig: Record) { + const deps = { + configPath: "/tmp/test.yaml", + agent: { + getConfig: () => mockConfig, + }, + } as unknown as WebUIServerDeps; + + const app = new Hono(); + app.route("/api/config", createConfigRoutes(deps)); + return app; +} + +describe("GET /api/config — array keys", () => { + let app: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + app = createTestApp({}); + }); + + it("returns array value as JSON string", async () => { + mockReadRawConfig.mockReturnValue({ telegram: { admin_ids: [123, 456] } }); + + const res = await app.request("/api/config"); + expect(res.status).toBe(200); + const json = await res.json(); + const keyData = json.data.find((k: any) => k.key === "telegram.admin_ids"); + expect(keyData.value).toBe("[123,456]"); + }); + + it("returns type 'array' and itemType 'number'", async () => { + mockReadRawConfig.mockReturnValue({ telegram: { admin_ids: [123] } }); + + const res = await app.request("/api/config"); + const json = await res.json(); + const keyData = json.data.find((k: any) => k.key === "telegram.admin_ids"); + expect(keyData.type).toBe("array"); + expect(keyData.itemType).toBe("number"); + }); + + it("returns null value for unset array", async () => { + mockReadRawConfig.mockReturnValue({ telegram: {} }); + + const res = await app.request("/api/config"); + const json = await res.json(); + const keyData = json.data.find((k: any) => k.key === "telegram.admin_ids"); + expect(keyData.set).toBe(false); + expect(keyData.value).toBeNull(); + }); +}); + +describe("PUT /api/config/:key — arrays", () => { + let app: ReturnType; + let mockConfig: Record; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = { telegram: {} }; + app = createTestApp(mockConfig); + mockReadRawConfig.mockReturnValue({ telegram: {} }); + mockWriteRawConfig.mockImplementation(() => {}); + }); + + it("accepts valid array of strings", async () => { + const res = await app.request("/api/config/telegram.admin_ids", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: ["123", "456"] }), + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + + // writeRawConfig should be called with parsed numbers + expect(mockWriteRawConfig).toHaveBeenCalledTimes(1); + const rawArg = mockWriteRawConfig.mock.calls[0][0]; + expect(rawArg.telegram.admin_ids).toEqual([123, 456]); + }); + + it("rejects non-array value for array key", async () => { + const res = await app.request("/api/config/telegram.admin_ids", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: "123" }), + }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toContain("must be an array"); + }); + + it("rejects array with invalid item", async () => { + const res = await app.request("/api/config/telegram.admin_ids", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: ["123", "abc"] }), + }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.success).toBe(false); + }); + + it("accepts empty array", async () => { + const res = await app.request("/api/config/telegram.admin_ids", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: [] }), + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + + const rawArg = mockWriteRawConfig.mock.calls[0][0]; + expect(rawArg.telegram.admin_ids).toEqual([]); + }); + + it("updates runtime config for array", async () => { + await app.request("/api/config/telegram.admin_ids", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: ["123"] }), + }); + + expect(mockConfig.telegram.admin_ids).toEqual([123]); + }); +}); + +describe("PUT /api/config/:key — existing scalars unchanged", () => { + let app: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + app = createTestApp({ agent: { model: "old-model" } }); + mockReadRawConfig.mockReturnValue({ agent: { model: "old-model" } }); + mockWriteRawConfig.mockImplementation(() => {}); + }); + + it("still accepts string value for string key", async () => { + const res = await app.request("/api/config/agent.model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: "claude-opus-4-6" }), + }); + expect(res.status).toBe(200); + }); + + it("still rejects non-whitelisted key", async () => { + const res = await app.request("/api/config/some.unknown.key", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: "x" }), + }); + expect(res.status).toBe(400); + }); +}); + +describe("DELETE /api/config/:key — arrays", () => { + let app: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + app = createTestApp({ telegram: { admin_ids: [123] } }); + mockReadRawConfig.mockReturnValue({ telegram: { admin_ids: [123] } }); + mockWriteRawConfig.mockImplementation(() => {}); + }); + + it("unsets array key", async () => { + const res = await app.request("/api/config/telegram.admin_ids", { + method: "DELETE", + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + expect(json.data.set).toBe(false); + expect(json.data.value).toBeNull(); + }); +}); diff --git a/src/webui/__tests__/tools-rag-persistence.test.ts b/src/webui/__tests__/tools-rag-persistence.test.ts new file mode 100644 index 0000000..909877f --- /dev/null +++ b/src/webui/__tests__/tools-rag-persistence.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +const mockReadRawConfig = vi.fn(); +const mockWriteRawConfig = vi.fn(); + +vi.mock("../../config/configurable-keys.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readRawConfig: (...args: any[]) => mockReadRawConfig(...args), + writeRawConfig: (...args: any[]) => mockWriteRawConfig(...args), + }; +}); + +import { createToolsRoutes } from "../routes/tools.js"; +import type { WebUIServerDeps } from "../types.js"; + +function createTestApp(config: Record) { + const deps = { + configPath: "/tmp/test.yaml", + agent: { + getConfig: () => config, + }, + toolRegistry: { + getAll: () => [], + getAvailableModules: () => [], + getModuleTools: () => [], + getToolConfig: () => null, + getToolCategory: () => undefined, + getToolIndex: () => ({ isIndexed: true }), + count: 50, + has: () => false, + isPluginModule: () => false, + }, + } as unknown as WebUIServerDeps; + + const app = new Hono(); + app.route("/api/tools", createToolsRoutes(deps)); + return app; +} + +function defaultConfig() { + return { + tool_rag: { + enabled: false, + top_k: 20, + always_include: [] as string[], + skip_unlimited_providers: false, + }, + }; +} + +describe("PUT /api/tools/rag — persistence", () => { + let config: ReturnType; + let app: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + config = defaultConfig(); + app = createTestApp(config); + mockReadRawConfig.mockReturnValue({ tool_rag: { enabled: false, top_k: 20 } }); + mockWriteRawConfig.mockImplementation(() => {}); + }); + + it("persists enabled change to YAML", async () => { + const res = await app.request("/api/tools/rag", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }); + expect(res.status).toBe(200); + expect(mockWriteRawConfig).toHaveBeenCalledTimes(1); + }); + + it("persists topK change to YAML", async () => { + const res = await app.request("/api/tools/rag", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topK: 30 }), + }); + expect(res.status).toBe(200); + expect(mockWriteRawConfig).toHaveBeenCalledTimes(1); + // Verify the raw config was updated with top_k + const rawArg = mockWriteRawConfig.mock.calls[0][0]; + expect(rawArg.tool_rag.top_k).toBe(30); + }); + + it("persists both enabled and topK together", async () => { + const res = await app.request("/api/tools/rag", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false, topK: 15 }), + }); + expect(res.status).toBe(200); + expect(mockWriteRawConfig).toHaveBeenCalledTimes(1); + const rawArg = mockWriteRawConfig.mock.calls[0][0]; + expect(rawArg.tool_rag.enabled).toBe(false); + expect(rawArg.tool_rag.top_k).toBe(15); + }); +}); + +describe("PUT /api/tools/rag — new fields", () => { + let config: ReturnType; + let app: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + config = defaultConfig(); + app = createTestApp(config); + mockReadRawConfig.mockReturnValue({ tool_rag: { enabled: false, top_k: 20 } }); + mockWriteRawConfig.mockImplementation(() => {}); + }); + + it("accepts and persists alwaysInclude", async () => { + const res = await app.request("/api/tools/rag", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alwaysInclude: ["telegram_send_*", "journal_*"] }), + }); + expect(res.status).toBe(200); + expect(config.tool_rag.always_include).toEqual(["telegram_send_*", "journal_*"]); + expect(mockWriteRawConfig).toHaveBeenCalledTimes(1); + }); + + it("accepts and persists skipUnlimitedProviders", async () => { + const res = await app.request("/api/tools/rag", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ skipUnlimitedProviders: true }), + }); + expect(res.status).toBe(200); + expect(config.tool_rag.skip_unlimited_providers).toBe(true); + expect(mockWriteRawConfig).toHaveBeenCalledTimes(1); + }); + + it("validates alwaysInclude is array of strings", async () => { + const res = await app.request("/api/tools/rag", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alwaysInclude: "not-array" }), + }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.success).toBe(false); + }); + + it("validates alwaysInclude items are non-empty", async () => { + const res = await app.request("/api/tools/rag", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alwaysInclude: ["valid", ""] }), + }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.success).toBe(false); + }); + + it("returns updated alwaysInclude in response", async () => { + const res = await app.request("/api/tools/rag", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alwaysInclude: ["web_*"] }), + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.alwaysInclude).toEqual(["web_*"]); + }); +}); + +describe("GET /api/tools/rag — existing behavior preserved", () => { + it("returns alwaysInclude from config", async () => { + const config = defaultConfig(); + config.tool_rag.always_include = ["telegram_send_*"]; + const app = createTestApp(config); + + const res = await app.request("/api/tools/rag"); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.alwaysInclude).toEqual(["telegram_send_*"]); + }); + + it("returns skipUnlimitedProviders from config", async () => { + const config = defaultConfig(); + config.tool_rag.skip_unlimited_providers = false; + const app = createTestApp(config); + + const res = await app.request("/api/tools/rag"); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.skipUnlimitedProviders).toBe(false); + }); +}); diff --git a/src/webui/__tests__/workspace-raw.test.ts b/src/webui/__tests__/workspace-raw.test.ts new file mode 100644 index 0000000..937cba9 --- /dev/null +++ b/src/webui/__tests__/workspace-raw.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + rmSync: vi.fn(), + renameSync: vi.fn(), + readdirSync: vi.fn(() => []), + statSync: vi.fn(), + existsSync: vi.fn(() => true), + lstatSync: vi.fn(), +})); + +vi.mock("../../workspace/validator.js", () => ({ + validateReadPath: vi.fn(), + validatePath: vi.fn(), + validateWritePath: vi.fn(), + validateDirectory: vi.fn(), + WorkspaceSecurityError: class WorkspaceSecurityError extends Error { + constructor( + message: string, + public readonly attemptedPath: string + ) { + super(message); + this.name = "WorkspaceSecurityError"; + } + }, +})); + +vi.mock("../../workspace/paths.js", () => ({ + WORKSPACE_ROOT: "/tmp/test-workspace", +})); + +vi.mock("../../utils/errors.js", () => ({ + getErrorMessage: vi.fn((e: unknown) => (e instanceof Error ? e.message : String(e))), +})); + +vi.mock("../../utils/logger.js", () => ({ + createLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), +})); + +import { readFileSync, statSync } from "node:fs"; +import { validateReadPath, WorkspaceSecurityError } from "../../workspace/validator.js"; +import { createWorkspaceRoutes } from "../routes/workspace.js"; +import type { WebUIServerDeps } from "../types.js"; + +describe("GET /workspace/raw", () => { + let app: Hono; + + beforeEach(() => { + vi.clearAllMocks(); + app = new Hono(); + app.route("/workspace", createWorkspaceRoutes({} as WebUIServerDeps)); + }); + + it("serves .png files with correct Content-Type", async () => { + const buf = Buffer.from("fake-png-data"); + vi.mocked(validateReadPath).mockReturnValue({ + absolutePath: "/tmp/test-workspace/test.png", + relativePath: "test.png", + exists: true, + isDirectory: false, + extension: ".png", + filename: "test.png", + }); + vi.mocked(statSync).mockReturnValue({ size: 1024 } as any); + vi.mocked(readFileSync).mockReturnValue(buf as any); + + const res = await app.request("/workspace/raw?path=test.png"); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("image/png"); + const body = Buffer.from(await res.arrayBuffer()); + expect(body).toEqual(buf); + }); + + it("serves .jpg files with correct Content-Type", async () => { + const buf = Buffer.from("fake-jpg-data"); + vi.mocked(validateReadPath).mockReturnValue({ + absolutePath: "/tmp/test-workspace/photo.jpg", + relativePath: "photo.jpg", + exists: true, + isDirectory: false, + extension: ".jpg", + filename: "photo.jpg", + }); + vi.mocked(statSync).mockReturnValue({ size: 1024 } as any); + vi.mocked(readFileSync).mockReturnValue(buf as any); + + const res = await app.request("/workspace/raw?path=photo.jpg"); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("image/jpeg"); + }); + + it("serves .svg files with sandbox CSP header", async () => { + const buf = Buffer.from(""); + vi.mocked(validateReadPath).mockReturnValue({ + absolutePath: "/tmp/test-workspace/icon.svg", + relativePath: "icon.svg", + exists: true, + isDirectory: false, + extension: ".svg", + filename: "icon.svg", + }); + vi.mocked(statSync).mockReturnValue({ size: 256 } as any); + vi.mocked(readFileSync).mockReturnValue(buf as any); + + const res = await app.request("/workspace/raw?path=icon.svg"); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("image/svg+xml"); + expect(res.headers.get("Content-Security-Policy")).toBe("sandbox"); + }); + + it("returns 415 for unsupported file types", async () => { + vi.mocked(validateReadPath).mockReturnValue({ + absolutePath: "/tmp/test-workspace/readme.txt", + relativePath: "readme.txt", + exists: true, + isDirectory: false, + extension: ".txt", + filename: "readme.txt", + }); + + const res = await app.request("/workspace/raw?path=readme.txt"); + + expect(res.status).toBe(415); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("Unsupported"); + }); + + it("returns 400 when path query param is missing", async () => { + const res = await app.request("/workspace/raw"); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("path"); + }); + + it("returns 403 on path traversal attempt", async () => { + vi.mocked(validateReadPath).mockImplementation(() => { + throw new WorkspaceSecurityError("Path traversal detected", "../../etc/passwd"); + }); + + const res = await app.request("/workspace/raw?path=../../etc/passwd"); + + expect(res.status).toBe(403); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("Path traversal"); + }); + + it("returns 413 when file exceeds 5MB limit", async () => { + vi.mocked(validateReadPath).mockReturnValue({ + absolutePath: "/tmp/test-workspace/huge.png", + relativePath: "huge.png", + exists: true, + isDirectory: false, + extension: ".png", + filename: "huge.png", + }); + vi.mocked(statSync).mockReturnValue({ + size: 6 * 1024 * 1024, + } as any); + + const res = await app.request("/workspace/raw?path=huge.png"); + + expect(res.status).toBe(413); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.error).toContain("5MB"); + }); +}); diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index 2d88647..709992b 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -18,6 +18,7 @@ import { interface ConfigKeyData { key: string; + label: string; set: boolean; value: string | null; sensitive: boolean; @@ -25,6 +26,8 @@ interface ConfigKeyData { category: ConfigCategory; description: string; options?: string[]; + optionLabels?: Record; + itemType?: "string" | "number"; } export function createConfigRoutes(deps: WebUIServerDeps) { @@ -36,17 +39,28 @@ export function createConfigRoutes(deps: WebUIServerDeps) { const raw = readRawConfig(deps.configPath); const data: ConfigKeyData[] = Object.entries(CONFIGURABLE_KEYS).map(([key, meta]) => { - const value = getNestedValue(raw, key); - const isSet = value != null && value !== ""; + const rawValue = getNestedValue(raw, key); + const isSet = + rawValue != null && + rawValue !== "" && + !(Array.isArray(rawValue) && rawValue.length === 0); + const displayValue = isSet + ? meta.type === "array" + ? JSON.stringify(rawValue) + : meta.mask(String(rawValue)) + : null; return { key, + label: meta.label, set: isSet, - value: isSet ? meta.mask(String(value)) : null, + value: displayValue, sensitive: meta.sensitive, type: meta.type, category: meta.category, description: meta.description, ...(meta.options ? { options: meta.options } : {}), + ...(meta.optionLabels ? { optionLabels: meta.optionLabels } : {}), + ...(meta.itemType ? { itemType: meta.itemType } : {}), }; }); @@ -75,7 +89,7 @@ export function createConfigRoutes(deps: WebUIServerDeps) { ); } - let body: { value?: string }; + let body: { value?: unknown }; try { body = await c.req.json(); } catch { @@ -83,6 +97,64 @@ export function createConfigRoutes(deps: WebUIServerDeps) { } const value = body.value; + + // ── Array keys ──────────────────────────────────────────────────── + if (meta.type === "array") { + if (!Array.isArray(value)) { + return c.json( + { success: false, error: "Value must be an array for array keys" } as APIResponse, + 400 + ); + } + + // Validate each item + for (let i = 0; i < value.length; i++) { + const itemStr = String(value[i]); + const itemErr = meta.validate(itemStr); + if (itemErr) { + return c.json( + { + success: false, + error: `Invalid item at index ${i} for ${key}: ${itemErr}`, + } as APIResponse, + 400 + ); + } + } + + try { + const parsed = value.map((item) => meta.parse(String(item))); + const raw = readRawConfig(deps.configPath); + setNestedValue(raw, key, parsed); + writeRawConfig(raw, deps.configPath); + + const runtimeConfig = deps.agent.getConfig() as Record; + setNestedValue(runtimeConfig, key, parsed); + + const result: ConfigKeyData = { + key, + label: meta.label, + set: parsed.length > 0, + value: JSON.stringify(parsed), + sensitive: meta.sensitive, + type: meta.type, + category: meta.category, + description: meta.description, + ...(meta.itemType ? { itemType: meta.itemType } : {}), + }; + return c.json({ success: true, data: result } as APIResponse); + } catch (err) { + return c.json( + { + success: false, + error: err instanceof Error ? err.message : String(err), + } as APIResponse, + 500 + ); + } + } + + // ── Scalar keys ─────────────────────────────────────────────────── if (value == null || typeof value !== "string") { return c.json( { success: false, error: "Missing or invalid 'value' field" } as APIResponse, @@ -102,14 +174,33 @@ export function createConfigRoutes(deps: WebUIServerDeps) { const parsed = meta.parse(value); const raw = readRawConfig(deps.configPath); setNestedValue(raw, key, parsed); + + // Auto-sync: setting owner_id also adds it to admin_ids + if (key === "telegram.owner_id" && typeof parsed === "number") { + const adminIds: number[] = (getNestedValue(raw, "telegram.admin_ids") as number[]) ?? []; + if (!adminIds.includes(parsed)) { + setNestedValue(raw, "telegram.admin_ids", [...adminIds, parsed]); + } + } + writeRawConfig(raw, deps.configPath); // Update runtime config for immediate effect const runtimeConfig = deps.agent.getConfig() as Record; setNestedValue(runtimeConfig, key, parsed); + // Sync runtime admin_ids too + if (key === "telegram.owner_id" && typeof parsed === "number") { + const rtAdminIds: number[] = + (getNestedValue(runtimeConfig, "telegram.admin_ids") as number[]) ?? []; + if (!rtAdminIds.includes(parsed)) { + setNestedValue(runtimeConfig, "telegram.admin_ids", [...rtAdminIds, parsed]); + } + } + const result: ConfigKeyData = { key, + label: meta.label, set: true, value: meta.mask(value), sensitive: meta.sensitive, @@ -153,6 +244,7 @@ export function createConfigRoutes(deps: WebUIServerDeps) { const result: ConfigKeyData = { key, + label: meta.label, set: false, value: null, sensitive: meta.sensitive, @@ -160,6 +252,7 @@ export function createConfigRoutes(deps: WebUIServerDeps) { category: meta.category, description: meta.description, ...(meta.options ? { options: meta.options } : {}), + ...(meta.itemType ? { itemType: meta.itemType } : {}), }; return c.json({ success: true, data: result } as APIResponse); } catch (err) { diff --git a/src/webui/routes/memory.ts b/src/webui/routes/memory.ts index d61c73e..c265fe1 100644 --- a/src/webui/routes/memory.ts +++ b/src/webui/routes/memory.ts @@ -1,5 +1,11 @@ import { Hono } from "hono"; -import type { WebUIServerDeps, MemorySearchResult, SessionInfo, APIResponse } from "../types.js"; +import type { + WebUIServerDeps, + MemorySearchResult, + MemorySourceFile, + SessionInfo, + APIResponse, +} from "../types.js"; import { getErrorMessage } from "../../utils/errors.js"; export function createMemoryRoutes(deps: WebUIServerDeps) { @@ -157,5 +163,95 @@ export function createMemoryRoutes(deps: WebUIServerDeps) { } }); + // Get chunks for a specific source + app.get("/sources/:sourceKey", (c) => { + try { + const sourceKey = decodeURIComponent(c.req.param("sourceKey")); + + const rows = deps.memory.db + .prepare( + ` + SELECT id, text, source, path, start_line, end_line, updated_at + FROM knowledge + WHERE COALESCE(path, source) = ? + ORDER BY start_line ASC, updated_at DESC + ` + ) + .all(sourceKey) as Array<{ + id: string; + text: string; + source: string; + path: string | null; + start_line: number | null; + end_line: number | null; + updated_at: number; + }>; + + const chunks = rows.map((row) => ({ + id: row.id, + text: row.text, + source: row.path || row.source, + startLine: row.start_line, + endLine: row.end_line, + updatedAt: row.updated_at, + })); + + const response: APIResponse = { + success: true, + data: chunks, + }; + + return c.json(response); + } catch (error) { + const response: APIResponse = { + success: false, + error: getErrorMessage(error), + }; + return c.json(response, 500); + } + }); + + // List indexed sources (grouped by file/source category) + app.get("/sources", (c) => { + try { + const rows = deps.memory.db + .prepare( + ` + SELECT + COALESCE(path, source) AS source_key, + COUNT(*) AS entry_count, + MAX(updated_at) AS last_updated + FROM knowledge + GROUP BY source_key + ORDER BY last_updated DESC + ` + ) + .all() as Array<{ + source_key: string; + entry_count: number; + last_updated: number; + }>; + + const sources: MemorySourceFile[] = rows.map((row) => ({ + source: row.source_key, + entryCount: row.entry_count, + lastUpdated: row.last_updated, + })); + + const response: APIResponse = { + success: true, + data: sources, + }; + + return c.json(response); + } catch (error) { + const response: APIResponse = { + success: false, + error: getErrorMessage(error), + }; + return c.json(response, 500); + } + }); + return app; } diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts index 51d48fa..e2fec63 100644 --- a/src/webui/routes/setup.ts +++ b/src/webui/routes/setup.ts @@ -471,7 +471,6 @@ export function createSetupRoutes(): Hono { }, storage: { sessions_file: `${workspace.root}/sessions.json`, - pairing_file: `${workspace.root}/pairing.json`, memory_file: `${workspace.root}/memory.json`, history_limit: 100, }, diff --git a/src/webui/routes/tasks.ts b/src/webui/routes/tasks.ts index 0ae346c..4669a50 100644 --- a/src/webui/routes/tasks.ts +++ b/src/webui/routes/tasks.ts @@ -4,6 +4,7 @@ import { getTaskStore, type TaskStatus } from "../../memory/agent/tasks.js"; import { getErrorMessage } from "../../utils/errors.js"; const VALID_STATUSES: TaskStatus[] = ["pending", "in_progress", "done", "failed", "cancelled"]; +const TERMINAL_STATUSES: TaskStatus[] = ["done", "failed", "cancelled"]; export function createTasksRoutes(deps: WebUIServerDeps) { const app = new Hono(); @@ -92,7 +93,38 @@ export function createTasksRoutes(deps: WebUIServerDeps) { } }); - // Clean done tasks (bulk delete) + // Clean tasks by terminal status (bulk delete) + app.post("/clean", async (c) => { + try { + const body = await c.req.json<{ status?: string }>().catch(() => ({ status: undefined })); + const status = body.status as TaskStatus | undefined; + + if (!status || !TERMINAL_STATUSES.includes(status as TaskStatus)) { + const response: APIResponse = { + success: false, + error: `Invalid status. Must be one of: ${TERMINAL_STATUSES.join(", ")}`, + }; + return c.json(response, 400); + } + + const tasks = store().listTasks({ status }); + let deleted = 0; + for (const t of tasks) { + if (store().deleteTask(t.id)) deleted++; + } + + const response: APIResponse = { success: true, data: { deleted } }; + return c.json(response); + } catch (error) { + const response: APIResponse = { + success: false, + error: getErrorMessage(error), + }; + return c.json(response, 500); + } + }); + + // Backward-compatible alias app.post("/clean-done", (c) => { try { const doneTasks = store().listTasks({ status: "done" }); diff --git a/src/webui/routes/tools.ts b/src/webui/routes/tools.ts index 4c1c129..898f9e2 100644 --- a/src/webui/routes/tools.ts +++ b/src/webui/routes/tools.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { WebUIServerDeps, ToolInfo, ModuleInfo, APIResponse } from "../types.js"; import { getErrorMessage } from "../../utils/errors.js"; +import { readRawConfig, setNestedValue, writeRawConfig } from "../../config/configurable-keys.js"; export function createToolsRoutes(deps: WebUIServerDeps) { const app = new Hono(); @@ -86,7 +87,12 @@ export function createToolsRoutes(deps: WebUIServerDeps) { try { const config = deps.agent.getConfig(); const body = await c.req.json(); - const { enabled, topK } = body as { enabled?: boolean; topK?: number }; + const { enabled, topK, alwaysInclude, skipUnlimitedProviders } = body as { + enabled?: boolean; + topK?: number; + alwaysInclude?: string[]; + skipUnlimitedProviders?: boolean; + }; if (enabled !== undefined) { config.tool_rag.enabled = enabled; @@ -97,6 +103,33 @@ export function createToolsRoutes(deps: WebUIServerDeps) { } config.tool_rag.top_k = topK; } + if (alwaysInclude !== undefined) { + if ( + !Array.isArray(alwaysInclude) || + alwaysInclude.some((s) => typeof s !== "string" || s.length === 0) + ) { + return c.json( + { success: false, error: "alwaysInclude must be an array of non-empty strings" }, + 400 + ); + } + config.tool_rag.always_include = alwaysInclude; + } + if (skipUnlimitedProviders !== undefined) { + config.tool_rag.skip_unlimited_providers = skipUnlimitedProviders; + } + + // Persist to YAML + const raw = readRawConfig(deps.configPath); + setNestedValue(raw, "tool_rag.enabled", config.tool_rag.enabled); + setNestedValue(raw, "tool_rag.top_k", config.tool_rag.top_k); + setNestedValue(raw, "tool_rag.always_include", config.tool_rag.always_include); + setNestedValue( + raw, + "tool_rag.skip_unlimited_providers", + config.tool_rag.skip_unlimited_providers + ); + writeRawConfig(raw, deps.configPath); const toolIndex = deps.toolRegistry.getToolIndex(); const response: APIResponse = { @@ -106,6 +139,8 @@ export function createToolsRoutes(deps: WebUIServerDeps) { indexed: toolIndex?.isIndexed ?? false, topK: config.tool_rag.top_k, totalTools: deps.toolRegistry.count, + alwaysInclude: config.tool_rag.always_include, + skipUnlimitedProviders: config.tool_rag.skip_unlimited_providers, }, }; return c.json(response); diff --git a/src/webui/routes/workspace.ts b/src/webui/routes/workspace.ts index 84d2c24..aa66193 100644 --- a/src/webui/routes/workspace.ts +++ b/src/webui/routes/workspace.ts @@ -42,6 +42,17 @@ function errorResponse(c: any, error: unknown, status: number = 500) { return c.json(response, code); } +const IMAGE_MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", +}; + /** Recursively count files and total size */ function getWorkspaceStats(dir: string): { files: number; size: number } { let files = 0; @@ -140,6 +151,57 @@ export function createWorkspaceRoutes(_deps: WebUIServerDeps) { } }); + // Serve raw image file with correct MIME type + app.get("/raw", (c) => { + try { + const path = c.req.query("path"); + if (!path) { + const response: APIResponse = { success: false, error: "Missing 'path' query parameter" }; + return c.json(response, 400); + } + + const validated = validateReadPath(path); + const mime = IMAGE_MIME_TYPES[validated.extension]; + + if (!mime) { + const response: APIResponse = { + success: false, + error: "Unsupported file type for raw preview", + }; + return c.json(response, 415); + } + + const stats = statSync(validated.absolutePath); + + // 5MB limit for image preview + if (stats.size > 5 * 1024 * 1024) { + const response: APIResponse = { + success: false, + error: "Image too large for preview (max 5MB)", + }; + return c.json(response, 413); + } + + const buffer = readFileSync(validated.absolutePath); + + const headers: Record = { + "Content-Type": mime, + "Content-Length": String(buffer.byteLength), + "Content-Disposition": "inline", + "Cache-Control": "private, max-age=60", + }; + + // SVG security: sandbox to prevent script execution if opened directly + if (validated.extension === ".svg") { + headers["Content-Security-Policy"] = "sandbox"; + } + + return c.body(buffer, 200, headers); + } catch (error) { + return errorResponse(c, error); + } + }); + // Read file content app.get("/read", (c) => { try { diff --git a/src/webui/types.ts b/src/webui/types.ts index e5e1354..0055fa4 100644 --- a/src/webui/types.ts +++ b/src/webui/types.ts @@ -137,3 +137,9 @@ export interface SessionInfo { contextTokens: number; lastActivity: number; } + +export interface MemorySourceFile { + source: string; + entryCount: number; + lastUpdated: number; +} diff --git a/web/src/components/ArrayInput.tsx b/web/src/components/ArrayInput.tsx new file mode 100644 index 0000000..efc9976 --- /dev/null +++ b/web/src/components/ArrayInput.tsx @@ -0,0 +1,260 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; + +interface ArrayInputProps { + value: string[]; + onChange: (values: string[]) => void; + validate?: (item: string) => string | null; + placeholder?: string; + disabled?: boolean; +} + +export function ArrayInput({ value, onChange, validate, placeholder, disabled }: ArrayInputProps) { + const [draft, setDraft] = useState(value); + const [inputValue, setInputValue] = useState(''); + const [error, setError] = useState(null); + const [focusedChip, setFocusedChip] = useState(-1); + const [hoverRemove, setHoverRemove] = useState(-1); + + const inputRef = useRef(null); + const chipRefs = useRef<(HTMLDivElement | null)[]>([]); + + // Reset draft when props.value changes + useEffect(() => { + setDraft(value); + }, [value]); + + const isDirty = JSON.stringify(draft) !== JSON.stringify(value); + + const addItem = useCallback((raw: string) => { + const item = raw.trim(); + if (!item) return; + if (validate) { + const err = validate(item); + if (err) { setError(err); return; } + } + if (draft.includes(item)) { + setError('Duplicate item'); + return; + } + setError(null); + setDraft(prev => [...prev, item]); + setInputValue(''); + }, [draft, validate]); + + const removeItem = useCallback((index: number) => { + setDraft(prev => prev.filter((_, i) => i !== index)); + // Focus adjacent chip or input + if (draft.length <= 1) { + inputRef.current?.focus(); + setFocusedChip(-1); + } else if (index >= draft.length - 1) { + // Removed last chip, focus the new last + const newIdx = index - 1; + setFocusedChip(newIdx); + setTimeout(() => chipRefs.current[newIdx]?.focus(), 0); + } else { + // Focus the chip that takes this position + setFocusedChip(index); + setTimeout(() => chipRefs.current[index]?.focus(), 0); + } + }, [draft.length]); + + const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.nativeEvent.isComposing) return; + + if (e.key === 'Enter') { + e.preventDefault(); + addItem(inputValue); + } else if (e.key === 'Backspace' && inputValue === '' && draft.length > 0) { + const lastIdx = draft.length - 1; + setFocusedChip(lastIdx); + chipRefs.current[lastIdx]?.focus(); + } + }, [inputValue, addItem, draft.length]); + + const handleChipKeyDown = useCallback((e: React.KeyboardEvent, index: number) => { + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + if (index > 0) { + setFocusedChip(index - 1); + chipRefs.current[index - 1]?.focus(); + } + break; + case 'ArrowRight': + e.preventDefault(); + if (index < draft.length - 1) { + setFocusedChip(index + 1); + chipRefs.current[index + 1]?.focus(); + } else { + setFocusedChip(-1); + inputRef.current?.focus(); + } + break; + case 'Delete': + case 'Backspace': + e.preventDefault(); + removeItem(index); + break; + case 'Escape': + e.preventDefault(); + setFocusedChip(-1); + inputRef.current?.focus(); + break; + case 'Home': + e.preventDefault(); + setFocusedChip(0); + chipRefs.current[0]?.focus(); + break; + case 'End': + e.preventDefault(); + if (draft.length > 0) { + const last = draft.length - 1; + setFocusedChip(last); + chipRefs.current[last]?.focus(); + } + break; + } + }, [draft.length, removeItem]); + + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const text = e.clipboardData.getData('text'); + const items = text.split(/[,;\n\t]+/).map(s => s.trim()).filter(Boolean); + if (items.length <= 1) return; // Let normal paste handle single values + + e.preventDefault(); + const toAdd: string[] = []; + for (const item of items) { + if (draft.includes(item) || toAdd.includes(item)) continue; + if (validate) { + const err = validate(item); + if (err) continue; // Skip invalid items silently on paste + } + toAdd.push(item); + } + if (toAdd.length > 0) { + setDraft(prev => [...prev, ...toAdd]); + setError(null); + } + }, [draft, validate]); + + const handleSave = () => { onChange(draft); }; + const handleCancel = () => { setDraft(value); setError(null); }; + + return ( +
+
{ if (!disabled) inputRef.current?.focus(); }} + > + {draft.map((item, idx) => ( +
{ chipRefs.current[idx] = el; }} + role="option" + aria-selected="true" + tabIndex={focusedChip === idx ? 0 : -1} + onKeyDown={e => handleChipKeyDown(e, idx)} + onFocus={() => setFocusedChip(idx)} + onBlur={() => { if (focusedChip === idx) setFocusedChip(-1); }} + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--space-xs)', + padding: '2px var(--space-sm)', + background: 'var(--accent-dim)', + color: 'var(--text)', + borderRadius: 'calc(var(--radius-sm) - 2px)', + fontSize: 'var(--font-sm)', + outline: focusedChip === idx ? '2px solid var(--accent)' : 'none', + outlineOffset: focusedChip === idx ? '1px' : undefined, + }} + > + {item} + +
+ ))} + { setInputValue(e.target.value); setError(null); }} + onKeyDown={handleInputKeyDown} + onPaste={handlePaste} + placeholder={draft.length === 0 ? placeholder : undefined} + disabled={disabled} + aria-invalid={error ? true : undefined} + style={{ + flex: 1, + minWidth: '120px', + background: 'transparent', + border: 'none', + outline: 'none', + color: 'var(--text)', + fontSize: 'var(--font-sm)', + padding: 0, + }} + /> + +
+ {error && ( +
+ {error} +
+ )} + {isDirty && ( +
+ + +
+ )} +
+ ); +} diff --git a/web/src/components/TelegramSettingsPanel.tsx b/web/src/components/TelegramSettingsPanel.tsx index 2eca6bb..05903d8 100644 --- a/web/src/components/TelegramSettingsPanel.tsx +++ b/web/src/components/TelegramSettingsPanel.tsx @@ -46,7 +46,8 @@ export function TelegramSettingsPanel({ getLocal, setLocal, saveConfig, extended saveConfig('telegram.group_policy', v)} />
diff --git a/web/src/hooks/useConfigState.ts b/web/src/hooks/useConfigState.ts index 03ad558..ab5f340 100644 --- a/web/src/hooks/useConfigState.ts +++ b/web/src/hooks/useConfigState.ts @@ -71,7 +71,7 @@ export function useConfigState() { } }; - const saveToolRag = async (update: { enabled?: boolean; topK?: number }) => { + const saveToolRag = async (update: { enabled?: boolean; topK?: number; alwaysInclude?: string[]; skipUnlimitedProviders?: boolean }) => { try { const res = await api.updateToolRag(update); setToolRag(res.data); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d2c4da6..3f4c975 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -120,6 +120,21 @@ export interface SearchResult { keywordScore?: number; } +export interface MemorySourceFile { + source: string; + entryCount: number; + lastUpdated: number; +} + +export interface MemoryChunk { + id: string; + text: string; + source: string; + startLine: number | null; + endLine: number | null; + updatedAt: number; +} + export interface ToolInfo { name: string; description: string; @@ -206,11 +221,14 @@ export interface McpServerInfo { export interface ConfigKeyData { key: string; + label: string; set: boolean; value: string | null; sensitive: boolean; - type: 'string' | 'number' | 'boolean' | 'enum'; + type: 'string' | 'number' | 'boolean' | 'enum' | 'array'; + itemType?: 'string' | 'number'; options?: string[]; + optionLabels?: Record; category: string; description: string; } @@ -347,6 +365,14 @@ export const api = { return fetchAPI>(`/memory/search?q=${encodeURIComponent(query)}&limit=${limit}`); }, + async getMemorySources() { + return fetchAPI>('/memory/sources'); + }, + + async getSourceChunks(sourceKey: string) { + return fetchAPI>(`/memory/sources/${encodeURIComponent(sourceKey)}`); + }, + async getSoulFile(filename: string) { return fetchAPI>(`/soul/${filename}`); }, @@ -366,7 +392,7 @@ export const api = { return fetchAPI>('/tools/rag'); }, - async updateToolRag(config: { enabled?: boolean; topK?: number }) { + async updateToolRag(config: { enabled?: boolean; topK?: number; alwaysInclude?: string[]; skipUnlimitedProviders?: boolean }) { return fetchAPI>('/tools/rag', { method: 'PUT', body: JSON.stringify(config), @@ -444,6 +470,10 @@ export const api = { return fetchAPI>('/workspace/info'); }, + workspaceRawUrl(path: string): string { + return `/api/workspace/raw?path=${encodeURIComponent(path)}`; + }, + async tasksList(_status?: string) { const qs = _status ? `?status=${_status}` : ''; return fetchAPI>(`/tasks${qs}`); @@ -461,6 +491,13 @@ export const api = { return fetchAPI>(`/tasks/${_id}/cancel`, { method: 'POST' }); }, + async tasksClean(status: string) { + return fetchAPI>('/tasks/clean', { + method: 'POST', + body: JSON.stringify({ status }), + }); + }, + async tasksCleanDone() { return fetchAPI>('/tasks/clean-done', { method: 'POST' }); }, @@ -469,7 +506,7 @@ export const api = { return fetchAPI>('/config'); }, - async setConfigKey(key: string, value: string) { + async setConfigKey(key: string, value: string | string[]) { return fetchAPI>(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }), diff --git a/web/src/pages/Config.tsx b/web/src/pages/Config.tsx index ee4f27e..a93a20b 100644 --- a/web/src/pages/Config.tsx +++ b/web/src/pages/Config.tsx @@ -6,6 +6,7 @@ import { PillBar } from '../components/PillBar'; import { AgentSettingsPanel } from '../components/AgentSettingsPanel'; import { TelegramSettingsPanel } from '../components/TelegramSettingsPanel'; import { Select } from '../components/Select'; +import { ArrayInput } from '../components/ArrayInput'; const TABS = [ { id: 'llm', label: 'LLM' }, @@ -16,7 +17,16 @@ const TABS = [ ]; const API_KEY_KEYS = ['agent.api_key', 'telegram.bot_token', 'tavily_api_key', 'tonapi_key']; -const ADVANCED_KEYS = ['embedding.provider', 'webui.port', 'webui.log_requests', 'deals.enabled', 'dev.hot_reload']; +const TELEGRAM_KEYS = [ + 'telegram.admin_ids', 'telegram.allow_from', 'telegram.group_allow_from', + 'telegram.owner_id', 'telegram.max_message_length', + 'telegram.rate_limit_messages_per_second', 'telegram.rate_limit_groups_per_minute', +]; +const ADVANCED_KEYS = [ + 'embedding.provider', 'embedding.model', 'webui.port', 'webui.log_requests', + 'deals.enabled', 'deals.expiry_seconds', 'deals.buy_max_floor_percent', 'deals.sell_min_floor_percent', + 'agent.base_url', 'dev.hot_reload', +]; export function Config() { const [searchParams, setSearchParams] = useSearchParams(); @@ -35,9 +45,9 @@ export function Config() { setSearchParams({ tab: id }, { replace: true }); }; - // Load raw config keys when switching to API Keys or Advanced tab + // Load raw config keys when switching to tabs that use renderKeyValueList useEffect(() => { - if (activeTab === 'api-keys' || activeTab === 'advanced') { + if (activeTab === 'api-keys' || activeTab === 'advanced' || activeTab === 'telegram') { setKeysLoading(true); api.getConfigKeys() .then((res) => { setConfigKeys(res.data); setKeysLoading(false); }) @@ -100,6 +110,20 @@ export function Config() { setEditValue(''); }; + const handleArraySave = async (key: string, values: string[]) => { + setSaving(true); + config.setError(null); + try { + await api.setConfigKey(key, values); + config.showSuccess(`${key} updated successfully`); + loadKeys(); + } catch (err) { + config.setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + if (config.loading) return
Loading...
; const renderKeyValueList = (filterKeys: string[]) => { @@ -117,7 +141,7 @@ export function Config() { >
- {item.key} + {item.label} )} +
+ {item.key} +
-
- - {item.set && ( + {item.type !== 'array' && ( +
- )} -
+ {item.set && ( + + )} +
+ )}
{item.description}
- {item.set && item.value && editingKey !== item.key && ( -
- {item.value} + {item.type === 'array' ? ( +
+ handleArraySave(item.key, values)} + validate={item.itemType === 'number' ? (v) => (/^\d+$/.test(v) ? null : 'Must be a number') : undefined} + placeholder={item.itemType === 'number' ? 'Enter ID...' : 'Enter value...'} + disabled={saving} + />
- )} + ) : ( + <> + {item.set && item.value && editingKey !== item.key && ( +
+ {item.optionLabels?.[item.value] ?? item.value} +
+ )} - {editingKey === item.key && ( -
- {item.type === 'boolean' ? ( - - ) : ( - setEditValue(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSave(item.key)} - placeholder={`Enter value for ${item.key}...`} - autoFocus - style={{ width: '100%', marginBottom: '8px' }} - /> + {editingKey === item.key && ( +
+ {item.type === 'boolean' ? ( + item.optionLabels![o] ?? o) : undefined} + onChange={setEditValue} + style={{ width: '100%', marginBottom: '8px' }} + /> + ) : ( + setEditValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSave(item.key)} + placeholder={`Enter value for ${item.key}...`} + autoFocus + style={{ width: '100%', marginBottom: '8px' }} + /> + )} +
+ + +
+
)} -
- - -
-
+ )}
)); @@ -256,6 +300,26 @@ export function Config() { />
+ {config.getLocal('agent.provider') === 'cocoon' && ( +
+
Cocoon
+
+ + config.setLocal('cocoon.port', e.target.value)} + onBlur={(e) => config.saveConfig('cocoon.port', e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && config.saveConfig('cocoon.port', e.currentTarget.value)} + placeholder="11434" + style={{ width: '100%' }} + /> +
+
+ )} + {config.toolRag && (
@@ -293,6 +357,31 @@ export function Config() { {config.toolRag.totalTools}
+
+ + +
+
+ + config.saveToolRag({ alwaysInclude: values })} + placeholder="e.g. telegram_send_*" + /> +
)} @@ -301,14 +390,20 @@ export function Config() { {/* Telegram Tab */} {activeTab === 'telegram' && ( -
- -
+ <> +
+ +
+
+
Telegram Settings
+ {renderKeyValueList(TELEGRAM_KEYS)} +
+ )} {/* Session Tab */} diff --git a/web/src/pages/Memory.tsx b/web/src/pages/Memory.tsx index 3fae86c..0bd9428 100644 --- a/web/src/pages/Memory.tsx +++ b/web/src/pages/Memory.tsx @@ -1,72 +1,195 @@ -import { useState } from 'react'; -import { api, SearchResult } from '../lib/api'; +import React, { useState, useEffect, useCallback } from 'react'; +import { api, MemorySourceFile, MemoryChunk } from '../lib/api'; + +function formatDate(epoch: number): string { + return new Date(epoch * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} export function Memory() { - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(''); + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [hasSearched, setHasSearched] = useState(false); - const search = async () => { - if (!query.trim()) return; + // Expanded source state + const [expandedSource, setExpandedSource] = useState(null); + const [chunks, setChunks] = useState([]); + const [chunksLoading, setChunksLoading] = useState(false); + const loadSources = useCallback(async () => { setLoading(true); setError(null); try { - const res = await api.searchKnowledge(query); - setResults(res.data); - setHasSearched(true); + const res = await api.getMemorySources(); + setSources(res.data ?? []); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } + }, []); + + useEffect(() => { + loadSources(); + }, [loadSources]); + + const toggleSource = async (sourceKey: string) => { + if (expandedSource === sourceKey) { + setExpandedSource(null); + setChunks([]); + return; + } + + setExpandedSource(sourceKey); + setChunksLoading(true); + try { + const res = await api.getSourceChunks(sourceKey); + setChunks(res.data ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setChunksLoading(false); + } }; + const lowerFilter = filter.toLowerCase(); + const filtered = lowerFilter + ? sources.filter((s) => s.source.toLowerCase().includes(lowerFilter)) + : sources; + return (

Memory

-

Search knowledge base

+

Browse indexed knowledge sources

-
-
- -
- setQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && search()} - placeholder="Enter search query..." - style={{ flex: 1 }} - /> - -
+
+ {/* Search + refresh bar */} +
+ setFilter(e.target.value)} + placeholder="Filter sources..." + style={{ flex: 1, padding: '6px 10px', fontSize: '13px' }} + /> +
- {error &&
{error}
} - - {hasSearched && results.length === 0 && !error && ( -
No results found
+ {error && ( +
+ {error} + +
)} - {results.length > 0 && ( -
-
Results ({results.length})
- {results.map((result, i) => ( -
-
- {result.source} · Score: {result.score?.toFixed(3) ?? '—'} -
-
{result.text}
-
- ))} + {/* Sources table */} + {loading ? ( +
Loading...
+ ) : filtered.length === 0 ? ( +
+ {filter ? 'No matching sources' : 'No memory files indexed'}
+ ) : ( + + + + + + + + + + {filtered.map((src) => { + const isExpanded = expandedSource === src.source; + return ( + + toggleSource(src.source)} + style={{ + cursor: 'pointer', + borderBottom: isExpanded ? 'none' : '1px solid var(--separator)', + backgroundColor: isExpanded ? 'rgba(255,255,255,0.03)' : undefined, + }} + className="file-row" + > + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
SourceChunksLast Updated
+ + {isExpanded ? '\u25BC' : '\u25B6'} + + {src.source} + + {src.entryCount} + + {formatDate(src.lastUpdated)} +
+ {chunksLoading ? ( +
Loading chunks...
+ ) : chunks.length === 0 ? ( +
No chunks
+ ) : ( +
+ {chunks.map((chunk) => ( +
+
+ {chunk.startLine != null && chunk.endLine != null && ( + Lines {chunk.startLine}–{chunk.endLine} · + )} + {formatDate(chunk.updatedAt)} +
+
+                                    {chunk.text}
+                                  
+
+ ))} +
+ )} +
)}
diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx index 14ceec1..577cb0a 100644 --- a/web/src/pages/Tasks.tsx +++ b/web/src/pages/Tasks.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { api, TaskData } from '../lib/api'; type TaskStatus = TaskData['status']; @@ -20,6 +20,7 @@ const STATUS_LABELS: Record = { cancelled: 'Cancelled', }; + function StatusBadge({ status }: { status: TaskStatus }) { return ( (null); const [filter, setFilter] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); // Detail panel const [selected, setSelected] = useState(null); + // Clean dropdown + confirm modal + const [cleanMenuOpen, setCleanMenuOpen] = useState(false); + const [cleanConfirm, setCleanConfirm] = useState(null); + const cleanRef = useRef(null); + // Always fetch ALL tasks so filter counts are accurate const loadTasks = useCallback(async () => { setLoading(true); @@ -98,6 +105,32 @@ export function Tasks() { loadTasks(); }, [loadTasks]); + // Close clean dropdown on click outside + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (cleanRef.current && !cleanRef.current.contains(e.target as Node)) { + setCleanMenuOpen(false); + } + } + if (cleanMenuOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [cleanMenuOpen]); + + const handleClean = async (status: TaskStatus) => { + setCleanConfirm(null); + setCleanMenuOpen(false); + try { + await api.tasksClean(status); + setError(null); + loadTasks(); + setSelected(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + const cancelTask = async (id: string) => { if (!confirm('Cancel this task?')) return; try { @@ -128,7 +161,16 @@ export function Tasks() { }, {} as Record ); - const filteredTasks = filter ? tasks.filter((t) => t.status === filter) : tasks; + const terminalCount = (counts['done'] || 0) + (counts['failed'] || 0) + (counts['cancelled'] || 0); + const statusFiltered = filter ? tasks.filter((t) => t.status === filter) : tasks; + const trimmedQuery = searchQuery.trim().toLowerCase(); + const filteredTasks = trimmedQuery + ? statusFiltered.filter((t) => + t.description.toLowerCase().includes(trimmedQuery) || + (t.reason ?? '').toLowerCase().includes(trimmedQuery) || + t.id.toLowerCase().includes(trimmedQuery) + ) + : statusFiltered; return (
@@ -147,7 +189,7 @@ export function Tasks() { )} {/* Stats bar */} -
+
setFilter('')} style={{ @@ -174,39 +216,131 @@ export function Tasks() { ))} - {(counts['done'] || 0) > 0 && ( - +
+
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Escape') setSearchQuery(''); }} + style={{ + padding: '4px 24px 4px 12px', + fontSize: '13px', + border: '1px solid var(--separator)', + borderRadius: '14px', + backgroundColor: 'transparent', + color: 'var(--text)', + width: '180px', + outline: 'none', + }} + /> + {searchQuery && ( + + )} +
+ {trimmedQuery && ( + + {filteredTasks.length} of {statusFiltered.length} tasks + + )} +
+ + {/* Clean dropdown */} + {terminalCount > 0 && ( +
+ + {cleanMenuOpen && ( +
+ {(['done', 'failed', 'cancelled'] as TaskStatus[]) + .filter((s) => (counts[s] || 0) > 0) + .map((s) => ( +
{ setCleanMenuOpen(false); setCleanConfirm(s); }} + style={{ display: 'flex', alignItems: 'center', gap: '8px' }} + > + + {STATUS_LABELS[s]} ({counts[s]}) +
+ ))} +
+ )} +
)}
+ {/* Clean confirmation modal */} + {cleanConfirm && ( +
setCleanConfirm(null)}> +
e.stopPropagation()}> +

Clean {STATUS_LABELS[cleanConfirm].toLowerCase()} tasks

+

+ Permanently delete all {counts[cleanConfirm] || 0}{' '} + + {STATUS_LABELS[cleanConfirm].toLowerCase()} + {' '} + tasks? This cannot be undone. +

+
+ + +
+
+
+ )} + {/* Task list */}
{loading ? (
Loading...
) : filteredTasks.length === 0 ? (
- {filter ? `No ${STATUS_LABELS[filter].toLowerCase()} tasks` : 'No tasks yet'} + {trimmedQuery + ? 'No tasks match your search' + : filter ? `No ${STATUS_LABELS[filter].toLowerCase()} tasks` : 'No tasks yet'}
) : ( diff --git a/web/src/pages/Workspace.tsx b/web/src/pages/Workspace.tsx index 46f15a5..17e2227 100644 --- a/web/src/pages/Workspace.tsx +++ b/web/src/pages/Workspace.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { api, FileEntry, WorkspaceInfo } from '../lib/api'; function formatSize(bytes: number): string { @@ -13,6 +13,19 @@ function formatDate(iso: string): string { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } +const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']); +const BINARY_EXTENSIONS = new Set(['zip', 'tar', 'gz', 'bin', 'exe', 'dll', 'so', 'db', 'sqlite', 'wasm', 'pdf']); + +function getExtension(path: string): string { + return path.split('/').pop()?.split('.').pop()?.toLowerCase() ?? ''; +} +function isImageFile(path: string): boolean { + return IMAGE_EXTENSIONS.has(getExtension(path)); +} +function isBinaryFile(path: string): boolean { + return BINARY_EXTENSIONS.has(getExtension(path)); +} + export function Workspace() { const [currentPath, setCurrentPath] = useState(''); const [entries, setEntries] = useState([]); @@ -25,6 +38,7 @@ export function Workspace() { const [editContent, setEditContent] = useState(''); const [editDirty, setEditDirty] = useState(false); const [saving, setSaving] = useState(false); + const [fileMode, setFileMode] = useState<'text' | 'image' | 'binary'>('text'); // Dialog state const [dialog, setDialog] = useState<{ type: 'newFile' | 'newFolder' | 'rename'; target?: string } | null>(null); @@ -58,6 +72,14 @@ export function Workspace() { loadInfo(); }, [loadDir, loadInfo]); + // Warn on tab close when dirty + useEffect(() => { + if (!editDirty) return; + const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [editDirty]); + const navigateTo = (path: string) => { if (!closeEditor()) return; loadDir(path); @@ -70,23 +92,46 @@ export function Workspace() { const openFile = async (path: string) => { try { setError(null); - const res = await api.workspaceRead(path); - setEditingFile(path); - setEditContent(res.data?.content ?? ''); - setEditDirty(false); + if (isImageFile(path)) { + setEditingFile(path); + setFileMode('image'); + setEditContent(''); + setEditDirty(false); + } else if (isBinaryFile(path)) { + setEditingFile(path); + setFileMode('binary'); + setEditContent(''); + setEditDirty(false); + } else { + const res = await api.workspaceRead(path); + setEditingFile(path); + setFileMode('text'); + setEditContent(res.data?.content ?? ''); + setEditDirty(false); + } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }; const closeEditor = (): boolean => { - if (editDirty && !confirm('You have unsaved changes. Discard?')) return false; + if (fileMode === 'text' && editDirty && !confirm('You have unsaved changes. Discard?')) return false; setEditingFile(null); setEditContent(''); setEditDirty(false); + setFileMode('text'); return true; }; + const handleFileClick = (path: string) => { + if (editingFile === path) { + closeEditor(); + } else { + if (!closeEditor()) return; + openFile(path); + } + }; + const saveFile = async () => { if (!editingFile) return; setSaving(true); @@ -282,96 +327,131 @@ export function Workspace() { )} - {entries.map((entry) => ( - entry.isDirectory ? navigateTo(entry.path) : openFile(entry.path)} - style={{ cursor: 'pointer', borderBottom: '1px solid var(--separator)' }} - className="file-row" - > - - - - entry.isDirectory ? navigateTo(entry.path) : handleFileClick(entry.path)} + style={{ + cursor: 'pointer', + borderBottom: isExpanded ? 'none' : '1px solid var(--separator)', + backgroundColor: isExpanded ? 'rgba(255,255,255,0.03)' : undefined, + }} + className="file-row" > - 🗑 - - - - ))} + + + + + + {isExpanded && ( + +
- {entry.isDirectory ? '\u{1F4C2}' : '\u{1F4C4}'} - {entry.name} - - {entry.isDirectory ? '' : formatSize(entry.size)} - - {formatDate(entry.mtime)} - e.stopPropagation()}> - -
+ {entry.isDirectory ? ( + {'\u{1F4C2}'} + ) : ( + + {isExpanded ? '\u25BC' : '\u25B6'} + + )} + {entry.name} + + {entry.isDirectory ? '' : formatSize(entry.size)} + + {formatDate(entry.mtime)} + e.stopPropagation()}> + + +
+
+ + {editingFile} + {fileMode === 'text' && editDirty && *} + +
+ {fileMode === 'text' && ( + + )} + +
+
+ {fileMode === 'text' && ( +