From 041f855ce9d76732dc458d246d322f36ae6b91e8 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Wed, 3 Jun 2026 03:14:34 +0200 Subject: [PATCH 01/16] feat(gocoon): replace cocoon provider with native gocoon + turnkey lifecycle - Replace the cocoon LLM provider with gocoon (pure-Go COCOON client, native OpenAI tool-calling); delete the XML tool-injection shim (src/cocoon/). - src/gocoon/: installer (download v0.2.0 + SHA-256 + cache), supervisor (spawn gocoon-runner + health + watchdog), lifecycle (init/waitFunded/ topup/withdrawAll) with a shared ProgressSink. - CLI: teleton gocoon init|topup|withdraw|status. - WebUI: /api/gocoon/* routes + Gocoon panel in Config. - Auto-start: provider=gocoon installs + supervises the runner on start. --- CHANGELOG.md | 4 + GETTING_STARTED.md | 4 +- README.md | 10 +- docs/configuration.md | 16 +- docs/deployment.md | 2 +- src/agent/__tests__/runtime-hooks.test.ts | 6 +- src/agent/client.ts | 47 +-- src/agent/runtime.ts | 16 +- .../tools/telegram/media/vision-analyze.ts | 2 +- src/cli/commands/doctor.ts | 2 +- src/cli/commands/gocoon.ts | 166 +++++++++ src/cli/commands/onboard.ts | 46 +-- src/cli/index.ts | 4 + src/cocoon/__tests__/cocoon-mock.test.ts | 208 ----------- src/cocoon/__tests__/tool-adapter.test.ts | 328 ------------------ src/cocoon/tool-adapter.ts | 246 ------------- .../__tests__/configurable-keys.test.ts | 4 +- src/config/configurable-keys.ts | 6 +- src/config/providers.ts | 14 +- src/config/schema.ts | 10 +- src/gocoon/cli.ts | 140 ++++++++ src/gocoon/index.ts | 29 ++ src/gocoon/installer.ts | 132 +++++++ src/gocoon/lifecycle.ts | 328 ++++++++++++++++++ src/gocoon/paths.ts | 26 ++ src/gocoon/supervisor.ts | 196 +++++++++++ src/index.ts | 49 ++- .../__tests__/observation-masking.test.ts | 21 -- src/memory/observation-masking.ts | 38 +- src/providers/model-resolver.ts | 24 +- src/webui/__tests__/setup-routes.test.ts | 18 +- src/webui/routes/config.ts | 2 +- src/webui/routes/gocoon.ts | 139 ++++++++ src/webui/routes/setup.ts | 4 +- src/webui/routes/shared.ts | 2 + web/src/components/GocoonPanel.tsx | 229 ++++++++++++ web/src/components/ProviderControl.tsx | 4 +- web/src/components/setup/ProviderStep.tsx | 16 +- web/src/components/setup/SetupContext.tsx | 14 +- web/src/lib/api.ts | 25 +- web/src/pages/Config.tsx | 24 +- 41 files changed, 1592 insertions(+), 1009 deletions(-) create mode 100644 src/cli/commands/gocoon.ts delete mode 100644 src/cocoon/__tests__/cocoon-mock.test.ts delete mode 100644 src/cocoon/__tests__/tool-adapter.test.ts delete mode 100644 src/cocoon/tool-adapter.ts create mode 100644 src/gocoon/cli.ts create mode 100644 src/gocoon/index.ts create mode 100644 src/gocoon/installer.ts create mode 100644 src/gocoon/lifecycle.ts create mode 100644 src/gocoon/paths.ts create mode 100644 src/gocoon/supervisor.ts create mode 100644 src/webui/routes/gocoon.ts create mode 100644 web/src/components/GocoonPanel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 58be54bb..66226d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Replaced the `cocoon` provider with `gocoon`** — a native OpenAI-compatible, pure-Go COCOON client ([gocoon](https://github.com/TONresistor/gocoon)). Tool-calling now uses the model's native `tools`/`tool_calls` instead of the old XML-injection shim, which has been removed (`src/cocoon/` deleted). **Breaking:** set `agent.provider: gocoon` and rename the `cocoon:` config block to `gocoon:` (`gocoon.port`, default `10000`). + ## [0.8.1] - 2026-03-05 ### Added diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index de017ddb..f99105b0 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -113,7 +113,7 @@ Configuration is in `~/.teleton/config.yaml`. The setup wizard generates everyth ```yaml agent: - provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | cocoon | local + provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | gocoon | local model: "claude-opus-4-5-20251101" max_tokens: 4096 temperature: 0.7 @@ -142,7 +142,7 @@ agent: model: "gpt-4o" ``` -Supported: `anthropic`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `cocoon`, `local` +Supported: `anthropic`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `gocoon`, `local` --- diff --git a/README.md b/README.md index 63b25bd7..51a853ed 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,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/), [Moonshot](https://platform.moonshot.ai/), [Mistral](https://console.mistral.ai/), [Cerebras](https://cloud.cerebras.ai/), [ZAI](https://open.bigmodel.cn/), [MiniMax](https://platform.minimaxi.com/), [Hugging Face](https://huggingface.co/settings/tokens) — or keyless: Codex (auto-detect), Cocoon (TON), Local (Ollama/vLLM) +- **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/), [Cerebras](https://cloud.cerebras.ai/), [ZAI](https://open.bigmodel.cn/), [MiniMax](https://platform.minimaxi.com/), [Hugging Face](https://huggingface.co/settings/tokens) — or keyless: Codex (auto-detect), Gocoon (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) @@ -172,7 +172,7 @@ The `teleton setup` wizard generates a fully configured `~/.teleton/config.yaml` ```yaml agent: - provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | cocoon | local + provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | gocoon | local api_key: "sk-ant-api03-..." model: "claude-haiku-4-5-20251001" utility_model: "claude-haiku-4-5-20251001" # for summarization, compaction, vision @@ -235,7 +235,7 @@ ton_proxy: # Optional: .ton domain proxy
ZAI
GLM-5


MiniMax
M2.5


Hugging Face
DeepSeek V3.2

-
Cocoon
Decentralized (TON)

+
Gocoon
Decentralized (TON)


Local
Ollama, vLLM, LM Studio

@@ -475,7 +475,7 @@ The SDK provides namespaced access to core services: | Layer | Technology | |-------|------------| -| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (15 providers: Anthropic, Codex, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, Cocoon, Local) | +| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (15 providers: Anthropic, Codex, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, Gocoon, Local) | | Telegram Userbot | [GramJS](https://gram.js.org/) Layer 223 fork (MTProto) | | Inline Bot | [Grammy](https://grammy.dev/) (Bot API, for deals) | | Blockchain | [TON SDK](https://github.com/ton-org/ton) (W5R1 wallet) | @@ -593,7 +593,7 @@ packages/sdk/ # Published @teleton-agent/sdk | **Payment security** | `INSERT OR IGNORE` on tx hashes prevents double-spend, atomic status transitions prevent race conditions | | **Exec audit** | All YOLO mode commands logged to `exec_audit` table with user, command, output, and timestamps | | **Pino redaction** | Structured logging with automatic redaction of apiKey, password, secret, token, mnemonic fields | -| **Tool scoping** | Financial tools DM-only, moderation group-only, admin-only policies, per-chat permissions configurable at runtime | +| **Tool access control** | Per-tool access level (all, allow-list, admin, off), DM vs group gated by global policies, per-group module permissions, all runtime-configurable | ### Reporting Vulnerabilities diff --git a/docs/configuration.md b/docs/configuration.md index 60e3364f..f81bbfb8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,7 +23,7 @@ Run `teleton setup` to generate a config file interactively, or copy `config.exa - [plugins](#plugins) - [ton_proxy](#ton_proxy) - [api](#api) -- [cocoon](#cocoon) +- [gocoon](#gocoon) - [tonapi_key](#tonapi_key) - [toncenter_api_key](#toncenter_api_key) - [tavily_api_key](#tavily_api_key) @@ -38,7 +38,7 @@ LLM provider and agentic loop configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `agent.provider` | `enum` | `"anthropic"` | LLM provider. One of: `anthropic`, `codex`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `cocoon`, `local`. | +| `agent.provider` | `enum` | `"anthropic"` | LLM provider. One of: `anthropic`, `codex`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `gocoon`, `local`. | | `agent.api_key` | `string` | `""` | API key for the chosen provider. Can be overridden with `TELETON_API_KEY` env var. | | `agent.model` | `string` | `"claude-haiku-4-5-20251001"` | Primary model ID. Auto-detected from provider if not set (only for non-Anthropic providers). | | `agent.utility_model` | `string` | *auto-detected* | Cheap/fast model used for summarization and compaction. If omitted, the platform selects one based on the provider (e.g., `claude-haiku-4-5-20251001` for Anthropic, `gpt-4o-mini` for OpenAI). | @@ -96,7 +96,7 @@ When you change the `provider` and omit `model`, the platform auto-selects: | `zai` | `glm-5.1` | `glm-4.5-air` | | `minimax` | `MiniMax-M2.7` | `MiniMax-M2.7` | | `huggingface` | `deepseek-ai/DeepSeek-V3.2` | `Qwen/Qwen3-Next-80B-A3B-Instruct` | -| `cocoon` | `Qwen/Qwen3-32B` | `Qwen/Qwen3-32B` | +| `gocoon` | `Qwen/Qwen3-32B` | `Qwen/Qwen3-32B` | | `local` | `auto` | `auto` | --- @@ -485,20 +485,20 @@ api: --- -## cocoon +## gocoon -Cocoon Network configuration. The Cocoon provider is a decentralized LLM proxy that pays in TON. It requires an external `cocoon-cli` process running on the specified port. +Gocoon configuration. Gocoon is a pure-Go COCOON client — a decentralized LLM that pays in TON. It exposes a native OpenAI-compatible API (with function calling) and requires the `gocoon-runner` process running on the specified port. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `cocoon.port` | `number` | `10000` | HTTP port of the `cocoon-cli` proxy (1-65535). | +| `gocoon.port` | `number` | `10000` | HTTP port of the `gocoon-runner` OpenAI-compatible API (1-65535). | -The `cocoon` section is optional. Only needed when `agent.provider` is set to `"cocoon"`. +The `gocoon` section is optional. Only needed when `agent.provider` is set to `"gocoon"`. ### Example ```yaml -cocoon: +gocoon: port: 10000 ``` diff --git a/docs/deployment.md b/docs/deployment.md index 9b7341a4..4c157d68 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -27,7 +27,7 @@ Before deploying, make sure you have: 1. **Node.js 20+** (required by the `engines` field in `package.json`) 2. **npm** (ships with Node.js) 3. **Telegram API credentials** -- obtain `api_id` and `api_hash` from [my.telegram.org/apps](https://my.telegram.org/apps) -4. **LLM API key** -- from your chosen provider (Anthropic, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, or Cocoon) +4. **LLM API key** -- from your chosen provider (Anthropic, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, or Gocoon) 5. **Build tools** (only for source/Docker builds) -- `python3`, `make`, `g++` for native modules (`better-sqlite3`) --- diff --git a/src/agent/__tests__/runtime-hooks.test.ts b/src/agent/__tests__/runtime-hooks.test.ts index b970e779..bdd78a09 100644 --- a/src/agent/__tests__/runtime-hooks.test.ts +++ b/src/agent/__tests__/runtime-hooks.test.ts @@ -286,8 +286,8 @@ describe("Runtime Hook Integration", () => { expect(mockLogger.error).not.toHaveBeenCalled(); }); - it("5.9 Cocoon provider path: hooks fire same as standard path", async () => { - // Hooks fire before the Cocoon/standard fork — same event for both paths + it("5.9 gocoon provider path: hooks fire same as standard path", async () => { + // Hooks fire before the gocoon/standard fork — same event for both paths const beforeCalls: string[] = []; const afterCalls: string[] = []; @@ -310,7 +310,7 @@ describe("Runtime Hook Integration", () => { const runner = createHookRunner(registry, { logger: mockLogger }); - // Simulate tool call (same event regardless of Cocoon or standard provider) + // Simulate tool call (same event regardless of gocoon or standard provider) const beforeEvent: BeforeToolCallEvent = { toolName: "ton_get_balance", params: {}, diff --git a/src/agent/client.ts b/src/agent/client.ts index 89ceafe3..a174fab3 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -19,7 +19,7 @@ import { getProviderModel } from "../providers/model-resolver.js"; // layer so non-agent consumers (e.g. memory) can resolve models without importing // from agent/. Re-exported here for backward compatibility with existing importers. export { - registerCocoonModels, + registerGocoonModels, registerLocalModels, getProviderModel, getUtilityModel, @@ -43,10 +43,10 @@ export function isOAuthToken(apiKey: string, provider?: string): boolean { return apiKey.startsWith("sk-ant-oat01-"); } -/** Resolve the effective API key for a provider (local/cocoon need no real key) */ +/** Resolve the effective API key for a provider (local/gocoon need no real key) */ export function getEffectiveApiKey(provider: string, rawKey: string): string { if (provider === "local") return "local"; - if (provider === "cocoon") return ""; + if (provider === "gocoon") return "gocoon"; if (provider === "codex") return getCodexApiKey(rawKey); return rawKey; } @@ -71,7 +71,7 @@ const THINK_RE = /[\s\S]*?<\/think>/g; /** * Shared post-processing for both complete() and stream() responses: strip - * blocks (Cocoon, Mistral, etc.), persist the transcript, extract the + * blocks (Mistral, local models, etc.), persist the transcript, extract the * text content, and append the response to the context. */ function finalizeResponse( @@ -106,23 +106,10 @@ export async function chatWithContext( ): Promise { const provider = (config.provider || "anthropic") as SupportedProvider; const model = getProviderModel(provider, config.model); - const isCocoon = provider === "cocoon"; - - let tools = + const tools = provider === "google" && options.tools ? sanitizeToolsForGemini(options.tools) : options.tools; - // Cocoon: disable thinking mode + inject tools into system prompt - let systemPrompt = options.systemPrompt || options.context.systemPrompt || ""; - let cocoonAllowedTools: Set | undefined; - if (isCocoon) { - systemPrompt = "/no_think\n" + systemPrompt; - if (tools && tools.length > 0) { - cocoonAllowedTools = new Set(tools.map((t) => t.name)); - const { injectToolsIntoSystemPrompt } = await import("../cocoon/tool-adapter.js"); - systemPrompt = injectToolsIntoSystemPrompt(systemPrompt, tools); - tools = undefined; // Don't send via API - } - } + const systemPrompt = options.systemPrompt || options.context.systemPrompt || ""; const context: Context = { ...options.context, @@ -139,10 +126,6 @@ export async function chatWithContext( sessionId: options.sessionId, cacheRetention: "long", }; - if (isCocoon) { - const { stripCocoonPayload } = await import("../cocoon/tool-adapter.js"); - completeOptions.onPayload = stripCocoonPayload; - } let response = await complete(model, context, completeOptions as ProviderStreamOptions); @@ -157,24 +140,6 @@ export async function chatWithContext( } } - // Cocoon: parse from text response - if (isCocoon) { - const textBlock = response.content.find((b) => b.type === "text"); - if (textBlock?.type === "text" && textBlock.text.includes("")) { - const { parseToolCallsFromText, extractPlainText } = - await import("../cocoon/tool-adapter.js"); - const syntheticCalls = parseToolCallsFromText(textBlock.text, cocoonAllowedTools); - if (syntheticCalls.length > 0) { - const plainText = extractPlainText(textBlock.text); - response.content = [ - ...(plainText ? [{ type: "text" as const, text: plainText }] : []), - ...syntheticCalls, - ]; - (response as { stopReason: AssistantMessage["stopReason"] }).stopReason = "toolUse"; - } - } - } - return finalizeResponse(response, context, options); } diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts index 40e92f54..c7c910d3 100644 --- a/src/agent/runtime.ts +++ b/src/agent/runtime.ts @@ -44,6 +44,7 @@ import type { ToolCall, AssistantMessage, Message, + ToolResultMessage, } from "@mariozechner/pi-ai"; import { CompactionManager, DEFAULT_COMPACTION_CONFIG } from "../memory/compaction.js"; import { maskOldToolResults } from "../memory/observation-masking.js"; @@ -1029,13 +1030,14 @@ export class AgentRuntime { log.warn(`Tool result too large, truncated to ${resultText.length} chars`); } - const { buildToolResultMessage } = await import("../cocoon/tool-adapter.js"); - const resultMsg = buildToolResultMessage( - sink.provider, - block, - resultText, - !exec.result.success - ); + const resultMsg: ToolResultMessage = { + role: "toolResult", + toolCallId: block.id, + toolName: block.name, + content: [{ type: "text", text: resultText }], + isError: !exec.result.success, + timestamp: Date.now(), + }; resultMessages.push(resultMsg); appendToTranscript(sink.sessionId, resultMsg); } diff --git a/src/agent/tools/telegram/media/vision-analyze.ts b/src/agent/tools/telegram/media/vision-analyze.ts index b97aa959..9e61d326 100644 --- a/src/agent/tools/telegram/media/vision-analyze.ts +++ b/src/agent/tools/telegram/media/vision-analyze.ts @@ -103,7 +103,7 @@ export const visionAnalyzeExecutor: ToolExecutor = async ( // Get API key from context const currentProvider = context.config?.agent?.provider; const apiKey = context.config?.agent?.api_key; - if (!apiKey && currentProvider !== "local" && currentProvider !== "cocoon") { + if (!apiKey && currentProvider !== "local" && currentProvider !== "gocoon") { return { success: false, error: "No API key configured for vision analysis", diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 76bf39ad..baebb666 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -140,7 +140,7 @@ async function checkApiKey(workspaceDir: string): Promise { }; } - if (provider === "cocoon" || provider === "local") { + if (provider === "gocoon" || provider === "local") { return { name: `${meta.displayName}`, status: "ok", diff --git a/src/cli/commands/gocoon.ts b/src/cli/commands/gocoon.ts new file mode 100644 index 00000000..8f82891a --- /dev/null +++ b/src/cli/commands/gocoon.ts @@ -0,0 +1,166 @@ +import type { Command } from "commander"; +import { confirm } from "@inquirer/prompts"; +import { + ensureGocoonBinaries, + init, + isInstalled, + runnerBaseUrl, + topup, + waitFunded, + walletInfo, + withdrawAll, + GOCOON_DEFAULT_PORT, + GOCOON_VERSION, + type GocoonProgress, +} from "../../gocoon/index.js"; +import { BOLD, DIM, GREEN, RED, TON, YELLOW, noteBox } from "../prompts.js"; +import { getErrorMessage } from "../../utils/errors.js"; + +/** Render a lifecycle progress event to the terminal. */ +function renderProgress(e: GocoonProgress): void { + const tag = DIM(`[${e.stage}]`); + if (e.status === "ok") console.log(` ${GREEN("✓")} ${tag} ${e.message}`); + else if (e.status === "error") console.log(` ${RED("✗")} ${tag} ${e.message}`); + else if (e.status === "warn" || e.status === "skipped") + console.log(` ${YELLOW("•")} ${tag} ${e.message}`); + else console.log(` ${TON("›")} ${tag} ${e.message}`); +} + +const indent = (line: string): void => console.log(DIM(" " + line)); + +/** `teleton gocoon init` — install gocoon, generate the COCOON wallet, fund it. */ +async function runInit(): Promise { + console.log(BOLD(TON("\n Gocoon setup — decentralized LLM on TON\n"))); + + console.log(` ${TON("›")} Installing gocoon ${GOCOON_VERSION}…`); + await ensureGocoonBinaries(); + console.log(` ${GREEN("✓")} Binaries ready`); + + console.log(` ${TON("›")} Generating COCOON wallet…`); + const summary = await init(); + console.log(` ${GREEN("✓")} Wallet ready`); + + noteBox( + `Send ${BOLD(summary.recommendedFundingTon + " TON")} (mainnet) to:\n\n` + + ` ${summary.fundAddress}\n\n` + + `This stakes your COCOON payment channel. Funds are recoverable later with\n` + + DIM("teleton gocoon withdraw "), + "Fund COCOON wallet", + YELLOW + ); + + const sent = await confirm({ message: "Have you sent the TON?", default: false }); + if (!sent) { + console.log(YELLOW("\n Setup paused — run `teleton gocoon init` again once funded.\n")); + return; + } + + console.log(` ${TON("›")} Waiting for funding to confirm on-chain…`); + await waitFunded(indent); + console.log(` ${GREEN("✓")} Wallet funded`); + + noteBox( + `Set ${BOLD("agent.provider: gocoon")} in your config.\n` + + `On ${BOLD("teleton start")} the runner launches automatically.`, + "Gocoon ready", + GREEN + ); +} + +async function runTopup(amountTon: string): Promise { + await topup(amountTon, GOCOON_DEFAULT_PORT, indent); + console.log(` ${GREEN("✓")} Topped up channel with ${amountTon} TON`); +} + +async function runWithdraw(destination: string, skipConfirm: boolean): Promise { + if (!skipConfirm) { + const ok = await confirm({ + message: `Withdraw EVERYTHING to ${destination}? Closes the channel and drains the COCOON + agent wallets. Irreversible.`, + default: false, + }); + if (!ok) { + console.log(YELLOW("\n Withdraw cancelled.\n")); + return; + } + } + console.log(); + await withdrawAll(destination, renderProgress); +} + +async function runStatus(): Promise { + console.log(BOLD("\n Gocoon status\n")); + const installed = isInstalled(); + console.log( + ` Installed: ${installed ? GREEN(GOCOON_VERSION) : YELLOW("not installed — run `teleton gocoon init`")}` + ); + + // Only read the wallet when installed — avoids a surprise download on a read-only status. + if (installed) { + try { + const info = await walletInfo(); + console.log(` COCOON wallet: ${info.ownerAddress}`); + console.log( + ` Balance: ${info.balanceTon} TON ${info.funded ? GREEN("(funded)") : YELLOW("(not funded)")}` + ); + } catch { + console.log(` COCOON wallet: ${DIM("not set up — run `teleton gocoon init`")}`); + } + } + + const up = await fetch(`${runnerBaseUrl()}/jsonstats`, { signal: AbortSignal.timeout(800) }) + .then(() => true) + .catch(() => false); + console.log(` Runner: ${up ? GREEN("running") : DIM("not running")}\n`); +} + +/** Wrap a command action: report errors cleanly and exit non-zero. */ +async function guard(fn: () => Promise): Promise { + try { + await fn(); + } catch (err) { + console.error(RED(`\n ✗ ${getErrorMessage(err)}\n`)); + process.exit(1); + } +} + +/** Register the `teleton gocoon` command group. */ +export function registerGocoonCommand(program: Command): void { + const gocoon = program + .command("gocoon") + .description("Manage gocoon — decentralized LLM on TON (install, setup, top-up, withdraw)") + .option("-i, --init", "Install and set up gocoon (wallet + funding)") + .action(async (opts: { init?: boolean }) => { + if (opts.init) { + await guard(runInit); + return; + } + gocoon.help(); + }); + + gocoon + .command("init") + .alias("i") + .description("Install gocoon and set up the COCOON wallet + funding") + .action(() => guard(runInit)); + + gocoon + .command("topup") + .description("Add TON to the payment channel (the runner must be active)") + .requiredOption("--amount ", "Amount in TON to add (e.g. 5)") + .action((opts: { amount: string }) => guard(() => runTopup(opts.amount))); + + gocoon + .command("withdraw ") + .description( + "Close the channel + withdraw COCOON & agent wallets to (TON address or .ton)" + ) + .option("--yes", "Skip the confirmation prompt") + .action((destination: string, opts: { yes?: boolean }) => + guard(() => runWithdraw(destination, Boolean(opts.yes))) + ); + + gocoon + .command("status") + .description("Show gocoon install + wallet + runner status") + .action(() => guard(runStatus)); +} diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index a3f8cf74..7e51528e 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -262,7 +262,7 @@ interface BuildConfigInput { tonapiKey?: string; toncenterApiKey?: string; tavilyApiKey?: string; - cocoonPort?: number; + gocoonPort?: number; sessionPath: string; workspaceRoot: string; } @@ -367,8 +367,8 @@ function buildConfig(input: BuildConfigInput): Config { self_configurable: false, }, plugins: {}, - ...(input.provider === "cocoon" && input.cocoonPort - ? { cocoon: { port: input.cocoonPort } } + ...(input.provider === "gocoon" && input.gocoonPort + ? { gocoon: { port: input.gocoonPort } } : {}), tonapi_key: input.tonapiKey, toncenter_api_key: input.toncenterApiKey, @@ -466,7 +466,7 @@ interface OnboardState { selectedModel: string; apiKey: string; localBaseUrl: string; - cocoonInstance: number; + gocoonInstance: number; apiId: number; apiHash: string; phone: string; @@ -592,7 +592,7 @@ async function stepProvider( selectedProvider: SupportedProvider; apiKey: string; localBaseUrl: string; - cocoonInstance: number; + gocoonInstance: number; selectedModel: string; }> { redraw(1); @@ -622,16 +622,16 @@ async function stepProvider( ); } - // API key (or Cocoon / Local setup) + // API key (or Gocoon / Local setup) let apiKey = ""; let localBaseUrl = ""; - let cocoonInstance = 10000; - if (selectedProvider === "cocoon") { - // Cocoon Network — no API key, managed externally via cocoon-cli + let gocoonInstance = 10000; + if (selectedProvider === "gocoon") { + // Gocoon — no API key, decentralized LLM on TON via the gocoon-runner apiKey = ""; - const cocoonPort = await input({ - message: "Cocoon proxy HTTP port", + const gocoonPort = await input({ + message: "gocoon-runner HTTP port", default: "10000", theme, validate: (value = "") => { @@ -639,17 +639,17 @@ async function stepProvider( return n >= 1 && n <= 65535 ? true : "Must be a port number (1-65535)"; }, }); - cocoonInstance = parseInt(cocoonPort.trim(), 10); + gocoonInstance = parseInt(gocoonPort.trim(), 10); noteBox( - "Cocoon Network — Decentralized LLM on TON\n" + - "No API key needed. Requires cocoon-cli running externally.\n" + - `Teleton will connect to http://localhost:${cocoonInstance}/v1/`, - "Cocoon Network", + "Gocoon — Decentralized LLM on TON\n" + + "No API key needed. Requires the gocoon runner running externally.\n" + + `Teleton will connect to http://localhost:${gocoonInstance}/v1/`, + "Gocoon", TON ); - STEPS[1].value = `${providerMeta.displayName} ${DIM(`port ${cocoonInstance}`)}`; + STEPS[1].value = `${providerMeta.displayName} ${DIM(`port ${gocoonInstance}`)}`; } else if (selectedProvider === "local") { // Local LLM — no API key, needs base URL apiKey = ""; @@ -733,7 +733,7 @@ async function stepProvider( // Model selection (advanced mode only, after provider + API key) let selectedModel = providerMeta.defaultModel; - if (selectedProvider !== "cocoon" && selectedProvider !== "local") { + if (selectedProvider !== "gocoon" && selectedProvider !== "local") { const providerModels = getModelsForProvider(selectedProvider); const modelChoices = [ ...providerModels, @@ -762,7 +762,7 @@ async function stepProvider( STEPS[1].value = `${STEPS[1].value ?? providerMeta.displayName}, ${modelLabel}`; } - return { selectedProvider, apiKey, localBaseUrl, cocoonInstance, selectedModel }; + return { selectedProvider, apiKey, localBaseUrl, gocoonInstance, selectedModel }; } /** Step 2: Config — admin user ID, DM/group policies, iterations, exec mode. */ @@ -1255,7 +1255,7 @@ async function stepConnect( tonapiKey: state.tonapiKey, toncenterApiKey: state.toncenterApiKey, tavilyApiKey: state.tavilyApiKey, - cocoonPort: state.selectedProvider === "cocoon" ? state.cocoonInstance : undefined, + gocoonPort: state.selectedProvider === "gocoon" ? state.gocoonInstance : undefined, sessionPath: workspace.sessionPath, workspaceRoot: workspace.root, }); @@ -1324,7 +1324,7 @@ async function runInteractiveOnboarding( selectedModel: "", apiKey: "", localBaseUrl: "", - cocoonInstance: 10000, + gocoonInstance: 10000, apiId: 0, apiHash: "", phone: "", @@ -1360,7 +1360,7 @@ async function runInteractiveOnboarding( state.selectedProvider = provider.selectedProvider; state.apiKey = provider.apiKey; state.localBaseUrl = provider.localBaseUrl; - state.cocoonInstance = provider.cocoonInstance; + state.gocoonInstance = provider.gocoonInstance; state.selectedModel = provider.selectedModel; // Step 2: Config @@ -1418,7 +1418,7 @@ async function runNonInteractiveOnboarding( ): Promise { const selectedProvider = options.provider || "anthropic"; const nonInteractiveMode = options.mode || "user"; - const needsApiKey = selectedProvider !== "cocoon" && selectedProvider !== "local"; + const needsApiKey = selectedProvider !== "gocoon" && selectedProvider !== "local"; if (nonInteractiveMode === "bot") { if (!options.botToken) { prompter.error("Non-interactive bot mode requires: --bot-token"); diff --git a/src/cli/index.ts b/src/cli/index.ts index bc8fdfab..7b3b19b4 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,6 +4,7 @@ import { doctorCommand } from "./commands/doctor.js"; import { mcpAddCommand, mcpRemoveCommand, mcpListCommand } from "./commands/mcp.js"; import { configCommand } from "./commands/config.js"; import { apiRotateKeyCommand, apiFingerprintCommand } from "./commands/api.js"; +import { registerGocoonCommand } from "./commands/gocoon.js"; import { main as startApp } from "../index.js"; import { configExists, getDefaultConfigPath } from "../config/loader.js"; import { readFileSync, existsSync } from "fs"; @@ -239,6 +240,9 @@ program } }); +// gocoon — decentralized LLM on TON (install, setup, top-up, withdraw) +registerGocoonCommand(program); + program.action(() => { program.help(); }); diff --git a/src/cocoon/__tests__/cocoon-mock.test.ts b/src/cocoon/__tests__/cocoon-mock.test.ts deleted file mode 100644 index ab7cfe2d..00000000 --- a/src/cocoon/__tests__/cocoon-mock.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http"; -import { registerCocoonModels } from "../../agent/client.js"; - -// ── Mock Cocoon Proxy ──────────────────────────────────────────────── - -const MOCK_MODELS = [ - { id: "Qwen/Qwen3-32B", object: "model" }, - { id: "Qwen/Qwen3-8B", object: "model" }, -]; - -let server: Server; -let port: number; -let socketAvailable = true; - -function createMockCocoonProxy(): Promise<{ server: Server; port: number }> { - return new Promise((resolve, reject) => { - const srv = createServer((req: IncomingMessage, res: ServerResponse) => { - let body = ""; - req.on("data", (chunk: Buffer) => (body += chunk.toString())); - req.on("end", () => { - res.setHeader("Content-Type", "application/json"); - - if (req.url === "/v1/models" && req.method === "GET") { - res.end(JSON.stringify({ object: "list", data: MOCK_MODELS })); - return; - } - - if (req.url === "/v1/chat/completions" && req.method === "POST") { - const payload = JSON.parse(body); - - const violations: string[] = []; - if (payload.tools) violations.push("tools should be stripped"); - if (payload.tool_choice) violations.push("tool_choice should be stripped"); - if (payload.store) violations.push("store should be stripped"); - - if (violations.length > 0) { - res.statusCode = 400; - res.end(JSON.stringify({ error: { message: violations.join(", ") } })); - return; - } - - const systemMsg = payload.messages?.find((m: { role: string }) => m.role === "system"); - const hasToolsInPrompt = systemMsg?.content?.includes(""); - - if (hasToolsInPrompt) { - res.end( - JSON.stringify({ - id: "mock-1", - object: "chat.completion", - choices: [ - { - index: 0, - message: { - role: "assistant", - content: `I'll search for that information.\n\n{"name": "web_search", "arguments": {"query": "test query"}}\n`, - }, - finish_reason: "stop", - }, - ], - usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }, - }) - ); - } else { - res.end( - JSON.stringify({ - id: "mock-2", - object: "chat.completion", - choices: [ - { - index: 0, - message: { role: "assistant", content: "Hello! How can I help you?" }, - finish_reason: "stop", - }, - ], - usage: { prompt_tokens: 50, completion_tokens: 20, total_tokens: 70 }, - }) - ); - } - return; - } - - res.statusCode = 404; - res.end(JSON.stringify({ error: { message: "Not found" } })); - }); - }); - - srv.listen(0, "127.0.0.1", () => { - const addr = srv.address(); - const p = typeof addr === "object" && addr ? addr.port : 0; - resolve({ server: srv, port: p }); - }); - srv.once("error", reject); - }); -} - -// ── Tests ──────────────────────────────────────────────────────────── - -describe("Cocoon Mock Server", () => { - beforeAll(async () => { - try { - const mock = await createMockCocoonProxy(); - server = mock.server; - port = mock.port; - } catch { - socketAvailable = false; - } - }); - - afterAll( - () => - new Promise((resolve) => { - if (!server) { - resolve(); - return; - } - server.close(() => resolve()); - }) - ); - - // ── registerCocoonModels ───────────────────────────────────────── - - it("should discover models from /v1/models", async () => { - if (!socketAvailable) return; - const ids = await registerCocoonModels(port); - expect(ids).toHaveLength(2); - expect(ids).toContain("Qwen/Qwen3-32B"); - expect(ids).toContain("Qwen/Qwen3-8B"); - }); - - it("should return empty array if proxy is down", async () => { - const ids = await registerCocoonModels(59999); - expect(ids).toEqual([]); - }); - - it("should return empty array if response has no models", async () => { - if (!socketAvailable) return; - const emptySrv = createServer((_req, res) => { - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ object: "list", data: [] })); - }); - await new Promise((resolve, reject) => { - emptySrv.once("error", reject); - emptySrv.listen(0, "127.0.0.1", () => resolve()); - }); - const addr = emptySrv.address(); - const emptyPort = typeof addr === "object" && addr ? addr.port : 0; - - const ids = await registerCocoonModels(emptyPort); - expect(ids).toEqual([]); - - await new Promise((resolve) => emptySrv.close(() => resolve())); - }); - - // ── Payload validation ─────────────────────────────────────────── - - it("should reject requests with unsupported fields", async () => { - if (!socketAvailable) return; - const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: "Qwen/Qwen3-32B", - messages: [{ role: "user", content: "hi" }], - tools: [{ name: "bad" }], - }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: { message: string } }; - expect(body.error.message).toContain("tools should be stripped"); - }); - - it("should accept clean cocoon payload", async () => { - if (!socketAvailable) return; - const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: "Qwen/Qwen3-32B", - messages: [{ role: "user", content: "hi" }], - presence_penalty: 1.5, - }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { choices: { message: { content: string } }[] }; - expect(body.choices[0].message.content).toContain("Hello"); - }); - - it("should return tool_call in response when tools in system prompt", async () => { - if (!socketAvailable) return; - const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: "Qwen/Qwen3-32B", - messages: [ - { role: "system", content: "You are helpful.\n\n[...]" }, - { role: "user", content: "search for cats" }, - ], - presence_penalty: 1.5, - }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { choices: { message: { content: string } }[] }; - expect(body.choices[0].message.content).toContain(""); - expect(body.choices[0].message.content).toContain("web_search"); - }); -}); diff --git a/src/cocoon/__tests__/tool-adapter.test.ts b/src/cocoon/__tests__/tool-adapter.test.ts deleted file mode 100644 index 72ba1adc..00000000 --- a/src/cocoon/__tests__/tool-adapter.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - injectToolsIntoSystemPrompt, - stripCocoonPayload, - parseToolCallsFromText, - extractPlainText, - wrapToolResult, -} from "../tool-adapter.js"; -import type { Tool } from "@mariozechner/pi-ai"; - -// ── Helpers ────────────────────────────────────────────────────────── - -const makeTool = (name: string, desc = "A test tool"): Tool => ({ - name, - description: desc, - parameters: { - type: "object", - properties: { query: { type: "string", description: "search query" } }, - required: ["query"], - }, -}); - -// ── injectToolsIntoSystemPrompt ────────────────────────────────────── - -describe("injectToolsIntoSystemPrompt", () => { - it("should append tool definitions in XML block", () => { - const result = injectToolsIntoSystemPrompt("You are helpful.", [makeTool("web_search")]); - expect(result).toContain("You are helpful."); - expect(result).toContain(""); - expect(result).toContain(""); - expect(result).toContain('"name":"web_search"'); - expect(result).toContain(""); - }); - - it("should include all tools", () => { - const tools = [makeTool("tool_a"), makeTool("tool_b"), makeTool("tool_c")]; - const result = injectToolsIntoSystemPrompt("sys", tools); - expect(result).toContain('"name":"tool_a"'); - expect(result).toContain('"name":"tool_b"'); - expect(result).toContain('"name":"tool_c"'); - }); - - it("should return system prompt unchanged if no tools", () => { - expect(injectToolsIntoSystemPrompt("sys", [])).toBe("sys"); - }); - - it("should include parameter schemas", () => { - const result = injectToolsIntoSystemPrompt("sys", [makeTool("t")]); - expect(result).toContain('"parameters"'); - expect(result).toContain('"query"'); - }); - - it("should handle tools with complex parameter schemas", () => { - const tool: Tool = { - name: "complex_tool", - description: "Tool with nested params", - parameters: { - type: "object", - properties: { - config: { - type: "object", - properties: { - nested: { type: "array", items: { type: "string" } }, - }, - }, - }, - required: [], - }, - }; - const result = injectToolsIntoSystemPrompt("sys", [tool]); - expect(result).toContain('"complex_tool"'); - expect(result).toContain('"nested"'); - }); -}); - -// ── stripCocoonPayload ─────────────────────────────────────────────── - -describe("stripCocoonPayload", () => { - it("should remove unsupported fields", () => { - const payload: Record = { - model: "Qwen/Qwen3-32B", - messages: [], - tools: [{ name: "a" }], - tool_choice: "auto", - store: true, - reasoning_effort: "high", - stream_options: { include_usage: true }, - }; - stripCocoonPayload(payload); - expect(payload.model).toBe("Qwen/Qwen3-32B"); - expect(payload.messages).toEqual([]); - expect(payload).not.toHaveProperty("tools"); - expect(payload).not.toHaveProperty("tool_choice"); - expect(payload).not.toHaveProperty("store"); - expect(payload).not.toHaveProperty("reasoning_effort"); - expect(payload).not.toHaveProperty("stream_options"); - }); - - it("should set presence_penalty to 1.5 if not provided", () => { - const payload: Record = {}; - stripCocoonPayload(payload); - expect(payload.presence_penalty).toBe(1.5); - }); - - it("should preserve existing presence_penalty", () => { - const payload: Record = { presence_penalty: 2.0 }; - stripCocoonPayload(payload); - expect(payload.presence_penalty).toBe(2.0); - }); - - it("should preserve presence_penalty = 0", () => { - // 0 is falsy but should NOT be overridden by ?? 1.5 - const payload: Record = { presence_penalty: 0 }; - stripCocoonPayload(payload); - expect(payload.presence_penalty).toBe(0); - }); - - it("should handle null payload gracefully", () => { - expect(() => stripCocoonPayload(null)).not.toThrow(); - }); - - it("should handle non-object payload gracefully", () => { - expect(() => stripCocoonPayload("string")).not.toThrow(); - expect(() => stripCocoonPayload(42)).not.toThrow(); - expect(() => stripCocoonPayload(undefined)).not.toThrow(); - }); -}); - -// ── parseToolCallsFromText ─────────────────────────────────────────── - -describe("parseToolCallsFromText", () => { - it("should parse a single tool call", () => { - const text = `I'll search for that. - -{"name": "web_search", "arguments": {"query": "hello world"}} -`; - const calls = parseToolCallsFromText(text); - expect(calls).toHaveLength(1); - expect(calls[0].type).toBe("toolCall"); - expect(calls[0].name).toBe("web_search"); - expect(calls[0].arguments).toEqual({ query: "hello world" }); - expect(calls[0].id).toMatch(/^cocoon_/); - }); - - it("should parse multiple tool calls", () => { - const text = ` -{"name": "tool_a", "arguments": {"x": 1}} - - -{"name": "tool_b", "arguments": {"y": 2}} -`; - const calls = parseToolCallsFromText(text); - expect(calls).toHaveLength(2); - expect(calls[0].name).toBe("tool_a"); - expect(calls[1].name).toBe("tool_b"); - }); - - it("should handle nested JSON in arguments", () => { - const text = ` -{"name": "send_msg", "arguments": {"text": "He said \\"hello\\"", "config": {"nested": true}}} -`; - const calls = parseToolCallsFromText(text); - expect(calls).toHaveLength(1); - expect(calls[0].name).toBe("send_msg"); - expect(calls[0].arguments.config).toEqual({ nested: true }); - }); - - it("should strip blocks before parsing", () => { - const text = ` -I need to search for this. - - -{"name": "web_search", "arguments": {"query": "test"}} -`; - const calls = parseToolCallsFromText(text); - expect(calls).toHaveLength(1); - expect(calls[0].name).toBe("web_search"); - }); - - it("should return empty array for no tool calls", () => { - expect(parseToolCallsFromText("Just a regular response.")).toEqual([]); - }); - - it("should return empty array for malformed JSON", () => { - const text = ` -{not valid json} -`; - const calls = parseToolCallsFromText(text); - expect(calls).toEqual([]); - }); - - it("should skip tool calls without a name", () => { - const text = ` -{"arguments": {"x": 1}} -`; - const calls = parseToolCallsFromText(text); - expect(calls).toEqual([]); - }); - - it("should default arguments to empty object", () => { - const text = ` -{"name": "simple_tool"} -`; - const calls = parseToolCallsFromText(text); - expect(calls).toHaveLength(1); - expect(calls[0].arguments).toEqual({}); - }); - - it("should handle unclosed tool_call tag", () => { - const text = ` -{"name": "broken", "arguments": {}} -no closing tag`; - const calls = parseToolCallsFromText(text); - expect(calls).toEqual([]); - }); - - it("should handle empty tool_call tags", () => { - const text = ``; - const calls = parseToolCallsFromText(text); - expect(calls).toEqual([]); - }); - - it("should generate unique IDs for each call", () => { - const text = ` -{"name": "a", "arguments": {}} - - -{"name": "b", "arguments": {}} -`; - const calls = parseToolCallsFromText(text); - expect(calls[0].id).not.toBe(calls[1].id); - }); - - it("should handle arguments with special characters", () => { - // JSON requires \" for quotes inside strings — use String.raw to preserve backslashes - const text = String.raw` -{"name": "send", "arguments": {"text": "Hello & \"friends\""}} -`; - const calls = parseToolCallsFromText(text); - expect(calls).toHaveLength(1); - expect(calls[0].arguments.text).toBe('Hello & "friends"'); - }); -}); - -// ── extractPlainText ───────────────────────────────────────────────── - -describe("extractPlainText", () => { - it("should remove tool_call blocks and keep surrounding text", () => { - const text = `Hello world. - -{"name": "test", "arguments": {}} - -Goodbye.`; - expect(extractPlainText(text)).toBe("Hello world.\n\nGoodbye."); - }); - - it("should remove multiple tool_call blocks", () => { - const text = `AXBYC`; - expect(extractPlainText(text)).toBe("ABC"); - }); - - it("should remove think blocks", () => { - const text = `reasoning hereThe answer is 42.`; - expect(extractPlainText(text)).toBe("The answer is 42."); - }); - - it("should remove both think and tool_call blocks", () => { - const text = `hmmHello{"name":"x","arguments":{}} world`; - expect(extractPlainText(text)).toBe("Hello world"); - }); - - it("should return empty string for pure tool calls", () => { - const text = ` -{"name": "a", "arguments": {}} -`; - expect(extractPlainText(text)).toBe(""); - }); - - it("should handle text with no special blocks", () => { - expect(extractPlainText("Just regular text.")).toBe("Just regular text."); - }); - - it("should handle consecutive tool_call blocks", () => { - const text = `ABC`; - expect(extractPlainText(text)).toBe(""); - }); -}); - -// ── wrapToolResult ─────────────────────────────────────────────────── - -describe("wrapToolResult", () => { - it("should wrap in tool_response with CDATA", () => { - const result = wrapToolResult("success: true"); - expect(result).toBe(`\n\n`); - }); - - it("should escape ]]> in content (CDATA injection)", () => { - const result = wrapToolResult("data with ]]> inside"); - expect(result).toBe( - `\n inside]]>\n` - ); - }); - - it("should handle empty result", () => { - const result = wrapToolResult(""); - expect(result).toBe(`\n\n`); - }); - - it("should handle result with XML-like content", () => { - const result = wrapToolResult('
Hello & World
'); - expect(result).toContain('
Hello & World
'); - }); - - it("should handle multiple ]]> sequences", () => { - const result = wrapToolResult("a]]>b]]>c"); - // Each ]]> gets split - expect(result).not.toContain("]]>]]>"); - // But the structure should be valid CDATA - expect(result).toContain(""); - expect(result).toContain(""); - }); - - it("should handle large content", () => { - const bigContent = "x".repeat(100000); - const result = wrapToolResult(bigContent); - expect(result).toContain(bigContent); - }); -}); diff --git a/src/cocoon/tool-adapter.ts b/src/cocoon/tool-adapter.ts deleted file mode 100644 index 781479ed..00000000 --- a/src/cocoon/tool-adapter.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Cocoon Tool Adapter - * - * Translation layer for tool calling via Cocoon proxy. - * The proxy doesn't support the OpenAI `tools` parameter, so we: - * 1. Inject tool definitions into the system prompt (Qwen3 Hermes format) - * 2. Strip unsupported fields from the request body - * 3. Parse XML tags from the model's text response - */ - -import { randomUUID } from "crypto"; -import { createLogger } from "../utils/logger.js"; -import type { Tool, UserMessage, ToolResultMessage } from "@mariozechner/pi-ai"; - -const log = createLogger("Cocoon"); - -// ── System Prompt Injection ────────────────────────────────────────── - -const TOOL_PREAMBLE = ` - -# Tools - -You may call one or more functions to assist with the user query. - -You are provided with function signatures within XML tags: - -`; - -const TOOL_POSTAMBLE = ` - -For each function call, return a json object with function name and arguments within XML tags: - -{"name": , "arguments": } -`; - -/** - * Append Qwen3-style tool definitions to the system prompt. - */ -export function injectToolsIntoSystemPrompt(systemPrompt: string, tools: Tool[]): string { - if (!tools || tools.length === 0) return systemPrompt; - - const toolLines = tools.map((t) => - JSON.stringify({ - type: "function", - function: { - name: t.name, - description: t.description, - parameters: t.parameters, - }, - }) - ); - - return systemPrompt + TOOL_PREAMBLE + toolLines.join("\n") + "\n" + TOOL_POSTAMBLE; -} - -// ── Payload Stripping (onPayload callback) ─────────────────────────── - -/** Fields the Cocoon proxy rejects as "unknown option". */ -const UNSUPPORTED_FIELDS = ["tools", "tool_choice", "store", "reasoning_effort", "stream_options"]; - -/** - * pi-ai `onPayload` callback — mutates the request body to remove - * fields that the Cocoon proxy doesn't understand, and adds - * Qwen3-recommended parameters. - */ -export function stripCocoonPayload(payload: unknown): void { - if (typeof payload !== "object" || payload === null) return; - const obj = payload as Record; - for (const field of UNSUPPORTED_FIELDS) { - delete obj[field]; - } - // Qwen3 recommended: reduce repetitions (e.g. "How can I assist you?") - obj.presence_penalty = obj.presence_penalty ?? 1.5; -} - -// ── Response Parsing ───────────────────────────────────────────────── - -const TOOL_CALL_OPEN = ""; -const TOOL_CALL_CLOSE = ""; -const THINK_RE = /[\s\S]*?<\/think>/g; - -export interface SyntheticToolCall { - type: "toolCall"; - id: string; - name: string; - arguments: Record; -} - -/** - * Extract a complete JSON object from text starting at `startIndex`, - * handling nested braces correctly. - */ -function extractJsonObject( - text: string, - startIndex: number -): { json: string; endIndex: number } | null { - let braceCount = 0; - let inString = false; - let escaped = false; - - for (let i = startIndex; i < text.length; i++) { - const ch = text[i]; - if (escaped) { - escaped = false; - continue; - } - if (ch === "\\" && inString) { - escaped = true; - continue; - } - if (ch === '"') { - inString = !inString; - continue; - } - if (!inString) { - if (ch === "{") braceCount++; - if (ch === "}") { - braceCount--; - if (braceCount === 0) { - return { json: text.slice(startIndex, i + 1), endIndex: i }; - } - } - } - } - return null; -} - -/** - * Parse `` blocks from the model's text response. - * Uses balanced brace parsing to handle nested JSON in arguments. - * Returns an array of synthetic ToolCall objects compatible with pi-ai. - */ -export function parseToolCallsFromText( - text: string, - allowedTools?: Set -): SyntheticToolCall[] { - // Strip blocks before parsing - const cleaned = text.replace(THINK_RE, "").trim(); - - const calls: SyntheticToolCall[] = []; - let searchFrom = 0; - - while (true) { - const openIdx = cleaned.indexOf(TOOL_CALL_OPEN, searchFrom); - if (openIdx === -1) break; - - const contentStart = openIdx + TOOL_CALL_OPEN.length; - const closeIdx = cleaned.indexOf(TOOL_CALL_CLOSE, contentStart); - if (closeIdx === -1) break; - - // Find the first '{' inside the tag content - const braceStart = cleaned.indexOf("{", contentStart); - if (braceStart === -1 || braceStart >= closeIdx) { - searchFrom = closeIdx + TOOL_CALL_CLOSE.length; - continue; - } - - // Extract balanced JSON object - const extracted = extractJsonObject(cleaned, braceStart); - if (extracted) { - try { - const parsed = JSON.parse(extracted.json); - if (parsed.name && typeof parsed.name === "string") { - if (allowedTools && !allowedTools.has(parsed.name)) { - log.warn(`Cocoon: rejected tool call "${parsed.name}" — not in allowed set`); - } else { - calls.push({ - type: "toolCall", - id: `cocoon_${randomUUID()}`, - name: parsed.name, - arguments: parsed.arguments ?? {}, - }); - } - } - } catch (e) { - log.debug(`Failed to parse tool call JSON: ${String(e)}`); - log.debug(`Raw: ${extracted.json.slice(0, 200)}`); - } - } - - searchFrom = closeIdx + TOOL_CALL_CLOSE.length; - } - - return calls; -} - -/** - * Extract the plain text content from a response, stripping - * and blocks. - */ -export function extractPlainText(text: string): string { - let result = text.replace(THINK_RE, ""); - - // Remove all ... blocks - let searchFrom = 0; - while (true) { - const openIdx = result.indexOf(TOOL_CALL_OPEN, searchFrom); - if (openIdx === -1) break; - const closeIdx = result.indexOf(TOOL_CALL_CLOSE, openIdx); - if (closeIdx === -1) break; - result = result.slice(0, openIdx) + result.slice(closeIdx + TOOL_CALL_CLOSE.length); - searchFrom = openIdx; - } - - return result.trim(); -} - -// ── Tool Result Formatting ─────────────────────────────────────────── - -/** - * Wrap a tool result string in `` tags for Qwen3. - * Uses CDATA to prevent XML injection from result content. - */ -export function wrapToolResult(resultText: string): string { - const safe = resultText.replace(/]]>/g, "]]]]>"); - return `\n\n`; -} - -/** - * Build the message that carries a tool result back to the model. Cocoon has no - * native tool-result role, so its results are wrapped as a plain user message; - * every other provider uses the standard toolResult message. Centralising the - * branch here keeps the cocoon-specific shape out of the agentic loop. - */ -export function buildToolResultMessage( - provider: string, - block: { id: string; name: string }, - resultText: string, - isError: boolean -): UserMessage | ToolResultMessage { - if (provider === "cocoon") { - return { - role: "user", - content: [{ type: "text", text: wrapToolResult(resultText) }], - timestamp: Date.now(), - }; - } - return { - role: "toolResult", - toolCallId: block.id, - toolName: block.name, - content: [{ type: "text", text: resultText }], - isError, - timestamp: Date.now(), - }; -} diff --git a/src/config/__tests__/configurable-keys.test.ts b/src/config/__tests__/configurable-keys.test.ts index 1d5081e8..2b7be541 100644 --- a/src/config/__tests__/configurable-keys.test.ts +++ b/src/config/__tests__/configurable-keys.test.ts @@ -150,8 +150,8 @@ describe("CONFIGURABLE_KEYS — new scalar entries", () => { }); }); - describe("cocoon.port", () => { - const meta = CONFIGURABLE_KEYS["cocoon.port"]; + describe("gocoon.port", () => { + const meta = CONFIGURABLE_KEYS["gocoon.port"]; it("accepts 1-65535", () => { expect(meta.validate("10000")).toBeUndefined(); diff --git a/src/config/configurable-keys.ts b/src/config/configurable-keys.ts index 4c4612c9..c389e538 100644 --- a/src/config/configurable-keys.ts +++ b/src/config/configurable-keys.ts @@ -205,11 +205,11 @@ export const CONFIGURABLE_KEYS: Record = { mask: identity, parse: identity, }, - "cocoon.port": { + "gocoon.port": { type: "number", category: "Agent", - label: "Cocoon Port", - description: "Cocoon proxy port (requires restart)", + label: "Gocoon Port", + description: "gocoon-runner port (requires restart)", sensitive: false, hotReload: "restart", validate: numberInRange(1, 65535), diff --git a/src/config/providers.ts b/src/config/providers.ts index d3a1d8fa..92dca3ba 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -12,7 +12,7 @@ export type SupportedProvider = | "zai" | "minimax" | "huggingface" - | "cocoon" + | "gocoon" | "local"; export interface ProviderMetadata { @@ -185,17 +185,17 @@ const PROVIDER_REGISTRY: Record = { toolLimit: 128, piAiProvider: "huggingface", }, - cocoon: { - id: "cocoon", - displayName: "Cocoon Network (Decentralized)", + gocoon: { + id: "gocoon", + displayName: "Gocoon (Decentralized, TON)", envVar: "", keyPrefix: null, keyHint: "No API key needed — pays in TON", - consoleUrl: "https://cocoon.network", + consoleUrl: "https://github.com/TONresistor/gocoon", defaultModel: "Qwen/Qwen3-32B", utilityModel: "Qwen/Qwen3-32B", toolLimit: 128, - piAiProvider: "cocoon", + piAiProvider: "gocoon", }, local: { id: "local", @@ -235,7 +235,7 @@ export const SUPPORTED_PROVIDER_IDS = Object.keys(PROVIDER_REGISTRY) as [ 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" || provider === "codex") return undefined; + if (provider === "gocoon" || provider === "local" || provider === "codex") return undefined; 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 689bda22..d87a7dab 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -356,17 +356,21 @@ export const ConfigSchema = z.object({ .record(z.string(), z.unknown()) .default({}) .describe("Per-plugin config (key = plugin name with underscores)"), - cocoon: z + gocoon: z .object({ port: z .number() .min(1) .max(65535) .default(10000) - .describe("HTTP port of the cocoon-cli proxy"), + .describe("HTTP port of the gocoon-runner OpenAI-compatible API"), + auto_start: z + .boolean() + .optional() + .describe("Auto-install and supervise the gocoon-runner on start (default: true)"), }) .optional() - .describe("Cocoon Network — expects external cocoon-cli running on this port"), + .describe("Gocoon — pure-Go COCOON client (decentralized LLM on TON)"), tonapi_key: z .string() .optional() diff --git a/src/gocoon/cli.ts b/src/gocoon/cli.ts new file mode 100644 index 00000000..0ee25e01 --- /dev/null +++ b/src/gocoon/cli.ts @@ -0,0 +1,140 @@ +import { execFile, spawn } from "child_process"; +import { promisify } from "util"; +import { mkdirSync } from "fs"; +import { createLogger } from "../utils/logger.js"; +import { fetchWithTimeout } from "../utils/fetch.js"; +import { ensureGocoonBinaries } from "./installer.js"; +import { clientConfigPath, gocoonDataDir, runnerBaseUrl, walletPath } from "./paths.js"; + +const log = createLogger("gocoon"); +const execFileAsync = promisify(execFile); +const MAX_BUFFER = 8 * 1024 * 1024; + +/** Run a gocoon subcommand and capture stdout. Use for `--json` / short commands. */ +export async function runGocoon(args: string[], timeoutMs = 60_000): Promise { + const { gocoon } = await ensureGocoonBinaries(); + const { stdout } = await execFileAsync(gocoon, args, { + timeout: timeoutMs, + maxBuffer: MAX_BUFFER, + }); + return stdout; +} + +/** + * Run a long-running / streaming gocoon subcommand (wait-funded, channel close, + * wallet withdraw…), forwarding each output line to `onLine`. Resolves on exit 0, + * rejects otherwise. + */ +export async function streamGocoon(args: string[], onLine?: (line: string) => void): Promise { + const { gocoon } = await ensureGocoonBinaries(); + await new Promise((resolve, reject) => { + const child = spawn(gocoon, args, { stdio: ["ignore", "pipe", "pipe"] }); + const emit = (buf: Buffer): void => { + const line = buf.toString().trimEnd(); + if (line) (onLine ?? ((l) => log.debug(l)))(line); + }; + child.stdout?.on("data", emit); + child.stderr?.on("data", emit); + child.on("error", reject); + child.on("exit", (code) => + code === 0 + ? resolve() + : reject(new Error(`gocoon ${args[0]} exited with code ${code ?? "null"}`)) + ); + }); +} + +function parseJson(out: string): Record { + const start = out.indexOf("{"); + const end = out.lastIndexOf("}"); + if (start === -1 || end < start) { + throw new Error(`gocoon: expected JSON output, got: ${out.slice(0, 200)}`); + } + return JSON.parse(out.slice(start, end + 1)) as Record; +} + +export interface InitSummary { + fundAddress: string; + ownerAddress: string; + recommendedFundingTon: string; + recommendedFundingNano: string; + walletPath: string; + configPath: string; +} + +/** `gocoon init --dir --json --force` — generates the COCOON wallet + config (spends nothing). */ +export async function gocoonInit(): Promise { + mkdirSync(gocoonDataDir(), { recursive: true }); + const j = parseJson( + await runGocoon(["init", "--dir", gocoonDataDir(), "--json", "--force"], 120_000) + ); + return { + fundAddress: String(j.fund_address ?? ""), + ownerAddress: String(j.owner_address ?? ""), + recommendedFundingTon: String(j.recommended_funding_ton ?? "20"), + recommendedFundingNano: String(j.recommended_funding_nano ?? "20000000000"), + walletPath: String(j.wallet_path ?? walletPath()), + configPath: String(j.config_path ?? clientConfigPath()), + }; +} + +export interface WalletInfo { + ownerAddress: string; + fundAddress: string; + balanceNano: bigint; + balanceTon: string; + funded: boolean; +} + +/** `gocoon wallet info --json` — current COCOON wallet balance + funding state. */ +export async function walletInfo(): Promise { + const j = parseJson( + await runGocoon([ + "wallet", + "info", + "--wallet", + walletPath(), + "--config", + clientConfigPath(), + "--json", + ]) + ); + return { + ownerAddress: String(j.owner_address ?? ""), + fundAddress: String(j.fund_address ?? ""), + balanceNano: BigInt(String(j.balance_nano ?? "0")), + balanceTon: String(j.balance_ton ?? "0"), + funded: Boolean(j.funded), + }; +} + +/** Block until the COCOON wallet funding confirms on-chain (`gocoon wallet wait-funded`). */ +export async function waitFunded(onLine?: (line: string) => void): Promise { + await streamGocoon( + ["wallet", "wait-funded", "--wallet", walletPath(), "--config", clientConfigPath()], + onLine + ); +} + +/** + * Read the active proxy's `client_sc` (channel smart-contract) address from the + * running runner's /jsonstats. Required by channel topup/close. The runner must + * be up with a registered proxy. + */ +export async function fetchClientSC(port: number): Promise { + const res = await fetchWithTimeout(`${runnerBaseUrl(port)}/jsonstats`, { timeoutMs: 3_000 }); + if (!res.ok) + throw new Error(`runner /jsonstats → HTTP ${res.status} (is the gocoon runner active?)`); + const j = (await res.json()) as { proxies?: { sc_address?: string }[] }; + const addr = j.proxies?.[0]?.sc_address?.trim(); + if (!addr) throw new Error("runner has no proxy/client_sc yet — wait for discovery and retry"); + return addr; +} + +/** Convert a decimal TON string into integer nanoTON (≤ 9 decimals). */ +export function tonToNano(ton: string): string { + const s = ton.trim(); + if (!/^\d+(\.\d{1,9})?$/.test(s)) throw new Error(`invalid TON amount: "${ton}"`); + const [int, frac = ""] = s.split("."); + return (BigInt(int) * 1_000_000_000n + BigInt(frac.padEnd(9, "0"))).toString(); +} diff --git a/src/gocoon/index.ts b/src/gocoon/index.ts new file mode 100644 index 00000000..790a7ede --- /dev/null +++ b/src/gocoon/index.ts @@ -0,0 +1,29 @@ +// Public gocoon API surface — CLI and WebUI import from here. +export { + ensureGocoonBinaries, + isInstalled, + detectPlatform, + type GocoonBinaries, +} from "./installer.js"; +export { GocoonSupervisor, type SupervisorOptions } from "./supervisor.js"; +export { + init, + waitFunded, + walletInfo, + topup, + withdrawAll, + tonToNano, + type InitSummary, + type WalletInfo, + type ProgressSink, + type GocoonProgress, + type GocoonStage, + type GocoonStatus, +} from "./lifecycle.js"; +export { + GOCOON_VERSION, + GOCOON_DEFAULT_PORT, + gocoonDataDir, + clientConfigPath, + runnerBaseUrl, +} from "./paths.js"; diff --git a/src/gocoon/installer.ts b/src/gocoon/installer.ts new file mode 100644 index 00000000..5c98dd76 --- /dev/null +++ b/src/gocoon/installer.ts @@ -0,0 +1,132 @@ +import { createHash } from "crypto"; +import { execFileSync } from "child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "fs"; +import { join } from "path"; +import { createLogger } from "../utils/logger.js"; +import { fetchWithTimeout } from "../utils/fetch.js"; +import { GOCOON_VERSION, binDir, gocoonBin, runnerBin, versionSentinel } from "./paths.js"; + +const log = createLogger("gocoon"); + +const REPO = "TONresistor/gocoon"; +const DOWNLOAD_TIMEOUT_MS = 120_000; + +interface Platform { + os: "darwin" | "linux" | "windows"; + arch: "amd64" | "arm64"; +} + +/** Map Node's platform/arch onto gocoon's release asset naming. */ +export function detectPlatform(): Platform { + const osMap: Record = { + darwin: "darwin", + linux: "linux", + win32: "windows", + }; + const archMap: Record = { x64: "amd64", arm64: "arm64" }; + const os = osMap[process.platform]; + const arch = archMap[process.arch]; + if (!os || !arch) { + throw new Error( + `gocoon: unsupported platform ${process.platform}/${process.arch} (need darwin|linux|windows × amd64|arm64)` + ); + } + return { os, arch }; +} + +export interface GocoonBinaries { + gocoon: string; + runner: string; +} + +/** + * Ensure the `gocoon` + `gocoon-runner` binaries are installed and return their + * paths. Downloads the pinned release ({@link GOCOON_VERSION}) for the current + * platform, verifies its SHA-256, and extracts both binaries into `~/.teleton/bin`. + * + * Idempotent: a version sentinel short-circuits re-download once installed. + */ +export async function ensureGocoonBinaries(): Promise { + const out: GocoonBinaries = { gocoon: gocoonBin(), runner: runnerBin() }; + + if (isInstalled()) return out; + + const { os, arch } = detectPlatform(); + const archive = `gocoon-${GOCOON_VERSION}-${os}-${arch}.tar.gz`; + const base = `https://github.com/${REPO}/releases/download/${GOCOON_VERSION}`; + log.info(`Installing gocoon ${GOCOON_VERSION} (${os}/${arch})…`); + + // 1. Download tarball + its checksum. + const [tar, shaLine] = await Promise.all([ + fetchBuffer(`${base}/${archive}`), + fetchText(`${base}/${archive}.sha256`), + ]); + + // 2. Verify SHA-256 (`.sha256` format: " "). + const expected = shaLine.trim().split(/\s+/)[0]?.toLowerCase(); + const actual = createHash("sha256").update(tar).digest("hex"); + if (!expected || expected !== actual) { + throw new Error( + `gocoon checksum mismatch for ${archive} (expected ${expected}, got ${actual})` + ); + } + + // 3. Extract both binaries. Temp dir lives under binDir so the final rename + // stays on the same filesystem (no EXDEV across volumes). + mkdirSync(binDir(), { recursive: true }); + const tmp = join(binDir(), `.extract-${process.pid}`); + rmSync(tmp, { recursive: true, force: true }); + mkdirSync(tmp, { recursive: true }); + const tarPath = join(tmp, archive); + writeFileSync(tarPath, tar); + try { + execFileSync("tar", ["-xzf", tarPath, "-C", tmp], { stdio: "pipe" }); + const ext = os === "windows" ? ".exe" : ""; + for (const [name, dest] of [ + ["gocoon", out.gocoon], + ["gocoon-runner", out.runner], + ] as const) { + const src = join(tmp, name + ext); + if (!existsSync(src)) throw new Error(`gocoon: archive ${archive} is missing ${name}${ext}`); + renameSync(src, dest); + if (os !== "windows") chmodSync(dest, 0o755); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + // 4. Stamp the sentinel last, so a crash mid-install retries cleanly. + writeFileSync(versionSentinel(), GOCOON_VERSION); + log.info(`gocoon ${GOCOON_VERSION} installed → ${binDir()}`); + return out; +} + +/** True when the pinned version is already on disk (both binaries + matching sentinel). */ +export function isInstalled(): boolean { + return ( + existsSync(versionSentinel()) && + readFileSync(versionSentinel(), "utf-8").trim() === GOCOON_VERSION && + existsSync(gocoonBin()) && + existsSync(runnerBin()) + ); +} + +async function fetchBuffer(url: string): Promise { + const res = await fetchWithTimeout(url, { timeoutMs: DOWNLOAD_TIMEOUT_MS }); + if (!res.ok) throw new Error(`gocoon: download failed ${url} → HTTP ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +async function fetchText(url: string): Promise { + const res = await fetchWithTimeout(url, { timeoutMs: DOWNLOAD_TIMEOUT_MS }); + if (!res.ok) throw new Error(`gocoon: download failed ${url} → HTTP ${res.status}`); + return res.text(); +} diff --git a/src/gocoon/lifecycle.ts b/src/gocoon/lifecycle.ts new file mode 100644 index 00000000..452c58db --- /dev/null +++ b/src/gocoon/lifecycle.ts @@ -0,0 +1,328 @@ +import { spawn, type ChildProcess } from "child_process"; +import { Address, fromNano, internal, SendMode } from "@ton/core"; +import { createLogger } from "../utils/logger.js"; +import { getErrorMessage } from "../utils/errors.js"; +import { tonapiFetch } from "../constants/api-endpoints.js"; +import { getCachedTonClient } from "../ton/wallet-service.js"; +import { openWallet } from "../ton/wallet-open.js"; +import { sendWalletTx } from "../ton/confirm.js"; +import { ensureGocoonBinaries } from "./installer.js"; +import { + fetchClientSC, + gocoonInit, + streamGocoon, + tonToNano, + waitFunded, + walletInfo, + type InitSummary, + type WalletInfo, +} from "./cli.js"; +import { killProcessGroup, waitReady } from "./supervisor.js"; +import { + GOCOON_DEFAULT_PORT, + clientConfigPath, + runnerBaseUrl, + runnerBin, + walletPath, +} from "./paths.js"; + +const log = createLogger("gocoon"); + +// Re-exported so CLI and WebUI import the whole lifecycle from one place. +export { gocoonInit as init, waitFunded, walletInfo, tonToNano }; +export type { InitSummary, WalletInfo }; + +// ── Shared progress sink (CLI renders to terminal, WebUI streams to the browser) ── + +export type GocoonStage = + | "resolve" + | "find_channel" + | "spawn_runner" + | "close_channel" + | "wait_refund" + | "withdraw_cocoon" + | "withdraw_agent" + | "complete"; + +export type GocoonStatus = "started" | "ok" | "info" | "warn" | "skipped" | "error"; + +export interface GocoonProgress { + stage: GocoonStage; + status: GocoonStatus; + message: string; + at: number; +} + +export type ProgressSink = (e: GocoonProgress) => void; + +const REFUND_TIMEOUT_MS = 180_000; // 3 min — matches myduckai +const REFUND_MIN_DELTA_NANO = 5_000_000_000n; // 5 TON lower bound on the channel-close refund +const MIN_COCOON_WITHDRAW_NANO = 50_000_000n; // 0.05 TON — below this it's not worth the gas +const AGENT_FEE_BUFFER_NANO = 10_000_000n; // 0.01 TON left for fees +const COCOON_CLIENT_OPS = new Set([ + "CocoonOwnerClientRegister", + "CocoonExtClientTopUp", + "CocoonOwnerRequestRefund", + "CocoonOwnerWithdraw", +]); + +// ── Top-up (add stake to the open channel) ──────────────────────────── + +/** Add TON to the payment channel. The runner must be active (reads client_sc from /jsonstats). */ +export async function topup( + amountTon: string, + port: number = GOCOON_DEFAULT_PORT, + onLine?: (line: string) => void +): Promise { + const nano = tonToNano(amountTon); + const clientSC = await fetchClientSC(port); + log.info(`Topping up channel with ${amountTon} TON (${nano} nanoTON)…`); + await streamGocoon(["channel", "topup", "--amount", nano, "--client-sc", clientSC], onLine); +} + +// ── Withdraw everything to a chosen destination (unstake + drain) ────── + +/** + * Close the COCOON channel, wait for the staked TON to refund, then withdraw the + * COCOON wallet AND the agent wallet to `destination`. The runner must NOT be + * running. Faithful port of myduckai's `runWithdrawAll` (withdraw_all.go). + */ +export async function withdrawAll( + destination: string, + sink: ProgressSink = () => {}, + port: number = GOCOON_DEFAULT_PORT +): Promise { + const emit = (stage: GocoonStage, status: GocoonStatus, message: string): void => + sink({ stage, status, message, at: Date.now() }); + + // 1. Resolve destination (TON address or .ton domain). + emit("resolve", "started", "Resolving destination…"); + const dest = await resolveDest(destination); + emit("resolve", "ok", `Destination → ${dest.label}`); + + // 2. Refuse if a runner is up — closing a live channel concurrently is unsafe. + if (await isRunnerUp(port)) { + throw new Error("the gocoon runner is active — stop teleton first, then retry the withdraw"); + } + + // 3. Locate the COCOON wallet and look for an active channel on chain. + const cocoon = await walletInfo(); + emit( + "find_channel", + "started", + `Looking for an active channel via ${shortAddr(cocoon.ownerAddress)}…` + ); + let clientSC: string | null = null; + try { + clientSC = await findClientSC(cocoon.ownerAddress); + emit("find_channel", "ok", "Channel located"); + } catch (err) { + emit( + "find_channel", + "skipped", + `No active channel — will only withdraw wallets (${getErrorMessage(err)})` + ); + } + + // 4. If a channel exists: spawn a transient runner, close it, wait for the refund. + if (clientSC) { + emit("spawn_runner", "started", "Starting gocoon-runner (transient)…"); + const runner = await spawnTransientRunner(); + try { + await waitReady(`${runnerBaseUrl(port)}/jsonstats`, 30_000); + emit("spawn_runner", "ok", "Runner ready"); + + emit("close_channel", "started", "Closing channel…"); + await streamGocoon([ + "channel", + "close", + "--client-sc", + clientSC, + "--url", + runnerBaseUrl(port), + ]); + emit("close_channel", "ok", "Channel close transaction sent"); + + emit( + "wait_refund", + "started", + "Waiting for the network to return your staked TON (up to 3 min)…" + ); + await waitForRefund(REFUND_TIMEOUT_MS); + emit("wait_refund", "ok", "Refund landed on chain"); + } finally { + if (runner.pid != null) killProcessGroup(runner.pid); + } + } + + // 5. Withdraw the COCOON wallet (if it holds more than dust). + const balance = (await walletInfo()).balanceNano; + if (balance > MIN_COCOON_WITHDRAW_NANO) { + emit("withdraw_cocoon", "started", `Withdrawing COCOON wallet → ${dest.label}…`); + await streamGocoon([ + "wallet", + "withdraw", + "--wallet", + walletPath(), + "--config", + clientConfigPath(), + "--to", + dest.address.toString({ bounceable: false }), + ]); + emit("withdraw_cocoon", "ok", "COCOON wallet withdrawn"); + } else { + emit("withdraw_cocoon", "skipped", `COCOON wallet too low (${balance} nanoTON) — skipping`); + } + + // 6. Withdraw the agent wallet (teleton's own TON wallet). + emit("withdraw_agent", "started", `Withdrawing agent wallet → ${dest.label}…`); + await withdrawAgentWallet(dest.address, emit); + + emit("complete", "ok", `All funds sent to ${dest.label}`); +} + +// ── Helpers (ported from withdraw_resolve.go / withdraw_chain.go / withdraw_runner.go) ── + +interface ResolvedDest { + address: Address; + label: string; +} + +/** Accept a TON address (EQ/UQ/raw) or a `.ton` domain → parsed Address + a log label. */ +async function resolveDest(raw: string): Promise { + const s = raw.trim(); + if (!s) throw new Error("empty destination"); + if (s.toLowerCase().endsWith(".ton")) { + const res = await tonapiFetch(`/dns/${encodeURIComponent(s.toLowerCase())}/resolve`); + if (!res.ok) throw new Error(`tonapi dns resolve ${s} → HTTP ${res.status}`); + const j = (await res.json()) as { wallet?: { address?: string } }; + const addr = j.wallet?.address; + if (!addr) throw new Error(`${s} has no wallet record`); + return { address: Address.parse(addr), label: `${s} (${shortAddr(addr)})` }; + } + try { + return { address: Address.parse(s), label: s }; + } catch { + throw new Error(`not a TON address or .ton domain: "${raw}"`); + } +} + +/** Is something listening on the runner's control plane? (concurrent-bot guard) */ +async function isRunnerUp(port: number): Promise { + try { + await fetch(`${runnerBaseUrl(port)}/jsonstats`, { signal: AbortSignal.timeout(800) }); + return true; // any HTTP response means the port is bound + } catch { + return false; + } +} + +/** + * Find the `client_sc` (channel contract) this COCOON wallet interacted with, by + * scanning its on-chain events for cocoon_client `SmartContractExec` ops, and + * verify it is still active. Port of findClientSCFromChain (withdraw_chain.go). + */ +async function findClientSC(walletAddr: string): Promise { + const res = await tonapiFetch(`/accounts/${encodeURIComponent(walletAddr)}/events?limit=50`); + if (!res.ok) throw new Error(`tonapi events → HTTP ${res.status}`); + const j = (await res.json()) as { + events?: { + actions?: { + type?: string; + SmartContractExec?: { + executor?: { address?: string }; + contract?: { address?: string }; + operation?: string; + }; + }[]; + }[]; + }; + + const me = normalizeRaw(walletAddr); + let candidate: string | undefined; + for (const event of j.events ?? []) { + for (const action of event.actions ?? []) { + const exec = action.SmartContractExec; + if (action.type !== "SmartContractExec" || !exec) continue; + if (normalizeRaw(exec.executor?.address ?? "") !== me) continue; + if (!COCOON_CLIENT_OPS.has(exec.operation ?? "")) continue; + candidate = exec.contract?.address; + break; + } + if (candidate) break; + } + if (!candidate) { + throw new Error("no cocoon_client interaction found — channel may never have been staked"); + } + + const stat = await tonapiFetch(`/accounts/${encodeURIComponent(candidate)}`); + if (!stat.ok) throw new Error(`tonapi account → HTTP ${stat.status}`); + const status = ((await stat.json()) as { status?: string }).status ?? "unknown"; + if (status !== "active") throw new Error(`client_sc is ${status} (already closed?)`); + + return Address.parse(candidate).toString({ bounceable: false }); +} + +/** Poll the COCOON wallet balance until it grows by ≥5 TON (the channel refund). */ +async function waitForRefund(timeoutMs: number): Promise { + const baseline = (await walletInfo()).balanceNano; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep(5_000); + try { + const now = (await walletInfo()).balanceNano; + if (now > baseline && now - baseline >= REFUND_MIN_DELTA_NANO) return; + } catch { + /* transient RPC error — keep polling */ + } + } + throw new Error(`refund did not land within ${Math.round(timeoutMs / 1000)}s`); +} + +/** Send (balance − 0.01 TON) from teleton's agent wallet to `dest`. Skips if empty/uninitialized. */ +async function withdrawAgentWallet( + dest: Address, + emit: (stage: GocoonStage, status: GocoonStatus, message: string) => void +): Promise { + const client = await getCachedTonClient(); + const opened = await openWallet(client); + if (!opened) { + emit("withdraw_agent", "skipped", "No agent wallet on disk — skipping"); + return; + } + const balance = await client.getBalance(opened.contract.address); + const send = balance - AGENT_FEE_BUFFER_NANO; + if (send <= 0n) { + emit("withdraw_agent", "skipped", `Agent wallet too low (${balance} nanoTON) — skipping`); + return; + } + const sent = await sendWalletTx(client, opened.contract, { + secretKey: opened.keyPair.secretKey, + messages: [internal({ to: dest, value: send, bounce: false, body: "withdraw" })], + sendMode: SendMode.PAY_GAS_SEPARATELY, + }); + if (!sent) throw new Error("agent wallet transfer was not confirmed on-chain"); + emit("withdraw_agent", "ok", `Agent wallet withdrawn (${fromNano(send)} TON sent)`); +} + +/** Start a background gocoon-runner (its own process group) so `channel close` has a control plane. */ +async function spawnTransientRunner(): Promise { + await ensureGocoonBinaries(); + return spawn(runnerBin(), ["--config", clientConfigPath()], { detached: true, stdio: "ignore" }); +} + +/** Collapse EQ/UQ/raw forms to "wc:hex" lowercase for byte-level comparison. */ +function normalizeRaw(s: string): string { + try { + const a = Address.parse(s); + return `${a.workChain}:${a.hash.toString("hex")}`.toLowerCase(); + } catch { + return s.toLowerCase(); + } +} + +function shortAddr(s: string): string { + return s.length > 18 ? `${s.slice(0, 8)}…${s.slice(-6)}` : s; +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/gocoon/paths.ts b/src/gocoon/paths.ts new file mode 100644 index 00000000..02f85157 --- /dev/null +++ b/src/gocoon/paths.ts @@ -0,0 +1,26 @@ +import { join } from "path"; +import { TELETON_ROOT } from "../workspace/paths.js"; + +/** Pinned gocoon release. Bump deliberately — the installer verifies the SHA-256 of this exact tag. */ +export const GOCOON_VERSION = "v0.2.0"; + +/** gocoon control-plane / OpenAI-compatible API default port. */ +export const GOCOON_DEFAULT_PORT = 10000; + +const exe = (name: string): string => (process.platform === "win32" ? `${name}.exe` : name); + +/** Directory holding managed binaries (shared with other tools). */ +export const binDir = (): string => join(TELETON_ROOT, "bin"); +export const gocoonBin = (): string => join(binDir(), exe("gocoon")); +export const runnerBin = (): string => join(binDir(), exe("gocoon-runner")); +export const versionSentinel = (): string => join(binDir(), ".gocoon-version"); + +/** gocoon data dir: wallet.json, client-config.json, ton-config.json. */ +export const gocoonDataDir = (): string => join(TELETON_ROOT, "gocoon"); +export const walletPath = (): string => join(gocoonDataDir(), "wallet.json"); +export const clientConfigPath = (): string => join(gocoonDataDir(), "client-config.json"); +export const tonConfigPath = (): string => join(gocoonDataDir(), "ton-config.json"); + +/** Local runner base URL for a given port. */ +export const runnerBaseUrl = (port: number = GOCOON_DEFAULT_PORT): string => + `http://127.0.0.1:${port}`; diff --git a/src/gocoon/supervisor.ts b/src/gocoon/supervisor.ts new file mode 100644 index 00000000..f7c4b1e4 --- /dev/null +++ b/src/gocoon/supervisor.ts @@ -0,0 +1,196 @@ +import { execFileSync } from "child_process"; +import { type ChildProcess, spawn } from "child_process"; +import { createLogger } from "../utils/logger.js"; +import { getErrorMessage } from "../utils/errors.js"; +import { runnerBin } from "./paths.js"; + +const log = createLogger("gocoon"); + +export interface SupervisorOptions { + /** Path to client-config.json produced by `gocoon init`. */ + configPath: string; + /** Readiness probe, e.g. http://127.0.0.1:10000/v1/models. */ + healthUrl: string; + /** Max wait for the first healthy probe (default 30s). */ + startGraceMs?: number; + /** gocoon-runner verbosity flag (-v N); 0 disables it. */ + verbosity?: number; + /** Restart backoff ceiling (default 60s). */ + backoffCapMs?: number; + /** Receives runner stdout/stderr lines (default: debug log). */ + onLog?: (line: string) => void; +} + +const READY_PROBE_TIMEOUT_MS = 2_000; +const READY_POLL_INTERVAL_MS = 250; +const KILL_GRACE_MS = 5_000; +const MAX_RESTART_FAILURES = 5; + +/** + * Supervises a long-lived `gocoon-runner` child: starts it, waits for the health + * URL to come up, then auto-restarts on crash with exponential backoff until + * {@link GocoonSupervisor.stop} is called. Node port of myduckai's + * `supervisor.go` + `health.go`. + */ +export class GocoonSupervisor { + private child: ChildProcess | null = null; + private stopped = false; + private runningFlag = false; + private failures = 0; + private backoffMs = 1_000; + + private readonly configPath: string; + private readonly healthUrl: string; + private readonly startGraceMs: number; + private readonly verbosity: number; + private readonly backoffCapMs: number; + private readonly onLog: (line: string) => void; + + constructor(opts: SupervisorOptions) { + this.configPath = opts.configPath; + this.healthUrl = opts.healthUrl; + this.startGraceMs = opts.startGraceMs ?? 30_000; + this.verbosity = opts.verbosity ?? 0; + this.backoffCapMs = opts.backoffCapMs ?? 60_000; + this.onLog = opts.onLog ?? ((line) => log.debug(line)); + } + + /** Launch the runner and resolve once it is healthy. Throws if it never becomes ready. */ + async start(): Promise { + this.stopped = false; + this.failures = 0; + this.backoffMs = 1_000; + this.spawnChild(); + try { + await waitReady(this.healthUrl, this.startGraceMs); + } catch (err) { + // Abort supervision: mark stopped first so the exit handler doesn't respawn. + this.stopped = true; + this.killChild(); + throw new Error(`gocoon-runner did not become healthy: ${getErrorMessage(err)}`); + } + log.info(`gocoon-runner ready (pid ${this.child?.pid ?? "?"})`); + } + + /** Signal shutdown and kill the runner (idempotent). */ + stop(): void { + if (this.stopped) return; + this.stopped = true; + this.killChild(); + } + + isRunning(): boolean { + return this.runningFlag; + } + + private spawnChild(): void { + const args = ["--config", this.configPath]; + if (this.verbosity > 0) args.push(`-v${this.verbosity}`); + + const child = spawn(runnerBin(), args, { detached: true, stdio: ["ignore", "pipe", "pipe"] }); + this.child = child; + this.runningFlag = true; + log.info(`gocoon-runner started (pid ${child.pid ?? "?"})`); + + const onData = (buf: Buffer): void => { + const line = buf.toString().trimEnd(); + if (line) this.onLog(line); + }; + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + child.on("error", (err) => log.error(`gocoon-runner process error: ${getErrorMessage(err)}`)); + child.on("exit", (code, signal) => this.onChildExit(code, signal)); + } + + private onChildExit(code: number | null, signal: NodeJS.Signals | null): void { + this.runningFlag = false; + if (this.stopped) { + log.info("gocoon-runner stopped"); + return; + } + log.warn( + `gocoon-runner exited unexpectedly (code=${code ?? "null"} signal=${signal ?? "null"}); restarting in ${this.backoffMs}ms` + ); + setTimeout(() => void this.restart(), this.backoffMs); + } + + private async restart(): Promise { + if (this.stopped) return; + try { + this.spawnChild(); + await waitReady(this.healthUrl, this.startGraceMs); + this.failures = 0; + this.backoffMs = 1_000; + log.info("gocoon-runner recovered"); + } catch (err) { + this.failures += 1; + this.backoffMs = Math.min(this.backoffMs * 2, this.backoffCapMs); + if (this.failures >= MAX_RESTART_FAILURES) { + this.stopped = true; + this.killChild(); + log.error(`gocoon-runner failed ${this.failures}× — giving up supervision`); + return; + } + log.error( + `gocoon-runner restart unhealthy (${this.failures}/${MAX_RESTART_FAILURES}): ${getErrorMessage(err)}` + ); + // Kill the unhealthy child; its 'exit' event reschedules the next restart. + this.killChild(); + } + } + + private killChild(): void { + const child = this.child; + this.child = null; + if (child?.pid != null) killProcessGroup(child.pid); + } +} + +/** + * Kill a child's whole process group: SIGTERM then SIGKILL after `graceMs` + * (POSIX, requires the child spawned `detached`); `taskkill /T /F` on Windows. + * Shared by the supervisor and the transient runner in the withdraw flow. + */ +export function killProcessGroup(pid: number, graceMs = KILL_GRACE_MS): void { + if (process.platform === "win32") { + try { + execFileSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" }); + } catch { + /* already gone */ + } + return; + } + try { + process.kill(-pid, "SIGTERM"); // negative pid → whole process group + setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + /* already reaped */ + } + }, graceMs); + } catch { + try { + process.kill(pid, "SIGKILL"); + } catch { + /* already gone */ + } + } +} + +/** Poll `url` with GET until it returns 2xx, or reject after `timeoutMs`. Port of health.go WaitReady. */ +export async function waitReady(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(READY_PROBE_TIMEOUT_MS) }); + if (res.ok) return; + } catch { + /* not up yet */ + } + await sleep(READY_POLL_INTERVAL_MS); + } + throw new Error(`health ${url} not ready within ${timeoutMs}ms`); +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/index.ts b/src/index.ts index 80e26294..7886e747 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,8 @@ import { setTonapiKey } from "./constants/api-endpoints.js"; import { setToncenterApiKey } from "./ton/endpoint.js"; import { TELETON_ROOT } from "./workspace/paths.js"; import { join } from "path"; +import { existsSync } from "fs"; +import type { GocoonSupervisor } from "./gocoon/index.js"; import { ToolRegistry } from "./agent/tools/registry.js"; import { registerAllTools } from "./agent/tools/register-all.js"; import { type PluginModuleWithHooks } from "./agent/tools/plugin-loader.js"; @@ -67,6 +69,7 @@ export class TeletonApp { private webuiServer: WebUIServer | null = null; private apiServer: ApiServer | null = null; private pluginWatcher: PluginWatcher | null = null; + private gocoonSupervisor: GocoonSupervisor | null = null; private mcpConnections: McpConnection[] = []; private callbackHandlerRegistered = false; private messageHandlersRegistered = false; @@ -429,7 +432,7 @@ ${blue} ┌────────────────────── // Initialize context builder for RAG search in agent this.agent.initializeContextBuilder(this.memory.embedder, getDatabase().isVectorSearchReady()); - // Register provider-specific models (Cocoon / local LLM) + // Register provider-specific models (gocoon / local LLM) await this.initializeProviders(); // Connect to Telegram @@ -669,24 +672,37 @@ ${blue} ┌────────────────────── } /** - * Register provider-specific models (Cocoon Network / local LLM). + * Register provider-specific models (gocoon / local LLM). */ private async initializeProviders(): Promise { - if (this.config.agent.provider === "cocoon") { + if (this.config.agent.provider === "gocoon") { + const port = this.config.gocoon?.port ?? 10000; + const autoStart = this.config.gocoon?.auto_start ?? true; try { - const { registerCocoonModels } = await import("./agent/client.js"); - const port = this.config.cocoon?.port ?? 10000; - const models = await registerCocoonModels(port); + if (autoStart) { + const { ensureGocoonBinaries, GocoonSupervisor, runnerBaseUrl, clientConfigPath } = + await import("./gocoon/index.js"); + if (!existsSync(clientConfigPath())) { + throw new Error("gocoon is not set up yet — run `teleton gocoon init` first"); + } + await ensureGocoonBinaries(); + this.gocoonSupervisor = new GocoonSupervisor({ + configPath: clientConfigPath(), + healthUrl: `${runnerBaseUrl(port)}/v1/models`, + }); + await this.gocoonSupervisor.start(); + log.info(`Gocoon runner started on port ${port}`); + } + const { registerGocoonModels } = await import("./agent/client.js"); + const models = await registerGocoonModels(port); if (models.length === 0) { throw new Error(`No models found on port ${port}`); } - log.info(`Cocoon Network ready — ${models.length} model(s) on port ${port}`); + log.info(`Gocoon ready — ${models.length} model(s) on port ${port}`); } catch (error: unknown) { - log.error( - `Cocoon Network unavailable on port ${this.config.cocoon?.port ?? 10000}: ${getErrorMessage(error)}` - ); - log.error("Start the Cocoon client first: cocoon start"); - throw new Error(`Cocoon Network unavailable: ${getErrorMessage(error)}`); + log.error(`Gocoon unavailable on port ${port}: ${getErrorMessage(error)}`); + if (!autoStart) log.error("Start the gocoon runner first: teleton gocoon status"); + throw new Error(`Gocoon unavailable: ${getErrorMessage(error)}`); } } @@ -1092,6 +1108,15 @@ ${blue} ┌────────────────────── } } + // Stop the gocoon runner (when teleton supervises it) + if (this.gocoonSupervisor) { + try { + this.gocoonSupervisor.stop(); + } catch (error: unknown) { + log.error({ err: error }, "gocoon supervisor stop failed"); + } + } + // Close MCP connections if (this.mcpConnections.length > 0) { try { diff --git a/src/memory/__tests__/observation-masking.test.ts b/src/memory/__tests__/observation-masking.test.ts index 894b82a8..33ac5015 100644 --- a/src/memory/__tests__/observation-masking.test.ts +++ b/src/memory/__tests__/observation-masking.test.ts @@ -23,14 +23,6 @@ function makeUserMsg(text: string): UserMessage { return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() }; } -function makeCocoonToolResult(text: string): UserMessage { - return { - role: "user", - content: [{ type: "text", text: `${text}` }], - timestamp: Date.now(), - }; -} - const SHORT_RESULT = JSON.stringify({ success: true, data: { message: "Done" } }); const LONG_RESULT = JSON.stringify({ success: true, @@ -181,19 +173,6 @@ describe("maskOldToolResults — inter-iteration truncation", () => { expect(result[0]).toBe(messages[0]); // kept intact }); - it("truncates Cocoon-style tool results", () => { - const config: MaskingConfig = { ...DEFAULT_MASKING_CONFIG, keepRecentCount: 20 }; - const longCocoon = "x".repeat(5000); - const messages: Message[] = [ - makeCocoonToolResult(longCocoon), // index 0, prev iteration - makeToolResult("current", SHORT_RESULT), // index 1, current - ]; - const result = maskOldToolResults(messages, { config, currentIterationStartIndex: 1 }); - const text = (result[0] as UserMessage).content[0]; - expect(text.type === "text" && text.text.length).toBeLessThan(longCocoon.length + 30); - expect(text.type === "text" && text.text).toContain("truncated"); - }); - it("uses summary field for truncation when available", () => { const config: MaskingConfig = { ...DEFAULT_MASKING_CONFIG, keepRecentCount: 20 }; const withSummary = JSON.stringify({ diff --git a/src/memory/observation-masking.ts b/src/memory/observation-masking.ts index df8b70ab..959e3499 100644 --- a/src/memory/observation-masking.ts +++ b/src/memory/observation-masking.ts @@ -1,4 +1,4 @@ -import type { Message, ToolResultMessage, UserMessage, TextContent } from "@mariozechner/pi-ai"; +import type { Message, ToolResultMessage, TextContent } from "@mariozechner/pi-ai"; import type { ToolRegistry } from "../agent/tools/registry.js"; import { MASKING_KEEP_RECENT_COUNT, @@ -26,12 +26,6 @@ export interface MaskingOptions { currentIterationStartIndex?: number; } -/** Detect Cocoon-style tool results (UserMessage with `` CDATA). */ -const isCocoonToolResult = (msg: Message): boolean => - msg.role === "user" && - Array.isArray(msg.content) && - msg.content.some((c) => c.type === "text" && c.text.includes("")); - /** Check if a tool result should be exempt from masking/truncation. */ function isExempt( toolMsg: ToolResultMessage, @@ -82,7 +76,7 @@ export function maskOldToolResults(messages: Message[], options?: MaskingOptions const toolResults = messages .map((msg, index) => ({ msg, index })) - .filter(({ msg }) => msg.role === "toolResult" || isCocoonToolResult(msg)); + .filter(({ msg }) => msg.role === "toolResult"); // Quick exit: nothing to mask or truncate const needsMasking = toolResults.length > config.keepRecentCount; @@ -98,14 +92,6 @@ export function maskOldToolResults(messages: Message[], options?: MaskingOptions const toMask = toolResults.slice(0, -config.keepRecentCount); for (const { msg, index } of toMask) { - if (isCocoonToolResult(msg)) { - result[index] = { - ...msg, - content: [{ type: "text" as const, text: "[Tool response masked]" }], - } as UserMessage; - continue; - } - const toolMsg = msg as ToolResultMessage; if (isExempt(toolMsg, config, toolRegistry)) continue; @@ -142,24 +128,6 @@ export function maskOldToolResults(messages: Message[], options?: MaskingOptions // Never truncate results from the current iteration if (index >= iterStart) continue; - if (isCocoonToolResult(msg)) { - const userMsg = msg as UserMessage; - if (!Array.isArray(userMsg.content)) continue; - const textBlock = userMsg.content.find((c): c is TextContent => c.type === "text"); - if (textBlock && textBlock.text.length > config.truncationThreshold) { - result[index] = { - ...userMsg, - content: [ - { - type: "text" as const, - text: truncateToolResult(textBlock.text, config.truncationKeepChars), - }, - ], - } as UserMessage; - } - continue; - } - const toolMsg = msg as ToolResultMessage; if (isExempt(toolMsg, config, toolRegistry)) continue; @@ -188,7 +156,7 @@ export function calculateMaskingSavings( const countChars = (messages: Message[]): number => { let total = 0; for (const msg of messages) { - if (msg.role === "toolResult" || isCocoonToolResult(msg)) { + if (msg.role === "toolResult") { for (const block of msg.content) { if (typeof block !== "string" && block.type === "text") { total += block.text.length; diff --git a/src/providers/model-resolver.ts b/src/providers/model-resolver.ts index f42994db..74085241 100644 --- a/src/providers/model-resolver.ts +++ b/src/providers/model-resolver.ts @@ -7,10 +7,10 @@ const log = createLogger("LLM"); const modelCache = new Map>(); -const COCOON_MODELS: Record> = {}; +const GOCOON_MODELS: Record> = {}; -/** Register models discovered from a running Cocoon client */ -export async function registerCocoonModels(httpPort: number): Promise { +/** Register models discovered from a running gocoon-runner (native OpenAI-compatible API). */ +export async function registerGocoonModels(httpPort: number): Promise { try { const res = await fetchWithTimeout(`http://localhost:${httpPort}/v1/models`, { timeoutMs: 3000, @@ -25,11 +25,11 @@ export async function registerCocoonModels(httpPort: number): Promise const ids: string[] = []; for (const m of models) { const id = m.id || m.name || String(m); - COCOON_MODELS[id] = { + GOCOON_MODELS[id] = { id, name: id, api: "openai-completions", - provider: "cocoon", + provider: "gocoon", baseUrl: `http://localhost:${httpPort}/v1`, reasoning: false, input: ["text"], @@ -40,6 +40,8 @@ export async function registerCocoonModels(httpPort: number): Promise supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, + supportsStrictMode: false, + maxTokensField: "max_tokens", }, }; ids.push(id); @@ -47,7 +49,7 @@ export async function registerCocoonModels(httpPort: number): Promise return ids; } catch (error) { if (error instanceof Error && error.name === "TimeoutError") { - log.warn({ port: httpPort }, "Cocoon /v1/models timed out after 3s, returning empty list"); + log.warn({ port: httpPort }, "gocoon /v1/models timed out after 3s, returning empty list"); } return []; } @@ -116,17 +118,17 @@ export function getProviderModel(provider: SupportedProvider, modelId: string): const meta = getProviderMetadata(provider); - if (meta.piAiProvider === "cocoon") { - let model = COCOON_MODELS[modelId]; + if (meta.piAiProvider === "gocoon") { + let model = GOCOON_MODELS[modelId]; if (!model) { - model = Object.values(COCOON_MODELS)[0]; - if (model) log.warn(`Cocoon model "${modelId}" not found, using "${model.id}"`); + model = Object.values(GOCOON_MODELS)[0]; + if (model) log.warn(`gocoon model "${modelId}" not found, using "${model.id}"`); } if (model) { modelCache.set(cacheKey, model); return model; } - throw new Error("No Cocoon models available. Is the cocoon client running?"); + throw new Error("No gocoon models available. Is the gocoon runner running?"); } if (meta.piAiProvider === "local") { diff --git a/src/webui/__tests__/setup-routes.test.ts b/src/webui/__tests__/setup-routes.test.ts index e468c9ba..42f385a9 100644 --- a/src/webui/__tests__/setup-routes.test.ts +++ b/src/webui/__tests__/setup-routes.test.ts @@ -58,8 +58,8 @@ vi.mock("../../config/providers.js", () => ({ consoleUrl: "https://console.anthropic.com/", }, { - id: "cocoon", - displayName: "Cocoon Network", + id: "gocoon", + displayName: "Gocoon", defaultModel: "auto", utilityModel: "auto", toolLimit: null, @@ -182,8 +182,8 @@ describe("Setup API Routes", () => { consoleUrl: "https://console.anthropic.com/", }, { - id: "cocoon", - displayName: "Cocoon Network", + id: "gocoon", + displayName: "Gocoon", defaultModel: "auto", utilityModel: "auto", toolLimit: null, @@ -289,7 +289,7 @@ describe("Setup API Routes", () => { expect(data.data).toHaveLength(2); expect(data.data[0].id).toBe("anthropic"); expect(data.data[0].requiresApiKey).toBe(true); - expect(data.data[1].id).toBe("cocoon"); + expect(data.data[1].id).toBe("gocoon"); expect(data.data[1].requiresApiKey).toBe(false); }); }); @@ -887,18 +887,18 @@ describe("Setup API Routes", () => { expect(ConfigSchema.parse).toHaveBeenCalled(); }); - it("includes cocoon config when provided", async () => { + it("includes gocoon config when provided", async () => { const input = { ...validInput, - cocoon: { endpoint: "https://cocoon.network" }, + gocoon: { port: 10000 }, }; const res = await post(app, "/config/save", input); expect(res.status).toBe(200); - // Check that writeFileSync was called with YAML containing cocoon + // Check that writeFileSync was called with YAML containing gocoon const writeCall = (writeFileSync as Mock).mock.calls[0]; - expect(writeCall[1]).toContain("cocoon"); + expect(writeCall[1]).toContain("gocoon"); }); it("returns 400 on Zod validation failure", async () => { diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index 8d92aa7c..79d7853e 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -335,7 +335,7 @@ export function createConfigRoutes(deps: WebUIServerDeps) { const provider = c.req.param("provider"); try { const meta = getProviderMetadata(provider as SupportedProvider); - const needsKey = provider !== "cocoon" && provider !== "local"; + const needsKey = provider !== "gocoon" && provider !== "local"; return c.json({ success: true, data: { diff --git a/src/webui/routes/gocoon.ts b/src/webui/routes/gocoon.ts new file mode 100644 index 00000000..4c75826d --- /dev/null +++ b/src/webui/routes/gocoon.ts @@ -0,0 +1,139 @@ +import { Hono } from "hono"; +import type { WebUIServerDeps, APIResponse } from "../types.js"; +import { getErrorMessage } from "../../utils/errors.js"; +import { + ensureGocoonBinaries, + init, + isInstalled, + runnerBaseUrl, + topup, + walletInfo, + withdrawAll, + GOCOON_DEFAULT_PORT, + GOCOON_VERSION, + type GocoonProgress, +} from "../../gocoon/index.js"; + +/** + * Gocoon management routes — thin shells over the shared lifecycle core, so the + * WebUI drives the exact same install/setup/top-up/withdraw flow as the CLI. + * Mounted at /api/gocoon (WebUI) and /v1/gocoon (Management API). + */ +export function createGocoonRoutes(deps: WebUIServerDeps) { + const app = new Hono(); + const port = (): number => deps.agent.getConfig().gocoon?.port ?? GOCOON_DEFAULT_PORT; + + // One withdraw at a time: POST starts it, GET polls progress (myduckai pattern). + let withdraw: { + running: boolean; + done: boolean; + events: GocoonProgress[]; + error?: string; + } | null = null; + + app.get("/status", async (c) => { + const data: Record = { + installed: isInstalled(), + version: isInstalled() ? GOCOON_VERSION : null, + }; + try { + const w = await walletInfo(); + data.wallet = { ownerAddress: w.ownerAddress, balanceTon: w.balanceTon, funded: w.funded }; + } catch { + data.wallet = null; + } + data.runner = await fetch(`${runnerBaseUrl(port())}/jsonstats`, { + signal: AbortSignal.timeout(800), + }) + .then(() => true) + .catch(() => false); + return c.json({ success: true, data } as APIResponse); + }); + + app.post("/install", async (c) => { + try { + await ensureGocoonBinaries(); + return c.json({ success: true, data: { version: GOCOON_VERSION } } as APIResponse); + } catch (err) { + return c.json({ success: false, error: getErrorMessage(err) } as APIResponse, 500); + } + }); + + app.post("/init", async (c) => { + try { + const s = await init(); + return c.json({ + success: true, + data: { fundAddress: s.fundAddress, recommendedFundingTon: s.recommendedFundingTon }, + } as APIResponse); + } catch (err) { + return c.json({ success: false, error: getErrorMessage(err) } as APIResponse, 500); + } + }); + + // Poll this until `funded` flips true after the user sends the TON. + app.get("/balance", async (c) => { + try { + const w = await walletInfo(); + return c.json({ + success: true, + data: { balanceTon: w.balanceTon, balanceNano: w.balanceNano.toString(), funded: w.funded }, + } as APIResponse); + } catch (err) { + return c.json({ success: false, error: getErrorMessage(err) } as APIResponse, 400); + } + }); + + app.post("/topup", async (c) => { + try { + const body = (await c.req.json().catch(() => ({}))) as { amount?: string | number }; + const amount = String(body.amount ?? "").trim(); + if (!amount) + return c.json({ success: false, error: "amount (TON) is required" } as APIResponse, 400); + await topup(amount, port()); + return c.json({ success: true, data: { amount } } as APIResponse); + } catch (err) { + return c.json({ success: false, error: getErrorMessage(err) } as APIResponse, 400); + } + }); + + app.post("/withdraw", async (c) => { + if (withdraw?.running) { + return c.json( + { success: false, error: "a withdraw is already in progress" } as APIResponse, + 409 + ); + } + const body = (await c.req.json().catch(() => ({}))) as { destination?: string }; + const destination = String(body.destination ?? "").trim(); + if (!destination) + return c.json({ success: false, error: "destination is required" } as APIResponse, 400); + + const job: { running: boolean; done: boolean; events: GocoonProgress[]; error?: string } = { + running: true, + done: false, + events: [], + }; + withdraw = job; + void withdrawAll(destination, (e) => job.events.push(e), port()) + .then(() => { + job.running = false; + job.done = true; + }) + .catch((err) => { + job.running = false; + job.done = true; + job.error = getErrorMessage(err); + }); + return c.json({ success: true, data: { started: true } } as APIResponse); + }); + + app.get("/withdraw", (c) => + c.json({ + success: true, + data: withdraw ?? { running: false, done: false, events: [] }, + } as APIResponse) + ); + + return app; +} diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts index 41932985..898ff6e1 100644 --- a/src/webui/routes/setup.ts +++ b/src/webui/routes/setup.ts @@ -92,7 +92,7 @@ export function createSetupRoutes(options?: { keyHash?: string }): Hono { toolLimit: p.toolLimit, keyPrefix: p.keyPrefix, consoleUrl: p.consoleUrl, - requiresApiKey: p.id !== "cocoon" && p.id !== "local", + requiresApiKey: p.id !== "gocoon" && p.id !== "local", requiresBaseUrl: p.id === "local", })); return c.json({ success: true, data: providers }); @@ -542,7 +542,7 @@ export function createSetupRoutes(options?: { keyHash?: string }): Hono { }, mcp: { servers: {} }, plugins: {}, - ...(input.cocoon ? { cocoon: input.cocoon } : {}), + ...(input.gocoon ? { gocoon: input.gocoon } : {}), ...(input.tonapi_key ? { tonapi_key: input.tonapi_key } : {}), ...(input.toncenter_api_key ? { toncenter_api_key: input.toncenter_api_key } : {}), ...(input.tavily_api_key ? { tavily_api_key: input.tavily_api_key } : {}), diff --git a/src/webui/routes/shared.ts b/src/webui/routes/shared.ts index 58bbde12..36224b7a 100644 --- a/src/webui/routes/shared.ts +++ b/src/webui/routes/shared.ts @@ -14,6 +14,7 @@ import { createConfigRoutes } from "./config.js"; import { createMarketplaceRoutes } from "./marketplace.js"; import { createHooksRoutes } from "./hooks.js"; import { createTonProxyRoutes } from "./ton-proxy.js"; +import { createGocoonRoutes } from "./gocoon.js"; /** A route factory shared by the WebUI and Management API servers. */ export type RouteFactory = (deps: WebUIServerDeps) => Hono; @@ -42,4 +43,5 @@ export const SHARED_ROUTE_FACTORIES: ReadonlyArray<[string, RouteFactory]> = [ ["marketplace", createMarketplaceRoutes], ["hooks", createHooksRoutes], ["ton-proxy", createTonProxyRoutes], + ["gocoon", createGocoonRoutes], ]; diff --git a/web/src/components/GocoonPanel.tsx b/web/src/components/GocoonPanel.tsx new file mode 100644 index 00000000..de077108 --- /dev/null +++ b/web/src/components/GocoonPanel.tsx @@ -0,0 +1,229 @@ +import { useEffect, useRef, useState } from 'react'; +import { api } from '../lib/api'; +import { errMsg } from '../lib/utils'; +import { toast } from '../lib/toast'; + +interface GocoonStatus { + installed: boolean; + version: string | null; + wallet: { ownerAddress: string; balanceTon: string; funded: boolean } | null; + runner: boolean; +} + +type WEvent = { stage: string; status: string; message: string }; + +const short = (a: string): string => (a.length > 18 ? `${a.slice(0, 8)}…${a.slice(-6)}` : a); + +/** + * Gocoon management — install, set up + fund the COCOON wallet, top up, and + * withdraw. Thin UI over /api/gocoon/* (the same lifecycle the CLI drives). + */ +export function GocoonPanel() { + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(null); + const [fund, setFund] = useState<{ fundAddress: string; recommendedFundingTon: string } | null>(null); + const [waitingFunds, setWaitingFunds] = useState(false); + const [topupAmount, setTopupAmount] = useState(''); + const [withdrawDest, setWithdrawDest] = useState(''); + const [events, setEvents] = useState([]); + const pollRef = useRef | null>(null); + + const refresh = async (): Promise => { + try { + const r = await api.gocoonStatus(); + if (r.success) setStatus(r.data); + } catch { + /* keep last known status */ + } + }; + + useEffect(() => { + void refresh(); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + // Poll the balance after init until the funding lands on-chain. + useEffect(() => { + if (!waitingFunds) return; + const t = setInterval(async () => { + try { + const r = await api.gocoonBalance(); + if (r.success && r.data.funded) { + setWaitingFunds(false); + toast.success('COCOON wallet funded'); + void refresh(); + } + } catch { + /* retry */ + } + }, 5000); + return () => clearInterval(t); + }, [waitingFunds]); + + const run = async (label: string, fn: () => Promise): Promise => { + setBusy(label); + try { + await fn(); + } catch (err) { + toast.error(errMsg(err)); + } finally { + setBusy(null); + } + }; + + const onInstall = (): Promise => + run('install', async () => { + const r = await api.gocoonInstall(); + toast.success(`Installed gocoon ${r.data.version}`); + await refresh(); + }); + + const onInit = (): Promise => + run('init', async () => { + const r = await api.gocoonInit(); + setFund(r.data); + setWaitingFunds(true); + }); + + const onTopup = (): Promise => + run('topup', async () => { + if (!topupAmount.trim()) return; + await api.gocoonTopup(topupAmount.trim()); + toast.success(`Topped up ${topupAmount} TON`); + setTopupAmount(''); + await refresh(); + }); + + const onWithdraw = (): Promise => + run('withdraw', async () => { + const dest = withdrawDest.trim(); + if (!dest) return; + await api.gocoonWithdrawStart(dest); + setEvents([]); + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = setInterval(async () => { + const s = await api.gocoonWithdrawStatus(); + if (!s.success) return; + setEvents(s.data.events.map((e) => ({ stage: e.stage, status: e.status, message: e.message }))); + if (s.data.done) { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = null; + if (s.data.error) toast.error(s.data.error); + else toast.success('Withdraw complete'); + void refresh(); + } + }, 1500); + }); + + if (!status) return
Loading gocoon…
; + + const wallet = status.wallet; + + return ( +
+ {/* Status rows */} +
+ + {status.installed ? ( + {status.version} + ) : ( + + )} + + {wallet ? short(wallet.ownerAddress) : not set up} + {wallet && ( + + {wallet.balanceTon} TON{' '} + {wallet.funded ? 'funded' : 'not funded'} + + )} + {status.runner ? running : not running} +
+ + {/* Set up + fund */} + {status.installed && !wallet?.funded && ( +
+ {!fund ? ( + + ) : ( +
+
+ Send {fund.recommendedFundingTon} TON (mainnet) to: +
+ {fund.fundAddress} +
+ {waitingFunds ? 'Waiting for funding to confirm on-chain…' : 'Funded.'} +
+
+ )} +
+ )} + + {/* Top up */} + {wallet && ( +
+ +
+ setTopupAmount(e.target.value)} + /> + +
+
Adds stake to the payment channel (the runner must be active).
+
+ )} + + {/* Withdraw everything */} + {wallet && ( +
+ +
+ setWithdrawDest(e.target.value)} + /> + +
+
+ Closes the channel and drains the COCOON + agent wallets. Stop the agent first. Irreversible. +
+ {events.length > 0 && ( +
+ {events.map((e, i) => ( +
+ {e.status === 'ok' ? '✓' : e.status === 'error' ? '✗' : '›'} [{e.stage}] {e.message} +
+ ))} +
+ )} +
+ )} +
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/web/src/components/ProviderControl.tsx b/web/src/components/ProviderControl.tsx index 6c0c8d38..36f377dc 100644 --- a/web/src/components/ProviderControl.tsx +++ b/web/src/components/ProviderControl.tsx @@ -1,7 +1,7 @@ import type { ProviderMeta } from '../hooks/useConfigState'; -export const PROVIDER_OPTIONS = ['anthropic', 'openai', 'google', 'xai', 'groq', 'openrouter', 'moonshot', 'mistral', 'cerebras', 'zai', 'minimax', 'huggingface', 'cocoon', 'local']; -export const PROVIDER_LABELS = ['Anthropic', 'OpenAI', 'Google', 'xAI', 'Groq', 'OpenRouter', 'Moonshot', 'Mistral', 'Cerebras', 'ZAI (Zhipu)', 'MiniMax', 'HuggingFace', 'Cocoon', 'Local']; +export const PROVIDER_OPTIONS = ['anthropic', 'openai', 'google', 'xai', 'groq', 'openrouter', 'moonshot', 'mistral', 'cerebras', 'zai', 'minimax', 'huggingface', 'gocoon', 'local']; +export const PROVIDER_LABELS = ['Anthropic', 'OpenAI', 'Google', 'xAI', 'Groq', 'OpenRouter', 'Moonshot', 'Mistral', 'Cerebras', 'ZAI (Zhipu)', 'MiniMax', 'HuggingFace', 'Gocoon', 'Local']; interface ProviderSwitchZoneProps { pendingMeta: ProviderMeta; diff --git a/web/src/components/setup/ProviderStep.tsx b/web/src/components/setup/ProviderStep.tsx index 3b507f33..584742d8 100644 --- a/web/src/components/setup/ProviderStep.tsx +++ b/web/src/components/setup/ProviderStep.tsx @@ -27,7 +27,7 @@ export function ProviderStep({ data, onChange }: StepProps) { // Load models when provider changes useEffect(() => { - if (!data.provider || data.provider === 'cocoon' || data.provider === 'local') { + if (!data.provider || data.provider === 'gocoon' || data.provider === 'local') { setModels([]); return; } @@ -139,23 +139,23 @@ export function ProviderStep({ data, onChange }: StepProps) { )} - {selected && !selected.requiresApiKey && selected.id === 'cocoon' && ( + {selected && !selected.requiresApiKey && selected.id === 'gocoon' && (
- Cocoon Network uses a local proxy. No API key required. + Gocoon runs a local decentralized LLM on TON. No API key required.
- + onChange({ ...data, cocoonPort: parseInt(e.target.value) || 0 })} + value={data.gocoonPort} + onChange={(e) => onChange({ ...data, gocoonPort: parseInt(e.target.value) || 0 })} min={1} max={65535} className="w-full" />
- Port where the Cocoon client proxy is running (1-65535). + Port where the gocoon runner is listening (1-65535).
@@ -182,7 +182,7 @@ export function ProviderStep({ data, onChange }: StepProps) { )} - {selected && selected.id !== 'cocoon' && selected.id !== 'local' && ( + {selected && selected.id !== 'gocoon' && selected.id !== 'local' && (
{loadingModels ? ( diff --git a/web/src/components/setup/SetupContext.tsx b/web/src/components/setup/SetupContext.tsx index af65838a..aecd6c3f 100644 --- a/web/src/components/setup/SetupContext.tsx +++ b/web/src/components/setup/SetupContext.tsx @@ -27,7 +27,7 @@ export interface WizardData { agentName: string; provider: string; apiKey: string; - cocoonPort: number; + gocoonPort: number; localUrl: string; apiId: number; apiHash: string; @@ -71,7 +71,7 @@ const DEFAULTS: WizardData = { agentName: 'Nova', provider: '', apiKey: '', - cocoonPort: 11435, + gocoonPort: 10000, localUrl: 'http://localhost:11434/v1', apiId: 0, apiHash: '', @@ -113,8 +113,8 @@ function validateStep(step: number, data: WizardData): boolean { return data.riskAccepted; case 1: if (!data.provider) return false; - if (data.provider === 'cocoon') { - return data.cocoonPort >= 1 && data.cocoonPort <= 65535; + if (data.provider === 'gocoon') { + return data.gocoonPort >= 1 && data.gocoonPort <= 65535; } if (data.provider === 'local') { try { new URL(data.localUrl); return true; } @@ -123,7 +123,7 @@ function validateStep(step: number, data: WizardData): boolean { return data.apiKey.length > 0; case 2: { // Config - if (data.provider !== 'cocoon' && data.provider !== 'local') { + if (data.provider !== 'gocoon' && data.provider !== 'local') { const modelValue = data.model === '__custom__' ? data.customModel : data.model; if (!modelValue) return false; } @@ -204,7 +204,7 @@ export function SetupProvider({ children }: { children: ReactNode }) { return { agent: { provider: data.provider, - ...(data.provider !== 'cocoon' && data.provider !== 'local' && data.apiKey ? { api_key: data.apiKey } : {}), + ...(data.provider !== 'gocoon' && data.provider !== 'local' && data.apiKey ? { api_key: data.apiKey } : {}), ...(data.provider === 'local' ? { base_url: data.localUrl } : {}), ...(resolvedModel ? { model: resolvedModel } : {}), max_agentic_iterations: data.maxIterations, @@ -222,7 +222,7 @@ export function SetupProvider({ children }: { children: ReactNode }) { ...(data.botToken ? { bot_token: data.botToken } : {}), ...(data.botUsername ? { bot_username: data.botUsername } : {}), }, - ...(data.provider === 'cocoon' ? { cocoon: { port: data.cocoonPort } } : {}), + ...(data.provider === 'gocoon' ? { gocoon: { port: data.gocoonPort } } : {}), deals: { enabled: !!data.botToken, ...(data.customizeThresholds diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 07441445..9676d823 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -81,7 +81,7 @@ export interface SetupConfig { bot_token?: string; bot_username?: string; }; - cocoon?: { port: number }; + gocoon?: { port: number }; deals?: { enabled?: boolean; buy_max_floor_percent?: number; sell_min_floor_percent?: number }; tonapi_key?: string; toncenter_api_key?: string; @@ -392,6 +392,29 @@ export const api = { return fetchAPI>('/status'); }, + // ── gocoon — decentralized LLM on TON ── + async gocoonStatus() { + return fetchAPI>('/gocoon/status'); + }, + async gocoonInstall() { + return fetchAPI>('/gocoon/install', { method: 'POST' }); + }, + async gocoonInit() { + return fetchAPI>('/gocoon/init', { method: 'POST' }); + }, + async gocoonBalance() { + return fetchAPI>('/gocoon/balance'); + }, + async gocoonTopup(amount: string) { + return fetchAPI>('/gocoon/topup', { method: 'POST', body: JSON.stringify({ amount }) }); + }, + async gocoonWithdrawStart(destination: string) { + return fetchAPI>('/gocoon/withdraw', { method: 'POST', body: JSON.stringify({ destination }) }); + }, + async gocoonWithdrawStatus() { + return fetchAPI>('/gocoon/withdraw'); + }, + async getTools() { return fetchAPI>('/tools'); }, diff --git a/web/src/pages/Config.tsx b/web/src/pages/Config.tsx index 3e486484..14d9fecf 100644 --- a/web/src/pages/Config.tsx +++ b/web/src/pages/Config.tsx @@ -11,6 +11,7 @@ import { EditableField } from '../components/EditableField'; import { ConfigSection } from '../components/ConfigSection'; import { InfoTip } from '../components/InfoTip'; import { Alert } from '../components/Alert'; +import { GocoonPanel } from '../components/GocoonPanel'; import { errMsg } from '../lib/utils'; import { toast } from '../lib/toast'; import { useConfirm } from '../components/ConfirmDialog'; @@ -144,23 +145,24 @@ export function Config() { />
- {config.getLocal('agent.provider') === 'cocoon' && ( + {config.getLocal('agent.provider') === 'gocoon' && ( <> -
Cocoon
+
Gocoon
+
config.setLocal('cocoon.port', v)} - onSave={(v) => config.saveConfig('cocoon.port', v)} - onCancel={() => config.cancelLocal('cocoon.port')} + value={config.getLocal('gocoon.port')} + serverValue={config.getServer('gocoon.port')} + onChange={(v) => config.setLocal('gocoon.port', v)} + onSave={(v) => config.saveConfig('gocoon.port', v)} + onCancel={() => config.cancelLocal('gocoon.port')} min={1} max={65535} - placeholder="11434" + placeholder="10000" hotReload="restart" />
From d2608ddd4249031b07bf186a8289af65965dd1ab Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:16:30 +0200 Subject: [PATCH 02/16] feat: replace cocoon with native gocoon provider Swap the XML tool-injection cocoon shim for gocoon, a pure-Go COCOON client exposing a native OpenAI-compatible API. Adds a turnkey TON channel lifecycle: download and verify release binaries, supervise the runner, init/fund/topup/withdraw, with auto-start when provider is gocoon. Exposes it via the `teleton gocoon` CLI and a dedicated Gocoon WebUI page. Pins gocoon v0.2.0; default model Qwen/Qwen3-32B. --- CHANGELOG.md | 2 +- docs/configuration.md | 2 +- src/cli/commands/gocoon.ts | 53 ++--- src/config/model-catalog.ts | 7 + src/config/providers.ts | 2 +- src/config/schema.ts | 2 +- src/gocoon/cli.ts | 46 ++-- src/gocoon/index.ts | 1 - src/gocoon/installer.ts | 48 ++-- src/gocoon/lifecycle.ts | 93 +++----- src/gocoon/paths.ts | 7 +- src/gocoon/supervisor.ts | 43 +--- src/index.ts | 33 ++- src/providers/model-resolver.ts | 4 +- src/webui/routes/gocoon.ts | 20 +- web/src/App.tsx | 2 + web/src/components/GocoonPanel.tsx | 367 ++++++++++++++++++++--------- web/src/components/Layout.tsx | 11 + web/src/index.css | 82 +++++++ web/src/lib/api.ts | 4 +- web/src/pages/Config.tsx | 25 -- web/src/pages/Gocoon.tsx | 13 + 22 files changed, 522 insertions(+), 345 deletions(-) create mode 100644 web/src/pages/Gocoon.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 66226d39..727fe12a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Replaced the `cocoon` provider with `gocoon`** — a native OpenAI-compatible, pure-Go COCOON client ([gocoon](https://github.com/TONresistor/gocoon)). Tool-calling now uses the model's native `tools`/`tool_calls` instead of the old XML-injection shim, which has been removed (`src/cocoon/` deleted). **Breaking:** set `agent.provider: gocoon` and rename the `cocoon:` config block to `gocoon:` (`gocoon.port`, default `10000`). +- **Replaced the `cocoon` provider with `gocoon`**: a native OpenAI-compatible, pure-Go COCOON client ([gocoon](https://github.com/TONresistor/gocoon)). Tool-calling now uses the model's native `tools`/`tool_calls` instead of the old XML-injection shim, which has been removed (`src/cocoon/` deleted). **Breaking:** set `agent.provider: gocoon` and rename the `cocoon:` config block to `gocoon:` (`gocoon.port`, default `10000`). Turnkey lifecycle: auto-install the gocoon runner, supervise it, and set up/top-up/withdraw the TON channel from the CLI (`teleton gocoon`) or the WebUI Gocoon page. ## [0.8.1] - 2026-03-05 diff --git a/docs/configuration.md b/docs/configuration.md index f81bbfb8..b59220a2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -487,7 +487,7 @@ api: ## gocoon -Gocoon configuration. Gocoon is a pure-Go COCOON client — a decentralized LLM that pays in TON. It exposes a native OpenAI-compatible API (with function calling) and requires the `gocoon-runner` process running on the specified port. +Gocoon configuration. Gocoon is a pure-Go COCOON client: a decentralized LLM that pays in TON. It exposes a native OpenAI-compatible API (with function calling) and requires the `gocoon-runner` process running on the specified port. | Key | Type | Default | Description | |-----|------|---------|-------------| diff --git a/src/cli/commands/gocoon.ts b/src/cli/commands/gocoon.ts index 8f82891a..d32d8fb6 100644 --- a/src/cli/commands/gocoon.ts +++ b/src/cli/commands/gocoon.ts @@ -16,34 +16,32 @@ import { import { BOLD, DIM, GREEN, RED, TON, YELLOW, noteBox } from "../prompts.js"; import { getErrorMessage } from "../../utils/errors.js"; -/** Render a lifecycle progress event to the terminal. */ function renderProgress(e: GocoonProgress): void { const tag = DIM(`[${e.stage}]`); - if (e.status === "ok") console.log(` ${GREEN("✓")} ${tag} ${e.message}`); - else if (e.status === "error") console.log(` ${RED("✗")} ${tag} ${e.message}`); + if (e.status === "ok") console.log(` ${GREEN("ok")} ${tag} ${e.message}`); + else if (e.status === "error") console.log(` ${RED("x")} ${tag} ${e.message}`); else if (e.status === "warn" || e.status === "skipped") - console.log(` ${YELLOW("•")} ${tag} ${e.message}`); - else console.log(` ${TON("›")} ${tag} ${e.message}`); + console.log(` ${YELLOW("-")} ${tag} ${e.message}`); + else console.log(` ${TON(">")} ${tag} ${e.message}`); } const indent = (line: string): void => console.log(DIM(" " + line)); -/** `teleton gocoon init` — install gocoon, generate the COCOON wallet, fund it. */ async function runInit(): Promise { - console.log(BOLD(TON("\n Gocoon setup — decentralized LLM on TON\n"))); + console.log(BOLD(TON("\n Gocoon setup\n"))); - console.log(` ${TON("›")} Installing gocoon ${GOCOON_VERSION}…`); + console.log(` ${TON(">")} Installing gocoon ${GOCOON_VERSION}`); await ensureGocoonBinaries(); - console.log(` ${GREEN("✓")} Binaries ready`); + console.log(` ${GREEN("ok")} Binaries ready`); - console.log(` ${TON("›")} Generating COCOON wallet…`); + console.log(` ${TON(">")} Preparing COCOON wallet`); const summary = await init(); - console.log(` ${GREEN("✓")} Wallet ready`); + console.log(` ${GREEN("ok")} Wallet ready`); noteBox( `Send ${BOLD(summary.recommendedFundingTon + " TON")} (mainnet) to:\n\n` + ` ${summary.fundAddress}\n\n` + - `This stakes your COCOON payment channel. Funds are recoverable later with\n` + + `This stakes your COCOON payment channel. Recoverable later with\n` + DIM("teleton gocoon withdraw "), "Fund COCOON wallet", YELLOW @@ -51,13 +49,13 @@ async function runInit(): Promise { const sent = await confirm({ message: "Have you sent the TON?", default: false }); if (!sent) { - console.log(YELLOW("\n Setup paused — run `teleton gocoon init` again once funded.\n")); + console.log(YELLOW("\n Setup paused. Run `teleton gocoon init` again once funded.\n")); return; } - console.log(` ${TON("›")} Waiting for funding to confirm on-chain…`); + console.log(` ${TON(">")} Waiting for funding to confirm on-chain`); await waitFunded(indent); - console.log(` ${GREEN("✓")} Wallet funded`); + console.log(` ${GREEN("ok")} Wallet funded`); noteBox( `Set ${BOLD("agent.provider: gocoon")} in your config.\n` + @@ -69,13 +67,13 @@ async function runInit(): Promise { async function runTopup(amountTon: string): Promise { await topup(amountTon, GOCOON_DEFAULT_PORT, indent); - console.log(` ${GREEN("✓")} Topped up channel with ${amountTon} TON`); + console.log(` ${GREEN("ok")} Topped up channel with ${amountTon} TON`); } async function runWithdraw(destination: string, skipConfirm: boolean): Promise { if (!skipConfirm) { const ok = await confirm({ - message: `Withdraw EVERYTHING to ${destination}? Closes the channel and drains the COCOON + agent wallets. Irreversible.`, + message: `Withdraw everything to ${destination}? Closes the channel and drains the COCOON + agent wallets. Irreversible.`, default: false, }); if (!ok) { @@ -90,20 +88,15 @@ async function runWithdraw(destination: string, skipConfirm: boolean): Promise { console.log(BOLD("\n Gocoon status\n")); const installed = isInstalled(); - console.log( - ` Installed: ${installed ? GREEN(GOCOON_VERSION) : YELLOW("not installed — run `teleton gocoon init`")}` - ); + console.log(` Installed: ${installed ? GREEN(GOCOON_VERSION) : YELLOW("not installed")}`); - // Only read the wallet when installed — avoids a surprise download on a read-only status. if (installed) { try { const info = await walletInfo(); console.log(` COCOON wallet: ${info.ownerAddress}`); - console.log( - ` Balance: ${info.balanceTon} TON ${info.funded ? GREEN("(funded)") : YELLOW("(not funded)")}` - ); + console.log(` Balance: ${info.balanceTon} TON`); } catch { - console.log(` COCOON wallet: ${DIM("not set up — run `teleton gocoon init`")}`); + console.log(` COCOON wallet: ${DIM("not set up, run `teleton gocoon init`")}`); } } @@ -113,21 +106,19 @@ async function runStatus(): Promise { console.log(` Runner: ${up ? GREEN("running") : DIM("not running")}\n`); } -/** Wrap a command action: report errors cleanly and exit non-zero. */ async function guard(fn: () => Promise): Promise { try { await fn(); } catch (err) { - console.error(RED(`\n ✗ ${getErrorMessage(err)}\n`)); + console.error(RED(`\n ${getErrorMessage(err)}\n`)); process.exit(1); } } -/** Register the `teleton gocoon` command group. */ export function registerGocoonCommand(program: Command): void { const gocoon = program .command("gocoon") - .description("Manage gocoon — decentralized LLM on TON (install, setup, top-up, withdraw)") + .description("Manage gocoon (decentralized LLM on TON): install, setup, top-up, withdraw") .option("-i, --init", "Install and set up gocoon (wallet + funding)") .action(async (opts: { init?: boolean }) => { if (opts.init) { @@ -151,9 +142,7 @@ export function registerGocoonCommand(program: Command): void { gocoon .command("withdraw ") - .description( - "Close the channel + withdraw COCOON & agent wallets to (TON address or .ton)" - ) + .description("Close the channel and withdraw COCOON + agent wallets to ") .option("--yes", "Skip the confirmation prompt") .action((destination: string, opts: { yes?: boolean }) => guard(() => runWithdraw(destination, Boolean(opts.yes))) diff --git a/src/config/model-catalog.ts b/src/config/model-catalog.ts index 09514238..66b5e0c7 100644 --- a/src/config/model-catalog.ts +++ b/src/config/model-catalog.ts @@ -262,6 +262,13 @@ export const MODEL_OPTIONS: Record = { description: "Reasoning, 204K ctx, $0.30/M", }, ], + gocoon: [ + { + value: "Qwen/Qwen3-32B", + name: "Qwen3-32B", + description: "Decentralized inference on TON", + }, + ], }; /** Get models for a provider (codex → openai-codex) */ diff --git a/src/config/providers.ts b/src/config/providers.ts index 92dca3ba..c494b824 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -190,7 +190,7 @@ const PROVIDER_REGISTRY: Record = { displayName: "Gocoon (Decentralized, TON)", envVar: "", keyPrefix: null, - keyHint: "No API key needed — pays in TON", + keyHint: "No API key, pays in TON", consoleUrl: "https://github.com/TONresistor/gocoon", defaultModel: "Qwen/Qwen3-32B", utilityModel: "Qwen/Qwen3-32B", diff --git a/src/config/schema.ts b/src/config/schema.ts index d87a7dab..4a36801f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -370,7 +370,7 @@ export const ConfigSchema = z.object({ .describe("Auto-install and supervise the gocoon-runner on start (default: true)"), }) .optional() - .describe("Gocoon — pure-Go COCOON client (decentralized LLM on TON)"), + .describe("Gocoon: pure-Go COCOON client (decentralized LLM on TON)"), tonapi_key: z .string() .optional() diff --git a/src/gocoon/cli.ts b/src/gocoon/cli.ts index 0ee25e01..0e245102 100644 --- a/src/gocoon/cli.ts +++ b/src/gocoon/cli.ts @@ -1,6 +1,6 @@ import { execFile, spawn } from "child_process"; import { promisify } from "util"; -import { mkdirSync } from "fs"; +import { existsSync, mkdirSync } from "fs"; import { createLogger } from "../utils/logger.js"; import { fetchWithTimeout } from "../utils/fetch.js"; import { ensureGocoonBinaries } from "./installer.js"; @@ -10,7 +10,7 @@ const log = createLogger("gocoon"); const execFileAsync = promisify(execFile); const MAX_BUFFER = 8 * 1024 * 1024; -/** Run a gocoon subcommand and capture stdout. Use for `--json` / short commands. */ +// Run a gocoon subcommand and capture stdout (for --json / short commands). export async function runGocoon(args: string[], timeoutMs = 60_000): Promise { const { gocoon } = await ensureGocoonBinaries(); const { stdout } = await execFileAsync(gocoon, args, { @@ -20,11 +20,7 @@ export async function runGocoon(args: string[], timeoutMs = 60_000): Promise void): Promise { const { gocoon } = await ensureGocoonBinaries(); await new Promise((resolve, reject) => { @@ -62,8 +58,23 @@ export interface InitSummary { configPath: string; } -/** `gocoon init --dir --json --force` — generates the COCOON wallet + config (spends nothing). */ +export function walletExists(): boolean { + return existsSync(walletPath()) && existsSync(clientConfigPath()); +} + +// Create the COCOON wallet, or reuse the existing one (stable funding address). export async function gocoonInit(): Promise { + if (walletExists()) { + const info = await walletInfo(); + return { + fundAddress: info.fundAddress, + ownerAddress: info.ownerAddress, + recommendedFundingTon: info.recommendedFundingTon, + recommendedFundingNano: "", + walletPath: walletPath(), + configPath: clientConfigPath(), + }; + } mkdirSync(gocoonDataDir(), { recursive: true }); const j = parseJson( await runGocoon(["init", "--dir", gocoonDataDir(), "--json", "--force"], 120_000) @@ -84,9 +95,9 @@ export interface WalletInfo { balanceNano: bigint; balanceTon: string; funded: boolean; + recommendedFundingTon: string; } -/** `gocoon wallet info --json` — current COCOON wallet balance + funding state. */ export async function walletInfo(): Promise { const j = parseJson( await runGocoon([ @@ -105,10 +116,10 @@ export async function walletInfo(): Promise { balanceNano: BigInt(String(j.balance_nano ?? "0")), balanceTon: String(j.balance_ton ?? "0"), funded: Boolean(j.funded), + recommendedFundingTon: String(j.recommended_funding_ton ?? "20"), }; } -/** Block until the COCOON wallet funding confirms on-chain (`gocoon wallet wait-funded`). */ export async function waitFunded(onLine?: (line: string) => void): Promise { await streamGocoon( ["wallet", "wait-funded", "--wallet", walletPath(), "--config", clientConfigPath()], @@ -116,22 +127,19 @@ export async function waitFunded(onLine?: (line: string) => void): Promise ); } -/** - * Read the active proxy's `client_sc` (channel smart-contract) address from the - * running runner's /jsonstats. Required by channel topup/close. The runner must - * be up with a registered proxy. - */ +// Read the active proxy's client_sc from the running runner. Needed by topup/close. export async function fetchClientSC(port: number): Promise { const res = await fetchWithTimeout(`${runnerBaseUrl(port)}/jsonstats`, { timeoutMs: 3_000 }); - if (!res.ok) - throw new Error(`runner /jsonstats → HTTP ${res.status} (is the gocoon runner active?)`); + if (!res.ok) { + throw new Error(`runner /jsonstats returned HTTP ${res.status} (is the gocoon runner active?)`); + } const j = (await res.json()) as { proxies?: { sc_address?: string }[] }; const addr = j.proxies?.[0]?.sc_address?.trim(); - if (!addr) throw new Error("runner has no proxy/client_sc yet — wait for discovery and retry"); + if (!addr) throw new Error("runner has no proxy/client_sc yet; wait for discovery and retry"); return addr; } -/** Convert a decimal TON string into integer nanoTON (≤ 9 decimals). */ +// Decimal TON string to integer nanoTON (max 9 decimals). export function tonToNano(ton: string): string { const s = ton.trim(); if (!/^\d+(\.\d{1,9})?$/.test(s)) throw new Error(`invalid TON amount: "${ton}"`); diff --git a/src/gocoon/index.ts b/src/gocoon/index.ts index 790a7ede..97f90257 100644 --- a/src/gocoon/index.ts +++ b/src/gocoon/index.ts @@ -1,4 +1,3 @@ -// Public gocoon API surface — CLI and WebUI import from here. export { ensureGocoonBinaries, isInstalled, diff --git a/src/gocoon/installer.ts b/src/gocoon/installer.ts index 5c98dd76..bc69340a 100644 --- a/src/gocoon/installer.ts +++ b/src/gocoon/installer.ts @@ -24,7 +24,6 @@ interface Platform { arch: "amd64" | "arm64"; } -/** Map Node's platform/arch onto gocoon's release asset naming. */ export function detectPlatform(): Platform { const osMap: Record = { darwin: "darwin", @@ -35,9 +34,7 @@ export function detectPlatform(): Platform { const os = osMap[process.platform]; const arch = archMap[process.arch]; if (!os || !arch) { - throw new Error( - `gocoon: unsupported platform ${process.platform}/${process.arch} (need darwin|linux|windows × amd64|arm64)` - ); + throw new Error(`gocoon: unsupported platform ${process.platform}/${process.arch}`); } return { os, arch }; } @@ -47,30 +44,31 @@ export interface GocoonBinaries { runner: string; } -/** - * Ensure the `gocoon` + `gocoon-runner` binaries are installed and return their - * paths. Downloads the pinned release ({@link GOCOON_VERSION}) for the current - * platform, verifies its SHA-256, and extracts both binaries into `~/.teleton/bin`. - * - * Idempotent: a version sentinel short-circuits re-download once installed. - */ +export function isInstalled(): boolean { + return ( + existsSync(versionSentinel()) && + readFileSync(versionSentinel(), "utf-8").trim() === GOCOON_VERSION && + existsSync(gocoonBin()) && + existsSync(runnerBin()) + ); +} + +// Download the pinned release, verify its SHA-256, extract both binaries into +// ~/.teleton/bin. Idempotent via the version sentinel. export async function ensureGocoonBinaries(): Promise { const out: GocoonBinaries = { gocoon: gocoonBin(), runner: runnerBin() }; - if (isInstalled()) return out; const { os, arch } = detectPlatform(); const archive = `gocoon-${GOCOON_VERSION}-${os}-${arch}.tar.gz`; const base = `https://github.com/${REPO}/releases/download/${GOCOON_VERSION}`; - log.info(`Installing gocoon ${GOCOON_VERSION} (${os}/${arch})…`); + log.info(`Installing gocoon ${GOCOON_VERSION} (${os}/${arch})`); - // 1. Download tarball + its checksum. const [tar, shaLine] = await Promise.all([ fetchBuffer(`${base}/${archive}`), fetchText(`${base}/${archive}.sha256`), ]); - // 2. Verify SHA-256 (`.sha256` format: " "). const expected = shaLine.trim().split(/\s+/)[0]?.toLowerCase(); const actual = createHash("sha256").update(tar).digest("hex"); if (!expected || expected !== actual) { @@ -79,9 +77,8 @@ export async function ensureGocoonBinaries(): Promise { ); } - // 3. Extract both binaries. Temp dir lives under binDir so the final rename - // stays on the same filesystem (no EXDEV across volumes). mkdirSync(binDir(), { recursive: true }); + // Temp dir under binDir so the final rename stays on one filesystem (no EXDEV). const tmp = join(binDir(), `.extract-${process.pid}`); rmSync(tmp, { recursive: true, force: true }); mkdirSync(tmp, { recursive: true }); @@ -103,30 +100,19 @@ export async function ensureGocoonBinaries(): Promise { rmSync(tmp, { recursive: true, force: true }); } - // 4. Stamp the sentinel last, so a crash mid-install retries cleanly. writeFileSync(versionSentinel(), GOCOON_VERSION); - log.info(`gocoon ${GOCOON_VERSION} installed → ${binDir()}`); + log.info(`gocoon ${GOCOON_VERSION} installed`); return out; } -/** True when the pinned version is already on disk (both binaries + matching sentinel). */ -export function isInstalled(): boolean { - return ( - existsSync(versionSentinel()) && - readFileSync(versionSentinel(), "utf-8").trim() === GOCOON_VERSION && - existsSync(gocoonBin()) && - existsSync(runnerBin()) - ); -} - async function fetchBuffer(url: string): Promise { const res = await fetchWithTimeout(url, { timeoutMs: DOWNLOAD_TIMEOUT_MS }); - if (!res.ok) throw new Error(`gocoon: download failed ${url} → HTTP ${res.status}`); + if (!res.ok) throw new Error(`gocoon: download failed ${url} (HTTP ${res.status})`); return Buffer.from(await res.arrayBuffer()); } async function fetchText(url: string): Promise { const res = await fetchWithTimeout(url, { timeoutMs: DOWNLOAD_TIMEOUT_MS }); - if (!res.ok) throw new Error(`gocoon: download failed ${url} → HTTP ${res.status}`); + if (!res.ok) throw new Error(`gocoon: download failed ${url} (HTTP ${res.status})`); return res.text(); } diff --git a/src/gocoon/lifecycle.ts b/src/gocoon/lifecycle.ts index 452c58db..d4b8a189 100644 --- a/src/gocoon/lifecycle.ts +++ b/src/gocoon/lifecycle.ts @@ -28,12 +28,9 @@ import { const log = createLogger("gocoon"); -// Re-exported so CLI and WebUI import the whole lifecycle from one place. export { gocoonInit as init, waitFunded, walletInfo, tonToNano }; export type { InitSummary, WalletInfo }; -// ── Shared progress sink (CLI renders to terminal, WebUI streams to the browser) ── - export type GocoonStage = | "resolve" | "find_channel" @@ -55,10 +52,10 @@ export interface GocoonProgress { export type ProgressSink = (e: GocoonProgress) => void; -const REFUND_TIMEOUT_MS = 180_000; // 3 min — matches myduckai -const REFUND_MIN_DELTA_NANO = 5_000_000_000n; // 5 TON lower bound on the channel-close refund -const MIN_COCOON_WITHDRAW_NANO = 50_000_000n; // 0.05 TON — below this it's not worth the gas -const AGENT_FEE_BUFFER_NANO = 10_000_000n; // 0.01 TON left for fees +const REFUND_TIMEOUT_MS = 180_000; +const REFUND_MIN_DELTA_NANO = 5_000_000_000n; +const MIN_COCOON_WITHDRAW_NANO = 50_000_000n; +const AGENT_FEE_BUFFER_NANO = 10_000_000n; const COCOON_CLIENT_OPS = new Set([ "CocoonOwnerClientRegister", "CocoonExtClientTopUp", @@ -66,9 +63,7 @@ const COCOON_CLIENT_OPS = new Set([ "CocoonOwnerWithdraw", ]); -// ── Top-up (add stake to the open channel) ──────────────────────────── - -/** Add TON to the payment channel. The runner must be active (reads client_sc from /jsonstats). */ +// Add TON to the open channel. The runner must be active (reads client_sc from /jsonstats). export async function topup( amountTon: string, port: number = GOCOON_DEFAULT_PORT, @@ -76,17 +71,12 @@ export async function topup( ): Promise { const nano = tonToNano(amountTon); const clientSC = await fetchClientSC(port); - log.info(`Topping up channel with ${amountTon} TON (${nano} nanoTON)…`); + log.info(`Topping up channel with ${amountTon} TON (${nano} nanoTON)`); await streamGocoon(["channel", "topup", "--amount", nano, "--client-sc", clientSC], onLine); } -// ── Withdraw everything to a chosen destination (unstake + drain) ────── - -/** - * Close the COCOON channel, wait for the staked TON to refund, then withdraw the - * COCOON wallet AND the agent wallet to `destination`. The runner must NOT be - * running. Faithful port of myduckai's `runWithdrawAll` (withdraw_all.go). - */ +// Close the channel, wait for the refund, then withdraw the COCOON and agent +// wallets to destination. The runner must not be running. export async function withdrawAll( destination: string, sink: ProgressSink = () => {}, @@ -95,22 +85,19 @@ export async function withdrawAll( const emit = (stage: GocoonStage, status: GocoonStatus, message: string): void => sink({ stage, status, message, at: Date.now() }); - // 1. Resolve destination (TON address or .ton domain). - emit("resolve", "started", "Resolving destination…"); + emit("resolve", "started", "Resolving destination"); const dest = await resolveDest(destination); - emit("resolve", "ok", `Destination → ${dest.label}`); + emit("resolve", "ok", `Destination: ${dest.label}`); - // 2. Refuse if a runner is up — closing a live channel concurrently is unsafe. if (await isRunnerUp(port)) { - throw new Error("the gocoon runner is active — stop teleton first, then retry the withdraw"); + throw new Error("the gocoon runner is active; stop teleton first, then retry the withdraw"); } - // 3. Locate the COCOON wallet and look for an active channel on chain. const cocoon = await walletInfo(); emit( "find_channel", "started", - `Looking for an active channel via ${shortAddr(cocoon.ownerAddress)}…` + `Looking for an active channel via ${shortAddr(cocoon.ownerAddress)}` ); let clientSC: string | null = null; try { @@ -120,19 +107,18 @@ export async function withdrawAll( emit( "find_channel", "skipped", - `No active channel — will only withdraw wallets (${getErrorMessage(err)})` + `No active channel, will only withdraw wallets (${getErrorMessage(err)})` ); } - // 4. If a channel exists: spawn a transient runner, close it, wait for the refund. if (clientSC) { - emit("spawn_runner", "started", "Starting gocoon-runner (transient)…"); + emit("spawn_runner", "started", "Starting gocoon-runner (transient)"); const runner = await spawnTransientRunner(); try { await waitReady(`${runnerBaseUrl(port)}/jsonstats`, 30_000); emit("spawn_runner", "ok", "Runner ready"); - emit("close_channel", "started", "Closing channel…"); + emit("close_channel", "started", "Closing channel"); await streamGocoon([ "channel", "close", @@ -143,11 +129,7 @@ export async function withdrawAll( ]); emit("close_channel", "ok", "Channel close transaction sent"); - emit( - "wait_refund", - "started", - "Waiting for the network to return your staked TON (up to 3 min)…" - ); + emit("wait_refund", "started", "Waiting for the staked TON to return (up to 3 min)"); await waitForRefund(REFUND_TIMEOUT_MS); emit("wait_refund", "ok", "Refund landed on chain"); } finally { @@ -155,10 +137,9 @@ export async function withdrawAll( } } - // 5. Withdraw the COCOON wallet (if it holds more than dust). const balance = (await walletInfo()).balanceNano; if (balance > MIN_COCOON_WITHDRAW_NANO) { - emit("withdraw_cocoon", "started", `Withdrawing COCOON wallet → ${dest.label}…`); + emit("withdraw_cocoon", "started", `Withdrawing COCOON wallet to ${dest.label}`); await streamGocoon([ "wallet", "withdraw", @@ -171,30 +152,27 @@ export async function withdrawAll( ]); emit("withdraw_cocoon", "ok", "COCOON wallet withdrawn"); } else { - emit("withdraw_cocoon", "skipped", `COCOON wallet too low (${balance} nanoTON) — skipping`); + emit("withdraw_cocoon", "skipped", `COCOON wallet too low (${balance} nanoTON), skipping`); } - // 6. Withdraw the agent wallet (teleton's own TON wallet). - emit("withdraw_agent", "started", `Withdrawing agent wallet → ${dest.label}…`); + emit("withdraw_agent", "started", `Withdrawing agent wallet to ${dest.label}`); await withdrawAgentWallet(dest.address, emit); emit("complete", "ok", `All funds sent to ${dest.label}`); } -// ── Helpers (ported from withdraw_resolve.go / withdraw_chain.go / withdraw_runner.go) ── - interface ResolvedDest { address: Address; label: string; } -/** Accept a TON address (EQ/UQ/raw) or a `.ton` domain → parsed Address + a log label. */ +// Accept a TON address or a .ton domain. async function resolveDest(raw: string): Promise { const s = raw.trim(); if (!s) throw new Error("empty destination"); if (s.toLowerCase().endsWith(".ton")) { const res = await tonapiFetch(`/dns/${encodeURIComponent(s.toLowerCase())}/resolve`); - if (!res.ok) throw new Error(`tonapi dns resolve ${s} → HTTP ${res.status}`); + if (!res.ok) throw new Error(`tonapi dns resolve ${s} returned HTTP ${res.status}`); const j = (await res.json()) as { wallet?: { address?: string } }; const addr = j.wallet?.address; if (!addr) throw new Error(`${s} has no wallet record`); @@ -207,24 +185,19 @@ async function resolveDest(raw: string): Promise { } } -/** Is something listening on the runner's control plane? (concurrent-bot guard) */ async function isRunnerUp(port: number): Promise { try { await fetch(`${runnerBaseUrl(port)}/jsonstats`, { signal: AbortSignal.timeout(800) }); - return true; // any HTTP response means the port is bound + return true; } catch { return false; } } -/** - * Find the `client_sc` (channel contract) this COCOON wallet interacted with, by - * scanning its on-chain events for cocoon_client `SmartContractExec` ops, and - * verify it is still active. Port of findClientSCFromChain (withdraw_chain.go). - */ +// Find the client_sc this wallet interacted with on chain, and verify it is active. async function findClientSC(walletAddr: string): Promise { const res = await tonapiFetch(`/accounts/${encodeURIComponent(walletAddr)}/events?limit=50`); - if (!res.ok) throw new Error(`tonapi events → HTTP ${res.status}`); + if (!res.ok) throw new Error(`tonapi events returned HTTP ${res.status}`); const j = (await res.json()) as { events?: { actions?: { @@ -252,18 +225,18 @@ async function findClientSC(walletAddr: string): Promise { if (candidate) break; } if (!candidate) { - throw new Error("no cocoon_client interaction found — channel may never have been staked"); + throw new Error("no cocoon_client interaction found; channel may never have been staked"); } const stat = await tonapiFetch(`/accounts/${encodeURIComponent(candidate)}`); - if (!stat.ok) throw new Error(`tonapi account → HTTP ${stat.status}`); + if (!stat.ok) throw new Error(`tonapi account returned HTTP ${stat.status}`); const status = ((await stat.json()) as { status?: string }).status ?? "unknown"; if (status !== "active") throw new Error(`client_sc is ${status} (already closed?)`); return Address.parse(candidate).toString({ bounceable: false }); } -/** Poll the COCOON wallet balance until it grows by ≥5 TON (the channel refund). */ +// Poll the COCOON wallet balance until it grows by at least 5 TON (the refund). async function waitForRefund(timeoutMs: number): Promise { const baseline = (await walletInfo()).balanceNano; const deadline = Date.now() + timeoutMs; @@ -273,13 +246,13 @@ async function waitForRefund(timeoutMs: number): Promise { const now = (await walletInfo()).balanceNano; if (now > baseline && now - baseline >= REFUND_MIN_DELTA_NANO) return; } catch { - /* transient RPC error — keep polling */ + /* transient RPC error */ } } throw new Error(`refund did not land within ${Math.round(timeoutMs / 1000)}s`); } -/** Send (balance − 0.01 TON) from teleton's agent wallet to `dest`. Skips if empty/uninitialized. */ +// Send (balance - 0.01 TON) from teleton's agent wallet to dest. Skips if empty. async function withdrawAgentWallet( dest: Address, emit: (stage: GocoonStage, status: GocoonStatus, message: string) => void @@ -287,13 +260,13 @@ async function withdrawAgentWallet( const client = await getCachedTonClient(); const opened = await openWallet(client); if (!opened) { - emit("withdraw_agent", "skipped", "No agent wallet on disk — skipping"); + emit("withdraw_agent", "skipped", "No agent wallet on disk, skipping"); return; } const balance = await client.getBalance(opened.contract.address); const send = balance - AGENT_FEE_BUFFER_NANO; if (send <= 0n) { - emit("withdraw_agent", "skipped", `Agent wallet too low (${balance} nanoTON) — skipping`); + emit("withdraw_agent", "skipped", `Agent wallet too low (${balance} nanoTON), skipping`); return; } const sent = await sendWalletTx(client, opened.contract, { @@ -305,13 +278,11 @@ async function withdrawAgentWallet( emit("withdraw_agent", "ok", `Agent wallet withdrawn (${fromNano(send)} TON sent)`); } -/** Start a background gocoon-runner (its own process group) so `channel close` has a control plane. */ async function spawnTransientRunner(): Promise { await ensureGocoonBinaries(); return spawn(runnerBin(), ["--config", clientConfigPath()], { detached: true, stdio: "ignore" }); } -/** Collapse EQ/UQ/raw forms to "wc:hex" lowercase for byte-level comparison. */ function normalizeRaw(s: string): string { try { const a = Address.parse(s); @@ -322,7 +293,7 @@ function normalizeRaw(s: string): string { } function shortAddr(s: string): string { - return s.length > 18 ? `${s.slice(0, 8)}…${s.slice(-6)}` : s; + return s.length > 18 ? `${s.slice(0, 8)}...${s.slice(-6)}` : s; } const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/gocoon/paths.ts b/src/gocoon/paths.ts index 02f85157..69047b68 100644 --- a/src/gocoon/paths.ts +++ b/src/gocoon/paths.ts @@ -1,26 +1,21 @@ import { join } from "path"; import { TELETON_ROOT } from "../workspace/paths.js"; -/** Pinned gocoon release. Bump deliberately — the installer verifies the SHA-256 of this exact tag. */ +// Pinned release. The installer verifies the SHA-256 of this exact tag. export const GOCOON_VERSION = "v0.2.0"; - -/** gocoon control-plane / OpenAI-compatible API default port. */ export const GOCOON_DEFAULT_PORT = 10000; const exe = (name: string): string => (process.platform === "win32" ? `${name}.exe` : name); -/** Directory holding managed binaries (shared with other tools). */ export const binDir = (): string => join(TELETON_ROOT, "bin"); export const gocoonBin = (): string => join(binDir(), exe("gocoon")); export const runnerBin = (): string => join(binDir(), exe("gocoon-runner")); export const versionSentinel = (): string => join(binDir(), ".gocoon-version"); -/** gocoon data dir: wallet.json, client-config.json, ton-config.json. */ export const gocoonDataDir = (): string => join(TELETON_ROOT, "gocoon"); export const walletPath = (): string => join(gocoonDataDir(), "wallet.json"); export const clientConfigPath = (): string => join(gocoonDataDir(), "client-config.json"); export const tonConfigPath = (): string => join(gocoonDataDir(), "ton-config.json"); -/** Local runner base URL for a given port. */ export const runnerBaseUrl = (port: number = GOCOON_DEFAULT_PORT): string => `http://127.0.0.1:${port}`; diff --git a/src/gocoon/supervisor.ts b/src/gocoon/supervisor.ts index f7c4b1e4..2477dff8 100644 --- a/src/gocoon/supervisor.ts +++ b/src/gocoon/supervisor.ts @@ -7,17 +7,11 @@ import { runnerBin } from "./paths.js"; const log = createLogger("gocoon"); export interface SupervisorOptions { - /** Path to client-config.json produced by `gocoon init`. */ configPath: string; - /** Readiness probe, e.g. http://127.0.0.1:10000/v1/models. */ healthUrl: string; - /** Max wait for the first healthy probe (default 30s). */ startGraceMs?: number; - /** gocoon-runner verbosity flag (-v N); 0 disables it. */ verbosity?: number; - /** Restart backoff ceiling (default 60s). */ backoffCapMs?: number; - /** Receives runner stdout/stderr lines (default: debug log). */ onLog?: (line: string) => void; } @@ -26,12 +20,8 @@ const READY_POLL_INTERVAL_MS = 250; const KILL_GRACE_MS = 5_000; const MAX_RESTART_FAILURES = 5; -/** - * Supervises a long-lived `gocoon-runner` child: starts it, waits for the health - * URL to come up, then auto-restarts on crash with exponential backoff until - * {@link GocoonSupervisor.stop} is called. Node port of myduckai's - * `supervisor.go` + `health.go`. - */ +// Spawns gocoon-runner, waits for the health URL, then auto-restarts on crash +// with exponential backoff until stop() is called. export class GocoonSupervisor { private child: ChildProcess | null = null; private stopped = false; @@ -55,7 +45,6 @@ export class GocoonSupervisor { this.onLog = opts.onLog ?? ((line) => log.debug(line)); } - /** Launch the runner and resolve once it is healthy. Throws if it never becomes ready. */ async start(): Promise { this.stopped = false; this.failures = 0; @@ -64,15 +53,13 @@ export class GocoonSupervisor { try { await waitReady(this.healthUrl, this.startGraceMs); } catch (err) { - // Abort supervision: mark stopped first so the exit handler doesn't respawn. - this.stopped = true; + this.stopped = true; // prevent the exit handler from respawning this.killChild(); throw new Error(`gocoon-runner did not become healthy: ${getErrorMessage(err)}`); } log.info(`gocoon-runner ready (pid ${this.child?.pid ?? "?"})`); } - /** Signal shutdown and kill the runner (idempotent). */ stop(): void { if (this.stopped) return; this.stopped = true; @@ -86,12 +73,10 @@ export class GocoonSupervisor { private spawnChild(): void { const args = ["--config", this.configPath]; if (this.verbosity > 0) args.push(`-v${this.verbosity}`); - const child = spawn(runnerBin(), args, { detached: true, stdio: ["ignore", "pipe", "pipe"] }); this.child = child; this.runningFlag = true; log.info(`gocoon-runner started (pid ${child.pid ?? "?"})`); - const onData = (buf: Buffer): void => { const line = buf.toString().trimEnd(); if (line) this.onLog(line); @@ -109,7 +94,7 @@ export class GocoonSupervisor { return; } log.warn( - `gocoon-runner exited unexpectedly (code=${code ?? "null"} signal=${signal ?? "null"}); restarting in ${this.backoffMs}ms` + `gocoon-runner exited (code=${code ?? "null"} signal=${signal ?? "null"}); restarting in ${this.backoffMs}ms` ); setTimeout(() => void this.restart(), this.backoffMs); } @@ -128,14 +113,13 @@ export class GocoonSupervisor { if (this.failures >= MAX_RESTART_FAILURES) { this.stopped = true; this.killChild(); - log.error(`gocoon-runner failed ${this.failures}× — giving up supervision`); + log.error(`gocoon-runner failed ${this.failures} times, giving up`); return; } log.error( `gocoon-runner restart unhealthy (${this.failures}/${MAX_RESTART_FAILURES}): ${getErrorMessage(err)}` ); - // Kill the unhealthy child; its 'exit' event reschedules the next restart. - this.killChild(); + this.killChild(); // its exit event reschedules the next restart } } @@ -146,39 +130,34 @@ export class GocoonSupervisor { } } -/** - * Kill a child's whole process group: SIGTERM then SIGKILL after `graceMs` - * (POSIX, requires the child spawned `detached`); `taskkill /T /F` on Windows. - * Shared by the supervisor and the transient runner in the withdraw flow. - */ +// SIGTERM the whole process group, then SIGKILL after graceMs. taskkill on Windows. export function killProcessGroup(pid: number, graceMs = KILL_GRACE_MS): void { if (process.platform === "win32") { try { execFileSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" }); } catch { - /* already gone */ + /* gone */ } return; } try { - process.kill(-pid, "SIGTERM"); // negative pid → whole process group + process.kill(-pid, "SIGTERM"); setTimeout(() => { try { process.kill(-pid, "SIGKILL"); } catch { - /* already reaped */ + /* gone */ } }, graceMs); } catch { try { process.kill(pid, "SIGKILL"); } catch { - /* already gone */ + /* gone */ } } } -/** Poll `url` with GET until it returns 2xx, or reject after `timeoutMs`. Port of health.go WaitReady. */ export async function waitReady(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/src/index.ts b/src/index.ts index 7886e747..64ae1470 100644 --- a/src/index.ts +++ b/src/index.ts @@ -680,15 +680,31 @@ ${blue} ┌────────────────────── const autoStart = this.config.gocoon?.auto_start ?? true; try { if (autoStart) { - const { ensureGocoonBinaries, GocoonSupervisor, runnerBaseUrl, clientConfigPath } = - await import("./gocoon/index.js"); + const { + ensureGocoonBinaries, + GocoonSupervisor, + runnerBaseUrl, + clientConfigPath, + walletInfo, + } = await import("./gocoon/index.js"); if (!existsSync(clientConfigPath())) { - throw new Error("gocoon is not set up yet — run `teleton gocoon init` first"); + throw new Error( + "gocoon is not set up yet; run `teleton gocoon init` (or use the Gocoon page) first" + ); } await ensureGocoonBinaries(); + // Opening the channel needs free TON on-chain; fail clearly instead of a health timeout. + const wallet = await walletInfo(); + if (wallet.balanceNano < 2_000_000_000n) { + throw new Error( + `COCOON wallet has ${wallet.balanceTon} TON; gocoon needs at least 2 TON free to open the channel. ` + + `Fund ${wallet.fundAddress} (Gocoon page or \`teleton gocoon init\`), then restart.` + ); + } this.gocoonSupervisor = new GocoonSupervisor({ configPath: clientConfigPath(), healthUrl: `${runnerBaseUrl(port)}/v1/models`, + startGraceMs: 60_000, // first on-chain channel registration can take ~60s }); await this.gocoonSupervisor.start(); log.info(`Gocoon runner started on port ${port}`); @@ -698,11 +714,14 @@ ${blue} ┌────────────────────── if (models.length === 0) { throw new Error(`No models found on port ${port}`); } - log.info(`Gocoon ready — ${models.length} model(s) on port ${port}`); + log.info(`Gocoon ready: ${models.length} model(s) on port ${port}`); } catch (error: unknown) { - log.error(`Gocoon unavailable on port ${port}: ${getErrorMessage(error)}`); - if (!autoStart) log.error("Start the gocoon runner first: teleton gocoon status"); - throw new Error(`Gocoon unavailable: ${getErrorMessage(error)}`); + // Non-fatal: keep the agent and WebUI alive so gocoon can be installed and + // funded from the Gocoon page, then a restart activates it. + log.warn(`Gocoon not ready: ${getErrorMessage(error)}`); + log.warn( + "Agent is up but can't chat until gocoon is funded. Open the Gocoon page (or run `teleton gocoon init`), then restart." + ); } } diff --git a/src/providers/model-resolver.ts b/src/providers/model-resolver.ts index 74085241..81eb79c3 100644 --- a/src/providers/model-resolver.ts +++ b/src/providers/model-resolver.ts @@ -121,7 +121,9 @@ export function getProviderModel(provider: SupportedProvider, modelId: string): if (meta.piAiProvider === "gocoon") { let model = GOCOON_MODELS[modelId]; if (!model) { - model = Object.values(GOCOON_MODELS)[0]; + // Fall back to the provider default (a served model), not the first registered + // one, which may be an unusable model with no workers. + model = GOCOON_MODELS[meta.defaultModel] ?? Object.values(GOCOON_MODELS)[0]; if (model) log.warn(`gocoon model "${modelId}" not found, using "${model.id}"`); } if (model) { diff --git a/src/webui/routes/gocoon.ts b/src/webui/routes/gocoon.ts index 4c75826d..82da5018 100644 --- a/src/webui/routes/gocoon.ts +++ b/src/webui/routes/gocoon.ts @@ -14,16 +14,12 @@ import { type GocoonProgress, } from "../../gocoon/index.js"; -/** - * Gocoon management routes — thin shells over the shared lifecycle core, so the - * WebUI drives the exact same install/setup/top-up/withdraw flow as the CLI. - * Mounted at /api/gocoon (WebUI) and /v1/gocoon (Management API). - */ +// gocoon management routes (install/setup/top-up/withdraw), thin shells over the +// same lifecycle the CLI uses. Mounted at /api/gocoon and /v1/gocoon. export function createGocoonRoutes(deps: WebUIServerDeps) { const app = new Hono(); const port = (): number => deps.agent.getConfig().gocoon?.port ?? GOCOON_DEFAULT_PORT; - // One withdraw at a time: POST starts it, GET polls progress (myduckai pattern). let withdraw: { running: boolean; done: boolean; @@ -38,7 +34,14 @@ export function createGocoonRoutes(deps: WebUIServerDeps) { }; try { const w = await walletInfo(); - data.wallet = { ownerAddress: w.ownerAddress, balanceTon: w.balanceTon, funded: w.funded }; + data.wallet = { + fundAddress: w.fundAddress, + ownerAddress: w.ownerAddress, + balanceTon: w.balanceTon, + balanceNano: w.balanceNano.toString(), + funded: w.funded, + recommendedFundingTon: w.recommendedFundingTon, + }; } catch { data.wallet = null; } @@ -71,7 +74,7 @@ export function createGocoonRoutes(deps: WebUIServerDeps) { } }); - // Poll this until `funded` flips true after the user sends the TON. + // Poll this until funded flips true after the user sends the TON. app.get("/balance", async (c) => { try { const w = await walletInfo(); @@ -97,6 +100,7 @@ export function createGocoonRoutes(deps: WebUIServerDeps) { } }); + // Async: POST starts a withdraw, GET polls its progress. app.post("/withdraw", async (c) => { if (withdraw?.running) { return c.json( diff --git a/web/src/App.tsx b/web/src/App.tsx index 5756204f..3cbffa64 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,6 +20,7 @@ const Hooks = React.lazy(() => import('./pages/Hooks').then(m => ({ default: m.H const Logs = React.lazy(() => import('./pages/Logs').then(m => ({ default: m.Logs }))); const Conversations = React.lazy(() => import('./pages/Conversations').then(m => ({ default: m.Conversations }))); const Wallet = React.lazy(() => import('./pages/Wallet').then(m => ({ default: m.Wallet }))); +const Gocoon = React.lazy(() => import('./pages/Gocoon').then(m => ({ default: m.Gocoon }))); function App() { // Setup route bypasses auth entirely @@ -151,6 +152,7 @@ function RoutedContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/GocoonPanel.tsx b/web/src/components/GocoonPanel.tsx index de077108..dd6984f8 100644 --- a/web/src/components/GocoonPanel.tsx +++ b/web/src/components/GocoonPanel.tsx @@ -1,28 +1,64 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { api } from '../lib/api'; import { errMsg } from '../lib/utils'; import { toast } from '../lib/toast'; +import { RefreshButton } from './RefreshButton'; +interface Wallet { + fundAddress: string; + ownerAddress: string; + balanceTon: string; + balanceNano: string; + funded: boolean; + recommendedFundingTon: string; +} interface GocoonStatus { installed: boolean; version: string | null; - wallet: { ownerAddress: string; balanceTon: string; funded: boolean } | null; + wallet: Wallet | null; runner: boolean; } - type WEvent = { stage: string; status: string; message: string }; -const short = (a: string): string => (a.length > 18 ? `${a.slice(0, 8)}…${a.slice(-6)}` : a); +// 1 TON: above this free balance the wallet is treated as provisioned, since the +// stake (~15 TON) drops the free balance below gocoon's "funded" threshold. +const PROVISIONED_NANO = 1_000_000_000n; + +function GemIcon({ className }: { className?: string }) { + return ( + + ); +} +function CopyIcon() { + return ( + + + + + ); +} +function ExternalIcon() { + return ( + + + + + + ); +} + +const successBadge: CSSProperties = { + color: 'var(--success)', + borderColor: 'color-mix(in srgb, var(--success) 32%, transparent)', +}; -/** - * Gocoon management — install, set up + fund the COCOON wallet, top up, and - * withdraw. Thin UI over /api/gocoon/* (the same lifecycle the CLI drives). - */ export function GocoonPanel() { const [status, setStatus] = useState(null); const [busy, setBusy] = useState(null); - const [fund, setFund] = useState<{ fundAddress: string; recommendedFundingTon: string } | null>(null); - const [waitingFunds, setWaitingFunds] = useState(false); const [topupAmount, setTopupAmount] = useState(''); const [withdrawDest, setWithdrawDest] = useState(''); const [events, setEvents] = useState([]); @@ -44,23 +80,18 @@ export function GocoonPanel() { }; }, []); - // Poll the balance after init until the funding lands on-chain. + const w = status?.wallet ?? null; + const running = !!status?.runner; + const balNano = w ? BigInt(w.balanceNano || '0') : 0n; + const hasFunds = !!w && (w.funded || balNano > PROVISIONED_NANO); + const awaitingDeposit = !!status?.installed && !!w && !running && !hasFunds; + + // While waiting for the first deposit, poll so the balance is auto-detected. useEffect(() => { - if (!waitingFunds) return; - const t = setInterval(async () => { - try { - const r = await api.gocoonBalance(); - if (r.success && r.data.funded) { - setWaitingFunds(false); - toast.success('COCOON wallet funded'); - void refresh(); - } - } catch { - /* retry */ - } - }, 5000); + if (!awaitingDeposit) return; + const t = setInterval(() => void refresh(), 5000); return () => clearInterval(t); - }, [waitingFunds]); + }, [awaitingDeposit]); const run = async (label: string, fn: () => Promise): Promise => { setBusy(label); @@ -80,11 +111,10 @@ export function GocoonPanel() { await refresh(); }); - const onInit = (): Promise => - run('init', async () => { - const r = await api.gocoonInit(); - setFund(r.data); - setWaitingFunds(true); + const onSetup = (): Promise => + run('setup', async () => { + await api.gocoonInit(); + await refresh(); }); const onTopup = (): Promise => @@ -117,113 +147,218 @@ export function GocoonPanel() { }, 1500); }); - if (!status) return
Loading gocoon…
; + const copy = (s: string): void => { + void navigator.clipboard?.writeText(s).then(() => toast.success('Address copied')).catch(() => {}); + }; + + if (!status) { + return ( +
+
Loading gocoon...
+
+ ); + } - const wallet = status.wallet; + const heroBadge: { text: string; cls?: string; style?: CSSProperties } = running + ? { text: 'Running', style: successBadge } + : hasFunds + ? { text: 'Ready', style: successBadge } + : { text: 'Awaiting funds', cls: 'warn' }; return ( -
- {/* Status rows */} -
- - {status.installed ? ( - {status.version} - ) : ( - - )} - - {wallet ? short(wallet.ownerAddress) : not set up} - {wallet && ( - - {wallet.balanceTon} TON{' '} - {wallet.funded ? 'funded' : 'not funded'} - + <> +
+ + {!status.installed ? ( +
gocoon is not installed yet
+ ) : !w ? ( +
No funding wallet yet, create one below
+ ) : ( + <> +
+ COCOON Wallet + + {heroBadge.text} + +
+
+ {w.balanceTon} + TON +
+
+ {w.fundAddress} + + + + +
+ )} - {status.runner ? running : not running}
- {/* Set up + fund */} - {status.installed && !wallet?.funded && ( -
- {!fund ? ( - - ) : ( -
-
- Send {fund.recommendedFundingTon} TON (mainnet) to: -
- {fund.fundAddress} -
- {waitingFunds ? 'Waiting for funding to confirm on-chain…' : 'Funded.'} -
-
- )} -
- )} + - {/* Top up */} - {wallet && ( -
- -
- setTopupAmount(e.target.value)} +
+
+ {!status.installed && ( + + {busy === 'install' ? 'Installing...' : 'Install gocoon'} + + } + /> + )} + {status.installed && !w && ( + + {busy === 'setup' ? 'Creating...' : 'Create funding wallet'} + + } + /> + )} + {awaitingDeposit && ( + + Waiting for funds + + } + /> + )} + {!running && hasFunds && ( + Funded} /> - + )} + {running && ( + live} + /> + )} + +
+ + Runner{' '} + {running ? 'running' : 'stopped'} + + {status.installed && status.version && gocoon {status.version}} + + void refresh()} /> +
-
Adds stake to the payment channel (the runner must be active).
- )} +
- {/* Withdraw everything */} - {wallet && ( -
- -
- setWithdrawDest(e.target.value)} - /> - -
-
- Closes the channel and drains the COCOON + agent wallets. Stop the agent first. Irreversible. + {w && ( +
+
+ Manage
- {events.length > 0 && ( -
- {events.map((e, i) => ( -
- {e.status === 'ok' ? '✓' : e.status === 'error' ? '✗' : '›'} [{e.stage}] {e.message} +
+ {hasFunds && ( +
+ +
+ setTopupAmount(e.target.value)} + /> +
- ))} +
Adds stake to the channel (the runner must be active).
+
+ )} + +
+ +
+ setWithdrawDest(e.target.value)} + /> + +
+
+ Stop the agent first. Closes the channel and drains the COCOON + agent wallets. Irreversible. +
+ {events.length > 0 && ( +
+ {events.map((e, i) => ( +
+ {e.status === 'ok' ? '[ok]' : e.status === 'error' ? '[x]' : '[..]'} [{e.stage}] {e.message} +
+ ))} +
+ )}
- )} +
)} + + ); +} + +function Steps({ installed, hasWallet, funded, running }: { + installed: boolean; + hasWallet: boolean; + funded: boolean; + running: boolean; +}) { + const steps = [ + { label: 'Install', done: installed }, + { label: 'Wallet', done: hasWallet }, + { label: 'Fund', done: funded }, + { label: 'Run', done: running }, + ]; + const current = steps.findIndex((s) => !s.done); + return ( +
+ {steps.map((s, i) => { + const state = s.done ? 'done' : i === current ? 'current' : 'pending'; + return ( +
+ {s.done ? '✓' : i + 1} + {s.label} + {i < steps.length - 1 && } +
+ ); + })}
); } -function Row({ label, children }: { label: string; children: React.ReactNode }) { +function Action({ title, hint, right }: { title: string; hint: string; right: ReactNode }) { return ( -
- {label} - {children} +
+
+
{title}
+
{hint}
+
+
{right}
); } diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 6f1fc7b6..e082989e 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -70,6 +70,16 @@ function IconWallet() { ); } +function IconGocoon() { + return ( + + + + + + ); +} + function IconWorkspace() { return ( @@ -186,6 +196,7 @@ function DashboardNav() { { to: '/memory', icon: , label: 'Memory' }, { to: '/conversations', icon: , label: 'Chats' }, { to: '/wallet', icon: , label: 'Wallet' }, + { to: '/gocoon', icon: , label: 'Gocoon' }, { to: '/workspace', icon: , label: 'Workspace' }, { to: '/tasks', icon: , label: 'Tasks' }, { to: '/mcp', icon: , label: 'MCP' }, diff --git a/web/src/index.css b/web/src/index.css index d1d9c897..8ad54123 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -3195,3 +3195,85 @@ select:focus-visible, color: var(--accent); box-shadow: var(--shadow-sm); } + +/* Gocoon page */ +.gocoon-steps { + display: flex; + align-items: center; + padding: var(--space-md) var(--space-lg); + margin-bottom: var(--space-lg); + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-card); +} +.gocoon-step { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} +.gocoon-step:last-child { + flex: 0 0 auto; +} +.gocoon-step-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-pill); + font-size: var(--font-xs); + font-weight: 700; + flex-shrink: 0; + border: 1.5px solid var(--border); + background: var(--bg-secondary); + color: var(--text-tertiary); + transition: all var(--duration-color) var(--easing-standard); +} +.gocoon-step-dot.done { + background: var(--accent); + border-color: var(--accent); + color: var(--text-on-accent); +} +.gocoon-step-dot.current { + border-color: var(--accent); + color: var(--accent); +} +.gocoon-step-label { + font-size: var(--font-sm); + font-weight: 600; + color: var(--text-tertiary); + white-space: nowrap; +} +.gocoon-step-label.done, +.gocoon-step-label.current { + color: var(--text-primary); +} +.gocoon-step-line { + flex: 1; + height: 1.5px; + margin: 0 10px; + min-width: 14px; + background: var(--border); + border-radius: 2px; +} +.gocoon-step-line.done { + background: var(--accent); +} +.gocoon-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-lg); +} +.gocoon-meta { + display: flex; + align-items: center; + gap: var(--space-md); + margin-top: var(--space-lg); + padding-top: var(--space-md); + border-top: 1px solid var(--border-glass); + font-size: var(--font-sm); + color: var(--text-secondary); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9676d823..ab1f5f78 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -392,9 +392,9 @@ export const api = { return fetchAPI>('/status'); }, - // ── gocoon — decentralized LLM on TON ── + // gocoon: decentralized LLM on TON async gocoonStatus() { - return fetchAPI>('/gocoon/status'); + return fetchAPI>('/gocoon/status'); }, async gocoonInstall() { return fetchAPI>('/gocoon/install', { method: 'POST' }); diff --git a/web/src/pages/Config.tsx b/web/src/pages/Config.tsx index 14d9fecf..fdb3da61 100644 --- a/web/src/pages/Config.tsx +++ b/web/src/pages/Config.tsx @@ -11,7 +11,6 @@ import { EditableField } from '../components/EditableField'; import { ConfigSection } from '../components/ConfigSection'; import { InfoTip } from '../components/InfoTip'; import { Alert } from '../components/Alert'; -import { GocoonPanel } from '../components/GocoonPanel'; import { errMsg } from '../lib/utils'; import { toast } from '../lib/toast'; import { useConfirm } from '../components/ConfirmDialog'; @@ -144,30 +143,6 @@ export function Config() { handleProviderCancel={config.handleProviderCancel} />
- - {config.getLocal('agent.provider') === 'gocoon' && ( - <> -
Gocoon
- -
- config.setLocal('gocoon.port', v)} - onSave={(v) => config.saveConfig('gocoon.port', v)} - onCancel={() => config.cancelLocal('gocoon.port')} - min={1} - max={65535} - placeholder="10000" - hotReload="restart" - /> -
- - )} )} diff --git a/web/src/pages/Gocoon.tsx b/web/src/pages/Gocoon.tsx new file mode 100644 index 00000000..bdc237f6 --- /dev/null +++ b/web/src/pages/Gocoon.tsx @@ -0,0 +1,13 @@ +import { GocoonPanel } from '../components/GocoonPanel'; + +export function Gocoon() { + return ( +
+
+

Gocoon

+

Decentralized LLM on TON. Install, fund, top up, and withdraw.

+
+ +
+ ); +} From 75b071b3fb7796896008d02bf47fae4586f52beb Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:25:06 +0200 Subject: [PATCH 03/16] fix(gocoon): front the runner with a local SSE proxy pi-ai always sends stream:true and only parses Server-Sent Events, but the gocoon runner returns a single JSON document, so streaming clients parsed zero chunks and saw an empty response with zero tokens. Start a local proxy (when provider is gocoon) that forwards to the runner and frames its JSON reply as SSE, leaving gocoon itself unchanged. pi-ai points at the proxy instead of the runner. --- src/gocoon/index.ts | 1 + src/gocoon/sse-proxy.ts | 134 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 24 +++++-- 3 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 src/gocoon/sse-proxy.ts diff --git a/src/gocoon/index.ts b/src/gocoon/index.ts index 97f90257..e535521e 100644 --- a/src/gocoon/index.ts +++ b/src/gocoon/index.ts @@ -26,3 +26,4 @@ export { clientConfigPath, runnerBaseUrl, } from "./paths.js"; +export { GocoonSseProxy, completionToSse, type GocoonSseProxyOptions } from "./sse-proxy.js"; diff --git a/src/gocoon/sse-proxy.ts b/src/gocoon/sse-proxy.ts new file mode 100644 index 00000000..f31b0ffb --- /dev/null +++ b/src/gocoon/sse-proxy.ts @@ -0,0 +1,134 @@ +import http from "node:http"; +import { createLogger } from "../utils/logger.js"; + +const log = createLogger("gocoon-sse"); + +export interface GocoonSseProxyOptions { + runnerPort: number; + host?: string; +} + +// GocoonSseProxy sits in front of the gocoon runner. pi-ai always sends +// stream:true and only parses Server-Sent Events, but the runner returns a +// single JSON document. This proxy forwards requests to the runner and, when +// the client asked for streaming, frames the runner's JSON reply as SSE so +// pi-ai parses it. The gocoon runner itself stays untouched. +export class GocoonSseProxy { + private server: http.Server | null = null; + private readonly runnerPort: number; + private readonly host: string; + port = 0; + + constructor(opts: GocoonSseProxyOptions) { + this.runnerPort = opts.runnerPort; + this.host = opts.host ?? "127.0.0.1"; + } + + async start(): Promise { + const server = http.createServer((req, res) => { + void this.handle(req, res); + }); + this.server = server; + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, this.host, () => { + const addr = server.address(); + this.port = typeof addr === "object" && addr ? addr.port : 0; + resolve(); + }); + }); + log.info(`SSE proxy on ${this.host}:${this.port} -> runner ${this.runnerPort}`); + } + + stop(): void { + this.server?.close(); + this.server = null; + } + + private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const body = Buffer.concat(chunks); + const wantsStream = + req.method === "POST" && + /\/v1\/chat\/completions$/.test(req.url ?? "") && + requestWantsStream(body); + + let upstream: Response; + try { + upstream = await fetch(`http://127.0.0.1:${this.runnerPort}${req.url ?? ""}`, { + method: req.method, + headers: { "content-type": req.headers["content-type"] ?? "application/json" }, + body: req.method === "GET" || req.method === "HEAD" ? undefined : body, + }); + } catch (err) { + res.writeHead(502, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { message: `gocoon sse-proxy: ${String(err)}` } })); + return; + } + + const ctype = upstream.headers.get("content-type") ?? ""; + // Frame a single JSON completion as SSE only when the client wanted streaming. + if (wantsStream && ctype.includes("application/json")) { + const text = await upstream.text(); + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + for (const ev of completionToSse(text)) res.write(ev); + res.write("data: [DONE]\n\n"); + res.end(); + return; + } + // Pass everything else through unchanged (GET /v1/models, already-SSE, non-stream). + const buf = Buffer.from(await upstream.arrayBuffer()); + res.writeHead(upstream.status, { "content-type": ctype || "application/json" }); + res.end(buf); + } +} + +function requestWantsStream(body: Buffer): boolean { + try { + return (JSON.parse(body.toString("utf8")) as { stream?: boolean }).stream === true; + } catch { + return false; + } +} + +// completionToSse converts an OpenAI chat.completion document into the SSE +// "data:" events of a stream: one chunk carrying the message as a delta, then +// a final chunk carrying usage with no choices. +export function completionToSse(completion: string): string[] { + let doc: Record; + try { + doc = JSON.parse(completion) as Record; + } catch { + return [`data: ${completion}\n\n`]; + } + const choices = Array.isArray(doc.choices) ? (doc.choices as Record[]) : []; + const base = () => ({ + id: doc.id, + object: "chat.completion.chunk", + created: doc.created, + model: doc.model, + }); + + const deltaChoices = choices.map((choice, i) => { + const msg = (choice.message ?? {}) as Record; + const delta: Record = {}; + if (msg.role !== undefined) delta.role = msg.role; + if (msg.content !== undefined) delta.content = msg.content; + if (Array.isArray(msg.tool_calls)) { + delta.tool_calls = (msg.tool_calls as Record[]).map((tc, idx) => ({ + index: idx, + ...tc, + })); + } + const out: Record = { index: i, delta }; + if (choice.finish_reason !== undefined) out.finish_reason = choice.finish_reason; + return out; + }); + + const events = [`data: ${JSON.stringify({ ...base(), choices: deltaChoices })}\n\n`]; + if (doc.usage) { + events.push(`data: ${JSON.stringify({ ...base(), choices: [], usage: doc.usage })}\n\n`); + } + return events; +} diff --git a/src/index.ts b/src/index.ts index 64ae1470..e4ea3fed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,7 @@ import { setToncenterApiKey } from "./ton/endpoint.js"; import { TELETON_ROOT } from "./workspace/paths.js"; import { join } from "path"; import { existsSync } from "fs"; -import type { GocoonSupervisor } from "./gocoon/index.js"; +import type { GocoonSupervisor, GocoonSseProxy } from "./gocoon/index.js"; import { ToolRegistry } from "./agent/tools/registry.js"; import { registerAllTools } from "./agent/tools/register-all.js"; import { type PluginModuleWithHooks } from "./agent/tools/plugin-loader.js"; @@ -70,6 +70,7 @@ export class TeletonApp { private apiServer: ApiServer | null = null; private pluginWatcher: PluginWatcher | null = null; private gocoonSupervisor: GocoonSupervisor | null = null; + private gocoonProxy: GocoonSseProxy | null = null; private mcpConnections: McpConnection[] = []; private callbackHandlerRegistered = false; private messageHandlersRegistered = false; @@ -709,12 +710,20 @@ ${blue} ┌────────────────────── await this.gocoonSupervisor.start(); log.info(`Gocoon runner started on port ${port}`); } + // pi-ai always streams and only parses SSE; the gocoon runner returns a + // single JSON document. Front the runner with a local proxy that frames + // its reply as SSE so streaming clients parse it. Keeps gocoon untouched. + const { GocoonSseProxy } = await import("./gocoon/index.js"); + this.gocoonProxy = new GocoonSseProxy({ runnerPort: port }); + await this.gocoonProxy.start(); const { registerGocoonModels } = await import("./agent/client.js"); - const models = await registerGocoonModels(port); + const models = await registerGocoonModels(this.gocoonProxy.port); if (models.length === 0) { throw new Error(`No models found on port ${port}`); } - log.info(`Gocoon ready: ${models.length} model(s) on port ${port}`); + log.info( + `Gocoon ready: ${models.length} model(s) (runner ${port}, sse-proxy ${this.gocoonProxy.port})` + ); } catch (error: unknown) { // Non-fatal: keep the agent and WebUI alive so gocoon can be installed and // funded from the Gocoon page, then a restart activates it. @@ -1127,7 +1136,14 @@ ${blue} ┌────────────────────── } } - // Stop the gocoon runner (when teleton supervises it) + // Stop the gocoon SSE proxy and runner (when teleton supervises them) + if (this.gocoonProxy) { + try { + this.gocoonProxy.stop(); + } catch (error: unknown) { + log.error({ err: error }, "gocoon sse-proxy stop failed"); + } + } if (this.gocoonSupervisor) { try { this.gocoonSupervisor.stop(); From df2f40ffc33894b5d8c77ce18242aa0de82219af Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:39:08 +0200 Subject: [PATCH 04/16] feat(webui): show codex in the provider selector codex was in the backend registry but missing from the hardcoded frontend provider list, so it never appeared in the WebUI. Add it, and treat it as keyless in provider-meta (it authenticates via the Codex CLI ~/.codex/auth.json, no API key to paste) so the switch flow shows no key field. --- src/webui/routes/config.ts | 3 ++- web/src/components/ProviderControl.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index 79d7853e..ac17ba90 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -335,7 +335,8 @@ export function createConfigRoutes(deps: WebUIServerDeps) { const provider = c.req.param("provider"); try { const meta = getProviderMetadata(provider as SupportedProvider); - const needsKey = provider !== "gocoon" && provider !== "local"; + // codex authenticates via the Codex CLI (~/.codex/auth.json), no key to paste. + const needsKey = provider !== "gocoon" && provider !== "local" && provider !== "codex"; return c.json({ success: true, data: { diff --git a/web/src/components/ProviderControl.tsx b/web/src/components/ProviderControl.tsx index 36f377dc..3925809a 100644 --- a/web/src/components/ProviderControl.tsx +++ b/web/src/components/ProviderControl.tsx @@ -1,7 +1,7 @@ import type { ProviderMeta } from '../hooks/useConfigState'; -export const PROVIDER_OPTIONS = ['anthropic', 'openai', 'google', 'xai', 'groq', 'openrouter', 'moonshot', 'mistral', 'cerebras', 'zai', 'minimax', 'huggingface', 'gocoon', 'local']; -export const PROVIDER_LABELS = ['Anthropic', 'OpenAI', 'Google', 'xAI', 'Groq', 'OpenRouter', 'Moonshot', 'Mistral', 'Cerebras', 'ZAI (Zhipu)', 'MiniMax', 'HuggingFace', 'Gocoon', 'Local']; +export const PROVIDER_OPTIONS = ['anthropic', 'openai', 'codex', 'google', 'xai', 'groq', 'openrouter', 'moonshot', 'mistral', 'cerebras', 'zai', 'minimax', 'huggingface', 'gocoon', 'local']; +export const PROVIDER_LABELS = ['Anthropic', 'OpenAI', 'Codex (Auto)', 'Google', 'xAI', 'Groq', 'OpenRouter', 'Moonshot', 'Mistral', 'Cerebras', 'ZAI (Zhipu)', 'MiniMax', 'HuggingFace', 'Gocoon', 'Local']; interface ProviderSwitchZoneProps { pendingMeta: ProviderMeta; From 94014fd370936f9731e512f1eabfb0f248613b5e Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:50:30 +0200 Subject: [PATCH 05/16] feat(config): enable ToolSearch mode by default --- src/cli/commands/onboard.ts | 1 + src/config/schema.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index 7e51528e..01b1c48a 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -348,6 +348,7 @@ function buildConfig(input: BuildConfigInput): Config { ], skip_unlimited_providers: false, }, + tool_search: { enabled: true }, logging: { level: "info", pretty: true }, mcp: { servers: {} }, capabilities: { diff --git a/src/config/schema.ts b/src/config/schema.ts index 4a36801f..5f640807 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -262,7 +262,7 @@ export const McpConfigSchema = _McpObject.default(_McpObject.parse({})); const _ToolSearchObject = z.object({ enabled: z .boolean() - .default(false) + .default(true) .describe("Enable ToolSearch mode: core tools + meta-tool replaces RAG pre-selection"), }); export const ToolSearchConfigSchema = _ToolSearchObject.default(_ToolSearchObject.parse({})); @@ -346,7 +346,7 @@ export const ConfigSchema = z.object({ logging: LoggingConfigSchema, dev: DevConfigSchema, tool_rag: ToolRagConfigSchema, - tool_search: ToolSearchConfigSchema.optional(), + tool_search: ToolSearchConfigSchema, capabilities: CapabilitiesConfigSchema, api: ApiConfigSchema.optional(), ton_proxy: TonProxyConfigSchema, From 81bc5d175542428916db91e8c8de2ca1bb5b8312 Mon Sep 17 00:00:00 2001 From: TONresistor <240980241+TONresistor@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:56:11 +0200 Subject: [PATCH 06/16] feat(gocoon): webui withdraw + unstake fix + wallet reset gocoon webui withdraw matches myduckai-agent: a Stop agent control, destination validation, type-"withdraw" confirm, and live progress polling, over the same withdrawAll the CLI uses. Unstake fix: withdrawAll located the channel via the owner wallet instead of the fund wallet (findClientSC(fundAddress)). The owner wallet has no on-chain history, so the channel was never found, the close was skipped, and only the liquid balance was withdrawn while the staked TON stayed locked. It now closes the channel and waits for the refund before draining, so the stake comes back. Wallet reset: teleton gocoon reset (CLI), POST /gocoon/reset, and a Reset wallet button delete the local wallet + config so the next setup creates a fresh owner/node wallet. Guarded: refuses while the runner is up, or while funds or a non-closed channel with stake remain. channelInfoOnChain checks the channel state (not the contract account status, which stays active after a cooperative close). --force overrides the fund/channel checks. Also a knip dead-code pass (events/bus, memory/agent/sessions, telegram/callbacks, SearchInput, a11y, assorted dead exports) and a webui cleanup (Dashboard, styles, AllowLists). --- knip.json | 1 - src/agent/client.ts | 5 - src/agent/runtime.ts | 17 --- src/agent/token-usage.ts | 5 - src/agent/tools/exec/index.ts | 5 - src/agent/tools/registry.ts | 19 +-- src/agent/tools/scope.ts | 2 - src/agent/tools/ton/jetton-holders.ts | 6 - src/api/schemas/common.ts | 8 - src/bot/index.ts | 11 -- src/bot/services/deal-service.ts | 25 --- src/bot/services/message-builder.ts | 7 - src/bot/types.ts | 14 -- src/cli/commands/gocoon.ts | 30 ++++ src/config/configurable-keys.ts | 15 -- src/config/schema.ts | 8 - src/constants/limits.ts | 1 - src/constants/timeouts.ts | 1 - src/deals/gift-detector.ts | 14 -- src/deals/utils.ts | 8 - src/events/bus.ts | 26 ---- src/gocoon/cli.ts | 43 +++++- src/gocoon/index.ts | 1 + src/gocoon/lifecycle.ts | 61 +++++++- src/index.ts | 33 +++- src/memory/agent/index.ts | 1 - src/memory/agent/sessions.ts | 198 ------------------------ src/memory/ai-summarization.ts | 7 - src/memory/daily-logs.ts | 5 - src/memory/envelope.ts | 14 -- src/memory/feed/messages.ts | 6 - src/memory/observation-masking.ts | 28 ---- src/memory/tool-config.ts | 7 - src/memory/types/db-rows.ts | 18 --- src/providers/codex-credentials.ts | 5 - src/services/tts.ts | 33 ---- src/session/store.ts | 18 --- src/session/transcript.ts | 27 ---- src/telegram/callbacks/handler.ts | 79 ---------- src/telegram/callbacks/index.ts | 7 - src/telegram/callbacks/router.ts | 7 - src/telegram/callbacks/types.ts | 14 -- src/telegram/client.ts | 15 -- src/telegram/debounce.ts | 4 - src/telegram/offset-store.ts | 7 - src/ton/dex-constants.ts | 2 - src/utils/logger.ts | 23 --- src/webui/log-interceptor.ts | 18 +-- src/webui/routes/gocoon.ts | 32 ++++ src/webui/types.ts | 11 +- src/workspace/index.ts | 2 - src/workspace/manager.ts | 37 +---- web/src/components/AllowLists.tsx | 2 - web/src/components/GocoonPanel.tsx | 169 ++++++++++++++++---- web/src/components/SearchInput.tsx | 54 ------- web/src/components/Skeleton.tsx | 11 -- web/src/index.css | 143 +++++++++++------ web/src/lib/a11y.ts | 25 --- web/src/lib/api.ts | 33 +--- web/src/lib/log-store.ts | 2 - web/src/lib/utils.ts | 11 ++ web/src/pages/Dashboard.tsx | 212 +++++++++++++++++++------- web/src/pages/Setup.tsx | 2 +- 63 files changed, 606 insertions(+), 1049 deletions(-) delete mode 100644 src/events/bus.ts delete mode 100644 src/memory/agent/sessions.ts delete mode 100644 src/telegram/callbacks/handler.ts delete mode 100644 src/telegram/callbacks/index.ts delete mode 100644 src/telegram/callbacks/router.ts delete mode 100644 src/telegram/callbacks/types.ts delete mode 100644 web/src/components/SearchInput.tsx delete mode 100644 web/src/lib/a11y.ts diff --git a/knip.json b/knip.json index cc4af4a3..567b7121 100644 --- a/knip.json +++ b/knip.json @@ -7,7 +7,6 @@ "src/webui/**/*.ts", "src/agent/index.ts", "src/telegram/index.ts", - "src/telegram/callbacks/index.ts", "src/sdk/hooks/index.ts", "src/constants/index.ts", "src/api/**/*.ts" diff --git a/src/agent/client.ts b/src/agent/client.ts index a174fab3..449c9727 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -38,11 +38,6 @@ const RETRY_401_PROVIDERS: { provider: string; refresh: () => Promise r.chat_id); } - setSoul(soul: string): void { - this.soul = soul; - } - - configureCompaction(config: { - enabled?: boolean; - maxMessages?: number; - maxTokens?: number; - }): void { - this.compactionManager.updateConfig(config); - log.info({ config: this.compactionManager.getConfig() }, `Compaction config updated`); - } - - getCompactionConfig() { - return this.compactionManager.getConfig(); - } - private _memoryStatsCache: { data: { totalMessages: number; totalChats: number; knowledgeChunks: number }; expiry: number; diff --git a/src/agent/token-usage.ts b/src/agent/token-usage.ts index a7fec503..d7ee7067 100644 --- a/src/agent/token-usage.ts +++ b/src/agent/token-usage.ts @@ -16,8 +16,3 @@ export function accumulateTokenUsage(usage: { globalTokenUsage.totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite; globalTokenUsage.totalCost += usage.totalCost; } - -export function resetTokenUsage() { - globalTokenUsage.totalTokens = 0; - globalTokenUsage.totalCost = 0; -} diff --git a/src/agent/tools/exec/index.ts b/src/agent/tools/exec/index.ts index e27970e1..7dfce89d 100644 --- a/src/agent/tools/exec/index.ts +++ b/src/agent/tools/exec/index.ts @@ -1,6 +1 @@ export { default as execModule } from "./module.js"; -export { execRunTool, createExecRunExecutor } from "./run.js"; -export { execInstallTool, createExecInstallExecutor } from "./install.js"; -export { execServiceTool, createExecServiceExecutor } from "./service.js"; -export { execStatusTool, createExecStatusExecutor } from "./status.js"; -export type { ExecResult, ExecAuditEntry, RunOptions } from "./types.js"; diff --git a/src/agent/tools/registry.ts b/src/agent/tools/registry.ts index a1aaa29d..b3e37089 100644 --- a/src/agent/tools/registry.ts +++ b/src/agent/tools/registry.ts @@ -6,7 +6,6 @@ import type { RuntimeMode, Tool, ToolContext, - ToolEntry, ToolExecutor, ToolMode, ToolResult, @@ -340,7 +339,7 @@ export class ToolRegistry { /** * Legacy single-value scope view (derived from the level). Kept for backward- - * compatible callers (getEntry, getModuleTools). + * compatible callers (getModuleTools). */ private getEffectiveScope(toolName: string): ToolScope { return levelToScope(this.getEffectiveLevel(toolName)); @@ -525,22 +524,6 @@ export class ToolRegistry { return registered?.tool.parameters ?? null; } - /** - * Return a ToolEntry snapshot for a named tool (scope, mode, tags, executor). - * Returns null if the tool is not registered. - */ - getEntry(name: string): ToolEntry | null { - const registered = this.tools.get(name); - if (!registered) return null; - return { - tool: registered.tool, - executor: registered.executor, - scope: this.getEffectiveScope(name), - mode: this.tools.get(name)?.mode ?? "both", - tags: this.tools.get(name)?.tags, - }; - } - /** * Returns true if a tool passes all scope/mode/enabled filters for the given context. * Extracted from getForContext() for reuse by tool_search and getCoreTools(). diff --git a/src/agent/tools/scope.ts b/src/agent/tools/scope.ts index 6d80ca65..3545e400 100644 --- a/src/agent/tools/scope.ts +++ b/src/agent/tools/scope.ts @@ -12,8 +12,6 @@ import type { ToolScope } from "./types.js"; */ export type ToolAccessLevel = "all" | "allowlist" | "admin" | "off"; -export const TOOL_ACCESS_LEVELS = ["all", "allowlist", "admin", "off"] as const; - export function isToolAccessLevel(v: unknown): v is ToolAccessLevel { return v === "all" || v === "allowlist" || v === "admin" || v === "off"; } diff --git a/src/agent/tools/ton/jetton-holders.ts b/src/agent/tools/ton/jetton-holders.ts index c917a558..e3940e05 100644 --- a/src/agent/tools/ton/jetton-holders.ts +++ b/src/agent/tools/ton/jetton-holders.ts @@ -98,12 +98,6 @@ export const jettonHoldersExecutor: ToolExecutor = async ( }; }); - // Calculate concentration (top holder %) - const _totalTop = holders.reduce( - (sum: number, h: FormattedHolder) => sum + parseFloat(h.balance.replace(/,/g, "")), - 0 - ); - let message = `Top ${holders.length} holders of ${symbol}:\n\n`; holders.forEach((h) => { const nameTag = h.name ? ` (${h.name})` : ""; diff --git a/src/api/schemas/common.ts b/src/api/schemas/common.ts index 0bd5fd1a..b49202f9 100644 --- a/src/api/schemas/common.ts +++ b/src/api/schemas/common.ts @@ -12,14 +12,6 @@ export const ProblemDetailSchema = z.object({ export type ProblemDetail = z.infer; -/** Standard API response envelope */ -export const ApiResponseSchema = (dataSchema: T) => - z.object({ - success: z.boolean(), - data: dataSchema.optional(), - error: ProblemDetailSchema.optional(), - }); - /** Create a Problem Detail JSON object */ export function createProblem( status: number, diff --git a/src/bot/index.ts b/src/bot/index.ts index e23bcbe6..6a09a815 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -507,15 +507,4 @@ export class DealBot { } } -export { - getDeal, - getDealsAwaitingVerification, - getDealsAwaitingExecution, -} from "./services/deal-service.js"; -export { - buildCompletedMessage, - buildMessageForState, - buildSendingMessage, - buildFailedMessage, -} from "./services/message-builder.js"; export { VerificationPoller } from "./services/verification-poller.js"; diff --git a/src/bot/services/deal-service.ts b/src/bot/services/deal-service.ts index b77d0c28..382be3e4 100644 --- a/src/bot/services/deal-service.ts +++ b/src/bot/services/deal-service.ts @@ -78,13 +78,6 @@ export function getDeal(db: Database.Database, dealId: string): DealContext | nu return rowToDeal(row); } -/** - * Update deal status - */ -export function updateDealStatus(db: Database.Database, dealId: string, status: DealStatus): void { - db.prepare(`UPDATE deals SET status = ? WHERE id = ?`).run(status, dealId); -} - /** * Mark deal as accepted and extend expiry to 5 minutes from now */ @@ -169,24 +162,6 @@ export function getDealsAwaitingVerification(db: Database.Database): DealContext return rows.map(rowToDeal); } -/** - * Get verified deals awaiting execution - */ -export function getDealsAwaitingExecution(db: Database.Database): DealContext[] { - const rows = db - .prepare( - `SELECT - ${DEAL_COLUMNS} - FROM deals - WHERE status = 'verified' AND agent_sent_at IS NULL - ORDER BY user_payment_verified_at ASC - LIMIT 10` - ) - .all() as DealRow[]; - - return rows.map(rowToDeal); -} - /** * Update user trade stats */ diff --git a/src/bot/services/message-builder.ts b/src/bot/services/message-builder.ts index 6f38f30f..946adc7f 100644 --- a/src/bot/services/message-builder.ts +++ b/src/bot/services/message-builder.ts @@ -210,13 +210,6 @@ ${esc(error || "Unknown error")}`; return { text, buttons: [] }; } -/** - * Wrong user - */ -export function buildWrongUserMessage(deal: DealContext): string { - return `🚫 This deal is for @${esc(String(deal.username || deal.userId))} only.`; -} - /** * Not found */ diff --git a/src/bot/types.ts b/src/bot/types.ts index 26347b96..152a90e0 100644 --- a/src/bot/types.ts +++ b/src/bot/types.ts @@ -45,16 +45,6 @@ export type DealStatus = | "cancelled" | "failed"; -export type MessageState = - | "proposal" // Accept/Decline buttons - | "accepted" // Payment/gift instructions + "I've sent" - | "payment_claimed" // Verifying... - | "verified" // Sending agent's part... - | "completed" // Final recap - | "declined" // Declined message - | "expired" // Expired message - | "failed"; // Error message - export interface CallbackData { action: "accept" | "decline" | "sent" | "copy_addr" | "copy_memo" | "refresh"; dealId: string; @@ -74,10 +64,6 @@ export function splitPrefix(raw: string): { prefix: string; rest: string } | nul return { prefix: raw.slice(0, colonIdx), rest: raw.slice(colonIdx + 1) }; } -export function encodeCallback(data: CallbackData): string { - return `${data.action}:${data.dealId}`; -} - /** * Callback-data routing contract (Grammy `callback_query:data` middleware chain). * diff --git a/src/cli/commands/gocoon.ts b/src/cli/commands/gocoon.ts index d32d8fb6..bdd47626 100644 --- a/src/cli/commands/gocoon.ts +++ b/src/cli/commands/gocoon.ts @@ -9,6 +9,7 @@ import { waitFunded, walletInfo, withdrawAll, + resetWallet, GOCOON_DEFAULT_PORT, GOCOON_VERSION, type GocoonProgress, @@ -85,6 +86,24 @@ async function runWithdraw(destination: string, skipConfirm: boolean): Promise { + if (!skipConfirm) { + const ok = await confirm({ + message: + "Reset the COCOON wallet? Deletes the local wallet + config so a fresh wallet is created next setup. Withdraw everything first. Irreversible.", + default: false, + }); + if (!ok) { + console.log(YELLOW("\n Reset cancelled.\n")); + return; + } + } + await resetWallet({ force }); + console.log( + ` ${GREEN("ok")} COCOON wallet reset. Run ${DIM("teleton gocoon init")} to create a fresh wallet.` + ); +} + async function runStatus(): Promise { console.log(BOLD("\n Gocoon status\n")); const installed = isInstalled(); @@ -148,6 +167,17 @@ export function registerGocoonCommand(program: Command): void { guard(() => runWithdraw(destination, Boolean(opts.yes))) ); + gocoon + .command("reset") + .description( + "Delete the local COCOON wallet + config so a fresh wallet is created next setup (withdraw first)" + ) + .option("--yes", "Skip the confirmation prompt") + .option("--force", "Reset even if the wallet still has funds or an active channel") + .action((opts: { yes?: boolean; force?: boolean }) => + guard(() => runReset(Boolean(opts.yes), Boolean(opts.force))) + ); + gocoon .command("status") .description("Show gocoon install + wallet + runner status") diff --git a/src/config/configurable-keys.ts b/src/config/configurable-keys.ts index c389e538..6d77ffb7 100644 --- a/src/config/configurable-keys.ts +++ b/src/config/configurable-keys.ts @@ -677,21 +677,6 @@ export const CONFIGURABLE_KEYS: Record = { }, }; -// ── Category order for frontend grouping ─────────────────────────────── - -export const CATEGORY_ORDER: ConfigCategory[] = [ - "API Keys", - "Agent", - "Session", - "Telegram", - "Embedding", - "WebUI", - "Deals", - "TON Proxy", - "Coding Agent", - "Developer", -]; - // ── Dot-notation helpers ─────────────────────────────────────────────── const FORBIDDEN_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]); diff --git a/src/config/schema.ts b/src/config/schema.ts index 5f640807..9ee81129 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -388,19 +388,11 @@ export const ConfigSchema = z.object({ export type Config = z.infer; export type AgentConfig = z.infer; export type TelegramConfig = z.infer; -export type StorageConfig = z.infer; export type SessionResetPolicy = z.infer; export type DealsConfig = z.infer; export type WebUIConfig = z.infer; -export type EmbeddingConfig = z.infer; -export type LoggingConfig = z.infer; -export type DevConfig = z.infer; export type McpConfig = z.infer; -export type ToolRagConfig = z.infer; -export type ToolSearchConfig = z.infer; export type McpServerConfig = z.infer; -export type CapabilitiesConfig = z.infer; export type TonProxyConfig = z.infer; export type ApiConfig = z.infer; export type ExecConfig = z.infer; -export type HeartbeatConfig = z.infer; diff --git a/src/constants/limits.ts b/src/constants/limits.ts index d72d8d30..26caab1f 100644 --- a/src/constants/limits.ts +++ b/src/constants/limits.ts @@ -79,7 +79,6 @@ export const WEB_SEARCH_MAX_RESULTS = 10; // max allowed count export const TOOL_CONCURRENCY_LIMIT = 3; // ─── Tool RAG ────────────────────────────────────────────────── -export const TOOL_RAG_DEFAULT_TOP_K = 35; export const TOOL_RAG_MIN_SCORE = 0.1; export const TOOL_RAG_VECTOR_WEIGHT = 0.6; export const TOOL_RAG_KEYWORD_WEIGHT = 0.4; diff --git a/src/constants/timeouts.ts b/src/constants/timeouts.ts index 7d718e63..b8984fb2 100644 --- a/src/constants/timeouts.ts +++ b/src/constants/timeouts.ts @@ -1,6 +1,5 @@ /** TTS generation timeout */ export const TTS_TIMEOUT_MS = 30_000; -export const ONBOARDING_PROMPT_TIMEOUT_MS = 120_000; export const BATCH_TRIGGER_DELAY_MS = 500; export const DEFAULT_FETCH_TIMEOUT_MS = 15_000; export const RETRY_DEFAULT_MAX_ATTEMPTS = 3; diff --git a/src/deals/gift-detector.ts b/src/deals/gift-detector.ts index 5c37c87a..3cb3a16b 100644 --- a/src/deals/gift-detector.ts +++ b/src/deals/gift-detector.ts @@ -79,18 +79,4 @@ export class GiftDetector { return []; } } - - /** - * Reset cache for a specific user (useful after trades) - */ - resetCache(userId: number): void { - this.seenGifts.delete(userId); - } - - /** - * Clear all caches - */ - clearAll(): void { - this.seenGifts.clear(); - } } diff --git a/src/deals/utils.ts b/src/deals/utils.ts index 1ff7e69b..d08205eb 100644 --- a/src/deals/utils.ts +++ b/src/deals/utils.ts @@ -2,7 +2,6 @@ * Utility functions for deals system */ -import type { Deal } from "./types.js"; import { DEALS_CONFIG } from "./config.js"; /** @@ -39,13 +38,6 @@ export function calculateExpiry(): number { return Math.floor(Date.now() / 1000) + DEALS_CONFIG.expirySeconds; } -/** - * Check if deal has expired - */ -export function isDealExpired(deal: Deal): boolean { - return deal.expires_at < Math.floor(Date.now() / 1000); -} - /** * Format deal proposal message with inline buttons */ diff --git a/src/events/bus.ts b/src/events/bus.ts deleted file mode 100644 index db480d7a..00000000 --- a/src/events/bus.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { EventEmitter } from "events"; - -export interface EventMap { - "bridge:connected": { mode: "user" | "bot" }; - "bridge:disconnected": { mode: "user" | "bot" }; - "mode:changed": { from: "user" | "bot"; to: "user" | "bot" }; - "config:updated": { key: string; value: unknown }; - "tools:changed": { removed: string[]; added: string[] }; -} - -export class EventBus extends EventEmitter { - on(event: K, handler: (payload: EventMap[K]) => void): this { - return super.on(event, handler); - } - - emit(event: K, payload: EventMap[K]): boolean { - return super.emit(event, payload); - } - - off(event: K, handler: (payload: EventMap[K]) => void): this { - return super.off(event, handler); - } -} - -// Singleton instance -export const eventBus = new EventBus(); diff --git a/src/gocoon/cli.ts b/src/gocoon/cli.ts index 0e245102..54792039 100644 --- a/src/gocoon/cli.ts +++ b/src/gocoon/cli.ts @@ -4,7 +4,13 @@ import { existsSync, mkdirSync } from "fs"; import { createLogger } from "../utils/logger.js"; import { fetchWithTimeout } from "../utils/fetch.js"; import { ensureGocoonBinaries } from "./installer.js"; -import { clientConfigPath, gocoonDataDir, runnerBaseUrl, walletPath } from "./paths.js"; +import { + clientConfigPath, + gocoonDataDir, + runnerBaseUrl, + tonConfigPath, + walletPath, +} from "./paths.js"; const log = createLogger("gocoon"); const execFileAsync = promisify(execFile); @@ -120,6 +126,41 @@ export async function walletInfo(): Promise { }; } +export interface ChannelInfo { + stateName: string; + stakeNano: bigint; + balanceNano: bigint; +} + +// Read a channel's on-chain state directly (no runner needed). The contract +// account stays "active" after a cooperative close (it keeps a little storage +// TON), so callers must check stateName, not the account status, to tell a +// closed channel from a live one. Returns null if it can't be read. +export async function channelInfoOnChain(clientSC: string): Promise { + try { + const j = parseJson( + await runGocoon([ + "channel", + "info", + "--client-sc", + clientSC, + "--config", + clientConfigPath(), + "--ton-config", + tonConfigPath(), + "--json", + ]) + ); + return { + stateName: String(j.state_name ?? ""), + stakeNano: BigInt(String(j.stake_nano ?? "0")), + balanceNano: BigInt(String(j.balance_nano ?? "0")), + }; + } catch { + return null; + } +} + export async function waitFunded(onLine?: (line: string) => void): Promise { await streamGocoon( ["wallet", "wait-funded", "--wallet", walletPath(), "--config", clientConfigPath()], diff --git a/src/gocoon/index.ts b/src/gocoon/index.ts index e535521e..0a241c10 100644 --- a/src/gocoon/index.ts +++ b/src/gocoon/index.ts @@ -11,6 +11,7 @@ export { walletInfo, topup, withdrawAll, + resetWallet, tonToNano, type InitSummary, type WalletInfo, diff --git a/src/gocoon/lifecycle.ts b/src/gocoon/lifecycle.ts index d4b8a189..445f7c2a 100644 --- a/src/gocoon/lifecycle.ts +++ b/src/gocoon/lifecycle.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from "child_process"; +import { rmSync } from "fs"; import { Address, fromNano, internal, SendMode } from "@ton/core"; import { createLogger } from "../utils/logger.js"; import { getErrorMessage } from "../utils/errors.js"; @@ -8,6 +9,7 @@ import { openWallet } from "../ton/wallet-open.js"; import { sendWalletTx } from "../ton/confirm.js"; import { ensureGocoonBinaries } from "./installer.js"; import { + channelInfoOnChain, fetchClientSC, gocoonInit, streamGocoon, @@ -21,6 +23,7 @@ import { killProcessGroup, waitReady } from "./supervisor.js"; import { GOCOON_DEFAULT_PORT, clientConfigPath, + gocoonDataDir, runnerBaseUrl, runnerBin, walletPath, @@ -56,6 +59,9 @@ const REFUND_TIMEOUT_MS = 180_000; const REFUND_MIN_DELTA_NANO = 5_000_000_000n; const MIN_COCOON_WITHDRAW_NANO = 50_000_000n; const AGENT_FEE_BUFFER_NANO = 10_000_000n; +// Above this leftover balance, reset refuses: we never delete the mnemonic of a +// wallet that still controls funds. +const RESET_MAX_DUST_NANO = 100_000_000n; const COCOON_CLIENT_OPS = new Set([ "CocoonOwnerClientRegister", "CocoonExtClientTopUp", @@ -94,14 +100,18 @@ export async function withdrawAll( } const cocoon = await walletInfo(); + // The COCOON register/topup ops are sent from the fund wallet (the node + // wallet you fund), not the owner wallet. Scanning ownerAddress finds no + // channel, silently skips the close, and only the liquid balance is + // withdrawn while the stake stays locked. Match myduckai: scan fund_address. emit( "find_channel", "started", - `Looking for an active channel via ${shortAddr(cocoon.ownerAddress)}` + `Looking for an active channel via ${shortAddr(cocoon.fundAddress)}` ); let clientSC: string | null = null; try { - clientSC = await findClientSC(cocoon.ownerAddress); + clientSC = await findClientSC(cocoon.fundAddress); emit("find_channel", "ok", "Channel located"); } catch (err) { emit( @@ -161,6 +171,53 @@ export async function withdrawAll( emit("complete", "ok", `All funds sent to ${dest.label}`); } +// Delete the local COCOON wallet + config so the next init generates a fresh +// owner/node wallet (gocoonInit reuses wallet.json when present). Binaries live +// in binDir() and are kept. Run after a full withdraw. Guard: refuses while the +// runner is up, or (without force) while the wallet still holds funds or an +// active channel exists, so we never destroy keys that still control value. +export async function resetWallet( + opts: { force?: boolean } = {}, + port: number = GOCOON_DEFAULT_PORT +): Promise { + if (await isRunnerUp(port)) { + throw new Error("the gocoon runner is active; stop it first, then retry the reset"); + } + if (!opts.force) { + let info: WalletInfo | null = null; + try { + info = await walletInfo(); + } catch { + info = null; // no wallet on disk; nothing to guard + } + if (info) { + if (info.balanceNano > RESET_MAX_DUST_NANO) { + throw new Error( + `fund wallet still holds ${info.balanceTon} TON; withdraw first (or reset with force)` + ); + } + if (info.fundAddress) { + let liveChannel = false; + try { + const clientSC = await findClientSC(info.fundAddress); + const ch = await channelInfoOnChain(clientSC); + // findClientSC also returns a closed channel's contract (its account + // stays active to hold storage TON), so check the channel state, not + // the account status: only a not-closed channel that still holds + // stake controls recoverable value and must block the reset. + liveChannel = !!ch && ch.stateName !== "closed" && ch.stakeNano > 0n; + } catch { + liveChannel = false; // no client_sc found (never staked) + } + if (liveChannel) { + throw new Error("a funded channel still exists; withdraw first (or reset with force)"); + } + } + } + } + rmSync(gocoonDataDir(), { recursive: true, force: true }); +} + interface ResolvedDest { address: Address; label: string; diff --git a/src/index.ts b/src/index.ts index e4ea3fed..673f07f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,6 @@ import type { TelegramMessage } from "./telegram/bridge.js"; import type { ITelegramBridge } from "./telegram/bridge-interface.js"; import { isBotBridge, isUserBridge } from "./telegram/bridge-guards.js"; import { createBridge } from "./telegram/factory.js"; -import { eventBus } from "./events/bus.js"; import { MessageHandler } from "./telegram/handlers.js"; import { AdminHandler } from "./telegram/admin.js"; import { MessageDebouncer } from "./telegram/debounce.js"; @@ -138,9 +137,40 @@ export class TeletonApp { rewireHooks: () => this.wirePluginEventHooks(), }, userHookEvaluator: this.userHookEvaluator, + gocoonControl: { + stopRunner: () => this.stopGocoonRunner(), + }, }; } + /** + * Stop the supervised gocoon runner + SSE proxy. A withdraw refuses to run + * while the runner is active, so the Gocoon page calls this first. The agent + * stays up; gocoon inference is unavailable until the next restart. + */ + stopGocoonRunner(): boolean { + let stopped = false; + if (this.gocoonProxy) { + try { + this.gocoonProxy.stop(); + } catch (error: unknown) { + log.error({ err: error }, "gocoon sse-proxy stop failed"); + } + this.gocoonProxy = null; + stopped = true; + } + if (this.gocoonSupervisor) { + try { + this.gocoonSupervisor.stop(); + } catch (error: unknown) { + log.error({ err: error }, "gocoon supervisor stop failed"); + } + this.gocoonSupervisor = null; + stopped = true; + } + return stopped; + } + constructor(configPath?: string) { this.configPath = configPath ?? getDefaultConfigPath(); this.config = loadConfig(this.configPath); @@ -441,7 +471,6 @@ ${blue} ┌────────────────────── if (!this.bridge.isAvailable()) { throw new Error("Failed to connect to Telegram"); } - eventBus.emit("bridge:connected", { mode: this.config.telegram.mode }); await this.resolveOwnerInfo(); const ownUserId = this.bridge.getOwnUserId(); if (ownUserId) { diff --git a/src/memory/agent/index.ts b/src/memory/agent/index.ts index eb994be6..61f9044a 100644 --- a/src/memory/agent/index.ts +++ b/src/memory/agent/index.ts @@ -1,3 +1,2 @@ export * from "./knowledge.js"; -export * from "./sessions.js"; export * from "./tasks.js"; diff --git a/src/memory/agent/sessions.ts b/src/memory/agent/sessions.ts deleted file mode 100644 index ec2cd63c..00000000 --- a/src/memory/agent/sessions.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type Database from "better-sqlite3"; -import type { EmbeddingProvider } from "../embeddings/provider.js"; -import { hashText, serializeEmbedding } from "../embeddings/index.js"; -import { randomUUID } from "crypto"; -import type { SessionRow } from "../types/db-rows.js"; -import { createLogger } from "../../utils/logger.js"; - -const log = createLogger("Memory"); - -export interface Session { - id: string; - chatId?: string; - startedAt: Date; - endedAt?: Date; - summary?: string; - messageCount: number; - tokensUsed: number; -} - -export interface SessionEntry { - role: "user" | "assistant"; - content: string; - timestamp: Date; -} - -export class SessionStore { - constructor( - private db: Database.Database, - private embedder: EmbeddingProvider, - private vectorEnabled: boolean - ) {} - - createSession(chatId?: string): Session { - const id = randomUUID(); - const now = Math.floor(Date.now() / 1000); - - this.db - .prepare( - ` - INSERT INTO sessions (id, chat_id, started_at, message_count, tokens_used) - VALUES (?, ?, ?, 0, 0) - ` - ) - .run(id, chatId ?? null, now); - - return { - id, - chatId, - startedAt: new Date(now * 1000), - messageCount: 0, - tokensUsed: 0, - }; - } - - endSession(sessionId: string, summary: string, tokensUsed: number = 0): void { - const now = Math.floor(Date.now() / 1000); - - this.db - .prepare( - ` - UPDATE sessions - SET ended_at = ?, summary = ?, tokens_used = ? - WHERE id = ? - ` - ) - .run(now, summary, tokensUsed, sessionId); - } - - incrementMessageCount(sessionId: string, count: number = 1): void { - this.db - .prepare( - ` - UPDATE sessions - SET message_count = message_count + ? - WHERE id = ? - ` - ) - .run(count, sessionId); - } - - getSession(id: string): Session | undefined { - const row = this.db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as - | SessionRow - | undefined; - - if (!row) return undefined; - - return { - id: row.id, - chatId: row.chat_id, - startedAt: new Date(row.started_at * 1000), - endedAt: row.ended_at ? new Date(row.ended_at * 1000) : undefined, - summary: row.summary ?? undefined, - messageCount: row.message_count, - tokensUsed: row.tokens_used, - }; - } - - getActiveSessions(): Session[] { - const rows = this.db - .prepare( - ` - SELECT * FROM sessions - WHERE ended_at IS NULL - ORDER BY started_at DESC - ` - ) - .all() as SessionRow[]; - - return rows.map((row) => ({ - id: row.id, - chatId: row.chat_id, - startedAt: new Date(row.started_at * 1000), - endedAt: undefined, - summary: row.summary ?? undefined, - messageCount: row.message_count, - tokensUsed: row.tokens_used, - })); - } - - getSessionsByChat(chatId: string, limit: number = 50): Session[] { - const rows = this.db - .prepare( - ` - SELECT * FROM sessions - WHERE chat_id = ? - ORDER BY started_at DESC - LIMIT ? - ` - ) - .all(chatId, limit) as SessionRow[]; - - return rows.map((row) => ({ - id: row.id, - chatId: row.chat_id, - startedAt: new Date(row.started_at * 1000), - endedAt: row.ended_at ? new Date(row.ended_at * 1000) : undefined, - summary: row.summary ?? undefined, - messageCount: row.message_count, - tokensUsed: row.tokens_used, - })); - } - - /** - * Index a session for search after ending. - * Creates a knowledge entry from the session summary for future retrieval. - */ - async indexSession(sessionId: string): Promise { - const session = this.getSession(sessionId); - if (!session || !session.summary) return; - - try { - const knowledgeId = `session:${sessionId}`; - const text = `Session from ${session.startedAt.toISOString()}:\n${session.summary}`; - const hash = hashText(text); - - let embedding: number[] | null = null; - if (this.vectorEnabled) { - embedding = await this.embedder.embedQuery(text); - } - - this.db - .prepare( - ` - INSERT INTO knowledge (id, source, path, text, hash, created_at, updated_at) - VALUES (?, 'session', ?, ?, ?, unixepoch(), unixepoch()) - ON CONFLICT(id) DO UPDATE SET - text = excluded.text, - hash = excluded.hash, - updated_at = excluded.updated_at - ` - ) - .run(knowledgeId, sessionId, text, hash); - - if (embedding && this.vectorEnabled) { - const embeddingBuffer = serializeEmbedding(embedding); - - this.db.prepare(`DELETE FROM knowledge_vec WHERE id = ?`).run(knowledgeId); - this.db - .prepare(`INSERT INTO knowledge_vec (id, embedding) VALUES (?, ?)`) - .run(knowledgeId, embeddingBuffer); - } - - log.info(`Indexed session ${sessionId} to knowledge base`); - } catch (error) { - log.error({ err: error }, "Error indexing session"); - } - } - - deleteSession(sessionId: string): void { - const knowledgeId = `session:${sessionId}`; - if (this.vectorEnabled) { - this.db.prepare(`DELETE FROM knowledge_vec WHERE id = ?`).run(knowledgeId); - } - this.db.prepare(`DELETE FROM sessions WHERE id = ?`).run(sessionId); - this.db.prepare(`DELETE FROM knowledge WHERE id = ?`).run(knowledgeId); - } -} diff --git a/src/memory/ai-summarization.ts b/src/memory/ai-summarization.ts index e6a9354c..d0ce0493 100644 --- a/src/memory/ai-summarization.ts +++ b/src/memory/ai-summarization.ts @@ -16,13 +16,6 @@ import { createLogger } from "../utils/logger.js"; const log = createLogger("Memory"); -export interface SummarizationConfig { - apiKey: string; - contextWindow: number; - maxSummaryTokens: number; - maxChunkTokens: number; -} - export interface SummarizationResult { summary: string; tokensUsed: number; diff --git a/src/memory/daily-logs.ts b/src/memory/daily-logs.ts index 0b3c87c9..f153c1a1 100644 --- a/src/memory/daily-logs.ts +++ b/src/memory/daily-logs.ts @@ -146,8 +146,3 @@ export function cleanupOldDailyLogs(maxAgeDays = 60): number { return deleted; } - -export function writeConversationMilestone(chatId: string, topic: string, details: string): void { - const content = `### Conversation Milestone\n\n**Chat**: ${chatId}\n**Topic**: ${topic}\n\n${details}`; - appendToDailyLog(content); -} diff --git a/src/memory/envelope.ts b/src/memory/envelope.ts index 2896278d..d7400706 100644 --- a/src/memory/envelope.ts +++ b/src/memory/envelope.ts @@ -140,17 +140,3 @@ export function formatMessageEnvelope(params: EnvelopeParams): string { } return `${header} ${body}`; } - -export function formatMessageEnvelopeSimple(params: { - senderId?: string; - senderName?: string; - body: string; - isGroup: boolean; -}): string { - if (!params.isGroup) { - return params.body; - } - - const sender = params.senderName || (params.senderId ? `user:${params.senderId}` : "unknown"); - return `${sender}: ${params.body}`; -} diff --git a/src/memory/feed/messages.ts b/src/memory/feed/messages.ts index bbc49006..761da6b9 100644 --- a/src/memory/feed/messages.ts +++ b/src/memory/feed/messages.ts @@ -90,12 +90,6 @@ export class MessageStore { })(); } - pruneOldMessages(maxAgeDays = 90): number { - const cutoffSec = Math.floor(Date.now() / 1000) - maxAgeDays * 86_400; - const result = this.db.prepare("DELETE FROM tg_messages WHERE timestamp < ?").run(cutoffSec); - return result.changes; - } - getRecentMessages(chatId: string, limit: number = 20): TelegramMessage[] { const rows = this.db .prepare( diff --git a/src/memory/observation-masking.ts b/src/memory/observation-masking.ts index 959e3499..5786b1bd 100644 --- a/src/memory/observation-masking.ts +++ b/src/memory/observation-masking.ts @@ -148,31 +148,3 @@ export function maskOldToolResults(messages: Message[], options?: MaskingOptions return result; } - -export function calculateMaskingSavings( - originalMessages: Message[], - maskedMessages: Message[] -): { originalChars: number; maskedChars: number; savings: number } { - const countChars = (messages: Message[]): number => { - let total = 0; - for (const msg of messages) { - if (msg.role === "toolResult") { - for (const block of msg.content) { - if (typeof block !== "string" && block.type === "text") { - total += block.text.length; - } - } - } - } - return total; - }; - - const originalChars = countChars(originalMessages); - const maskedChars = countChars(maskedMessages); - - return { - originalChars, - maskedChars, - savings: originalChars - maskedChars, - }; -} diff --git a/src/memory/tool-config.ts b/src/memory/tool-config.ts index 84ed10a9..28a246f4 100644 --- a/src/memory/tool-config.ts +++ b/src/memory/tool-config.ts @@ -100,10 +100,3 @@ export function initializeToolConfig( ).run(toolName, legacyEnabled, legacyScope, level); } } - -/** - * Delete tool configuration (reverts to defaults) - */ -export function deleteToolConfig(db: Database.Database, toolName: string): void { - db.prepare(`DELETE FROM tool_config WHERE tool_name = ?`).run(toolName); -} diff --git a/src/memory/types/db-rows.ts b/src/memory/types/db-rows.ts index 3ba3b25c..7f073a8f 100644 --- a/src/memory/types/db-rows.ts +++ b/src/memory/types/db-rows.ts @@ -20,24 +20,6 @@ export interface TaskRow { scheduled_message_id: number | null; } -export interface SessionRow { - id: string; - chat_id: string; - started_at: number; - updated_at: number; - ended_at: number | null; - summary: string | null; - message_count: number; - tokens_used: number; - last_message_id: number | null; - last_channel: string | null; - last_to: string | null; - context_tokens: number | null; - model: string | null; - provider: string | null; - last_reset_date: string | null; -} - export interface TgUserRow { id: string; username: string | null; diff --git a/src/providers/codex-credentials.ts b/src/providers/codex-credentials.ts index e341085f..05711e0b 100644 --- a/src/providers/codex-credentials.ts +++ b/src/providers/codex-credentials.ts @@ -144,8 +144,3 @@ export async function refreshCodexApiKey(): Promise { export function isCodexTokenValid(): boolean { return tokenCache.isValid(); } - -/** Reset internal cache — exposed for testing only */ -export function _resetCache(): void { - tokenCache.reset(); -} diff --git a/src/services/tts.ts b/src/services/tts.ts index 8ea81e75..ded16feb 100644 --- a/src/services/tts.ts +++ b/src/services/tts.ts @@ -396,36 +396,3 @@ async function generateElevenLabsTTS(text: string, voiceId: string): Promise { - return new Promise((resolve, reject) => { - const proc = spawn("edge-tts", ["--list-voices"], { - stdio: ["pipe", "pipe", "pipe"], - }); - - let stdout = ""; - proc.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - - proc.on("close", (code) => { - if (code === 0) { - // Parse voice names from output - const voices = stdout - .split("\n") - .filter((line) => line.startsWith("Name:")) - .map((line) => line.replace("Name: ", "").trim()); - resolve(voices); - } else { - reject(new Error("Failed to list voices")); - } - }); - - proc.on("error", (err) => { - reject(err); - }); - }); -} diff --git a/src/session/store.ts b/src/session/store.ts index 094ba97c..96778fe6 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -239,24 +239,6 @@ export function updateSession( .get(sessionKey) as SessionRow; return rowToSession(updated); } -export function incrementMessageCount(chatId: string): void { - const db = getDb(); - const sessionKey = sessionKeyFor(chatId); - - const result = db - .prepare( - `UPDATE sessions SET message_count = message_count + 1, updated_at = ? WHERE chat_id = ?` - ) - .run(Date.now(), sessionKey); - - // If no row existed, create the session first then increment - if (result.changes === 0) { - getOrCreateSession(chatId); - db.prepare( - `UPDATE sessions SET message_count = message_count + 1, updated_at = ? WHERE chat_id = ?` - ).run(Date.now(), sessionKey); - } -} export function getSession(chatId: string): SessionEntry | null { const db = getDb(); const sessionKey = sessionKeyFor(chatId); diff --git a/src/session/transcript.ts b/src/session/transcript.ts index cf6aeafc..9ad2bd7d 100644 --- a/src/session/transcript.ts +++ b/src/session/transcript.ts @@ -168,33 +168,6 @@ export function transcriptExists(sessionId: string): boolean { return existsSync(getTranscriptPath(sessionId)); } -export function getTranscriptSize(sessionId: string): number { - try { - const messages = readTranscript(sessionId); - return messages.length; - } catch { - return 0; - } -} - -export function deleteTranscript(sessionId: string): boolean { - const transcriptPath = getTranscriptPath(sessionId); - - if (!existsSync(transcriptPath)) { - return false; - } - - try { - unlinkSync(transcriptPath); - transcriptCache.delete(sessionId); - log.info(`Deleted transcript: ${sessionId}`); - return true; - } catch (error) { - log.error({ err: error }, `Failed to delete transcript ${sessionId}`); - return false; - } -} - /** * Archive a transcript (rename with timestamped .archived suffix). */ diff --git a/src/telegram/callbacks/handler.ts b/src/telegram/callbacks/handler.ts deleted file mode 100644 index 5e102885..00000000 --- a/src/telegram/callbacks/handler.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ITelegramBridge } from "../bridge-interface.js"; -import { isUserBridge } from "../bridge-guards.js"; -import { createLogger } from "../../utils/logger.js"; - -const log = createLogger("Telegram"); - -export type CallbackHandler = (data: { - action: string; - params: string[]; - queryId: bigint; - chatId: string; - messageId: number; - userId: number; -}) => Promise; - -export class CallbackQueryHandler { - private handlers: Map = new Map(); - - constructor(private bridge: ITelegramBridge) {} - - register(actionPrefix: string, handler: CallbackHandler): void { - this.handlers.set(actionPrefix, handler); - } - - async handle(event: { - queryId: bigint; - data?: Buffer; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- peer type varies across GramJS update shapes - peer?: any; - chatInstance?: string; - msgId?: number; - userId: bigint; - }): Promise { - try { - const queryId = event.queryId; - const data = event.data?.toString() || ""; - const chatId = event.peer?.toString() || event.chatInstance?.toString() || ""; - const messageId = event.msgId || 0; - const userId = Number(event.userId); - - log.info(`[Callback] Received: data="${data}" from user ${userId} in chat ${chatId}`); - - const parts = data.split(":"); - const action = parts[0]; - const params = parts.slice(1); - - const handler = this.handlers.get(action); - if (!handler) { - log.warn(`No handler for callback action: ${action}`); - await this.answerCallback(queryId, "Unknown action"); - return; - } - - await handler({ - action, - params, - queryId, - chatId, - messageId, - userId, - }); - } catch (error) { - log.error({ err: error }, "Error handling callback query"); - if (event?.queryId) { - await this.answerCallback(event.queryId, "An error occurred. Please try again."); - } - } - } - - private async answerCallback(queryId: bigint, message?: string, alert = false): Promise { - try { - if (isUserBridge(this.bridge)) { - await this.bridge.getClient().answerCallbackQuery(queryId, { message, alert }); - } - } catch (error) { - log.error({ err: error }, "Error answering callback"); - } - } -} diff --git a/src/telegram/callbacks/index.ts b/src/telegram/callbacks/index.ts deleted file mode 100644 index 4d8079e7..00000000 --- a/src/telegram/callbacks/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { - CallbackQuery, - InlineButton, - CallbackHandler as CallbackHandlerFunc, -} from "./types.js"; -export { CallbackQueryHandler } from "./handler.js"; -export { initializeCallbackRouter } from "./router.js"; diff --git a/src/telegram/callbacks/router.ts b/src/telegram/callbacks/router.ts deleted file mode 100644 index 7a26e18f..00000000 --- a/src/telegram/callbacks/router.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CallbackQueryHandler } from "./handler.js"; -import type { ITelegramBridge } from "../bridge-interface.js"; - -export function initializeCallbackRouter(bridge: ITelegramBridge): CallbackQueryHandler { - const handler = new CallbackQueryHandler(bridge); - return handler; -} diff --git a/src/telegram/callbacks/types.ts b/src/telegram/callbacks/types.ts deleted file mode 100644 index cf7c7bb5..00000000 --- a/src/telegram/callbacks/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface CallbackQuery { - id: string; - data: string; - chatId: string; - messageId: number; - userId: number; -} - -export interface InlineButton { - text: string; - callback_data: string; -} - -export type CallbackHandler = (query: CallbackQuery) => Promise; diff --git a/src/telegram/client.ts b/src/telegram/client.ts index 2b473085..3036c918 100644 --- a/src/telegram/client.ts +++ b/src/telegram/client.ts @@ -371,21 +371,6 @@ export class TelegramUserClient { } } - async resolveUsername(username: string): Promise { - const clean = username.replace("@", ""); - try { - // Call ResolveUsername directly — bypasses GramJS's VALID_USERNAME_RE - // which rejects collectible usernames shorter than 5 chars. - const result = await this.client.invoke( - new Api.contacts.ResolveUsername({ username: clean }) - ); - return result.users[0] || result.chats[0]; - } catch (error: unknown) { - log.error({ err: error }, `Failed to resolve username ${clean}`); - return undefined; - } - } - async getEntity(entity: string): Promise { return await this.client.getEntity(entity); } diff --git a/src/telegram/debounce.ts b/src/telegram/debounce.ts index f1e16990..dbe65af5 100644 --- a/src/telegram/debounce.ts +++ b/src/telegram/debounce.ts @@ -134,10 +134,6 @@ export class MessageDebouncer { } } - getBufferDepth(chatId: string): number { - return this.buffers.get(chatId)?.messages.length ?? 0; - } - async flushAll(): Promise { const keys = Array.from(this.buffers.keys()); for (const key of keys) { diff --git a/src/telegram/offset-store.ts b/src/telegram/offset-store.ts index 3576c650..a93d6277 100644 --- a/src/telegram/offset-store.ts +++ b/src/telegram/offset-store.ts @@ -94,10 +94,3 @@ export function writeOffset(messageId: number, chatId?: string): void { saveState(state); } } - -/** - * Get all chat offsets (for debugging) - */ -export function getAllOffsets(): Record { - return loadState().perChat; -} diff --git a/src/ton/dex-constants.ts b/src/ton/dex-constants.ts index 73052b9a..6b43110a 100644 --- a/src/ton/dex-constants.ts +++ b/src/ton/dex-constants.ts @@ -18,8 +18,6 @@ export const DEDUST_GAS = { SWAP_TON_TO_JETTON: "0.25", // Jetton to any asset swap SWAP_JETTON_TO_ANY: "0.3", - // Extra gas for multi-hop swaps - MULTIHOP_EXTRA: "0.1", // Forward gas for jetton transfers FORWARD_GAS: "0.2", }; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index e814de62..348fa0bb 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -16,14 +16,6 @@ export function addLogListener(fn: LogListener): () => void { return () => listeners.delete(fn); } -export function removeLogListener(fn: LogListener): void { - listeners.delete(fn); -} - -export function clearLogListeners(): void { - listeners.clear(); -} - // ── Pino level → WebUI LogEntry level mapping ───────────────────────── const LEVEL_MAP: Record = { 10: "log", // trace → log @@ -163,9 +155,6 @@ export function createLogger(module: string): pino.Logger { return rootLogger.child({ module }); } -/** The root pino logger (no module prefix). */ -export const logger = rootLogger; - /** * Apply logging config from YAML (called after config load in TonnetApp). * Wires config.logging.level to the live logger. @@ -201,22 +190,10 @@ export function setLogLevel(level: LogLevel): void { _verbose = level === "debug" || level === "trace"; } -/** - * Get current log level. - */ -export function getLogLevel(): string { - return rootLogger.level; -} - // ── Backward compatibility ──────────────────────────────────────────── let _verbose = rootLogger.isLevelEnabled("debug"); -/** @deprecated Use createLogger(module).debug() instead */ -export function verbose(...args: unknown[]): void { - if (_verbose) rootLogger.debug(args.map(String).join(" ")); -} - /** @deprecated Use setLogLevel("debug") / setLogLevel("info") instead */ export function setVerbose(v: boolean): void { setLogLevel(v ? "debug" : "info"); diff --git a/src/webui/log-interceptor.ts b/src/webui/log-interceptor.ts index 35718012..a77a86f5 100644 --- a/src/webui/log-interceptor.ts +++ b/src/webui/log-interceptor.ts @@ -6,7 +6,7 @@ * keeping the same API for routes/logs.ts and the SSE stream. */ import type { LogEntry } from "./types.js"; -import { addLogListener, clearLogListeners } from "../utils/logger.js"; +import { addLogListener } from "../utils/logger.js"; type LogListener = (entry: LogEntry) => void; @@ -40,22 +40,6 @@ class LogInterceptor { this.cleanups.delete(listener); }; } - - removeListener(listener: LogListener): void { - const cleanup = this.cleanups.get(listener); - if (cleanup) { - cleanup(); - this.cleanups.delete(listener); - } - } - - clear(): void { - for (const cleanup of this.cleanups.values()) { - cleanup(); - } - this.cleanups.clear(); - clearLogListeners(); - } } // Singleton instance diff --git a/src/webui/routes/gocoon.ts b/src/webui/routes/gocoon.ts index 82da5018..e851d125 100644 --- a/src/webui/routes/gocoon.ts +++ b/src/webui/routes/gocoon.ts @@ -5,6 +5,7 @@ import { ensureGocoonBinaries, init, isInstalled, + resetWallet, runnerBaseUrl, topup, walletInfo, @@ -139,5 +140,36 @@ export function createGocoonRoutes(deps: WebUIServerDeps) { } as APIResponse) ); + // Stop teleton's supervised runner so a withdraw can proceed (withdrawAll + // refuses while the runner is active). Waits for the port to go quiet so the + // caller knows it's safe to start the withdraw. + app.post("/runner/stop", async (c) => { + const wasRunning = deps.gocoonControl?.stopRunner() ?? false; + const deadline = Date.now() + 8000; + while (Date.now() < deadline) { + const up = await fetch(`${runnerBaseUrl(port())}/jsonstats`, { + signal: AbortSignal.timeout(800), + }) + .then(() => true) + .catch(() => false); + if (!up) break; + await new Promise((r) => setTimeout(r, 400)); + } + return c.json({ success: true, data: { stopped: wasRunning } } as APIResponse); + }); + + // Wipe the local COCOON wallet + config so the next setup creates a fresh + // wallet. Guarded in resetWallet: refuses while the runner is up, or while + // funds / an active channel remain (unless force). + app.post("/reset", async (c) => { + try { + const body = (await c.req.json().catch(() => ({}))) as { force?: boolean }; + await resetWallet({ force: Boolean(body.force) }, port()); + return c.json({ success: true, data: { reset: true } } as APIResponse); + } catch (err) { + return c.json({ success: false, error: getErrorMessage(err) } as APIResponse, 400); + } + }); + return app; } diff --git a/src/webui/types.ts b/src/webui/types.ts index 5963dbe9..95680589 100644 --- a/src/webui/types.ts +++ b/src/webui/types.ts @@ -43,6 +43,8 @@ export interface WebUIServerDeps { lifecycle?: AgentLifecycle; marketplace?: MarketplaceDeps; userHookEvaluator?: UserHookEvaluator | null; + /** Stop the supervised gocoon runner + proxy so a withdraw can close the channel. */ + gocoonControl?: { stopRunner: () => boolean }; } // ── Marketplace types ─────────────────────────────────────────────── @@ -121,15 +123,6 @@ export interface ModuleInfo { isPlugin: boolean; } -export interface PluginManifest { - name: string; - version: string; - author?: string; - description?: string; - dependencies?: string[]; - sdkVersion?: string; -} - export interface MemorySearchResult { id: string; text: string; diff --git a/src/workspace/index.ts b/src/workspace/index.ts index 7951176f..28ffbd4e 100644 --- a/src/workspace/index.ts +++ b/src/workspace/index.ts @@ -26,8 +26,6 @@ export { ensureWorkspace, isNewWorkspace, loadTemplate, - writeFileIfMissing, - getWorkspaceStats, type Workspace, type WorkspaceConfig, } from "./manager.js"; diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 84ff3ae3..c52e3c0e 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -1,6 +1,6 @@ // src/workspace/manager.ts -import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, copyFileSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { TELETON_ROOT, WORKSPACE_ROOT, WORKSPACE_PATHS } from "./paths.js"; @@ -154,38 +154,3 @@ export function loadTemplate(name: string): string { } return readFileSync(templatePath, "utf-8"); } - -/** - * Write file only if it doesn't exist - */ -export function writeFileIfMissing(path: string, content: string): void { - if (!existsSync(path)) { - const dir = dirname(path); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - writeFileSync(path, content, "utf-8"); - } -} - -/** - * Get workspace stats - */ -export function getWorkspaceStats(workspace: Workspace): { - exists: boolean; - hasConfig: boolean; - hasTemplates: boolean; - hasSession: boolean; - hasWallet: boolean; -} { - return { - exists: existsSync(workspace.workspace), - hasConfig: existsSync(workspace.configPath), - hasTemplates: - existsSync(workspace.soulPath) && - existsSync(workspace.memoryPath) && - existsSync(workspace.identityPath), - hasSession: existsSync(workspace.sessionPath), - hasWallet: existsSync(workspace.walletPath), - }; -} diff --git a/web/src/components/AllowLists.tsx b/web/src/components/AllowLists.tsx index 944b30e4..5e230434 100644 --- a/web/src/components/AllowLists.tsx +++ b/web/src/components/AllowLists.tsx @@ -41,8 +41,6 @@ export function AllowLists({ getLocal, onSave }: AllowListsProps) { return (
-
Allow Lists
- value={tab} onChange={setTab} diff --git a/web/src/components/GocoonPanel.tsx b/web/src/components/GocoonPanel.tsx index dd6984f8..0c277daa 100644 --- a/web/src/components/GocoonPanel.tsx +++ b/web/src/components/GocoonPanel.tsx @@ -24,6 +24,14 @@ type WEvent = { stage: string; status: string; message: string }; // stake (~15 TON) drops the free balance below gocoon's "funded" threshold. const PROVISIONED_NANO = 1_000_000_000n; +// Client-side sanity check only; the server resolves the destination authoritatively. +const RAW_TON = /^(?:EQ|UQ|kQ|0Q)[A-Za-z0-9_-]{46}$/; +const TON_DOMAIN = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+ton$/i; +const looksLikeTONDest = (s: string): boolean => { + const t = s.trim(); + return RAW_TON.test(t) || TON_DOMAIN.test(t); +}; + function GemIcon({ className }: { className?: string }) { return (
-
- setWithdrawDest(e.target.value)} - /> - -
-
- Stop the agent first. Closes the channel and drains the COCOON + agent wallets. Irreversible. -
+ {withdrawRunning ? ( +
+ Withdraw in progress (~3 min): closing channel and draining wallets. +
+ ) : ( + <> + {running && ( +
+ + The agent is running. Stop it before withdrawing. + + +
+ )} + setWithdrawDest(e.target.value)} + /> + {withdrawDest.trim() && !destValid && ( +
+ Doesn't look like a TON address or .ton domain. +
+ )} + setConfirmText(e.target.value)} + style={{ marginTop: 8 }} + /> + +
+ Closes the channel and drains the COCOON + agent wallets to the address above. Irreversible. +
+ + )} {events.length > 0 && (
{events.map((e, i) => ( @@ -315,6 +402,30 @@ export function GocoonPanel() {
)}
+ + {!hasFunds && !running && ( +
+ + setResetText(e.target.value)} + /> + +
+ Deletes the local COCOON wallet + config so a fresh one is created on the next setup. Withdraw everything first. Irreversible. +
+
+ )}
)} diff --git a/web/src/components/SearchInput.tsx b/web/src/components/SearchInput.tsx deleted file mode 100644 index 907e454a..00000000 --- a/web/src/components/SearchInput.tsx +++ /dev/null @@ -1,54 +0,0 @@ -interface SearchInputProps { - value: string; - onChange: (value: string) => void; - placeholder?: string; - style?: React.CSSProperties; - wrapperStyle?: React.CSSProperties; -} - -export function SearchInput({ value, onChange, placeholder = 'Search...', style, wrapperStyle }: SearchInputProps) { - return ( -
- onChange(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Escape') onChange(''); }} - style={{ - padding: '4px 24px 4px 12px', - fontSize: '13px', - border: '1px solid var(--border)', - backgroundColor: 'transparent', - color: 'var(--text-primary)', - width: '180px', - outline: 'none', - ...style, - }} - /> - {value && ( - - )} -
- ); -} diff --git a/web/src/components/Skeleton.tsx b/web/src/components/Skeleton.tsx index 4e91689e..8b5bd62d 100644 --- a/web/src/components/Skeleton.tsx +++ b/web/src/components/Skeleton.tsx @@ -17,17 +17,6 @@ export function Skeleton({ width = '100%', height = 16, radius, style }: Skeleto ); } -/** A few stacked skeleton lines, last one shorter — for text placeholders. */ -export function SkeletonText({ lines = 3 }: { lines?: number }) { - return ( -
- {Array.from({ length: lines }).map((_, i) => ( - - ))} -
- ); -} - /** Skeleton rows for list/table loading states. */ export function SkeletonRows({ rows = 5, height = 44 }: { rows?: number; height?: number }) { return ( diff --git a/web/src/index.css b/web/src/index.css index 8ad54123..a7fe05e6 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -371,8 +371,6 @@ body { .dashboard-root { display: flex; flex-direction: column; - height: calc(100vh - 64px); - min-height: 0; } .dashboard-settings { @@ -2869,61 +2867,110 @@ button.btn-destructive:hover { background: var(--red); opacity: 0.9; } .task-detail-actions { display: flex; gap: 8px; margin-top: 2px; } /* ── Dashboard ── */ -.dash-hero { display: flex; align-items: center; gap: 14px; padding: 16px 18px; } -.dash-orb { position: relative; width: 12px; height: 12px; border-radius: 50%; background: var(--green); flex-shrink: 0; } +/* ── Command-center top row ── */ +.dash-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; align-items: stretch; } +@media (max-width: 720px) { .dash-grid { grid-template-columns: 1fr; } } + +/* Card header: title + optional description, with right-aligned status/action */ +.dash-head { display: flex; align-items: center; gap: 11px; margin-bottom: 18px; } +.dash-head-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.dash-head-title { font-size: var(--font-base); font-weight: 600; letter-spacing: -0.2px; line-height: 1.15; color: var(--text-primary); } +.dash-head-desc { font-size: var(--font-xs); color: var(--text-secondary); } +.dash-head-right { margin-left: auto; display: flex; align-items: center; flex-shrink: 0; } + +/* Live status badge */ +.dash-status { + display: inline-flex; align-items: center; gap: 6px; + font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; + color: var(--green); background: var(--green-dim); padding: 4px 10px; border-radius: var(--radius-pill); +} +.dash-orb { position: relative; width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; } .dash-orb::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: var(--green); animation: orb-ping 1.9s ease-out infinite; } @keyframes orb-ping { 0% { opacity: 0.55; transform: scale(1); } 100% { opacity: 0; transform: scale(3.4); } } -.dash-hero-main { flex: 1; min-width: 0; } -.dash-hero-title { display: flex; align-items: center; gap: 10px; font-size: var(--font-lg); font-weight: 600; color: var(--text-primary); } -.dash-hero-state { - font-size: var(--font-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; - color: var(--green); background: var(--green-dim); padding: 2px 8px; border-radius: var(--radius-pill); -} -.dash-hero-sub { font-size: var(--font-sm); color: var(--text-secondary); margin-top: 3px; } -.dash-hero-selects { display: flex; gap: 12px; flex-shrink: 0; } -.dash-hero-field { display: flex; flex-direction: column; gap: 5px; width: 168px; } -.dash-hero-field.model { width: 232px; } + +/* Agent card */ +.dash-agent { display: flex; flex-direction: column; } +.dash-agent-model-name { display: block; font-size: var(--font-2xl); font-weight: 700; letter-spacing: -0.6px; line-height: 1.1; color: var(--text-primary); } +.dash-agent-provider { display: block; margin-top: 5px; font-size: var(--font-sm); color: var(--text-secondary); } +.dash-agent-selects { display: flex; gap: 12px; margin-top: auto; padding-top: 18px; border-top: 1px solid var(--border-glass); } +.dash-hero-field { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; } .dash-hero-label { - font-size: var(--font-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; - color: var(--text-tertiary); - padding-left: 2px; + font-size: var(--font-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; + color: var(--text-tertiary); padding-left: 2px; +} + +/* Usage card */ +.dash-usage { display: flex; flex-direction: column; } +.dash-usage-hero { display: flex; align-items: baseline; gap: 10px; } +.dash-usage-num { font-size: var(--font-2xl); font-weight: 700; letter-spacing: -0.8px; line-height: 1; color: var(--text-primary); font-variant-numeric: tabular-nums; } +.dash-usage-cost { font-size: var(--font-sm); color: var(--text-secondary); } +.dash-metrics { display: flex; margin-top: auto; padding-top: 18px; border-top: 1px solid var(--border-glass); } +.dash-metric { + flex: 1; display: flex; flex-direction: column; gap: 3px; align-items: flex-start; text-align: left; + height: auto; padding: 0 14px; background: none; color: inherit; border: none; border-radius: 0; +} +.dash-metric:first-child { padding-left: 0; } +.dash-metric + .dash-metric { border-left: 1px solid var(--border-glass); } +.dash-metric.clickable:hover .dash-metric-v { color: var(--accent); } +.dash-metric-v { font-size: var(--font-xl); font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; } +.dash-metric-k { font-size: var(--font-xs); color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.4px; } + +/* GRAM chip in the usage header */ +.dash-gram { + display: inline-flex; align-items: center; gap: 6px; + height: auto; padding: 5px 10px; background: var(--accent-subtle); color: var(--text-primary); + border: 1px solid transparent; border-radius: var(--radius-pill); + transition: border-color var(--duration-color) var(--easing-standard); } - -/* Compact menu-bar-style stats strip */ -.dash-statbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 2px 6px; - padding: 6px 10px; - margin-bottom: 16px; - background: var(--bg-secondary); - border: 1px solid var(--border-glass); - border-radius: var(--radius-pill); +.dash-gram:hover { background: var(--accent-subtle); border-color: var(--accent); } +.dash-gram-glyph { width: 15px; height: 15px; color: var(--accent); flex-shrink: 0; } +.dash-gram-amt { font-size: var(--font-sm); font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; } +.dash-gram-unit { font-size: var(--font-xs); font-weight: 600; color: var(--accent); } + +/* Recent activity */ +.dash-activity { display: flex; flex-direction: column; } +.dash-activity-sub { font-size: var(--font-sm); color: var(--text-tertiary); } +.dash-activity-list { display: flex; flex-direction: column; } +.dash-activity-row { + display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; + height: auto; padding: 8px 8px; background: none; color: inherit; + border: none; border-radius: var(--radius-sm); + transition: background var(--duration-color) var(--easing-standard); } -.stat-item { - display: inline-flex; - align-items: baseline; - gap: 5px; - white-space: nowrap; - padding: 3px 9px; - border-radius: var(--radius-pill); +.dash-activity-row:hover { background: var(--glass-thin); } +.dash-activity-ava { + width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + font-size: var(--font-sm); font-weight: 600; color: var(--accent); background: var(--accent-dim); +} +.dash-activity-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; } +.dash-activity-name { font-size: var(--font-md); font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dash-activity-snip { font-size: var(--font-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dash-activity-time { font-size: var(--font-xs); color: var(--text-tertiary); flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums; } +.dash-activity-all { + align-self: flex-start; margin-top: 6px; height: auto; padding: 6px 10px; + font-size: var(--font-sm); font-weight: 500; color: var(--accent); + background: none; border: none; border-radius: var(--radius-sm); +} +.dash-activity-all:hover { background: var(--accent-subtle); } + +.dashboard-settings { margin-bottom: 16px; align-items: stretch; } + +/* Settings cards */ +.dash-settings { display: flex; flex-direction: column; } +.dash-settings .allowlist-rows { min-height: 72px; } + +/* Access Policy: stacked label + pill rows, divided */ +.dash-policy { display: flex; flex-direction: column; flex: 1; } +.dash-policy-row { display: flex; flex-direction: column; gap: 10px; padding: 16px 0; } +.dash-policy-row + .dash-policy-row { border-top: 1px solid var(--border-glass); } +.dash-policy-label { + display: flex; align-items: center; gap: 6px; + font-size: var(--font-md); font-weight: 500; color: var(--text-primary); } -.stat-item .stat-v { font-size: var(--font-sm); font-weight: 600; color: var(--text-primary); } -.stat-item .stat-v.mono { font-family: var(--font-mono); } -.stat-item .stat-k { font-size: var(--font-xs); color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.3px; } -.stat-item.clickable { cursor: pointer; transition: background var(--duration-color) var(--easing-standard); } -.stat-item.clickable:hover { background: var(--glass-thin); } -.stat-item.clickable:hover .stat-k { color: var(--text-secondary); } - -.dashboard-settings { margin-bottom: 16px; } -.dash-policy { display: grid; gap: 16px; margin-top: 16px; } /* ── Allow Lists — iOS grouped list, one ID per row (Figma 6023:2101) ── */ .dash-card-fill { display: flex; flex-direction: column; } diff --git a/web/src/lib/a11y.ts b/web/src/lib/a11y.ts deleted file mode 100644 index 43d20e13..00000000 --- a/web/src/lib/a11y.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { KeyboardEvent } from 'react'; - -/** - * Returns the a11y props needed to make a non-button element keyboard-activatable. - * Handles Enter and Space exactly like a native button would. - * - * Usage: - * - */ -export function expandableRowProps(onActivate: () => void): { - onKeyDown: (e: KeyboardEvent) => void; - tabIndex: number; - role: string; -} { - return { - onKeyDown: (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onActivate(); - } - }, - tabIndex: 0, - role: 'button', - }; -} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ab1f5f78..ab444656 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -402,9 +402,6 @@ export const api = { async gocoonInit() { return fetchAPI>('/gocoon/init', { method: 'POST' }); }, - async gocoonBalance() { - return fetchAPI>('/gocoon/balance'); - }, async gocoonTopup(amount: string) { return fetchAPI>('/gocoon/topup', { method: 'POST', body: JSON.stringify({ amount }) }); }, @@ -414,6 +411,12 @@ export const api = { async gocoonWithdrawStatus() { return fetchAPI>('/gocoon/withdraw'); }, + async gocoonRunnerStop() { + return fetchAPI>('/gocoon/runner/stop', { method: 'POST' }); + }, + async gocoonReset() { + return fetchAPI>('/gocoon/reset', { method: 'POST' }); + }, async getTools() { return fetchAPI>('/tools'); @@ -466,10 +469,6 @@ export const api = { return fetchAPI>('/plugins'); }, - async getPluginPriorities() { - return fetchAPI>>('/plugins/priorities'); - }, - async setPluginPriority(pluginName: string, priority: number) { return fetchAPI>('/plugins/priorities', { method: 'POST', @@ -477,12 +476,6 @@ export const api = { }); }, - async resetPluginPriority(pluginName: string) { - return fetchAPI>(`/plugins/priorities/${encodeURIComponent(pluginName)}`, { - method: 'DELETE', - }); - }, - async getToolRag() { return fetchAPI>('/tools/rag'); }, @@ -574,10 +567,6 @@ export const api = { return fetchAPI>(`/tasks${qs}`); }, - async tasksGet(id: string) { - return fetchAPI>(`/tasks/${id}`); - }, - async tasksDelete(_id: string) { return fetchAPI>(`/tasks/${_id}`, { method: 'DELETE' }); }, @@ -593,10 +582,6 @@ export const api = { }); }, - async tasksCleanDone() { - return fetchAPI>('/tasks/clean-done', { method: 'POST' }); - }, - async getConfigKeys() { return fetchAPI>('/config'); }, @@ -608,12 +593,6 @@ export const api = { }); }, - async unsetConfigKey(key: string) { - return fetchAPI>(`/config/${key}`, { - method: 'DELETE', - }); - }, - async getModelsForProvider(provider: string) { return fetchAPI>>(`/config/models/${encodeURIComponent(provider)}`); }, diff --git a/web/src/lib/log-store.ts b/web/src/lib/log-store.ts index 7c7b4597..13f6ffb1 100644 --- a/web/src/lib/log-store.ts +++ b/web/src/lib/log-store.ts @@ -1,7 +1,5 @@ import { api, LogEntry } from './api'; -export type { LogEntry }; - type Listener = () => void; const MAX_LOGS = 2000; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index fc1485d1..dd86ef46 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -9,6 +9,17 @@ export function formatDate(input: string | number | null | undefined, epochScale return date.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' }); } +/** Compact relative time from a unix-seconds epoch: now, 5m, 3h, 2d, else date. */ +export function timeAgo(epochSeconds: number | null | undefined): string { + if (epochSeconds == null) return '—'; + const diff = Date.now() / 1000 - epochSeconds; + if (diff < 45) return 'now'; + if (diff < 3600) return `${Math.round(diff / 60)}m`; + if (diff < 86400) return `${Math.round(diff / 3600)}h`; + if (diff < 604800) return `${Math.round(diff / 86400)}d`; + return formatDate(epochSeconds, 1000); +} + export function formatDateTime(input: string | number | null | undefined, epochScale = 1): string { if (input == null) return '\u2014'; const date = typeof input === 'number' ? new Date(input * epochScale) : new Date(input); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 0dba2c0a..f66c4a13 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { useConfigState } from '../hooks/useConfigState'; import { POLICY_OPTIONS } from '../components/TelegramSettingsPanel'; @@ -8,9 +8,10 @@ import { PillTabs } from '../components/PillTabs'; import { InfoTip } from '../components/InfoTip'; import { Select } from '../components/Select'; import { ProviderSwitchZone, PROVIDER_OPTIONS, PROVIDER_LABELS } from '../components/ProviderControl'; -import { api, StatusData } from '../lib/api'; -import { errMsg } from '../lib/utils'; -import { Skeleton } from '../components/Skeleton'; +import { api, StatusData, ConversationChat } from '../lib/api'; +import { errMsg, timeAgo } from '../lib/utils'; +import { Skeleton, SkeletonRows } from '../components/Skeleton'; +import { EmptyState } from '../components/EmptyState'; import { Alert } from '../components/Alert'; const PLATFORM_LABEL: Record = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' }; @@ -20,18 +21,54 @@ function fmtUptime(sec: number): string { return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; } -function StatItem({ label, value, mono, to }: { label: string; value: string | number; mono?: boolean; to?: string }) { +function providerLabel(provider: string): string { + const i = PROVIDER_OPTIONS.indexOf(provider); + return i >= 0 ? PROVIDER_LABELS[i] : provider; +} + +function CardHead({ title, desc, right }: { title: string; desc?: string; right?: ReactNode }) { + return ( +
+
+ {title} + {desc && {desc}} +
+ {right &&
{right}
} +
+ ); +} + +function StatusBadge() { + return ( + + + ); +} + +function GramGlyph() { + return ( + + ); +} + +function Metric({ label, value, to }: { label: string; value: string | number; to?: string }) { const navigate = useNavigate(); const clickable = !!to; return ( - navigate(to) : undefined} - {...(clickable ? { role: 'button', tabIndex: 0, onKeyDown: (e: React.KeyboardEvent) => { if (e.key === 'Enter') navigate(to); } } : {})} > - {value} - {label} - + {value} + {label} + ); } @@ -45,6 +82,7 @@ export function Dashboard() { handleProviderChange, handleProviderConfirm, handleProviderCancel, loadData, } = useConfigState(); + const navigate = useNavigate(); const handleArraySave = async (key: string, values: string[]) => { try { @@ -55,14 +93,20 @@ export function Dashboard() { } }; - // Poll /api/status every 10s for live metrics (tokens, uptime). + // Live metrics (tokens, uptime) + wallet balance + recent chats. const [liveStatus, setLiveStatus] = useState(null); const [balance, setBalance] = useState(null); + const [recent, setRecent] = useState(null); useEffect(() => { let active = true; const poll = () => api.getStatus().then((r) => { if (active) setLiveStatus(r.data); }).catch(() => {}); const id = setInterval(poll, 10_000); api.getWallet().then((r) => { if (active) setBalance(r.data?.balance ?? null); }).catch(() => {}); + api.getConversations().then((r) => { + if (!active) return; + const chats = (r.data ?? []).slice().sort((a, b) => (b.last_message_at ?? 0) - (a.last_message_at ?? 0)); + setRecent(chats); + }).catch(() => {}); return () => { active = false; clearInterval(id); }; }, []); @@ -70,8 +114,11 @@ export function Dashboard() { return (

Dashboard

System overview

-
-
+
+
+
+
+
); } @@ -79,6 +126,11 @@ export function Dashboard() { const s = liveStatus ?? status; const platform = s.platform ? (PLATFORM_LABEL[s.platform] ?? s.platform) : null; + const provider = pendingProvider ?? getLocal('agent.provider'); + const modelLabel = modelOptions.find((m) => m.value === getLocal('agent.model'))?.name ?? getLocal('agent.model'); + const tokens = s.tokenUsage ? `${(s.tokenUsage.totalTokens / 1000).toFixed(1)}K` : '0'; + const cost = s.tokenUsage ? `$${s.tokenUsage.totalCost.toFixed(3)}` : '$0.000'; + const recentTop = (recent ?? []).slice(0, 7); return (
@@ -86,42 +138,59 @@ export function Dashboard() { {error && setError(null)} style={{ marginBottom: '14px' }} />} - {/* ── Status hero ── */} -
-