From 1e3c951339b52a6d2587dd3b4f4749f50e466d34 Mon Sep 17 00:00:00 2001 From: verte Date: Sun, 5 Apr 2026 17:50:02 -0400 Subject: [PATCH] chore(release): prepare release v0.7.0 Made-with: Cursor --- .gitignore | 6 +- AGENTS.md | 3 +- CHANGELOG.md | 39 +- README.md | 149 +- VERSION | 2 +- cmd/localforge/SECURITY.md | 303 --- cmd/localforge/config.example.yaml | 30 +- cmd/localforge/src/agent_manager.go | 32 +- cmd/localforge/src/config_manager.go | 28 - cmd/localforge/src/config_manager_test.go | 5 +- cmd/localforge/src/handlers_config.go | 61 +- cmd/localforge/src/handlers_knowledge.go | 200 +- cmd/localforge/src/handlers_providers.go | 1 + cmd/localforge/src/handlers_webhook.go | 269 ++- cmd/localforge/src/main.go | 39 +- cmd/localforge/src/provider_registry.go | 37 + cmd/localforge/src/providers/instagram.go | 6 +- cmd/localforge/src/providers/telegram.go | 229 ++- .../src/providers/telegram_command_test.go | 41 + cmd/localforge/src/push_registry.go | 13 + cmd/localforge/src/server.go | 73 +- cmd/localforge/src/static/css/knowledge.css | 9 +- cmd/localforge/src/static/css/styles.css | 693 ++++++- cmd/localforge/src/static/index.html | 80 +- cmd/localforge/src/static/js/app.js | 27 +- cmd/localforge/src/static/js/chat.js | 132 +- cmd/localforge/src/static/js/knowledge.js | 28 + cmd/localforge/src/static/js/settings.js | 42 - cmd/localforge/src/static/js/todos.js | 2 +- cmd/localforge/src/static/knowledge.html | 6 +- cmd/localforge/src/static/login.html | 6 +- cmd/localforge/src/static/settings.html | 365 +--- cmd/localforge/src/telegram_thread_store.go | 92 + .../src/telegram_thread_store_test.go | 34 + cmd/localforge/src/types.go | 7 +- docs/AGENT_BUILDER.md | 5 +- docs/CONFIG.md | 19 +- docs/DISCOVERABLE.md | 26 +- docs/EXPAND_TOOL.md | 260 +-- docs/FILE_STRUCTURE.md | 49 +- docs/INTERFACES.md | 51 +- docs/LOGGER.md | 2 +- docs/TOOLS.md | 160 +- docs/agents/architecture.md | 16 +- docs/agents/code-style.md | 7 - docs/agents/configuration.md | 40 + docs/agents/how-to-plugins.md | 2 +- docs/agents/how-to-system-agents.md | 51 +- docs/agents/how-to-tools.md | 6 +- docs/agents/overview.md | 2 +- docs/agents/quickref.md | 1 + go.mod | 13 +- go.sum | 22 + installation/templates/config.yaml.template | 15 +- scripts/install-release.sh | 29 + scripts/install.sh | 5 + src/agents/agent.go | 145 +- src/agents/agentChat.go | 24 - src/agents/agentConfig.go | 25 +- src/agents/agentHistory.go | 19 +- src/agents/agentHooks.go | 289 ++- src/agents/agentInit.go | 102 +- src/agents/agentSubagent.go | 32 - src/agents/agent_chat_test.go | 10 +- src/agents/agent_error_history_test.go | 4 +- src/agents/agent_methods_test.go | 43 - src/agents/agent_reasoning_test.go | 92 - src/agents/agent_subagent_test.go | 125 -- src/agents/builder.go | 30 +- src/agents/builder_test.go | 26 +- src/agents/context/manager.go | 11 +- src/agents/context/manager_test.go | 66 - src/agents/execution/executor.go | 358 ++-- .../execution/executor_heartbeat_test.go | 21 + src/agents/handlers/system.go | 31 +- src/agents/handlers/system_test.go | 217 +-- src/agents/inject_system_prompt_test.go | 87 + src/agents/integration_test.go | 2 + src/agents/interfaces.go | 19 +- src/agents/interfaces_example_test.go | 19 +- src/agents/mocks/context_manager.go | 9 - src/agents/mocks/execution_engine.go | 7 +- src/agents/mocks/history_manager.go | 6 +- src/agents/prompts/builder.go | 38 +- src/agents/prompts/builder_test.go | 96 + src/agents/prompts/config.go | 6 +- src/agents/prompts/files/main/main-agent.md | 27 +- .../prompts/files/main/sub-agents-header.md | 8 - src/agents/prompts/files/main/tools-header.md | 8 +- .../prompts/files/system-agents/coding.md | 89 - src/agents/prompts/files/system-agents/git.md | 97 - .../prompts/files/system-agents/knowledge.md | 77 - src/agents/prompts/files/system-agents/os.md | 63 - .../prompts/files/system-agents/reasoning.md | 113 -- .../prompts/files/system-agents/vision.md | 57 - src/agents/prompts/files/system-agents/web.md | 140 -- src/agents/prompts/loader.go | 130 +- src/agents/system/constants.go | 46 - src/agents/system/loadFromMarkdown.go | 31 - src/agents/system/saCodingAgent.go | 16 - src/agents/system/saGitAgent.go | 11 - src/agents/system/saKnowledge.go | 14 - src/agents/system/saOsAgent.go | 15 - src/agents/system/saReasoning.go | 14 - src/agents/system/saVisionAgent.go | 12 - src/agents/system/saWebAgent.go | 14 - src/agents/system/systemAgentTemplate.go | 243 --- src/agents/systemAgentConstructors.go | 128 -- src/agents/systemHandlers.go | 119 -- src/agents/system_agents_test.go | 154 -- src/agents/testing_helpers.go | 81 - src/builder/README.md | 126 +- src/builder/agentBuilder.go | 247 +-- src/builder/agent_builder_spawn_test.go | 54 + src/builder/allplugins.go | 3 +- src/builder/llm.go | 2 +- src/builder/subagents.go | 107 -- src/builder/tools.go | 87 +- src/config.go | 36 +- src/core/agentContext.go | 85 +- src/core/agentContext_test.go | 23 +- src/core/interfaces.go | 79 +- src/core/tool.go | 75 +- src/core/tool_hooks_example_test.go | 8 +- src/heartbeatack/ack.go | 44 + src/heartbeatack/ack_test.go | 58 + src/history/manager.go | 14 +- src/history/manager_test.go | 15 +- src/llms/factory.go | 23 +- src/llms/interfaces.go | 5 +- src/llms/llmModels.go | 22 + src/llms/llms_test.go | 43 +- src/llms/models.go | 24 + src/llms/openai.go | 15 +- src/llms/openai_toolcall_test.go | 26 + src/persistence/interface.go | 3 +- src/persistence/json.go | 12 +- src/plugins/README.md | 55 +- src/plugins/brain/config.go | 80 + src/plugins/brain/dreaming.go | 611 ++++++ src/plugins/brain/dreaming_recategorize.go | 210 +++ src/plugins/brain/dreaming_test.go | 276 +++ src/plugins/brain/forget.go | 143 ++ src/plugins/brain/forget_artifacts.go | 81 + src/plugins/brain/forget_artifacts_test.go | 51 + src/plugins/brain/forget_test.go | 105 ++ .../{knowledge => brain}/graph_querier.go | 215 ++- src/plugins/brain/models.go | 138 ++ src/plugins/brain/node_scan.go | 73 + src/plugins/brain/plugin.go | 266 +++ src/plugins/brain/plugin_test.go | 1372 ++++++++++++++ src/plugins/brain/recall.go | 216 +++ src/plugins/brain/recall_test.go | 58 + src/plugins/brain/search.go | 68 + src/plugins/brain/storage.go | 1636 +++++++++++++++++ src/plugins/brain/storage_migrate.go | 167 ++ src/plugins/brain/tools.go | 376 ++++ src/plugins/heartbeat/active_hours.go | 43 + src/plugins/heartbeat/config.go | 76 + src/plugins/heartbeat/config_test.go | 66 + src/plugins/heartbeat/file_gate.go | 106 ++ src/plugins/heartbeat/plugin.go | 129 ++ src/plugins/heartbeat/plugin_test.go | 214 +++ src/plugins/knowledge/README.md | 567 ------ src/plugins/knowledge/models.go | 22 - src/plugins/knowledge/plugin.go | 341 ---- src/plugins/knowledge/plugin_test.go | 1340 -------------- src/plugins/knowledge/semantic.go | 25 - src/plugins/knowledge/storage.go | 1053 ----------- src/plugins/knowledge/tools.go | 297 --- src/plugins/logger/plugin.go | 9 +- src/plugins/procedures/adapt_skill.go | 225 +++ src/plugins/procedures/adapt_skill_test.go | 199 ++ .../learn-procedure/0/instructions.md | 2 +- src/plugins/procedures/plugin.go | 20 +- src/plugins/procedures/tools.go | 7 +- src/plugins/scheduler/plugin.go | 8 +- src/plugins/scheduler/tool.go | 4 +- src/plugins/todo/plugin.go | 14 +- src/plugins/todo/plugin_test.go | 4 +- src/plugins/todo/tools.go | 4 +- src/plugins/vault/fill_secret_args_test.go | 54 + src/plugins/vault/plugin.go | 53 +- src/plugins/vault/tools.go | 12 +- src/queue/queue.go | 6 + src/tools/api/tool.go | 4 +- src/tools/delegate/actDelegate.go | 99 - src/tools/delegate/tool.go | 80 - src/tools/delegate/types.go | 4 - src/tools/expand/actExpand.go | 46 +- src/tools/expand/expand_test.go | 110 +- src/tools/expand/findAgent.go | 45 - src/tools/expand/tool.go | 27 +- src/tools/expand/validate.go | 6 +- src/tools/fooTool.go | 30 - src/tools/fs/tool.go | 4 +- src/tools/git/tool.go | 4 +- src/tools/image/tool.go | 4 +- src/tools/instagram/tool.go | 4 +- src/tools/meta/actGetSubagents.go | 58 - src/tools/meta/handleMethod.go | 2 - src/tools/meta/tool.go | 16 +- src/tools/meta/tool_test.go | 6 - src/tools/meta/validate.go | 3 +- src/tools/postgres/actGetSchema.go | 129 -- src/tools/postgres/actGetTableSchema.go | 137 -- src/tools/postgres/actGetTables.go | 100 - src/tools/postgres/actInsert.go | 52 - src/tools/postgres/actSelect.go | 128 -- src/tools/postgres/actUpdate.go | 52 - src/tools/postgres/tool.go | 4 +- src/tools/spawn/tool.go | 105 ++ src/tools/telegram/actHealthStatus.go | 104 ++ src/tools/telegram/actRegisterToken.go | 74 + src/tools/telegram/actSetWebhook.go | 58 + src/tools/telegram/actStartNgrok.go | 206 +++ src/tools/telegram/tool.go | 123 ++ src/tools/telegram/tool_test.go | 457 +++++ src/tools/update/tool.go | 4 +- src/tools/web/actClick.go | 2 +- src/tools/web/actFetch.go | 72 + src/tools/web/actFill.go | 5 +- src/tools/web/actGetContent.go | 13 +- src/tools/web/actGetSnapshot.go | 70 + src/tools/web/actNavigate.go | 39 +- src/tools/web/actRefresh.go | 2 +- src/tools/web/actSaveContent.go | 2 +- src/tools/web/actUploadFile.go | 2 +- src/tools/web/actWebSearch.go | 182 +- src/tools/web/ax_snapshot.go | 440 +++++ src/tools/web/ax_snapshot_test.go | 104 ++ src/tools/web/browser.go | 37 +- src/tools/web/scripts.go | 10 + src/tools/web/scripts/interactive_tree.js | 68 +- src/tools/web/scripts/network_idle.js | 20 + src/tools/web/scripts/stealth_patch.js | 11 + src/tools/web/session_manager.go | 15 +- src/tools/web/tool.go | 78 +- src/tools/web/tool_test.go | 2 +- src/tools/web/utils.go | 56 +- src/tools/web/validate.go | 4 +- src/utils.go | 85 +- 242 files changed, 13332 insertions(+), 10141 deletions(-) delete mode 100644 cmd/localforge/SECURITY.md create mode 100644 cmd/localforge/src/providers/telegram_command_test.go create mode 100644 cmd/localforge/src/telegram_thread_store.go create mode 100644 cmd/localforge/src/telegram_thread_store_test.go delete mode 100644 src/agents/agentChat.go delete mode 100644 src/agents/agentSubagent.go delete mode 100644 src/agents/agent_reasoning_test.go delete mode 100644 src/agents/agent_subagent_test.go create mode 100644 src/agents/execution/executor_heartbeat_test.go create mode 100644 src/agents/inject_system_prompt_test.go create mode 100644 src/agents/prompts/builder_test.go delete mode 100644 src/agents/prompts/files/main/sub-agents-header.md delete mode 100644 src/agents/prompts/files/system-agents/coding.md delete mode 100644 src/agents/prompts/files/system-agents/git.md delete mode 100644 src/agents/prompts/files/system-agents/knowledge.md delete mode 100644 src/agents/prompts/files/system-agents/os.md delete mode 100644 src/agents/prompts/files/system-agents/reasoning.md delete mode 100644 src/agents/prompts/files/system-agents/vision.md delete mode 100644 src/agents/prompts/files/system-agents/web.md delete mode 100644 src/agents/system/constants.go delete mode 100644 src/agents/system/loadFromMarkdown.go delete mode 100644 src/agents/system/saCodingAgent.go delete mode 100644 src/agents/system/saGitAgent.go delete mode 100644 src/agents/system/saKnowledge.go delete mode 100644 src/agents/system/saOsAgent.go delete mode 100644 src/agents/system/saReasoning.go delete mode 100644 src/agents/system/saVisionAgent.go delete mode 100644 src/agents/system/saWebAgent.go delete mode 100644 src/agents/system/systemAgentTemplate.go delete mode 100644 src/agents/systemAgentConstructors.go delete mode 100644 src/agents/systemHandlers.go delete mode 100644 src/agents/system_agents_test.go delete mode 100644 src/agents/testing_helpers.go create mode 100644 src/builder/agent_builder_spawn_test.go delete mode 100644 src/builder/subagents.go create mode 100644 src/heartbeatack/ack.go create mode 100644 src/heartbeatack/ack_test.go create mode 100644 src/plugins/brain/config.go create mode 100644 src/plugins/brain/dreaming.go create mode 100644 src/plugins/brain/dreaming_recategorize.go create mode 100644 src/plugins/brain/dreaming_test.go create mode 100644 src/plugins/brain/forget.go create mode 100644 src/plugins/brain/forget_artifacts.go create mode 100644 src/plugins/brain/forget_artifacts_test.go create mode 100644 src/plugins/brain/forget_test.go rename src/plugins/{knowledge => brain}/graph_querier.go (72%) create mode 100644 src/plugins/brain/models.go create mode 100644 src/plugins/brain/node_scan.go create mode 100644 src/plugins/brain/plugin.go create mode 100644 src/plugins/brain/plugin_test.go create mode 100644 src/plugins/brain/recall.go create mode 100644 src/plugins/brain/recall_test.go create mode 100644 src/plugins/brain/search.go create mode 100644 src/plugins/brain/storage.go create mode 100644 src/plugins/brain/storage_migrate.go create mode 100644 src/plugins/brain/tools.go create mode 100644 src/plugins/heartbeat/active_hours.go create mode 100644 src/plugins/heartbeat/config.go create mode 100644 src/plugins/heartbeat/config_test.go create mode 100644 src/plugins/heartbeat/file_gate.go create mode 100644 src/plugins/heartbeat/plugin.go create mode 100644 src/plugins/heartbeat/plugin_test.go delete mode 100644 src/plugins/knowledge/README.md delete mode 100644 src/plugins/knowledge/models.go delete mode 100644 src/plugins/knowledge/plugin.go delete mode 100644 src/plugins/knowledge/plugin_test.go delete mode 100644 src/plugins/knowledge/semantic.go delete mode 100644 src/plugins/knowledge/storage.go delete mode 100644 src/plugins/knowledge/tools.go create mode 100644 src/plugins/procedures/adapt_skill.go create mode 100644 src/plugins/procedures/adapt_skill_test.go create mode 100644 src/plugins/vault/fill_secret_args_test.go delete mode 100644 src/tools/delegate/actDelegate.go delete mode 100644 src/tools/delegate/tool.go delete mode 100644 src/tools/delegate/types.go delete mode 100644 src/tools/expand/findAgent.go delete mode 100644 src/tools/fooTool.go delete mode 100644 src/tools/meta/actGetSubagents.go delete mode 100644 src/tools/postgres/actGetSchema.go delete mode 100644 src/tools/postgres/actGetTableSchema.go delete mode 100644 src/tools/postgres/actGetTables.go delete mode 100644 src/tools/postgres/actInsert.go delete mode 100644 src/tools/postgres/actSelect.go delete mode 100644 src/tools/postgres/actUpdate.go create mode 100644 src/tools/spawn/tool.go create mode 100644 src/tools/telegram/actHealthStatus.go create mode 100644 src/tools/telegram/actRegisterToken.go create mode 100644 src/tools/telegram/actSetWebhook.go create mode 100644 src/tools/telegram/actStartNgrok.go create mode 100644 src/tools/telegram/tool.go create mode 100644 src/tools/telegram/tool_test.go create mode 100644 src/tools/web/actFetch.go create mode 100644 src/tools/web/actGetSnapshot.go create mode 100644 src/tools/web/ax_snapshot.go create mode 100644 src/tools/web/ax_snapshot_test.go create mode 100644 src/tools/web/scripts/network_idle.js create mode 100644 src/tools/web/scripts/stealth_patch.js diff --git a/.gitignore b/.gitignore index c32b4e9..ce661db 100644 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,8 @@ UglyCatToysHeadOfMarketing/ ShelfCycle/ TEST/cmd/localforge/delegation-test.txt cmd/localforge/web/ -!.env.template \ No newline at end of file +!.env.template + +HEARTBEAT.md +cmd/localforge/brain/ +api_config/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 7952e00..67b9bd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,17 +9,18 @@ R6|update refs|on change,touches agents.md R7|propose refactor|module too big|>400 LOC per file R8|maintain patterns|existing,always R9|no XML tags|system prompts,tool descriptions|use [brackets] not +R10|no Legacy implementations. Ask to the user before implementing any legacy/retrocompatible code docs/agents R1|framework,purpose,principles,packages|docs/agents/overview.md R1|deps,structure,imports|docs/agents/architecture.md R2|builder,config,env|docs/agents/configuration.md +R2|brain plugin memory,dreaming|docs/agents/configuration.md#brain-plugin-yaml,src/plugins/README.md#brain-plugin,memorySpec.md R2|commands,build,test,lint|docs/agents/quickref.md R2|error handling,interfaces,conventions|docs/agents/patterns.md R2|formatting,naming,style|docs/agents/code-style.md R3|add tool,package,impl|docs/agents/how-to-tools.md -R3|system agent,template|docs/agents/how-to-system-agents.md R3|plugin,hook,tool provider|docs/agents/how-to-plugins.md R4|boundaries,permissions,ask-before|docs/agents/safety.md R4|debug,common issues|docs/agents/troubleshooting.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a918b1c..e0b8c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,45 @@ ## [Unreleased] +## [0.7.0] - 2026-04-05 + +Summary: working tree vs `HEAD` (v0.6.2) — ~192 files, large net deletion from removed plugins/subagent stack; new spawn, Telegram, heartbeat, and web automation pieces. + +### Breaking changes + +- Removed built-in **knowledge** plugin (`src/plugins/knowledge`) and dependent Localforge knowledge wiring; install template and examples drop `knowledge` and invalid postgres-without-URL blocks. +- Removed YAML **`subagents`**, **`delegate`** tool, **`meta` get_subagents**, and **`expand`** subagent discovery; ephemeral work is **`spawn_subagent`** with an explicit parent tool subset (`src/tools/spawn`). +- Removed markdown-loaded **system agents** implementation (`src/agents/system/*`, per-agent prompt files, `sub-agents-header.md`) and related constructors/tests; agent init, prompts, and handlers refactored. +- **Postgres** tool: removed discrete schema/table/CRUD action files; single validated **`query`** path remains (read/write mode, table and schema allowlists). +- Removed `cmd/localforge/SECURITY.md`. + +### Added + +- **`spawn_subagent`** tool: synchronous ephemeral subagent with configurable tools from the parent (`src/tools/spawn`). +- **`telegram`** tool for Localforge (ngrok, webhook secret, register/health); Telegram **`/new_conversation`** and **`telegram_thread_map.json`** via `telegram_thread_store`; provider/webhook updates and tests. +- **`heartbeat`** plugin and **`heartbeatack`**: interval synthetic inbox turns; executor and tests updated. +- **Web** tool: fetch and snapshot-style actions, accessibility snapshot helpers (`ax_snapshot`), `network_idle.js`, `stealth_patch.js`; browser session and search/navigation updates. +- **Procedures** plugin: **`adapt_skill`** (`adapt_skill.go` + tests); vault **`fill_secret_args`** tests; agent prompt-injection and prompts-builder tests; executor heartbeat test; Localforge Telegram command tests. + +### Changed + +- Agents: merged chat/subagent paths (`agentChat.go`, `agentSubagent.go` removed), executor, context manager, history, interfaces, builder, system handlers, mocks, and integration tests. +- **Expand** / **meta** tools simplified; **api**, **fs**, **git**, **image**, **instagram**, **update** touch-ups; **queue** and **`src/utils.go`** adjustments. +- **Core** / **persistence**: `agentContext`, tool hooks, JSON persistence. +- **LLMs**: factory, models, OpenAI and tool-call tests. +- **Localforge**: config, server, push and provider registry, static UI (HTML/CSS/JS). +- **Plugins**: logger, procedures, scheduler, todo, vault; **`src/plugins/README.md`**. +- **Scripts** (`install.sh`, `install-release.sh`), **`.gitignore`**, **`go.mod`** / **`go.sum`**. + +### Docs + +- Telegram threads and **`/new_conversation`** in [`docs/TOOLS.md`](docs/TOOLS.md#telegram-webhook-threads), [`README.md`](README.md), [`src/builder/README.md`](src/builder/README.md), [`cmd/localforge/config.example.yaml`](cmd/localforge/config.example.yaml). +- Tools/plugins tables and Quick Start (`ChatStream(ctx, …)`); removed stale `PUT /api/config/subagents` and YAML `subagents`; webhook links to Telegram tool; [`docs/agents/how-to-system-agents.md`](docs/agents/how-to-system-agents.md) no longer claims YAML `spawn_subagent` for Localforge; [`research.md`](research.md), [`plan.telegram-tool.md`](plan.telegram-tool.md), and install template aligned with removals. +- Brain / dreaming aligned with code ([`docs/agents/configuration.md`](docs/agents/configuration.md), [`src/plugins/README.md`](src/plugins/README.md), [`memorySpec.md`](memorySpec.md)); stale migration body removed from `memorySpec.md`. +- Wider refresh: `docs/AGENT_BUILDER.md`, `docs/CONFIG.md`, `docs/DISCOVERABLE.md`, `docs/EXPAND_TOOL.md`, `docs/FILE_STRUCTURE.md`, `docs/INTERFACES.md`, `docs/LOGGER.md`, `docs/TOOLS.md`, `docs/agents/*`, [`AGENTS.md`](AGENTS.md). + ## [0.6.0] - 2026-03-07 - [3ed2764](http://github.com/thinktwiceco/agent-forge/commit/3ed276486c13872649373f751ec2302d87a4eef4) - feat(agents!): parallel tool execution, context truncation, ModelInfo - -## [Unreleased] ## [0.4.14] - 2026-03-04 - [94495fe](http://github.com/thinktwiceco/agent-forge/commit/94495feb91cd2f90dc093b930e096ef7822c60f6) - chore(release): prepare release v0.4.14 (#38) - [ac67d85](http://github.com/thinktwiceco/agent-forge/commit/ac67d85724e2a6bbf59a4f8ea6eabf6fe9b592dd) - feat(plugins): add retention hook, bracket prompts, install templates, interactive_tree diff --git a/README.md b/README.md index 7304917..b6231e8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,4 @@ -
- Agent Forge Logo -
- -A Go framework and application for building AI agents with LLM integration, tool execution, and multi-agent orchestration. +A Go framework and application for building AI agents with LLM integration, tool execution, and optional ephemeral subagents via `spawn_subagent`. **Two ways to use agent-forge:** @@ -35,10 +31,10 @@ A Go framework and application for building AI agents with LLM integration, tool **Library:** - Simple agent creation with fluent API - Extensible tool system with custom tool support -- Multi-agent teams with delegation pattern +- Optional **spawn_subagent** tool for synchronous ephemeral sub-tasks with a subset of tools - Real-time streaming responses -- Multiple LLM providers (OpenAI, DeepSeek, TogetherAI) -- Built-in tools (filesystem, git, web, postgres, vector DB) +- Multiple LLM providers (OpenAI, DeepSeek, TogetherAI, OpenRouter) +- Built-in tools (filesystem, git, web, postgres, API client, Instagram Graph, update script, Telegram dev helper, vector DB when configured) - Plugin system for extending functionality - Conversation persistence and history management @@ -99,9 +95,9 @@ go get github.com/thinktwiceco/agent-forge src/ ├── agents/ # Agent creation and execution ├── builder/ # Config-driven agent builder -├── core/ # Core interfaces (Tool, Plugin, SubAgent) +├── core/ # Core interfaces (Tool, Plugin) ├── llms/ # LLM provider integrations -├── tools/ # Built-in tools (fs, git, web, postgres, api, vector) +├── tools/ # Built-in tools (fs, git, web, postgres, api, instagram, update, telegram, …) ├── plugins/ # Plugin system and built-in plugins ├── history/ # Conversation history management └── telemetry/ # Observability (tool exec, tokens, truncation) @@ -153,24 +149,23 @@ See [docs/AGENT_BUILDER.md](docs/AGENT_BUILDER.md) for comprehensive documentati | API | `api.NewApiTool(name, endpoints, authHook)` | HTTP API calls with auth support | | Vector | `vector.NewVectorTool(db, embeddings)` | Semantic search and indexing | -#### System Agents (Subagents) - -| Subagent | Constructor | Description | -|----------|------------|-------------| -| Reasoning | `agents.ReasoningAgent(llm)` | Analyzes questions, finds ambiguities | -| OS | `agents.OsAgent(llm, root)` | File system and OS tasks | -| Git | `agents.GitAgent(llm, workingDir)` | Git repository operations | -| Coding | `agents.CodingAgent(llm, root)` | Code generation and analysis | -| Web | `agents.WebAgent(llm, workingDir)` | Web navigation and automation | -| Vision | `agents.VisionAgent(llm, workingDir)` | Loads images and answers visual questions (requires vision-capable model, e.g. gpt-4o) | +#### Ephemeral subagents (`spawn_subagent`) -Add subagents with: +There is no fixed roster of system subagents or a **delegate** tool. To run an isolated sub-task with only some tools, enable **spawn_subagent** and call it from the model: ```go -agent.AddSystemAgent(agents.ReasoningAgent(llm)) -agent.AddSystemAgent(agents.WebAgent(llm, workingDir)) +agent, err := agents.NewBuilder(llm, "main"). + WithTools(fsTool, webTool). + WithSpawnSubagent(). + Build() ``` +- **Parameters:** `prompt` (task for the child), `tools` (names from the parent’s tool list). +- The child agent always gets **meta** and **expand**; it also gets the **todo** plugin if that plugin is registered in the binary. +- The call is **synchronous**: the tool returns the subagent’s final text when done. + +YAML (Localforge): set `agent.spawn_subagent: true` in `config.yaml`. See [docs/TOOLS.md](docs/TOOLS.md) for the full tool contract. + ### Adding Tools and Plugins #### Custom Tools @@ -195,7 +190,7 @@ agent.AddTools([]llms.Tool{tool}) #### Plugins -Plugins extend agents with tools, hooks, and system prompts. Available plugins: `logger`, `todo`, `vault`, `procedures`, `knowledge`. +Plugins extend agents with tools, hooks, and system prompts. Available plugins: `logger`, `todo`, `vault`, `procedures`, `brain`. ```yaml agent: @@ -270,7 +265,7 @@ func main() { }) // Chat with streaming - responseCh := agent.ChatStream("Hello! How can you help me?", "") + responseCh := agent.ChatStream(ctx, "Hello! How can you help me?", "") for chunk := range responseCh.Start() { if chunk.Content != "" { fmt.Print(chunk.Content) @@ -315,18 +310,16 @@ func main() { }, } - // Create agent with tools and subagents + // Create agent with tools agent, _ := agents.NewBuilder(llm, "MathAssistant"). WithSystemPrompt("You are a helpful math assistant."). WithTools(calcTool, fs.NewFsTool("/tmp")). WithPersistence("json"). AsMainAgent(). Build() - - agent.AddSystemAgent(agents.ReasoningAgent(llm)) - + // Chat with streaming - responseCh := agent.ChatStream("What is 15 multiplied by 23?", "") + responseCh := agent.ChatStream(ctx, "What is 15 multiplied by 23?", "") for chunk := range responseCh.Start() { if chunk.Content != "" { fmt.Print(chunk.Content) @@ -348,19 +341,11 @@ func main() { - Multi-agent orchestration - File uploads and knowledge integration -**Chat interface** — Real-time streaming, conversation history, active tasks: - -![Chat](assets/chat-main.png) - -**Settings** — Agent identity, sub-agents, plugins, and API keys: - -![Agent](assets/settings-agent.png) ![Sub-agents](assets/settings-subagents.png) - -![Plugins](assets/settings-plugins.png) ![API Keys](assets/settings-api-keys.png) +**Chat interface** — Real-time streaming, conversation history, and active tasks. -**Knowledge graph** — Node types, filters, and visualization: +**Settings** — Agent identity, plugins, and API keys. -![Knowledge Graph](assets/knowledge-graph.png) +**Knowledge graph** — Brain DB (`topic` -> `conversation` long-term memory graph), filters, and visualization on `/knowledge`. ### Installation @@ -369,7 +354,7 @@ func main() { ```bash curl -fsSL https://raw.githubusercontent.com/thinktwiceco/agent-forge/main/scripts/install-release.sh | bash -s -- ./my-agent cd my-agent -# Edit config.yaml (model, system_prompt, tools, subagents) +# Edit config.yaml (model, system_prompt, tools, plugins) # Add API keys to .env ./start.sh ``` @@ -419,6 +404,7 @@ Create a `.env` file: AF_OPENAI_API_KEY=your-key AF_DEEPSEEK_API_KEY=your-key AF_TOGETHERAI_API_KEY=your-key +AP_OPENROUTER_API_KEY=your-key AGENT_WORKING_DIR=/path/to/working/dir ``` @@ -468,7 +454,6 @@ GET /api/config # Get agent configuration PUT /api/config # Update agent config PUT /api/config/tools/:name # Update tool config PUT /api/config/plugins # Update plugins list -PUT /api/config/subagents # Update subagents GET /api/config/providers # Get push providers (Instagram, Telegram) PUT /api/config/providers # Update provider settings POST /api/agent/reload # Reload agent from config @@ -479,15 +464,18 @@ POST /api/agent/reload # Reload agent from config - `POST /api/upload` - Upload files - `GET /api/fs/list`, `GET /api/fs/read` - FS visualization - `GET /api/knowledge/graph`, `GET /api/knowledge/stats`, `GET /api/knowledge/node/:id` - Knowledge graph -- `POST /api/webhooks/:provider` - Webhook receiver (Instagram, Telegram) -- `POST /api/webhooks/:provider/sync` - Webhook sync +- `POST /api/webhooks/:provider` - Webhook receiver (Instagram, Telegram; set `WEBHOOK_SECRET_` for verification) +- `POST /api/webhooks/:provider/sync` - Webhook sync (SSE stream to caller) + +For Telegram local dev, enable the [telegram tool](docs/TOOLS.md#telegram-tool) in agent config (`tools: [{ name: telegram, port: "8080" }]`) or set `TELEGRAM_BOT_TOKEN` / tunnel manually. To start a **new** persisted chat thread from Telegram, send **`/new_conversation`**; see [Telegram webhook threads](docs/TOOLS.md#telegram-webhook-threads) in `docs/TOOLS.md`. #### Directory Structure ``` working_dir/ ├── data/ -│ └── conversations/ # Conversation history (JSON) +│ ├── conversations/ # Conversation history (JSON) +│ └── telegram_thread_map.json # Optional; Telegram chat → active thread id (see docs/TOOLS.md) ├── repos/ # Git tool cloned repos ├── web/ # Web tool saved content ├── vault/ # Vault plugin encrypted secrets @@ -506,7 +494,9 @@ working_dir/ | `working_dir` | string | No | Working directory for tools/plugins | - | | `persistence` | string | No | Conversation persistence | `""` (none) | | `tools` | array | No | List of tools to enable | `[]` | -| `subagents` | map | No | Map of subagent name to model | `{}` | +| `brain` | bool | No | Set `false` to disable the default brain plugin | omit (brain on) | +| `brain_plugin` | object | No | Dreaming schedule (`dream`, `dreamTime`) when brain is on | — | +| `heartbeat` | object | No | Proactive ticks; only used if `heartbeat` is in `plugins` | — | | `plugins` | array | No | List of plugin identifiers | `[]` | **Model Format:** `provider::model-name` @@ -518,50 +508,42 @@ working_dir/ | OpenAI | `openai` | `AF_OPENAI_API_KEY` | `gpt-5`, `gpt-5.1`, `gpt-5.2` | | DeepSeek | `deepseek` | `AF_DEEPSEEK_API_KEY` | `deepseek-chat`, `deepseek-reasoner` | | TogetherAI | `togetherai` | `AF_TOGETHERAI_API_KEY` | `meta-llama/Llama-3.2-3B-Instruct-Turbo`, `meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo`, `Qwen/Qwen2.5-7B-Instruct-Turbo`, `Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8`, `openai/gpt-oss-120b`, `zai-org/GLM-4.7`, `moonshotai/Kimi-K2.5` | +| OpenRouter | `openrouter` | `AP_OPENROUTER_API_KEY` | Any [OpenRouter](https://openrouter.ai/models) model id (e.g. `openai/gpt-4o`, `openai/gpt-4o-mini`); defaults in code use `openai/gpt-4o` | #### Tools Configuration -| Tool | Identifier | Required Parameters | Optional Parameters | -|------|-----------|-------------------|-------------------| -| File System | `fs` | `root` (string) | - | -| Git | `git` | `root` (string) | - | -| Web | `web` | `root` (string) | - | -| Postgres | `postgres` | `postgresURL` (string), `mode` (string), `allowedTables` (array) | `allowedSchemas` (array) | -| API | `api` | `endpoints` (array) | `onApiCallHook` (string) | -| Vector | `vector` | (requires vector-storage section) | - | +`agent.working_dir` is required for `fs`, `git`, `web`, `update`, and `api` (relative paths resolve under it). See [src/builder/README.md](src/builder/README.md#tools-configuration) and [docs/TOOLS.md](docs/TOOLS.md). + +| Tool | Identifier | Required YAML / env | Optional YAML | +|------|-----------|---------------------|---------------| +| File System | `fs` | `working_dir` | — | +| Git | `git` | `working_dir` | — | +| Web | `web` | `working_dir` | `headless` (bool) | +| Postgres | `postgres` | `postgresURL`, `mode`, `allowedTables` | `allowedSchemas` | +| API | `api` | `working_dir`, `config_folder` | — | +| Instagram | `instagram` | `INSTAGRAM_ACCESS_TOKEN` env | — | +| Update | `update` | `working_dir` | — | +| Telegram | `telegram` | — | `port` (default `8080`; ngrok tunnels this port) | +| Vector | `vector` | `vector-storage` section in YAML | — | **Example:** ```yaml -tools: - - name: fs - root: "/path/to/sandbox" - - name: postgres - postgresURL: "postgresql://user:pass@host:5432/db" - mode: "read" # or "write" - allowedTables: ["users", "products"] - allowedSchemas: ["public"] +agent: + working_dir: "/path/to/agent-data" + tools: + - name: fs + - name: web + - name: postgres + postgresURL: "postgresql://user:pass@host:5432/db" + mode: "read" + allowedTables: ["users", "products"] + allowedSchemas: ["public"] ``` -#### Subagents Configuration +#### Ephemeral subtasks (`spawn_subagent`) -| Subagent | Identifier | Requirements | Description | -|----------|-----------|--------------|-------------| -| Reasoning | `reasoning` | Model spec | Analyzes questions, finds ambiguities | -| OS | `os` | Model spec, working_dir | File system and OS operations | -| Git | `git` | Model spec, working_dir | Git repository operations | -| Web | `web` | Model spec, working_dir | Web navigation and automation | -| Coding | `coding` | Model spec, working_dir | Code generation and analysis | -| Vision | `vision` | Model spec (vision-capable, e.g. gpt-4o), working_dir | Loads images and answers visual questions | - -**Example:** - -```yaml -subagents: - reasoning: "deepseek::deepseek-reasoner" - web: "deepseek::deepseek-chat" - git: "deepseek::deepseek-chat" -``` +The old YAML `subagents` map and fixed system-agent roster are **removed**. For a short-lived child agent with a subset of tools, enable **spawn_subagent** in code (`agents.NewBuilder(...).WithSpawnSubagent().Build()`). See [docs/agents/how-to-system-agents.md](docs/agents/how-to-system-agents.md). #### Plugins @@ -571,7 +553,9 @@ subagents: | Todo | `todo` | Task management | None | | Vault | `vault` | Encrypted secret storage | Requires `VAULT_MASTER_KEY` env var | | Procedures | `procedures` | Multi-phase workflows | Auto-scans `procedures/` directory | -| Knowledge | `knowledge` | Knowledge graph integration | None | +| Scheduler | `scheduler` | Scheduled jobs | None | +| Heartbeat | `heartbeat` | Proactive timed agent turns | Optional `agent.heartbeat` YAML | +| Brain | *(default)* | Long-term memory graph; opt out with `brain: false` | Optional `agent.brain_plugin` for dreaming | **Example:** @@ -614,6 +598,7 @@ vector-storage: | `AF_OPENAI_API_KEY` | OpenAI API key | If using OpenAI | - | | `AF_DEEPSEEK_API_KEY` | DeepSeek API key | If using DeepSeek | - | | `AF_TOGETHERAI_API_KEY` | TogetherAI API key | If using TogetherAI | - | +| `AP_OPENROUTER_API_KEY` | OpenRouter API key | If using OpenRouter (`openrouter::...`) | - | | `AF_LOG_LEVEL` | Log level | No | `INFO` | | `AF_LOG_FILE` | Log file path | No | - | | `VAULT_MASTER_KEY` | Vault encryption key (base64-encoded 32 bytes) | If using vault plugin | - | diff --git a/VERSION b/VERSION index b616048..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.2 +0.7.0 diff --git a/cmd/localforge/SECURITY.md b/cmd/localforge/SECURITY.md deleted file mode 100644 index 705ec51..0000000 --- a/cmd/localforge/SECURITY.md +++ /dev/null @@ -1,303 +0,0 @@ -# Authentication & Security — Public Deployment Plan - -## Current State - -The server is a Gin-based HTTP API with **no authentication**. It is designed for local/trusted-network use. The following risks exist if exposed to a public network: - -| Risk | Location | -|---|---| -| All endpoints unauthenticated | Every route | -| API keys sent + stored over plain HTTP | `PUT /api/config/providers` | -| Postgres connection string returned in plaintext | `GET /api/config` | -| Config rewrite without auth (changes `workingDir`) | `PUT /api/config` | -| Arbitrary file write via upload | `POST /api/upload` | -| Wildcard CORS (`*`) | `corsMiddleware()` | -| Webhook signature verification silently skipped | `handlers_providers.go` | -| Symlink escape possible in FS endpoints | `GET /api/fs/*` | -| No TLS | `server.go` | - ---- - -## Design Principles - -1. **Backward compatible** — Auth is `disabled` by default. Local usage is unchanged. -2. **No external dependencies** — No Redis, no LDAP, no OAuth provider required. Self-contained Go. -3. **Defense in depth** — TLS + Auth + Filesystem sandbox + Secret masking, each independently valuable. -4. **Single-user first** — Personal agent tool. One admin user configured via env vars (not `config.yaml`, which is committed to git). - ---- - -## Layer 1 — TLS - -Add TLS support to `server.go`. Two modes: - -**Manual certs** — operator provides files (e.g. from certbot): - -```bash -AF_TLS_ENABLED=true -AF_TLS_CERT_FILE=/etc/letsencrypt/live/myagent.example.com/fullchain.pem -AF_TLS_KEY_FILE=/etc/letsencrypt/live/myagent.example.com/privkey.pem -``` - -**Auto TLS** — uses `golang.org/x/crypto/acme/autocert` to fetch + renew Let's Encrypt certificates automatically: - -```bash -AF_TLS_AUTO_DOMAIN=myagent.example.com -AF_TLS_ACME_EMAIL=me@example.com -``` - -When TLS is enabled, a plain HTTP listener on port 80 responds only with an HTTP→HTTPS redirect. All other traffic is rejected. - ---- - -## Layer 2 — Authentication - -### Mechanism: Session Cookie + Bearer Token - -Two parallel auth paths to support browser UI and programmatic/API access: - -``` -Request - ├── Has Authorization: Bearer ? - │ └── Hash token → compare SHA-256 to stored hash → ALLOW / 401 - └── Has session cookie? - └── Lookup session in memory store → ALLOW / redirect to /login -``` - -### Configuration (env vars, not config.yaml) - -```bash -AUTH_USERNAME=admin -AUTH_PASSWORD_HASH=$2a$12$... # bcrypt hash of your password -AUTH_SESSION_TTL=24h # optional, default 24h -AUTH_COOKIE_NAME=localforge_session # optional -AUTH_COOKIE_SECURE=true # optional; auto-detected from TLS/X-Forwarded-Proto -``` - -To generate a bcrypt hash for your password: - -```bash -# Using Go (from the project root): -go run - <<'GO' -package main -import ("fmt"; "golang.org/x/crypto/bcrypt") -func main() { - h, _ := bcrypt.GenerateFromPassword([]byte("your-password"), bcrypt.DefaultCost) - fmt.Println(string(h)) -} -GO -``` - -### Session Store - -Pure in-memory, no external state. Sessions are keyed by `SHA-256(token)` — the raw token is never stored on the server. - -```go -// cmd/localforge/src/auth/session_store.go - -type Session struct { - TokenHash string // SHA-256 of the token presented to client - UserID string - CreatedAt time.Time - ExpiresAt time.Time - IPAddr string // logged only, not enforced (VPN/mobile) -} - -type SessionStore struct { - mu sync.RWMutex - sessions map[string]*Session -} -``` - -A background goroutine sweeps expired sessions every hour. Sessions do not survive a server restart (by design — forces re-login). - -### Auth Endpoints (exempt from middleware) - -``` -POST /api/auth/login { username, password } → Set-Cookie: session=; HttpOnly; Secure; SameSite=Strict -POST /api/auth/logout → Delete cookie, remove session from store -GET /api/auth/me → { username, expiresAt } or 401 -GET /login → Serves login.html -``` - -### Auth Middleware - -Applied to all routes except `/login`, `/api/auth/login`, and `/api/auth/logout`. - -```go -// cmd/localforge/src/auth/middleware.go - -func AuthMiddleware(store *SessionStore, cfg *AuthConfig) gin.HandlerFunc { - return func(c *gin.Context) { - if !cfg.Enabled { - c.Next() - return - } - // 1. Bearer token (API / programmatic) - if token := extractBearer(c); token != "" { - if verifySHA256(token, cfg.APIKeyHash) { - c.Set("auth_method", "api_key") - c.Next() - return - } - c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"}) - return - } - // 2. Session cookie (browser) - if cookie, err := c.Cookie("session"); err == nil { - if session := store.Get(cookie); session != nil { - c.Set("session", session) - c.Next() - return - } - } - // 3. Reject - if isAPIPath(c.Request.URL.Path) { - c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"}) - } else { - c.Redirect(302, "/login?next="+url.QueryEscape(c.Request.URL.Path)) - } - } -} -``` - -### Login Rate Limiting - -Simple per-IP in-memory counter on `POST /api/auth/login`: - -- 5 failed attempts per IP per 15-minute window → `429 Too Many Requests` -- Counter resets on successful login -- No library required — ~50 lines of Go - ---- - -## Layer 3 — CORS Hardening - -Replace the wildcard CORS with an explicit allowlist when auth is enabled: - -```bash -AF_CORS_ORIGINS=https://myagent.example.com,https://admin.example.com -``` - -When `AF_AUTH_ENABLED=false` the current wildcard behavior is preserved for local dev. When auth is enabled and `AF_CORS_ORIGINS` is unset, CORS is denied entirely (same-origin only). - ---- - -## Layer 4 — Filesystem Sandbox - -The existing traversal check (`strings.HasPrefix(absPath, workingDir)`) is correct but has one gap: **symlinks can escape the sandbox**. A symlink inside `workingDir` pointing to `/etc/passwd` passes the prefix check before resolution. - -**Hardened helper** (replaces the current inline check): - -```go -// cmd/localforge/src/auth/safe_path.go - -func SafePath(workingDir, requestedPath string) (string, error) { - joined := filepath.Join(workingDir, filepath.Clean("/"+requestedPath)) - // Resolve symlinks BEFORE the sandbox check - resolved, err := filepath.EvalSymlinks(joined) - if err != nil { - return "", err - } - // Ensure trailing separator to prevent /workingdir-evil prefix match - sandboxPrefix := workingDir + string(os.PathSeparator) - if !strings.HasPrefix(resolved+string(os.PathSeparator), sandboxPrefix) { - return "", ErrPathEscape - } - return resolved, nil -} -``` - -Applied to: `GET /api/fs/list`, `GET /api/fs/read`. - -**Upload hardening:** - -- Uploaded files must resolve under `workingDir/uploaded/` specifically (not just `workingDir`) -- Add a configurable max file size (`AF_UPLOAD_MAX_BYTES`, default 50 MB) -- Block writes to protected filenames regardless of upload path: `config.yaml`, `.env`, `*.db`, `*.key` - ---- - -## Layer 5 — Secret Masking - -Currently `GET /api/config` returns `PostgresURL` verbatim. Fix: always redact in API responses. - -**Masking rules:** - -| Field | Browser session | Bearer API key client | -|---|---|---| -| `postgresURL` | `postgresql://***:***@host/db` (host only) | Full value | -| Provider API keys | `••••••••1234` (last 4) | Full value | -| System prompt | Returned as-is | Returned as-is | - -The distinction: a Bearer token client is a trusted machine caller (e.g. CI pipeline managing config). A browser session may be vulnerable to XSS, so secrets are masked there. - ---- - -## Layer 6 — Webhook Secret Enforcement - -Currently, if `WEBHOOK_SECRET_` is absent, signature verification is **silently skipped**. When `AF_AUTH_ENABLED=true`, make the webhook secret **required**: - -```go -if cfg.AuthEnabled && secret == "" { - c.AbortWithStatusJSON(403, gin.H{ - "error": "webhook secret not configured — set WEBHOOK_SECRET_", - }) - return -} -``` - ---- - -## Files to Create / Modify - -### New files - -| File | Purpose | -|---|---| -| `cmd/localforge/src/auth/session_store.go` | In-memory session CRUD with expiry sweep | -| `cmd/localforge/src/auth/middleware.go` | Gin auth middleware (session + bearer) | -| `cmd/localforge/src/auth/login_limiter.go` | Per-IP brute-force rate limiter | -| `cmd/localforge/src/auth/safe_path.go` | Symlink-aware filesystem sandbox helper | -| `cmd/localforge/src/handlers_auth.go` | `/api/auth/*` and `/login` handlers | -| `cmd/localforge/src/static/login.html` | Login page (matches existing UI style) | - -### Modified files - -| File | Change | -|---|---| -| `server.go` | Wire auth middleware; TLS startup; CORS allowlist; register `/api/auth/*` and `/login` routes | -| `config_manager.go` | Add `AuthConfig` struct; load from env vars at startup | -| `types.go` | Add `AuthConfig`, `Session`, `SessionStore` types | -| `handlers_config.go` | Mask `PostgresURL`; full reveal only for Bearer clients | -| `handlers_providers.go` | Same masking logic; enforce webhook secret requirement | - ---- - -## Deployment Reference - -Minimum `.env` for public deployment: - -```bash -# Auth (required to enable; auth is disabled if either is missing) -AUTH_USERNAME=admin -AUTH_PASSWORD_HASH=$2a$12$... # bcrypt hash of your password -AUTH_SESSION_TTL=24h # optional, default 24h -AUTH_COOKIE_SECURE=true # set if not behind a TLS-terminating proxy - -# Webhook secrets (per-provider; optional but strongly recommended) -WEBHOOK_SECRET_GITHUB=... -WEBHOOK_SECRET_STRIPE=... -WEBHOOK_SECRET_TELEGRAM=... -``` - -Local usage: omit `AUTH_USERNAME` and `AUTH_PASSWORD_HASH` → auth disabled, all routes open. - ---- - -## Out of Scope (future work) - -- Multi-user support with per-user conversation isolation -- OAuth2 / OIDC login (Google, GitHub) -- Audit log (who called what, when) -- Read-only role (view conversations/knowledge, no config writes) diff --git a/cmd/localforge/config.example.yaml b/cmd/localforge/config.example.yaml index 280ac93..7409c70 100644 --- a/cmd/localforge/config.example.yaml +++ b/cmd/localforge/config.example.yaml @@ -5,13 +5,39 @@ agent: system_prompt: | You are a helpful assistant. Customize this prompt for your use case. model: "togetherai::moonshotai/Kimi-K2.5" + # OpenRouter: set AP_OPENROUTER_API_KEY in .env, then e.g. model: "openrouter::openai/gpt-4o" working_dir: "${AGENT_WORKING_DIR}" persistence: "json" tools: - name: fs - name: web - subagents: - reasoning: "deepseek::deepseek-reasoner" + # headless: true # default when omitted: WEB_TOOL_HEADLESS env, else true; false = visible browser + # Telegram bot setup helper (register token + start ngrok tunnel): + # - name: telegram + # port: "8080" # local server port ngrok will tunnel (default: 8080) + # Set WEBHOOK_SECRET_TELEGRAM in .env before start_ngrok/set_webhook; Localforge requires it for Telegram webhooks. + # In Telegram, send /new_conversation to start a fresh JSON thread (map stored under data/telegram_thread_map.json). + # Telegram access control: comma-separated chat/user IDs allowed to talk to the agent. + # Leave unset (or empty) to allow all senders. Set TELEGRAM_ALLOWED_CHAT_IDS in your .env. + # To find your chat ID: send any message to the bot and check the Localforge debug logs. + # Optional: ephemeral sub-tasks with spawn_subagent (subset of parent tools): + # spawn_subagent: true + # brain is loaded by default (no need to list it here). + # To disable: uncomment the line below. + # brain: false + # Optional dreaming (distil conversations → brain/persistence/): + # brain_plugin: + # dream: "on" # or "off" + # dreamTime: "02:00" # local time HH:MM when RunPending runs daily plugins: - "todo" - "vault" + # - "heartbeat" + # Optional: only applies when "heartbeat" is listed under plugins. + # heartbeat: + # every: "30m" + # ack_max_chars: 300 + # active_hours: + # start: "08:00" + # end: "22:00" + # timezone: "America/New_York" diff --git a/cmd/localforge/src/agent_manager.go b/cmd/localforge/src/agent_manager.go index 61b110e..0cf190f 100644 --- a/cmd/localforge/src/agent_manager.go +++ b/cmd/localforge/src/agent_manager.go @@ -28,10 +28,11 @@ import ( ) type AgentManager struct { - mu sync.RWMutex - agent *agents.Agent - configMgr *ConfigManager - chunkRouter func(chatId string, chunk core.ExtendedChunkResponse) + mu sync.RWMutex + agent *agents.Agent + configMgr *ConfigManager + chunkRouter func(chatId string, chunk core.ExtendedChunkResponse) + turnCompleteRouter func(chatId string, fullContent string) } func NewAgentManager(configMgr *ConfigManager) (*AgentManager, error) { @@ -64,6 +65,18 @@ func (am *AgentManager) SetChunkRouter(fn func(chatId string, chunk core.Extende } } +// SetTurnCompleteRouter sets the callback invoked after each background drain turn completes. +// The router is preserved across Reload calls so it is applied to newly built agents. +func (am *AgentManager) SetTurnCompleteRouter(fn func(chatId string, fullContent string)) { + am.mu.Lock() + am.turnCompleteRouter = fn + agent := am.agent + am.mu.Unlock() + if agent != nil { + agent.SetTurnCompleteRouter(fn) + } +} + func (am *AgentManager) GetAgentName() string { cfg := am.configMgr.GetConfig() if cfg.Agent.Name != "" { @@ -86,6 +99,9 @@ func (am *AgentManager) Reload() error { if am.chunkRouter != nil { agent.SetChunkRouter(am.chunkRouter) } + if am.turnCompleteRouter != nil { + agent.SetTurnCompleteRouter(am.turnCompleteRouter) + } am.mu.Unlock() return nil } @@ -94,7 +110,7 @@ func (am *AgentManager) buildAgent() (*agents.Agent, error) { // Use the already-loaded config with interpolated environment variables cfg := am.configMgr.GetConfig() - agentBuilder, err := builder.NewAgentBuilderFromConfigStruct(cfg) + agentFactory, err := builder.NewAgentFactoryFromConfigStruct(cfg) if err != nil { return nil, fmt.Errorf("create agent builder: %w", err) } @@ -108,13 +124,13 @@ func (am *AgentManager) buildAgent() (*agents.Agent, error) { // Only build and set vector components if they're configured if err := vectorBuilder.Build(); err == nil { - agentBuilder.SetVectorDB(vectorBuilder.GetVectorDB()) - agentBuilder.SetEmbeddingGenerator(vectorBuilder.GetEmbeddingGenerator()) + agentFactory.SetVectorDB(vectorBuilder.GetVectorDB()) + agentFactory.SetEmbeddingGenerator(vectorBuilder.GetEmbeddingGenerator()) } // Silently ignore vector build errors - vector DB is optional } - agent, err := agentBuilder.Build() + agent, err := agentFactory.Build() if err != nil { return nil, fmt.Errorf("build agent: %w", err) } diff --git a/cmd/localforge/src/config_manager.go b/cmd/localforge/src/config_manager.go index 9256043..717ae33 100644 --- a/cmd/localforge/src/config_manager.go +++ b/cmd/localforge/src/config_manager.go @@ -285,34 +285,6 @@ func (cm *ConfigManager) UpdatePlugins(plugins []string) error { return cm.save() } -// UpdateSubagents replaces the subagents map in config.yaml. -func (cm *ConfigManager) UpdateSubagents(subagents map[string]string) error { - cm.mu.Lock() - defer cm.mu.Unlock() - - err := cm.patchYAMLNode(func(root *yaml.Node) error { - n, err := yamlMappingNode(root, "agent", "subagents") - if err != nil { - return err - } - n.Kind = yaml.MappingNode - n.Tag = "!!map" - n.Value = "" - n.Content = nil - for role, model := range subagents { - n.Content = append(n.Content, - &yaml.Node{Kind: yaml.ScalarNode, Value: role, Tag: "!!str"}, - &yaml.Node{Kind: yaml.ScalarNode, Value: model, Tag: "!!str"}, - ) - } - return nil - }) - if err != nil { - return err - } - return cm.save() -} - // UpdateToolConfig updates settings for a named tool in config.yaml. func (cm *ConfigManager) UpdateToolConfig(toolName string, update UpdateToolConfigRequest) error { if update.PostgresURL == nil && diff --git a/cmd/localforge/src/config_manager_test.go b/cmd/localforge/src/config_manager_test.go index 50148c3..7521a9c 100644 --- a/cmd/localforge/src/config_manager_test.go +++ b/cmd/localforge/src/config_manager_test.go @@ -130,7 +130,8 @@ func TestConfigManagerLoadWithInterpolation(t *testing.T) { } postgresTool := cfg.Agent.Tools[0] - if postgresTool.PostgresURL != testDBURL { - t.Errorf("PostgresURL = %v, want %v", postgresTool.PostgresURL, testDBURL) + postgresURL, _ := postgresTool.Params["postgresURL"].(string) + if postgresURL != testDBURL { + t.Errorf("PostgresURL = %v, want %v", postgresURL, testDBURL) } } diff --git a/cmd/localforge/src/handlers_config.go b/cmd/localforge/src/handlers_config.go index 128dfd1..e94ecbe 100644 --- a/cmd/localforge/src/handlers_config.go +++ b/cmd/localforge/src/handlers_config.go @@ -6,26 +6,51 @@ import ( "github.com/gin-gonic/gin" ) -// handleGetConfig returns the full agent configuration including subagents and plugins. +// handleGetConfig returns the full agent configuration including plugins. func (s *Server) handleGetConfig(c *gin.Context) { cfg := s.configMgr.GetConfig() tools := make([]ToolConfigResponse, 0, len(cfg.Agent.Tools)) for _, tool := range cfg.Agent.Tools { + var postgresURL, mode string + if v, ok := tool.Params["postgresURL"].(string); ok { + postgresURL = v + } + if v, ok := tool.Params["mode"].(string); ok { + mode = v + } + + var allowedTables, allowedSchemas []string + if tables, ok := tool.Params["allowedTables"].([]any); ok { + for _, table := range tables { + if s, ok := table.(string); ok { + allowedTables = append(allowedTables, s) + } + } + } + if schemas, ok := tool.Params["allowedSchemas"].([]any); ok { + for _, schema := range schemas { + if s, ok := schema.(string); ok { + allowedSchemas = append(allowedSchemas, s) + } + } + } + + var headless *bool + if h, ok := tool.Params["headless"].(bool); ok { + headless = &h + } + tools = append(tools, ToolConfigResponse{ Name: tool.Name, - PostgresURL: tool.PostgresURL, - Mode: tool.Mode, - AllowedTables: tool.AllowedTables, - AllowedSchemas: tool.AllowedSchemas, + PostgresURL: postgresURL, + Mode: mode, + AllowedTables: allowedTables, + AllowedSchemas: allowedSchemas, + Headless: headless, }) } - subagents := make(map[string]string, len(cfg.Agent.Subagents)) - for role, model := range cfg.Agent.Subagents { - subagents[string(role)] = string(model) - } - plugins := cfg.Agent.Plugins if plugins == nil { plugins = []string{} @@ -38,7 +63,6 @@ func (s *Server) handleGetConfig(c *gin.Context) { WorkingDir: cfg.Agent.WorkingDir, Persistence: cfg.Agent.Persistence, Tools: tools, - Subagents: subagents, Plugins: plugins, } c.JSON(http.StatusOK, response) @@ -81,21 +105,6 @@ func (s *Server) handleUpdatePlugins(c *gin.Context) { c.Status(http.StatusNoContent) } -// handleUpdateSubagents replaces the full subagents map in config.yaml. -func (s *Server) handleUpdateSubagents(c *gin.Context) { - var req UpdateSubagentsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) - return - } - - if err := s.configMgr.UpdateSubagents(req.Subagents); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Status(http.StatusNoContent) -} - // handleUpdateToolConfig updates settings for a single named tool. func (s *Server) handleUpdateToolConfig(c *gin.Context) { toolName := c.Param("toolName") diff --git a/cmd/localforge/src/handlers_knowledge.go b/cmd/localforge/src/handlers_knowledge.go index b5eae5d..367cca0 100644 --- a/cmd/localforge/src/handlers_knowledge.go +++ b/cmd/localforge/src/handlers_knowledge.go @@ -9,17 +9,20 @@ import ( "github.com/gin-gonic/gin" ) -type KnowledgeNode struct { - ID string `json:"id"` - Type string `json:"type"` - Content string `json:"content"` - EmbeddingID string `json:"embedding_id,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` +type BrainNode struct { + ID string `json:"id"` + Type string `json:"type"` + Content string `json:"content"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + DistillationReason string `json:"distillation_reason,omitempty"` + SearchText string `json:"search_text,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } -type KnowledgeEdge struct { +type BrainEdge struct { ID string `json:"id"` FromNodeID string `json:"from_node_id"` ToNodeID string `json:"to_node_id"` @@ -29,7 +32,7 @@ type KnowledgeEdge struct { CreatedAt string `json:"created_at"` } -type KnowledgeType struct { +type BrainType struct { ID string `json:"id"` Category string `json:"category"` Name string `json:"name"` @@ -39,10 +42,10 @@ type KnowledgeType struct { } type GraphResponse struct { - Nodes []KnowledgeNode `json:"nodes"` - Edges []KnowledgeEdge `json:"edges"` - Stats GraphStats `json:"stats"` - Types []KnowledgeType `json:"types"` + Nodes []BrainNode `json:"nodes"` + Edges []BrainEdge `json:"edges"` + Stats GraphStats `json:"stats"` + Types []BrainType `json:"types"` } type GraphStats struct { @@ -52,22 +55,22 @@ type GraphStats struct { } type NodeDetailResponse struct { - Node KnowledgeNode `json:"node"` + Node BrainNode `json:"node"` Neighbors GraphResponse `json:"neighbors"` } -// knowledgeDB returns the shared DB connection or an error if it was never opened. -func (s *Server) knowledgeDBConn() (*sql.DB, error) { - if s.knowledgeDB != nil { - return s.knowledgeDB, nil +// brainDBConn returns the shared DB connection or an error if it was never opened. +func (s *Server) brainDBConn() (*sql.DB, error) { + if s.brainDB != nil { + return s.brainDB, nil } - return nil, fmt.Errorf("knowledge database is not available") + return nil, fmt.Errorf("brain database is not available") } func (s *Server) handleGetKnowledgeGraph(c *gin.Context) { - db, err := s.knowledgeDBConn() + db, err := s.brainDBConn() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "knowledge database not available"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "brain database not available"}) return } @@ -109,9 +112,9 @@ func (s *Server) handleGetKnowledgeGraph(c *gin.Context) { } func (s *Server) handleGetKnowledgeStats(c *gin.Context) { - db, err := s.knowledgeDBConn() + db, err := s.brainDBConn() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "knowledge database not available"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "brain database not available"}) return } @@ -131,9 +134,9 @@ func (s *Server) handleGetKnowledgeNode(c *gin.Context) { return } - db, err := s.knowledgeDBConn() + db, err := s.brainDBConn() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "knowledge database not available"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "brain database not available"}) return } @@ -168,11 +171,12 @@ func (s *Server) handleGetKnowledgeNode(c *gin.Context) { c.JSON(http.StatusOK, response) } -func (s *Server) queryNodes(db *sql.DB, typeFilter, limit string) ([]KnowledgeNode, error) { +func (s *Server) queryNodes(db *sql.DB, typeFilter, limit string) ([]BrainNode, error) { query := ` - SELECT id, type, content, COALESCE(embedding_id, ''), COALESCE(metadata, '{}'), + SELECT id, type, content, COALESCE(metadata, '{}'), + title, description, distillation_reason, COALESCE(search_text, ''), created_at, updated_at - FROM knowledge_nodes + FROM brain_nodes ` args := []interface{}{} @@ -190,14 +194,16 @@ func (s *Server) queryNodes(db *sql.DB, typeFilter, limit string) ([]KnowledgeNo } defer func() { _ = rows.Close() }() - var nodes []KnowledgeNode + var nodes []BrainNode for rows.Next() { - var node KnowledgeNode + var node BrainNode var metadataJSON string + var title, description, distillationReason, searchText sql.NullString err := rows.Scan( - &node.ID, &node.Type, &node.Content, &node.EmbeddingID, - &metadataJSON, &node.CreatedAt, &node.UpdatedAt, + &node.ID, &node.Type, &node.Content, + &metadataJSON, &title, &description, &distillationReason, &searchText, + &node.CreatedAt, &node.UpdatedAt, ) if err != nil { return nil, err @@ -206,6 +212,18 @@ func (s *Server) queryNodes(db *sql.DB, typeFilter, limit string) ([]KnowledgeNo if metadataJSON != "" && metadataJSON != "{}" { _ = json.Unmarshal([]byte(metadataJSON), &node.Metadata) } + if title.Valid { + node.Title = title.String + } + if description.Valid { + node.Description = description.String + } + if distillationReason.Valid { + node.DistillationReason = distillationReason.String + } + if searchText.Valid { + node.SearchText = searchText.String + } nodes = append(nodes, node) } @@ -213,11 +231,11 @@ func (s *Server) queryNodes(db *sql.DB, typeFilter, limit string) ([]KnowledgeNo return nodes, rows.Err() } -func (s *Server) queryEdges(db *sql.DB, nodeIDFilter string) ([]KnowledgeEdge, error) { +func (s *Server) queryEdges(db *sql.DB, nodeIDFilter string) ([]BrainEdge, error) { query := ` SELECT id, from_node_id, to_node_id, relation_type, weight, COALESCE(metadata, '{}'), created_at - FROM knowledge_edges + FROM brain_edges ` args := []interface{}{} @@ -232,9 +250,9 @@ func (s *Server) queryEdges(db *sql.DB, nodeIDFilter string) ([]KnowledgeEdge, e } defer func() { _ = rows.Close() }() - var edges []KnowledgeEdge + var edges []BrainEdge for rows.Next() { - var edge KnowledgeEdge + var edge BrainEdge var metadataJSON string err := rows.Scan( @@ -255,10 +273,10 @@ func (s *Server) queryEdges(db *sql.DB, nodeIDFilter string) ([]KnowledgeEdge, e return edges, rows.Err() } -func (s *Server) queryTypes(db *sql.DB) ([]KnowledgeType, error) { +func (s *Server) queryTypes(db *sql.DB) ([]BrainType, error) { query := ` SELECT id, category, name, description, COALESCE(metadata, '{}'), created_at - FROM knowledge_types + FROM brain_types ORDER BY category, name ` @@ -268,9 +286,9 @@ func (s *Server) queryTypes(db *sql.DB) ([]KnowledgeType, error) { } defer func() { _ = rows.Close() }() - var types []KnowledgeType + var types []BrainType for rows.Next() { - var t KnowledgeType + var t BrainType var metadataJSON string err := rows.Scan( @@ -294,19 +312,19 @@ func (s *Server) queryTypes(db *sql.DB) ([]KnowledgeType, error) { func (s *Server) getGraphStats(db *sql.DB) (GraphStats, error) { var stats GraphStats - err := db.QueryRow("SELECT COUNT(*) FROM knowledge_nodes").Scan(&stats.TotalNodes) + err := db.QueryRow("SELECT COUNT(*) FROM brain_nodes").Scan(&stats.TotalNodes) if err != nil { return stats, err } - err = db.QueryRow("SELECT COUNT(*) FROM knowledge_edges").Scan(&stats.TotalEdges) + err = db.QueryRow("SELECT COUNT(*) FROM brain_edges").Scan(&stats.TotalEdges) if err != nil { return stats, err } rows, err := db.Query(` SELECT type, COUNT(*) as count - FROM knowledge_nodes + FROM brain_nodes GROUP BY type `) if err != nil { @@ -327,20 +345,23 @@ func (s *Server) getGraphStats(db *sql.DB) (GraphStats, error) { return stats, rows.Err() } -func (s *Server) getNodeByID(db *sql.DB, nodeID string) (*KnowledgeNode, error) { +func (s *Server) getNodeByID(db *sql.DB, nodeID string) (*BrainNode, error) { query := ` - SELECT id, type, content, COALESCE(embedding_id, ''), COALESCE(metadata, '{}'), + SELECT id, type, content, COALESCE(metadata, '{}'), + title, description, distillation_reason, COALESCE(search_text, ''), created_at, updated_at - FROM knowledge_nodes + FROM brain_nodes WHERE id = ? ` - var node KnowledgeNode + var node BrainNode var metadataJSON string + var title, description, distillationReason, searchText sql.NullString err := db.QueryRow(query, nodeID).Scan( - &node.ID, &node.Type, &node.Content, &node.EmbeddingID, - &metadataJSON, &node.CreatedAt, &node.UpdatedAt, + &node.ID, &node.Type, &node.Content, + &metadataJSON, &title, &description, &distillationReason, &searchText, + &node.CreatedAt, &node.UpdatedAt, ) if err != nil { return nil, err @@ -349,33 +370,43 @@ func (s *Server) getNodeByID(db *sql.DB, nodeID string) (*KnowledgeNode, error) if metadataJSON != "" && metadataJSON != "{}" { _ = json.Unmarshal([]byte(metadataJSON), &node.Metadata) } + if title.Valid { + node.Title = title.String + } + if description.Valid { + node.Description = description.String + } + if distillationReason.Valid { + node.DistillationReason = distillationReason.String + } + if searchText.Valid { + node.SearchText = searchText.String + } return &node, nil } -func (s *Server) getNodeNeighborhood(db *sql.DB, nodeID string, depth int) ([]KnowledgeNode, []KnowledgeEdge, error) { +func (s *Server) getNodeNeighborhood(db *sql.DB, nodeID string, depth int) ([]BrainNode, []BrainEdge, error) { query := ` - WITH RECURSIVE related(id, type, content, embedding_id, metadata, depth, path, created_at, updated_at) AS ( - SELECT id, type, content, embedding_id, metadata, 0 as depth, - ',' || id || ',' as path, created_at, updated_at - FROM knowledge_nodes + WITH RECURSIVE related(id, depth, path) AS ( + SELECT id, 0, ',' || id || ',' + FROM brain_nodes WHERE id = ? - UNION ALL - - SELECT n.id, n.type, n.content, n.embedding_id, n.metadata, r.depth + 1, - r.path || n.id || ',', n.created_at, n.updated_at - FROM knowledge_nodes n - JOIN knowledge_edges e ON (e.to_node_id = n.id OR e.from_node_id = n.id) + SELECT n.id, r.depth + 1, r.path || n.id || ',' + FROM brain_nodes n + JOIN brain_edges e ON (e.to_node_id = n.id OR e.from_node_id = n.id) JOIN related r ON (e.from_node_id = r.id OR e.to_node_id = r.id) - WHERE r.depth < ? + WHERE r.depth < ? AND NOT instr(r.path, ',' || n.id || ',') ) - SELECT id, type, content, COALESCE(embedding_id, ''), COALESCE(metadata, '{}'), - MIN(depth) as depth, created_at, updated_at - FROM related - GROUP BY id - ORDER BY depth, type, content + SELECT n.id, n.type, n.content, COALESCE(n.metadata, '{}'), + n.title, n.description, n.distillation_reason, COALESCE(n.search_text, ''), + MIN(r.depth), n.created_at, n.updated_at + FROM related r + JOIN brain_nodes n ON n.id = r.id + GROUP BY n.id + ORDER BY MIN(r.depth), n.type, n.content ` rows, err := db.Query(query, nodeID, depth) @@ -384,17 +415,19 @@ func (s *Server) getNodeNeighborhood(db *sql.DB, nodeID string, depth int) ([]Kn } defer func() { _ = rows.Close() }() - var nodes []KnowledgeNode + var nodes []BrainNode nodeIDs := make(map[string]bool) for rows.Next() { - var node KnowledgeNode + var node BrainNode var metadataJSON string + var title, description, distillationReason, searchText sql.NullString var nodeDepth int err := rows.Scan( - &node.ID, &node.Type, &node.Content, &node.EmbeddingID, - &metadataJSON, &nodeDepth, &node.CreatedAt, &node.UpdatedAt, + &node.ID, &node.Type, &node.Content, + &metadataJSON, &title, &description, &distillationReason, &searchText, + &nodeDepth, &node.CreatedAt, &node.UpdatedAt, ) if err != nil { return nil, nil, err @@ -403,10 +436,25 @@ func (s *Server) getNodeNeighborhood(db *sql.DB, nodeID string, depth int) ([]Kn if metadataJSON != "" && metadataJSON != "{}" { _ = json.Unmarshal([]byte(metadataJSON), &node.Metadata) } + if title.Valid { + node.Title = title.String + } + if description.Valid { + node.Description = description.String + } + if distillationReason.Valid { + node.DistillationReason = distillationReason.String + } + if searchText.Valid { + node.SearchText = searchText.String + } nodes = append(nodes, node) nodeIDs[node.ID] = true } + if err := rows.Err(); err != nil { + return nil, nil, err + } edges, err := s.queryEdgesBetweenNodes(db, nodeIDs) if err != nil { @@ -416,9 +464,9 @@ func (s *Server) getNodeNeighborhood(db *sql.DB, nodeID string, depth int) ([]Kn return nodes, edges, nil } -func (s *Server) queryEdgesBetweenNodes(db *sql.DB, nodeIDs map[string]bool) ([]KnowledgeEdge, error) { +func (s *Server) queryEdgesBetweenNodes(db *sql.DB, nodeIDs map[string]bool) ([]BrainEdge, error) { if len(nodeIDs) == 0 { - return []KnowledgeEdge{}, nil + return []BrainEdge{}, nil } placeholders := "" @@ -439,7 +487,7 @@ func (s *Server) queryEdgesBetweenNodes(db *sql.DB, nodeIDs map[string]bool) ([] query := ` SELECT id, from_node_id, to_node_id, relation_type, weight, COALESCE(metadata, '{}'), created_at - FROM knowledge_edges + FROM brain_edges WHERE from_node_id IN (` + placeholders + `) AND to_node_id IN (` + placeholders + `) ` @@ -449,9 +497,9 @@ func (s *Server) queryEdgesBetweenNodes(db *sql.DB, nodeIDs map[string]bool) ([] } defer func() { _ = rows.Close() }() - var edges []KnowledgeEdge + var edges []BrainEdge for rows.Next() { - var edge KnowledgeEdge + var edge BrainEdge var metadataJSON string err := rows.Scan( diff --git a/cmd/localforge/src/handlers_providers.go b/cmd/localforge/src/handlers_providers.go index 7abcb7c..a2a7d19 100644 --- a/cmd/localforge/src/handlers_providers.go +++ b/cmd/localforge/src/handlers_providers.go @@ -17,6 +17,7 @@ var knownProviders = []ProviderConfig{ {EnvKey: "AF_OPENAI_API_KEY", Label: "OpenAI", Group: "llm"}, {EnvKey: "AF_TOGETHERAI_API_KEY", Label: "TogetherAI", Group: "llm"}, {EnvKey: "AF_DEEPSEEK_API_KEY", Label: "DeepSeek", Group: "llm"}, + {EnvKey: "AP_OPENROUTER_API_KEY", Label: "OpenRouter", Group: "llm"}, {EnvKey: "AF_BRAVE_API_KEY", Label: "Brave Search", Group: "llm"}, {EnvKey: "INSTAGRAM_ACCESS_TOKEN", Label: "Instagram", Group: "messaging"}, {EnvKey: "TELEGRAM_BOT_TOKEN", Label: "Telegram", Group: "messaging"}, diff --git a/cmd/localforge/src/handlers_webhook.go b/cmd/localforge/src/handlers_webhook.go index 4d1c7c1..c002739 100644 --- a/cmd/localforge/src/handlers_webhook.go +++ b/cmd/localforge/src/handlers_webhook.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/gin-gonic/gin" "github.com/thinktwiceco/agent-forge/cmd/localforge/src/providers" @@ -65,34 +66,27 @@ func (s *Server) handleWebhook(c *gin.Context) { // Format webhook payload as a message to the agent message := s.formatWebhookMessage(provider, payload) - // Use a dedicated conversation ID for webhooks (or create a new one per webhook) - // Option 1: Shared webhook conversation - conversationID := fmt.Sprintf("webhook-%s", provider) - // Option 2: New conversation per webhook (comment line above, uncomment below) - // conversationID := "" - agentforge.Debug("Processing webhook from %s: %s", provider, message) - // Enrich message with metadata - enriched := queue.FormatHeaders(message, map[string]string{ - "sender": "webhook", - "provider": provider, - }) - - // Start agent processing in the background - // For webhooks, we typically don't stream back to the caller - // Instead, we acknowledge receipt and process asynchronously - // Use context.Background() inside the goroutine so handler return doesn't cancel processing + // Start agent processing in the background. + // context.Background() is used inside the goroutine so that the HTTP + // handler returning does not cancel the ongoing agent processing. go func() { ctx := context.Background() providerInst := s.providerRegistry.Get(provider) + + conversationID := fmt.Sprintf("webhook-%s", provider) + + enriched := queue.FormatHeaders(message, map[string]string{ + "sender": "webhook", + "provider": provider, + }) + if providerInst == nil { agentforge.Debug("No provider registered for %s", provider) - // Still process the message but don't send a response responseCh := agent.ChatStream(ctx, enriched, conversationID) stream := responseCh.Start() for range stream { - // Drain the stream } return } @@ -103,18 +97,67 @@ func (s *Server) handleWebhook(c *gin.Context) { return } - // For Telegram, send initial "processing" message to provide feedback - var telegramMessageID int + if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) { + agentforge.Debug("Blocked webhook from %s (chat ID %s not in allowlist)", provider, recipientID) + return + } + if provider == "telegram" { - if telegramProvider, ok := providerInst.(*providers.TelegramProvider); ok { - messageID, err := telegramProvider.SendMessageWithID(ctx, recipientID, "⏳ Processing your request...") - if err != nil { - agentforge.Debug("Failed to send initial Telegram message: %v", err) - } else { - telegramMessageID = messageID - agentforge.Debug("Sent initial Telegram message with ID %d", messageID) + if text, ok := providers.TelegramMessageText(payload); ok && providers.IsTelegramNewConversationCommand(text) { + s.telegramThreads.NewSession(recipientID) + if err := providerInst.SendMessage(ctx, recipientID, "Started a new conversation. Your next message will use a fresh thread."); err != nil { + agentforge.Debug("Failed to send new-conversation ack via %s: %v", provider, err) } + return } + conversationID = s.telegramThreads.ResolveConversationID(recipientID) + } else { + conversationID = fmt.Sprintf("webhook-%s-%s", provider, recipientID) + } + + // For Telegram callback_query updates, immediately answer the callback to + // dismiss the loading spinner on the client side. + if tp, ok := providerInst.(*providers.TelegramProvider); ok { + if cbq, ok := payload["callback_query"].(map[string]interface{}); ok { + if cbqID, ok := cbq["id"].(string); ok && cbqID != "" { + if err := tp.AnswerCallbackQuery(ctx, cbqID); err != nil { + agentforge.Debug("Failed to answer callback query: %v", err) + } + } + } + } + + // For providers that support editable messages, send an immediate + // placeholder and start a typing indicator loop, then replace the + // placeholder with the final response. Plain providers fall through + // to a simple SendMessage. + var msgRef string + var typingCancel context.CancelFunc + + if ep, ok := providerInst.(EditableProvider); ok { + ref, err := ep.SendInitialMessage(ctx, recipientID, "⏳ Processing your request...") + if err != nil { + agentforge.Debug("Failed to send initial message via %s: %v", provider, err) + } else { + msgRef = ref + agentforge.Debug("Sent initial message via %s (ref=%s)", provider, msgRef) + } + + // Typing indicator: Telegram expires it after ~5 s, so refresh every 4 s. + typingCtx, cancel := context.WithCancel(ctx) + typingCancel = cancel + go func() { + ticker := time.NewTicker(4 * time.Second) + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + _ = ep.SendTypingAction(typingCtx, recipientID) + } + } + }() } responseCh := agent.ChatStream(ctx, enriched, conversationID) @@ -126,39 +169,38 @@ func (s *Server) handleWebhook(c *gin.Context) { agentforge.Debug("Webhook processing error: %s", chunk.Content) continue } - // Accumulate content chunks only if chunk.Content != "" && chunk.Status != "tool_call" && chunk.Status != "tool_executing" && chunk.Status != "tool_result" { fullResponse.WriteString(chunk.Content) } } - // Send or edit accumulated response back via provider - if fullResponse.Len() > 0 { - // For Telegram, edit the initial message if we sent one - if provider == "telegram" && telegramMessageID > 0 { - if telegramProvider, ok := providerInst.(*providers.TelegramProvider); ok { - err := telegramProvider.EditMessage(ctx, recipientID, telegramMessageID, fullResponse.String()) - if err != nil { - agentforge.Debug("Failed to edit Telegram message: %v", err) - // Fallback: send as new message - err = providerInst.SendMessage(ctx, recipientID, fullResponse.String()) - if err != nil { - agentforge.Debug("Failed to send Telegram message: %v", err) - } - } else { - agentforge.Debug("Successfully edited Telegram message %d", telegramMessageID) - } - return - } - } + if typingCancel != nil { + typingCancel() + } - // For other providers or if edit failed, send as new message - err := providerInst.SendMessage(ctx, recipientID, fullResponse.String()) - if err != nil { - agentforge.Debug("Failed to send message via %s: %v", provider, err) + if fullResponse.Len() == 0 { + return + } + + response := fullResponse.String() + + if ep, ok := providerInst.(EditableProvider); ok && msgRef != "" { + if err := ep.UpdateMessage(ctx, recipientID, msgRef, response); err != nil { + agentforge.Debug("Failed to update message via %s: %v", provider, err) + // Fallback to a new message + if err := providerInst.SendMessage(ctx, recipientID, response); err != nil { + agentforge.Debug("Failed to send fallback message via %s: %v", provider, err) + } } else { - agentforge.Debug("Successfully sent response to %s via %s", recipientID, provider) + agentforge.Debug("Successfully updated message via %s (ref=%s)", provider, msgRef) } + return + } + + if err := providerInst.SendMessage(ctx, recipientID, response); err != nil { + agentforge.Debug("Failed to send message via %s: %v", provider, err) + } else { + agentforge.Debug("Successfully sent response to %s via %s", recipientID, provider) } }() @@ -172,6 +214,15 @@ func (s *Server) handleWebhook(c *gin.Context) { // verifyWebhookSignature verifies the webhook signature based on provider func (s *Server) verifyWebhookSignature(provider string, body []byte, headers http.Header) error { + // Telegram always requires a shared secret; unauthenticated webhooks are rejected. + if provider == "telegram" { + secret := strings.TrimSpace(os.Getenv("WEBHOOK_SECRET_TELEGRAM")) + if secret == "" { + return fmt.Errorf("WEBHOOK_SECRET_TELEGRAM is required for Telegram webhooks") + } + return verifyTelegramSignature(headers.Get("X-Telegram-Bot-Api-Secret-Token"), secret) + } + // Get webhook secret from environment secretEnvVar := fmt.Sprintf("WEBHOOK_SECRET_%s", strings.ToUpper(provider)) secret := os.Getenv(secretEnvVar) @@ -189,8 +240,6 @@ func (s *Server) verifyWebhookSignature(provider string, body []byte, headers ht return verifyStripeSignature(body, headers.Get("Stripe-Signature"), secret) case "instagram": return verifyInstagramSignature(body, headers.Get("X-Hub-Signature-256"), secret) - case "telegram": - return verifyTelegramSignature(headers.Get("X-Telegram-Bot-Api-Secret-Token"), secret) default: // Generic HMAC-SHA256 verification signature := headers.Get("X-Webhook-Signature") @@ -448,11 +497,45 @@ func (s *Server) formatInstagramWebhook(payload map[string]interface{}) string { return msg.String() } +// formatTelegramSender writes the sender's name/username from a Telegram +// message map into msg. +func formatTelegramSender(msg *strings.Builder, message map[string]interface{}) { + from, ok := message["from"].(map[string]interface{}) + if !ok { + return + } + if username, ok := from["username"].(string); ok { + fmt.Fprintf(msg, "From: @%s\n", username) + } else if firstName, ok := from["first_name"].(string); ok { + fmt.Fprintf(msg, "From: %s", firstName) + if lastName, ok := from["last_name"].(string); ok { + fmt.Fprintf(msg, " %s", lastName) + } + msg.WriteString("\n") + } +} + // formatTelegramWebhook formats Telegram-specific webhook events func (s *Server) formatTelegramWebhook(payload map[string]interface{}) string { var msg strings.Builder - // Try to get message or edited_message + // callback_query — inline keyboard button tap + if cbq, ok := payload["callback_query"].(map[string]interface{}); ok { + msg.WriteString("(Callback) ") + formatTelegramSender(&msg, cbq) + if data, ok := cbq["data"].(string); ok { + fmt.Fprintf(&msg, "Callback data: %s\n", data) + } + // Include the original message text for context if present + if origMsg, ok := cbq["message"].(map[string]interface{}); ok { + if text, ok := origMsg["text"].(string); ok { + fmt.Fprintf(&msg, "Original message: %s\n", text) + } + } + return msg.String() + } + + // Regular message or edited message var message map[string]interface{} if m, ok := payload["message"].(map[string]interface{}); ok { message = m @@ -465,22 +548,41 @@ func (s *Server) formatTelegramWebhook(payload map[string]interface{}) string { return "Invalid Telegram payload format" } - // Extract sender info - if from, ok := message["from"].(map[string]interface{}); ok { - if username, ok := from["username"].(string); ok { - fmt.Fprintf(&msg, "From: @%s\n", username) - } else if firstName, ok := from["first_name"].(string); ok { - fmt.Fprintf(&msg, "From: %s", firstName) - if lastName, ok := from["last_name"].(string); ok { - fmt.Fprintf(&msg, " %s", lastName) - } - msg.WriteString("\n") - } - } + formatTelegramSender(&msg, message) - // Extract message text + // Text message if text, ok := message["text"].(string); ok { fmt.Fprintf(&msg, "Message: %s\n", text) + return msg.String() + } + + // Media / attachment types — describe them so the agent has context + if caption, ok := message["caption"].(string); ok && caption != "" { + fmt.Fprintf(&msg, "Caption: %s\n", caption) + } + + if _, ok := message["photo"]; ok { + msg.WriteString("Message: [user sent a photo]\n") + } else if doc, ok := message["document"].(map[string]interface{}); ok { + if name, ok := doc["file_name"].(string); ok && name != "" { + fmt.Fprintf(&msg, "Message: [user sent a file: %s]\n", name) + } else { + msg.WriteString("Message: [user sent a file]\n") + } + } else if _, ok := message["voice"]; ok { + msg.WriteString("Message: [user sent a voice message]\n") + } else if _, ok := message["video"]; ok { + msg.WriteString("Message: [user sent a video]\n") + } else if _, ok := message["audio"]; ok { + msg.WriteString("Message: [user sent an audio file]\n") + } else if _, ok := message["sticker"]; ok { + msg.WriteString("Message: [user sent a sticker]\n") + } else if loc, ok := message["location"].(map[string]interface{}); ok { + lat, _ := loc["latitude"].(float64) + lon, _ := loc["longitude"].(float64) + fmt.Fprintf(&msg, "Message: [user shared a location: %.6f, %.6f]\n", lat, lon) + } else if _, ok := message["contact"]; ok { + msg.WriteString("Message: [user shared a contact]\n") } return msg.String() @@ -519,11 +621,38 @@ func (s *Server) handleWebhookSync(c *gin.Context) { return } + ctx, cancel := context.WithCancel(c.Request.Context()) + defer cancel() + message := s.formatWebhookMessage(provider, payload) conversationID := fmt.Sprintf("webhook-%s", provider) - ctx, cancel := context.WithCancel(c.Request.Context()) - defer cancel() + providerInst := s.providerRegistry.Get(provider) + if providerInst != nil { + recipientID, err := providerInst.ExtractRecipient(payload) + if err != nil { + agentforge.Debug("handleWebhookSync: extract recipient: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "could not extract recipient"}) + return + } + if ap, ok := providerInst.(AllowlistProvider); ok && !ap.IsAllowed(recipientID) { + c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"}) + return + } + if provider == "telegram" { + if text, ok := providers.TelegramMessageText(payload); ok && providers.IsTelegramNewConversationCommand(text) { + s.telegramThreads.NewSession(recipientID) + if err := providerInst.SendMessage(ctx, recipientID, "Started a new conversation. Your next message will use a fresh thread."); err != nil { + agentforge.Debug("handleWebhookSync: send ack: %v", err) + } + c.JSON(http.StatusOK, gin.H{"status": "accepted", "event": "new_conversation"}) + return + } + conversationID = s.telegramThreads.ResolveConversationID(recipientID) + } else { + conversationID = fmt.Sprintf("webhook-%s-%s", provider, recipientID) + } + } writer := NewSSEWriter(c) writer.SetHeaders() diff --git a/cmd/localforge/src/main.go b/cmd/localforge/src/main.go index 5d6b800..dffb560 100644 --- a/cmd/localforge/src/main.go +++ b/cmd/localforge/src/main.go @@ -8,11 +8,13 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" "github.com/joho/godotenv" agentforge "github.com/thinktwiceco/agent-forge/src" + "github.com/thinktwiceco/agent-forge/src/core" ) func main() { @@ -58,8 +60,41 @@ func main() { server := NewServer(agentMgr, configMgr, todoMgr, *devMode, appDir) - // Route background-drain chunks (sub-agent responses) to the push SSE endpoint. - agentMgr.SetChunkRouter(server.pushRegistry.Push) + // Route background-drain chunks to the push SSE endpoint. + // Heartbeat turns go to the fixed "heartbeat-live" push channel so the web + // client can maintain a permanent subscription for them. + agentMgr.SetChunkRouter(func(chatId string, chunk core.ExtendedChunkResponse) { + if strings.HasPrefix(chatId, "heartbeat-") { + server.pushRegistry.Push("heartbeat-live", chunk) + return + } + server.pushRegistry.Push(chatId, chunk) + }) + + // After each heartbeat turn, send the full response to all known Telegram recipients. + // Recipients are discovered from conversation files and the telegram thread store — + // no TELEGRAM_ALLOWED_CHAT_IDS env var required. + agentMgr.SetTurnCompleteRouter(func(chatId, fullContent string) { + if !strings.HasPrefix(chatId, "heartbeat-") { + return + } + log.Printf("[heartbeat] turn complete for %s (content len=%d)", chatId, len(fullContent)) + tp := server.providerRegistry.Get("telegram") + if tp == nil { + log.Printf("[heartbeat] no telegram provider registered — skipping broadcast") + return + } + recipients := server.knownTelegramChatIDs() + log.Printf("[heartbeat] broadcasting to %d telegram recipient(s): %v", len(recipients), recipients) + ctx := context.Background() + for _, recipientID := range recipients { + if err := tp.SendMessage(ctx, recipientID, fullContent); err != nil { + log.Printf("[heartbeat] telegram send to %s failed: %v", recipientID, err) + } else { + log.Printf("[heartbeat] telegram send to %s OK", recipientID) + } + } + }) shutdownCh := make(chan os.Signal, 1) signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM) diff --git a/cmd/localforge/src/provider_registry.go b/cmd/localforge/src/provider_registry.go index 38fab71..8c6a77d 100644 --- a/cmd/localforge/src/provider_registry.go +++ b/cmd/localforge/src/provider_registry.go @@ -12,6 +12,32 @@ type Provider interface { ExtractRecipient(payload map[string]interface{}) (string, error) } +// AllowlistProvider is an optional extension of Provider for platforms that +// support sender-level access control. If a Provider implements this interface +// the webhook handler will call IsAllowed before forwarding a message. +type AllowlistProvider interface { + Provider + // IsAllowed returns true when the given recipient (chat / user ID) is + // permitted to interact with the agent. + IsAllowed(recipient string) bool +} + +// EditableProvider is an optional extension of Provider for platforms that +// support sending a placeholder message and later editing it in-place (e.g. +// Telegram). Handlers should use a type assertion to check for this capability +// rather than provider-name comparisons. +type EditableProvider interface { + Provider + // SendInitialMessage sends a placeholder and returns an opaque message + // reference that can be passed to UpdateMessage. + SendInitialMessage(ctx context.Context, recipient string, text string) (msgRef string, err error) + // UpdateMessage replaces the content of a previously sent message. + // If the update fails, implementations should fall back to SendMessage. + UpdateMessage(ctx context.Context, recipient string, msgRef string, text string) error + // SendTypingAction signals to the user that the bot is working. + SendTypingAction(ctx context.Context, recipient string) error +} + // ProviderRegistry manages registered messaging providers type ProviderRegistry struct { mu sync.RWMutex @@ -46,3 +72,14 @@ func (r *ProviderRegistry) Has(name string) bool { _, exists := r.providers[name] return exists } + +// GetAll returns all registered providers. +func (r *ProviderRegistry) GetAll() []Provider { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Provider, 0, len(r.providers)) + for _, p := range r.providers { + out = append(out, p) + } + return out +} diff --git a/cmd/localforge/src/providers/instagram.go b/cmd/localforge/src/providers/instagram.go index 343a875..f87237c 100644 --- a/cmd/localforge/src/providers/instagram.go +++ b/cmd/localforge/src/providers/instagram.go @@ -6,12 +6,14 @@ import ( "encoding/json" "fmt" "net/http" + "time" ) // InstagramProvider implements the Provider interface for Instagram messaging type InstagramProvider struct { accessToken string apiVersion string + client *http.Client } // NewInstagramProvider creates a new Instagram provider @@ -19,6 +21,7 @@ func NewInstagramProvider(accessToken string) *InstagramProvider { return &InstagramProvider{ accessToken: accessToken, apiVersion: "v18.0", + client: &http.Client{Timeout: 30 * time.Second}, } } @@ -89,8 +92,7 @@ func (p *InstagramProvider) SendMessage(ctx context.Context, recipient string, m req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.accessToken)) - client := &http.Client{} - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } diff --git a/cmd/localforge/src/providers/telegram.go b/cmd/localforge/src/providers/telegram.go index 0cc2a34..a3e7cd9 100644 --- a/cmd/localforge/src/providers/telegram.go +++ b/cmd/localforge/src/providers/telegram.go @@ -6,18 +6,43 @@ import ( "encoding/json" "fmt" "net/http" + "strings" + "time" ) // TelegramProvider implements the Provider interface for Telegram messaging type TelegramProvider struct { - botToken string + botToken string + client *http.Client + allowedChatIDs map[string]struct{} // empty = allow all } -// NewTelegramProvider creates a new Telegram provider -func NewTelegramProvider(botToken string) *TelegramProvider { +// NewTelegramProvider creates a new Telegram provider. +// allowedChatIDs is an optional set of Telegram chat/user IDs that are +// permitted to interact with the agent. Pass nil or an empty slice to allow +// all senders (default / backward-compatible behaviour). +func NewTelegramProvider(botToken string, allowedChatIDs []string) *TelegramProvider { + allowed := make(map[string]struct{}, len(allowedChatIDs)) + for _, id := range allowedChatIDs { + if id != "" { + allowed[id] = struct{}{} + } + } return &TelegramProvider{ - botToken: botToken, + botToken: botToken, + client: &http.Client{Timeout: 30 * time.Second}, + allowedChatIDs: allowed, + } +} + +// IsAllowed returns true when the given chat/user ID is in the allowlist, or +// when no allowlist is configured (len == 0 means allow all). +func (p *TelegramProvider) IsAllowed(chatID string) bool { + if len(p.allowedChatIDs) == 0 { + return true } + _, ok := p.allowedChatIDs[chatID] + return ok } // Name returns the provider name @@ -25,30 +50,16 @@ func (p *TelegramProvider) Name() string { return "telegram" } -// ExtractRecipient extracts the chat ID from Telegram webhook payload -func (p *TelegramProvider) ExtractRecipient(payload map[string]interface{}) (string, error) { - // Telegram webhook structure: message.chat.id - message, ok := payload["message"].(map[string]interface{}) - if !ok { - // Try edited_message as fallback - message, ok = payload["edited_message"].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("missing 'message' or 'edited_message' field") - } - } - +// extractChatID parses a chat.id value from a Telegram message map. +func extractChatID(message map[string]interface{}) (string, error) { chat, ok := message["chat"].(map[string]interface{}) if !ok { return "", fmt.Errorf("missing 'chat' field") } - - // Chat ID can be either number or string chatID, ok := chat["id"] if !ok { return "", fmt.Errorf("missing chat 'id'") } - - // Convert to string if it's a number switch v := chatID.(type) { case float64: return fmt.Sprintf("%.0f", v), nil @@ -61,6 +72,88 @@ func (p *TelegramProvider) ExtractRecipient(payload map[string]interface{}) (str } } +// ExtractRecipient extracts the chat ID from a Telegram webhook payload. +// Handles message, edited_message, and callback_query update types. +func (p *TelegramProvider) ExtractRecipient(payload map[string]interface{}) (string, error) { + if message, ok := payload["message"].(map[string]interface{}); ok { + return extractChatID(message) + } + if message, ok := payload["edited_message"].(map[string]interface{}); ok { + return extractChatID(message) + } + // callback_query carries the originating message inside cbq.message + if cbq, ok := payload["callback_query"].(map[string]interface{}); ok { + if message, ok := cbq["message"].(map[string]interface{}); ok { + return extractChatID(message) + } + } + return "", fmt.Errorf("unsupported update type: no message, edited_message, or callback_query found") +} + +// TelegramMessageText returns the text of a regular or edited message, if any. +func TelegramMessageText(payload map[string]interface{}) (string, bool) { + var msg map[string]interface{} + if m, ok := payload["message"].(map[string]interface{}); ok { + msg = m + } else if m, ok := payload["edited_message"].(map[string]interface{}); ok { + msg = m + } else { + return "", false + } + text, ok := msg["text"].(string) + return text, ok && strings.TrimSpace(text) != "" +} + +// IsTelegramNewConversationCommand reports whether text is only /new_conversation +// with an optional @BotUsername suffix. +func IsTelegramNewConversationCommand(text string) bool { + text = strings.TrimSpace(text) + if text == "" { + return false + } + fields := strings.Fields(text) + if len(fields) != 1 { + return false + } + cmd := fields[0] + if i := strings.IndexByte(cmd, '@'); i >= 0 { + cmd = cmd[:i] + } + return strings.EqualFold(cmd, "/new_conversation") +} + +const telegramMaxLen = 4096 + +// splitMessage splits text into chunks that respect Telegram's 4096-character +// limit. It prefers splitting on newline boundaries to avoid mid-sentence cuts. +func splitMessage(text string) []string { + if len(text) <= telegramMaxLen { + return []string{text} + } + + var chunks []string + for len(text) > 0 { + if len(text) <= telegramMaxLen { + chunks = append(chunks, text) + break + } + + // Find the last newline within the allowed window. + window := text[:telegramMaxLen] + splitAt := strings.LastIndex(window, "\n") + if splitAt <= 0 { + // No newline found; hard-cut at the limit. + splitAt = telegramMaxLen + } else { + splitAt++ // include the newline in the current chunk + } + + chunks = append(chunks, text[:splitAt]) + text = text[splitAt:] + } + return chunks +} + // TelegramResponse represents the Telegram API response structure type TelegramResponse struct { Ok bool `json:"ok"` @@ -69,10 +162,16 @@ type TelegramResponse struct { } `json:"result"` } -// SendMessage sends a message to a Telegram chat via the Bot API +// SendMessage sends a message to a Telegram chat via the Bot API. +// Long messages are automatically split into multiple messages to respect +// Telegram's 4096-character limit. func (p *TelegramProvider) SendMessage(ctx context.Context, chatID string, message string) error { - _, err := p.SendMessageWithID(ctx, chatID, message) - return err + for _, chunk := range splitMessage(message) { + if _, err := p.SendMessageWithID(ctx, chatID, chunk); err != nil { + return err + } + } + return nil } // SendMessageWithID sends a message and returns the message ID @@ -96,8 +195,7 @@ func (p *TelegramProvider) SendMessageWithID(ctx context.Context, chatID string, req.Header.Set("Content-Type", "application/json") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { return 0, fmt.Errorf("failed to send request: %w", err) } @@ -143,8 +241,7 @@ func (p *TelegramProvider) EditMessage(ctx context.Context, chatID string, messa req.Header.Set("Content-Type", "application/json") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -159,6 +256,44 @@ func (p *TelegramProvider) EditMessage(ctx context.Context, chatID string, messa return nil } +// SendInitialMessage implements EditableProvider: sends a placeholder message +// and returns the message ID as the opaque msgRef string. +func (p *TelegramProvider) SendInitialMessage(ctx context.Context, recipient string, text string) (string, error) { + id, err := p.SendMessageWithID(ctx, recipient, text) + if err != nil { + return "", err + } + return fmt.Sprintf("%d", id), nil +} + +// UpdateMessage implements EditableProvider: edits an existing message identified +// by msgRef (the string message ID returned by SendInitialMessage). If the +// response exceeds Telegram's 4096-character limit it edits the placeholder with +// the first chunk and sends the remaining chunks as new messages. Falls back to +// SendMessage when msgRef is invalid. +func (p *TelegramProvider) UpdateMessage(ctx context.Context, recipient string, msgRef string, text string) error { + var msgID int + if _, err := fmt.Sscanf(msgRef, "%d", &msgID); err != nil || msgID == 0 { + return p.SendMessage(ctx, recipient, text) + } + + chunks := splitMessage(text) + if err := p.EditMessage(ctx, recipient, msgID, chunks[0]); err != nil { + return err + } + for _, chunk := range chunks[1:] { + if _, err := p.SendMessageWithID(ctx, recipient, chunk); err != nil { + return err + } + } + return nil +} + +// SendTypingAction implements EditableProvider: broadcasts a "typing" indicator. +func (p *TelegramProvider) SendTypingAction(ctx context.Context, recipient string) error { + return p.SendChatAction(ctx, recipient, "typing") +} + // SendChatAction sends a chat action (e.g., "typing") to show activity func (p *TelegramProvider) SendChatAction(ctx context.Context, chatID string, action string) error { url := fmt.Sprintf("https://api.telegram.org/bot%s/sendChatAction", p.botToken) @@ -180,8 +315,44 @@ func (p *TelegramProvider) SendChatAction(ctx context.Context, chatID string, ac req.Header.Set("Content-Type", "application/json") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := p.client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + var errorBody bytes.Buffer + _, _ = errorBody.ReadFrom(resp.Body) + return fmt.Errorf("telegram API error (status %d): %s", resp.StatusCode, errorBody.String()) + } + + return nil +} + +// AnswerCallbackQuery dismisses the loading spinner on the client after an +// inline keyboard button tap. callbackQueryID is taken from callback_query.id +// in the webhook payload. +func (p *TelegramProvider) AnswerCallbackQuery(ctx context.Context, callbackQueryID string) error { + url := fmt.Sprintf("https://api.telegram.org/bot%s/answerCallbackQuery", p.botToken) + + payload := map[string]interface{}{ + "callback_query_id": callbackQueryID, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } diff --git a/cmd/localforge/src/providers/telegram_command_test.go b/cmd/localforge/src/providers/telegram_command_test.go new file mode 100644 index 0000000..313ab27 --- /dev/null +++ b/cmd/localforge/src/providers/telegram_command_test.go @@ -0,0 +1,41 @@ +package providers + +import ( + "testing" +) + +func TestIsTelegramNewConversationCommand(t *testing.T) { + tests := []struct { + text string + want bool + }{ + {"/new_conversation", true}, + {"/NEW_CONVERSATION", true}, + {"/new_conversation@MyBot", true}, + {" /new_conversation ", true}, + {"/new_conversation extra", false}, + {"/start", false}, + {"", false}, + {"hello", false}, + } + for _, tt := range tests { + if got := IsTelegramNewConversationCommand(tt.text); got != tt.want { + t.Errorf("IsTelegramNewConversationCommand(%q) = %v, want %v", tt.text, got, tt.want) + } + } +} + +func TestTelegramMessageText(t *testing.T) { + payload := map[string]interface{}{ + "message": map[string]interface{}{ + "text": "hi", + }, + } + text, ok := TelegramMessageText(payload) + if !ok || text != "hi" { + t.Fatalf("expected hi, ok=true; got %q, %v", text, ok) + } + if _, ok := TelegramMessageText(map[string]interface{}{}); ok { + t.Fatal("expected false for empty payload") + } +} diff --git a/cmd/localforge/src/push_registry.go b/cmd/localforge/src/push_registry.go index 0cd0be1..dcc4ddd 100644 --- a/cmd/localforge/src/push_registry.go +++ b/cmd/localforge/src/push_registry.go @@ -53,3 +53,16 @@ func (r *PushRegistry) Push(chatId string, chunk core.ExtendedChunkResponse) { default: } } + +// Broadcast sends a chunk to all registered push channels. +// Non-blocking per channel: drops if a channel's buffer is full. +func (r *PushRegistry) Broadcast(chunk core.ExtendedChunkResponse) { + r.mu.RLock() + defer r.mu.RUnlock() + for _, ch := range r.chans { + select { + case ch <- chunk: + default: + } + } +} diff --git a/cmd/localforge/src/server.go b/cmd/localforge/src/server.go index 9d88956..2cf568d 100644 --- a/cmd/localforge/src/server.go +++ b/cmd/localforge/src/server.go @@ -30,12 +30,13 @@ type Server struct { convRegistry *ConversationRegistry pushRegistry *PushRegistry providerRegistry *ProviderRegistry - knowledgeDB *sql.DB // opened once at startup; nil if DB not yet available + brainDB *sql.DB // opened once at startup; nil if DB not yet available devMode bool appDir string staticFS fs.FS authConfig localauth.Config sessionStore *localauth.SessionStore + telegramThreads *TelegramThreadStore } func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoManager, devMode bool, appDir string) *Server { @@ -53,7 +54,13 @@ func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoMa // Register Telegram provider if token exists if token := os.Getenv("TELEGRAM_BOT_TOKEN"); token != "" { - providerRegistry.Register(providers.NewTelegramProvider(token)) + var allowedIDs []string + if raw := os.Getenv("TELEGRAM_ALLOWED_CHAT_IDS"); raw != "" { + for _, id := range strings.Split(raw, ",") { + allowedIDs = append(allowedIDs, strings.TrimSpace(id)) + } + } + providerRegistry.Register(providers.NewTelegramProvider(token, allowedIDs)) } server := &Server{ @@ -68,6 +75,7 @@ func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoMa appDir: appDir, authConfig: authConfig, sessionStore: localauth.NewSessionStore(), + telegramThreads: NewTelegramThreadStore(telegramThreadMapPath(configMgr)), } staticFS, err := server.staticFileSystem() @@ -79,27 +87,27 @@ func NewServer(agentMgr *AgentManager, configMgr *ConfigManager, todoMgr *TodoMa server.sessionStore.StartCleanup(time.Hour) } - // Open the knowledge DB once so all handlers share a connection pool. - // If the DB file doesn't exist yet we leave knowledgeDB nil and handlers + // Open the brain DB once so all handlers share a connection pool. + // If the DB file doesn't exist yet we leave brainDB nil and handlers // that need it will return an appropriate error. - if db, err := openKnowledgeDB(configMgr); err != nil { - log.Printf("knowledge DB not available at startup (will retry per-request): %v", err) + if db, err := openBrainDB(configMgr); err != nil { + log.Printf("brain DB not available at startup (will retry per-request): %v", err) } else { - server.knowledgeDB = db + server.brainDB = db } server.setupRoutes() return server } -// openKnowledgeDB opens the SQLite knowledge database using settings from the config. -func openKnowledgeDB(configMgr *ConfigManager) (*sql.DB, error) { +// openBrainDB opens the SQLite brain database using settings from the config. +func openBrainDB(configMgr *ConfigManager) (*sql.DB, error) { cfg := configMgr.GetConfig() workingDir := cfg.Agent.WorkingDir if workingDir == "" { workingDir = "." } - dbPath := filepath.Join(workingDir, "knowledge", "knowledge.db") + dbPath := filepath.Join(workingDir, "brain", "brain.db") db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1") if err != nil { return nil, err @@ -111,6 +119,46 @@ func openKnowledgeDB(configMgr *ConfigManager) (*sql.DB, error) { return db, nil } +// knownTelegramChatIDs returns all Telegram chat IDs we have ever communicated with. +// It merges two sources: the TelegramThreadStore (explicit /new_conversation sessions) +// and conversation files named webhook-telegram-.json (default session IDs). +func (s *Server) knownTelegramChatIDs() []string { + seen := make(map[string]struct{}) + var ids []string + + add := func(id string) { + if id == "" { + return + } + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + ids = append(ids, id) + } + } + + for _, id := range s.telegramThreads.KnownChatIDs() { + add(id) + } + + cfg := s.configMgr.GetConfig() + workingDir := cfg.Agent.WorkingDir + if workingDir == "" { + workingDir = "." + } + agentName := cfg.Agent.Name + convDir := filepath.Join(workingDir, "data", "conversations", agentName) + entries, _ := os.ReadDir(convDir) + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, "webhook-telegram-") || !strings.HasSuffix(name, ".json") { + continue + } + chatID := strings.TrimSuffix(strings.TrimPrefix(name, "webhook-telegram-"), ".json") + add(chatID) + } + return ids +} + func (s *Server) staticFileSystem() (fs.FS, error) { if s.devMode { return os.DirFS(filepath.Join(s.appDir, "src", "static")), nil @@ -164,7 +212,6 @@ func (s *Server) setupRoutes() { api.PUT("/config", s.handleUpdateAgentConfig) api.PUT("/config/tools/:toolName", s.handleUpdateToolConfig) api.PUT("/config/plugins", s.handleUpdatePlugins) - api.PUT("/config/subagents", s.handleUpdateSubagents) api.GET("/config/providers", s.handleGetProviders) api.PUT("/config/providers", s.handleUpdateProviders) api.POST("/agent/reload", s.handleReload) @@ -192,8 +239,8 @@ func (s *Server) Run(port string) error { } func (s *Server) Shutdown(ctx context.Context) error { - if s.knowledgeDB != nil { - _ = s.knowledgeDB.Close() + if s.brainDB != nil { + _ = s.brainDB.Close() } if s.httpSrv == nil { return nil diff --git a/cmd/localforge/src/static/css/knowledge.css b/cmd/localforge/src/static/css/knowledge.css index 6969173..7d9b231 100644 --- a/cmd/localforge/src/static/css/knowledge.css +++ b/cmd/localforge/src/static/css/knowledge.css @@ -35,10 +35,17 @@ } .brand-icon { - font-size: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; color: var(--accent); } +.brand-icon svg { + display: block; +} + .brand-name { font-size: 16px; font-weight: 600; diff --git a/cmd/localforge/src/static/css/styles.css b/cmd/localforge/src/static/css/styles.css index cd148ae..965f9e9 100644 --- a/cmd/localforge/src/static/css/styles.css +++ b/cmd/localforge/src/static/css/styles.css @@ -23,6 +23,24 @@ --radius-sm: 6px; --radius-md: 10px; --transition: 150ms ease; + + /* Semantic surfaces (chat, tools, code) */ + --bg-user-message: #1b2540; + --border-user-message: #2d3b55; + --bg-tool-call: #1a160a; + --border-tool-call: #2a2010; + --bg-tool-success: #0d1f12; + --border-tool-success: #142210; + --bg-tool-error: #1f0d0d; + --border-tool-error: #2a1010; + --bg-code: #0a0d12; + --bg-code-header: #070a0e; + --bg-conversation-active: #1a2030; + --bg-secondary: #2a2a2a; + --text-strong: #f1f3f5; + --text-code-inline: #c9d1d9; + --accent-soft: rgba(59, 130, 246, 0.08); + --accent-ring: rgba(59, 130, 246, 0.3); } /* ─── Reset ──────────────────────────────────────────────────────────────── */ @@ -159,10 +177,16 @@ body { } .brand-icon { - font-size: 18px; - color: var(--accent); - line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; flex-shrink: 0; + color: var(--accent); +} + +.brand-icon svg { + display: block; } .brand-name { @@ -171,6 +195,55 @@ body { color: var(--text-primary); } +/* ─── Sidebar primary nav ───────────────────────────────────────────────── */ +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 2px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 12px; +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 10px; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + text-decoration: none; + background: none; + border: none; + cursor: pointer; + font-family: inherit; + width: 100%; + text-align: left; + transition: background var(--transition), color var(--transition); +} + +.sidebar-nav-item:hover { + background: var(--bg-surface); + color: var(--text-primary); +} + +.sidebar-nav-item.active { + color: var(--accent); + background: var(--accent-soft); +} + +.nav-icon { + flex-shrink: 0; + opacity: 0.7; +} + +.sidebar-nav-item:hover .nav-icon, +.sidebar-nav-item.active .nav-icon { + opacity: 1; +} + /* ─── Conversation List ──────────────────────────────────────────────────── */ .conversation-list { display: flex; @@ -249,7 +322,7 @@ body { } .conv-rename:hover { - background: var(--accent, #5b9cf6); + background: var(--accent); color: #fff; } @@ -261,8 +334,8 @@ body { .conv-rename-input { flex: 1; min-width: 0; - background: var(--bg-secondary, #2a2a2a); - border: 1px solid var(--accent, #5b9cf6); + background: var(--bg-secondary); + border: 1px solid var(--accent); border-radius: 4px; color: var(--text-primary); font-size: 13px; @@ -387,8 +460,13 @@ body { overflow-x: hidden; display: flex; flex-direction: column; - gap: 10px; + align-items: stretch; + gap: 16px; scroll-behavior: smooth; + max-width: 760px; + width: 100%; + margin: 0 auto; + box-sizing: border-box; } /* ─── Scroll-to-bottom button ────────────────────────────────────────────── */ @@ -415,49 +493,95 @@ body { color: var(--text-primary); } -/* ─── Empty state ────────────────────────────────────────────────────────── */ -.empty-state { +/* ─── Welcome / empty chat ───────────────────────────────────────────────── */ +.welcome-state { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; - color: var(--text-muted); + padding: 60px 24px; text-align: center; - padding: 40px 20px; user-select: none; + min-height: 200px; } -.empty-icon { - font-size: 36px; - color: var(--border-hover); - line-height: 1; +.welcome-icon { + width: 52px; + height: 52px; + border-radius: 14px; + background: var(--accent-soft); + border: 1px solid var(--accent-ring); + display: flex; + align-items: center; + justify-content: center; } -.empty-state h2 { +.welcome-title { margin: 0; - font-size: 18px; + font-size: 20px; font-weight: 600; color: var(--text-primary); } -.empty-state p { +.welcome-sub { margin: 0; + font-size: 14px; + color: var(--text-muted); + max-width: 320px; +} + +.welcome-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin-top: 8px; +} + +.welcome-chip { + background: var(--bg-surface); + border: 1px solid var(--border-hover); + border-radius: 20px; + padding: 6px 14px; font-size: 13px; color: var(--text-muted); + cursor: pointer; + font-family: inherit; + transition: background var(--transition), color var(--transition), border-color var(--transition); +} + +.welcome-chip:hover { + background: var(--bg-panel); + color: var(--text-primary); + border-color: var(--accent); +} + +/* ─── Chat input (floating bar) ─────────────────────────────────────────── */ +.chat-input-outer { + padding: 0 16px 16px; + background: transparent; + flex-shrink: 0; } -/* ─── Chat input ─────────────────────────────────────────────────────────── */ .chat-input { - padding: 12px 20px 16px; - border-top: 1px solid var(--border); + padding: 10px 12px; + border: 1px solid var(--border-hover); + border-radius: 16px; + background: var(--bg-surface); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); display: flex; flex-direction: column; - gap: 8px; + gap: 0; flex-shrink: 0; } +.chat-input:focus-within { + border-color: var(--accent); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--accent-ring); +} + .chat-input-row { display: flex; gap: 10px; @@ -475,11 +599,11 @@ body { .chat-input textarea { width: 100%; resize: none; - border-radius: var(--radius-md); - border: 1px solid var(--border-hover); - background: var(--bg-input); + border: none; + border-radius: 0; + background: transparent; color: var(--text-primary); - padding: 10px 12px; + padding: 6px 8px; font-family: inherit; font-size: 14px; line-height: 1.5; @@ -491,7 +615,6 @@ body { .chat-input textarea:focus { outline: none; - border-color: var(--accent); } .chat-input textarea::placeholder { @@ -515,6 +638,30 @@ body { background: var(--border); } +.send-btn { + background: var(--accent); + border-color: var(--accent); + border-radius: 10px; + width: 36px; + height: 36px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: #fff; + align-self: flex-end; +} + +.send-btn:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.send-btn svg { + display: block; +} + /* ─── Image preview (inside input area) ──────────────────────────────────── */ .image-preview { display: flex; @@ -698,6 +845,18 @@ body { } /* ─── Messages ───────────────────────────────────────────────────────────── */ +@keyframes message-in { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + .message { border-radius: var(--radius-md); padding: 10px 14px; @@ -709,45 +868,72 @@ body { overflow-wrap: break-word; word-break: break-word; position: relative; + animation: message-in 180ms ease both; } .msg-user { align-self: flex-end; - background: #1b2540; - border-color: #2d3b55; - max-width: 80%; + max-width: 72%; + background: var(--bg-user-message); + border-color: var(--border-user-message); + border-radius: 18px 18px 4px 18px; +} + +.msg-user > .message-meta { + display: none; } .msg-assistant { - background: var(--bg-surface); - border-color: var(--border); + align-self: stretch; + width: 100%; + max-width: 100%; + background: transparent; + border: none; + padding-left: 36px; + position: relative; +} + +.msg-assistant::before { + content: ""; + position: absolute; + left: 12px; + top: 14px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + opacity: 0.85; +} + +.msg-assistant > .message-meta { + display: none; } .msg-tool-call { border-left: 3px solid var(--warning); - background: #1a160a; - border-color: #2a2010; + background: var(--bg-tool-call); + border-color: var(--border-tool-call); border-left-color: var(--warning); } .msg-tool-executing { border-left: 3px solid var(--warning); - background: #1a160a; - border-color: #2a2010; + background: var(--bg-tool-call); + border-color: var(--border-tool-call); border-left-color: var(--warning); } .msg-tool-result-success { border-left: 3px solid var(--success); - background: #0d1f12; - border-color: #142210; + background: var(--bg-tool-success); + border-color: var(--border-tool-success); border-left-color: var(--success); } .msg-tool-result-error { border-left: 3px solid var(--error); - background: #1f0d0d; - border-color: #2a1010; + background: var(--bg-tool-error); + border-color: var(--border-tool-error); border-left-color: var(--error); } @@ -799,9 +985,9 @@ body { /* ─── Tool call group (collapsible) ──────────────────────────────────────── */ .tool-group { border-radius: var(--radius-md); - border: 1px solid #2a2010; + border: 1px solid var(--border-tool-call); border-left: 3px solid var(--warning); - background: #1a160a; + background: var(--bg-tool-call); font-size: 13px; overflow: hidden; } @@ -843,7 +1029,7 @@ body { display: flex; flex-direction: column; gap: 1px; - border-top: 1px solid #2a2010; + border-top: 1px solid var(--border-tool-call); } .tool-group-entry { @@ -876,7 +1062,7 @@ body { .turn-tool-group { margin-top: 10px; border-radius: var(--radius-sm); - border: 1px solid #2a2010; + border: 1px solid var(--border-tool-call); border-left: 3px solid var(--warning); background: rgba(255, 200, 100, 0.04); font-size: 13px; @@ -906,7 +1092,7 @@ body { .message-body strong { font-weight: 600; - color: #f1f3f5; + color: var(--text-strong); } .message-body em { @@ -969,19 +1155,19 @@ body { } .message-body code { - background: #0a0d12; + background: var(--bg-code); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-family: "Consolas", "Monaco", "Courier New", monospace; font-size: 0.88em; - color: #c9d1d9; + color: var(--text-code-inline); overflow-wrap: break-word; word-break: break-word; } .message-body pre { - background: #0a0d12; + background: var(--bg-code); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; @@ -1001,6 +1187,67 @@ body { font-size: 0.88em; } +/* Fenced code blocks (marked custom renderer) */ +.code-block { + border-radius: var(--radius-sm); + border: 1px solid var(--border); + overflow: hidden; + margin: 8px 0; +} + +.code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: var(--bg-code-header); + border-bottom: 1px solid var(--border); +} + +.code-lang { + font-size: 11px; + font-family: monospace; + color: var(--text-faint); + text-transform: lowercase; +} + +.code-copy-btn { + font-size: 11px; + color: var(--text-muted); + background: none; + border: none; + cursor: pointer; + font-family: inherit; + padding: 0; + transition: color var(--transition); +} + +.code-copy-btn:hover { + color: var(--text-primary); +} + +.code-block pre { + margin: 0; + border: none; + border-radius: 0; + background: var(--bg-code); + padding: 12px 14px; + overflow-x: auto; + max-width: 100%; +} + +.code-block pre code { + background: none; + border: none; + padding: 0; + display: block; + white-space: pre; + overflow-wrap: normal; + word-break: normal; + font-size: 0.88em; + color: var(--text-code-inline); +} + .message-body blockquote { border-left: 3px solid var(--accent); margin: 8px 0; @@ -1030,7 +1277,7 @@ body { } .message-body table th { - background: #0a0d12; + background: var(--bg-code); font-weight: 600; } @@ -1238,10 +1485,11 @@ body { } .todos-empty { - padding: 24px 20px; + padding: 40px 16px; text-align: center; color: var(--text-faint); font-size: 13px; + line-height: 1.6; } /* ─── Thinking indicator ─────────────────────────────────────────────────── */ @@ -1444,4 +1692,347 @@ body { white-space: pre-wrap; word-break: break-all; color: var(--text-primary); -} \ No newline at end of file +} +/* ─── Settings Page ───────────────────────────────────────────────────────── */ +.settings-app { + display: grid; + grid-template-columns: 220px 1fr; + height: 100vh; + overflow: hidden; +} + +.settings-nav { + background: var(--bg-panel); + border-right: 1px solid var(--border); + padding: 16px; + overflow-y: auto; +} + +.settings-nav-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-bottom: 12px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.settings-nav-header a { + color: var(--text-muted); + text-decoration: none; + font-size: 12px; +} + +.settings-nav-header a:hover { + color: var(--text-primary); +} + +.settings-nav-header-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.settings-nav-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.settings-nav-item { + display: block; + padding: 8px 10px; + border-radius: var(--radius-sm); + color: var(--text-muted); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: background var(--transition), color var(--transition); + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.settings-nav-item:hover { + background: var(--bg-surface); + color: var(--text-primary); +} + +.settings-nav-item.active { + background: var(--bg-surface); + color: var(--accent); + border-left: 2px solid var(--accent); + padding-left: 8px; +} + +.settings-content { + overflow-y: auto; + padding: 32px 40px; + background: var(--bg-app); +} + +/* ─── Section cards ───────────────────────────────────────────────────── */ +.settings-section { + display: none; + max-width: 720px; +} + +.settings-section.active { + display: block; +} + +.settings-section-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 4px 0; +} + +.settings-section-desc { + font-size: 13px; + color: var(--text-muted); + margin: 0 0 24px 0; +} + +.settings-card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 20px; + margin-bottom: 16px; +} + +.settings-card-title { + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 16px 0; +} + +/* ─── Fields ──────────────────────────────────────────────────────────── */ +.field { + margin-bottom: 14px; +} + +.field:last-of-type { + margin-bottom: 0; +} + +.field label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + margin-bottom: 6px; +} + +.field input, +.field select, +.field textarea { + width: 100%; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + padding: 8px 10px; + box-sizing: border-box; + transition: border-color var(--transition); +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + outline: none; + border-color: var(--border-active); +} + +.field textarea { + resize: vertical; + min-height: 100px; + line-height: 1.5; +} + +.field textarea.tall { + min-height: 180px; +} + +.field-hint { + font-size: 11px; + color: var(--text-faint); + margin-top: 4px; +} + +/* ─── Password / token field ──────────────────────────────────────────── */ +.token-field { + display: flex; + gap: 8px; + align-items: center; +} + +.token-field input { + flex: 1; + font-family: monospace; + letter-spacing: 0.05em; +} + +.token-badge { + font-size: 11px; + padding: 2px 7px; + border-radius: 4px; + font-weight: 600; + flex-shrink: 0; +} + +.token-badge.set { + background: rgba(34, 197, 94, 0.15); + color: var(--success); +} + +.token-badge.unset { + background: rgba(239, 68, 68, 0.12); + color: var(--error); +} + +/* ─── Subagents table ─────────────────────────────────────────────────── */ +.subagent-row { + display: grid; + grid-template-columns: 1fr 2fr auto; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.subagent-row input { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + padding: 7px 10px; + width: 100%; + box-sizing: border-box; +} + +.subagent-row input:focus { + outline: none; + border-color: var(--border-active); +} + +/* ─── Plugin checkboxes ───────────────────────────────────────────────── */ +.plugin-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 8px; +} + +.plugin-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color var(--transition); +} + +.plugin-item:hover { + border-color: var(--border-hover); +} + +.plugin-item.checked { + border-color: var(--accent); + background: rgba(59, 130, 246, 0.07); +} + +.plugin-item input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent); + cursor: pointer; +} + +.plugin-item span { + font-size: 13px; + color: var(--text-primary); +} + +/* ─── Section footer ──────────────────────────────────────────────────── */ +.section-footer { + display: flex; + align-items: center; + gap: 10px; + margin-top: 16px; +} + +.save-status { + font-size: 12px; + color: var(--text-muted); + transition: color var(--transition); +} + +.save-status.ok { + color: var(--success); +} + +.save-status.err { + color: var(--error); +} + +/* ─── Reload bar ──────────────────────────────────────────────────────── */ +.reload-bar { + position: sticky; + top: 0; + z-index: 20; + background: rgba(15, 17, 21, 0.92); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); + padding: 10px 40px; + display: flex; + align-items: center; + gap: 12px; + margin: -32px -40px 28px -40px; +} + +.reload-bar-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.reload-status { + font-size: 12px; +} + +.reload-status.ok { + color: var(--success); +} + +.reload-status.err { + color: var(--error); +} + +/* ─── Provider group ──────────────────────────────────────────────────── */ +.provider-group-label { + font-size: 11px; + font-weight: 600; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 16px 0 8px 0; +} + +.provider-group-label:first-child { + margin-top: 0; +} diff --git a/cmd/localforge/src/static/index.html b/cmd/localforge/src/static/index.html index 7286652..99252af 100644 --- a/cmd/localforge/src/static/index.html +++ b/cmd/localforge/src/static/index.html @@ -13,7 +13,11 @@ @@ -46,27 +70,35 @@
-
-
- -
- + +
+
+
+ +
+ - -
-
-
-
- - -
+ +