diff --git a/README.md b/README.md
index adb3ea7..07a9cc5 100644
--- a/README.md
+++ b/README.md
@@ -181,7 +181,7 @@ Type these at the prompt during a session.
|---------|-------------|
| `/settings` | Open the settings workspace |
| `/agents` | Show the live subagent swarm dashboard |
-| `/update` | Check for updates and self-update |
+| `kosmokrator update` | Check for updates and update based on install method |
| `/feedback ` | Submit feedback or a bug report as a GitHub issue (requires `gh` CLI) |
| `/tasks clear` | Remove all tasks |
| `/clear` | Clear the terminal screen |
diff --git a/bin/kosmokrator b/bin/kosmokrator
index 1b14767..6b75202 100644
--- a/bin/kosmokrator
+++ b/bin/kosmokrator
@@ -10,6 +10,9 @@ use Kosmokrator\Command\CodexLogoutCommand;
use Kosmokrator\Command\CodexStatusCommand;
use Kosmokrator\Command\ConfigCommand;
use Kosmokrator\Command\SetupCommand;
+use Kosmokrator\Command\TelegramGatewayCommand;
+use Kosmokrator\Command\TelegramGatewayWorkerCommand;
+use Kosmokrator\Command\UpdateCommand;
use Kosmokrator\Kernel;
use NunoMaduro\Collision\Provider;
@@ -38,12 +41,15 @@ $console->addCommand(new CodexLogoutCommand($kernel->getContainer()));
$console->addCommand(new SetupCommand($kernel->getContainer()));
$console->addCommand(new ConfigCommand($kernel->getContainer()));
$console->addCommand(new AuthCommand($kernel->getContainer()));
+$console->addCommand(new UpdateCommand($kernel->getContainer(), $console->getVersion() ?? 'dev'));
+$console->addCommand(new TelegramGatewayCommand($kernel->getContainer()));
+$console->addCommand(new TelegramGatewayWorkerCommand($kernel->getContainer()));
// Default to agent in single-command mode unless another explicit command is requested.
// 'agent' is removed from the explicit list so that `kosmokrator agent "prompt"`
// enters single-command mode correctly (positional prompt binds to the command).
$args = $_SERVER['argv'] ?? [];
-$explicitCommands = ['setup', 'config', 'auth', 'codex:login', 'codex:status', 'codex:logout', 'list', 'help', '_complete', 'completion'];
+$explicitCommands = ['setup', 'config', 'auth', 'update', 'gateway:telegram', 'gateway:telegram:worker', 'codex:login', 'codex:status', 'codex:logout', 'list', 'help', '_complete', 'completion'];
$requestedCommand = $args[1] ?? null;
$isExplicitCommand = is_string($requestedCommand) && ! str_starts_with($requestedCommand, '-') && in_array($requestedCommand, $explicitCommands, true);
diff --git a/composer.json b/composer.json
index 5141b1e..45c89be 100644
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,7 @@
"amphp/amp": "^3.1",
"amphp/http-client": "^5.3",
"league/commonmark": "^2.9",
+ "league/html-to-markdown": "^5.1",
"opencompany/prism-codex": "dev-main",
"opencompanyapp/integration-clickup": "@dev",
"opencompanyapp/integration-coingecko": "@dev",
diff --git a/composer.lock b/composer.lock
index 2c8f262..967060f 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "4ac0888cfaf845b85f0c6dd58faea235",
+ "content-hash": "9c63f7f02f43f038616e6559fb27a064",
"packages": [
{
"name": "amphp/amp",
@@ -2778,6 +2778,95 @@
},
"time": "2026-01-23T15:30:45+00:00"
},
+ {
+ "name": "league/html-to-markdown",
+ "version": "5.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/html-to-markdown.git",
+ "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd",
+ "reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-xml": "*",
+ "php": "^7.2.5 || ^8.0"
+ },
+ "require-dev": {
+ "mikehaertl/php-shellcommand": "^1.1.0",
+ "phpstan/phpstan": "^1.8.8",
+ "phpunit/phpunit": "^8.5 || ^9.2",
+ "scrutinizer/ocular": "^1.6",
+ "unleashedtech/php-coding-standard": "^2.7 || ^3.0",
+ "vimeo/psalm": "^4.22 || ^5.0"
+ },
+ "bin": [
+ "bin/html-to-markdown"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.2-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\HTMLToMarkdown\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Colin O'Dell",
+ "email": "colinodell@gmail.com",
+ "homepage": "https://www.colinodell.com",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Nick Cernis",
+ "email": "nick@cern.is",
+ "homepage": "http://modernnerd.net",
+ "role": "Original Author"
+ }
+ ],
+ "description": "An HTML-to-markdown conversion helper for PHP",
+ "homepage": "https://github.com/thephpleague/html-to-markdown",
+ "keywords": [
+ "html",
+ "markdown"
+ ],
+ "support": {
+ "issues": "https://github.com/thephpleague/html-to-markdown/issues",
+ "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://www.colinodell.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.paypal.me/colinpodell/10.00",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/colinodell",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-07-12T21:21:09+00:00"
+ },
{
"name": "league/mime-type-detection",
"version": "1.16.0",
diff --git a/config/kosmokrator.yaml b/config/kosmokrator.yaml
index 3cbb1c7..d8e0a2d 100644
--- a/config/kosmokrator.yaml
+++ b/config/kosmokrator.yaml
@@ -125,6 +125,53 @@ ui:
integrations:
permissions_default: ask # Default for operations not explicitly set
+gateway:
+ telegram:
+ enabled: false
+ token: null
+ session_mode: thread
+ allowed_users: []
+ allowed_chats: []
+ require_mention: true
+ free_response_chats: []
+ poll_timeout_seconds: 20
+
+web:
+ cache:
+ keep_turns: 2
+ max_entries: 128
+ search:
+ default_provider: tavily
+ fallback_providers: []
+ max_results: 5
+ timeout: 20
+ providers:
+ tavily:
+ api_key: ${TAVILY_API_KEY}
+ zai:
+ api_key: ${ZAI_API_KEY}
+ remote_url: https://api.z.ai/api/mcp/web_search_prime/mcp
+ exa:
+ api_key: ${EXA_API_KEY}
+ google_custom_search:
+ api_key: ${GOOGLE_API_KEY}
+ search_engine_id: ${GOOGLE_SEARCH_ENGINE_ID}
+ fetch:
+ default_provider: direct
+ fallback_providers: []
+ timeout: 20
+ max_bytes: 10485760
+ max_chars: 12000
+ providers:
+ direct:
+ user_agent_mode: browser_like
+ zai:
+ api_key: ${ZAI_API_KEY}
+ base_url: https://api.z.ai/api/coding/paas/v4
+ firecrawl:
+ api_key: ${FIRECRAWL_API_KEY}
+ api_url: ${FIRECRAWL_API_URL}
+
tools:
# Tools that are always denied, overriding all modes (including Prometheus).
# Useful for hard-disabling tools in specific projects or CI environments.
@@ -146,6 +193,10 @@ tools:
- ask_user
- ask_choice
- subagent
+ - session_search
+ - session_read
+ - web_search
+ - web_fetch
- lua_list_docs
- lua_search_docs
- lua_read_doc
diff --git a/docs/README.md b/docs/README.md
index 686e400..d2266e9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -23,6 +23,7 @@ Forward-looking design docs. Not shipped — may reference classes or features t
| [ecosystem-architecture.md](proposals/ecosystem-architecture.md) | Lua code mode, MCP integration, OpenCompany tool ecosystem |
| [integration-refactor-plan.md](proposals/integration-refactor-plan.md) | Refactoring tool packages to framework-agnostic contracts |
| [desktop-app.md](proposals/desktop-app.md) | NativePHP + Electron desktop surface proposal |
+| [hermes-style-gateway.md](proposals/hermes-style-gateway.md) | Hermes-style Telegram-first gateway surface for KosmoKrator |
| [tui-ux-improvements.md](proposals/tui-ux-improvements.md) | 10 ranked UX improvements with mockups |
| [command-inspiration.md](proposals/command-inspiration.md) | Slash/power command ideas from competitive analysis |
| [laravel-ai-patterns.md](proposals/laravel-ai-patterns.md) | Patterns from Laravel AI SDK worth borrowing |
diff --git a/docs/proposals/desktop-app.md b/docs/proposals/desktop-app.md
index 6b51825..a1499fa 100644
--- a/docs/proposals/desktop-app.md
+++ b/docs/proposals/desktop-app.md
@@ -291,6 +291,6 @@ This means:
| Terminal (ANSI) | `AnsiRenderer` | readline | ANSI escape codes | `php bin/kosmokrator` |
| Terminal (TUI) | `TuiRenderer` | Symfony TUI InputWidget | TUI widgets + Revolt | `php bin/kosmokrator` |
| Desktop | `WebRenderer` | Vue frontend via WebSocket | Electron BrowserWindow | NativePHP (bundled PHP) |
-| *(future)* Mobile | `MobileRenderer` | Native UI via EDGE | Swift/Kotlin shell | NativePHP Mobile |
+| Kosmo (mobile + desktop) | Flutter thin client | touch + voice | Stream cards | Kosmo Cloud / OpenCompany backend |
-All implement `RendererInterface`. The engine doesn't know which surface it's running on.
+Terminal and Desktop surfaces implement `RendererInterface` — the engine doesn't know which surface it's on. Kosmo is a separate thin client that talks to the same backend (Kosmo Cloud / OpenCompany) over WebSocket, not a RendererInterface implementation.
diff --git a/docs/proposals/hermes-style-gateway.md b/docs/proposals/hermes-style-gateway.md
new file mode 100644
index 0000000..0403259
--- /dev/null
+++ b/docs/proposals/hermes-style-gateway.md
@@ -0,0 +1,412 @@
+# Hermes-Style Gateway for KosmoKrator
+
+> Status: Proposal. This document describes a Hermes-style external gateway surface for KosmoKrator, starting with Telegram. It is forward-looking and does not describe shipped behavior.
+
+## Overview
+
+KosmoKrator is currently a terminal-native agent. The next external surface should follow the Hermes model: a real gateway runtime with platform adapters, normalized inbound events, deterministic session routing, streamed outbound updates, and async approval handling.
+
+This proposal explicitly does **not** reuse Chatogrator as the core architecture. Chatogrator is useful as a source of Telegram transport ideas, but its Laravel webhook/request model is the wrong center of gravity for KosmoKrator.
+
+The target is:
+
+```text
+Telegram / other chat platforms
+ │
+ Gateway adapter layer
+ │
+ Normalized gateway events
+ │
+ Session router + approval bridge
+ │
+ KosmoKrator agent runtime (AgentLoop)
+ │
+ ToolExecutor / PermissionEvaluator
+ │
+ TUI / ANSI / Gateway outbound renderer
+```
+
+The gateway is a new surface over the existing engine, not a separate product and not a second agent core.
+
+## Why Hermes-Style
+
+Hermes gets the important architectural boundary right:
+
+- platform adapters are isolated from the core agent
+- sessions are keyed from platform/chat/thread identity
+- the runtime supports polling or webhook transport
+- outbound responses are streamed and edited in place
+- approvals can be resolved asynchronously from the chat surface
+
+That matches KosmoKrator's needs far better than a Laravel controller-oriented webhook package.
+
+## Goals
+
+- Add a first-class Telegram surface for KosmoKrator.
+- Reuse the existing agent core, tool execution, permission modes, Lua bridge, and session persistence.
+- Preserve KosmoKrator's security model instead of bypassing it with a separate bot-side permission layer.
+- Make external chat sessions feel native: streamed replies, approvals, resumable threads, and session continuity.
+- Design the gateway so additional platforms can follow the same adapter contract later.
+
+## Non-Goals
+
+- Rebuilding Chatogrator inside KosmoKrator.
+- A generic 16-platform gateway in v1.
+- Replacing the terminal UI.
+- Inventing a second session store separate from KosmoKrator's SQLite database.
+- Making Telegram the source of truth for settings, permissions, or agent state.
+
+## Principles
+
+1. One agent core, many surfaces.
+2. Gateway policy must flow through existing permission and mode logic.
+3. Session identity must be deterministic and explicit.
+4. Platform-specific concerns belong in adapters, not in `AgentLoop`.
+5. Polling-first is acceptable for v1; webhook mode is an optimization.
+6. Streaming and edit-in-place matter more than fancy commands in the first release.
+
+## Current State
+
+KosmoKrator already has the pieces needed behind the gateway boundary:
+
+- agent loop and session assembly in [AgentLoop.php](/Users/rutger/Projects/kosmokrator/src/Agent/AgentLoop.php) and [AgentSessionBuilder.php](/Users/rutger/Projects/kosmokrator/src/Agent/AgentSessionBuilder.php)
+- session persistence in [SessionManager.php](/Users/rutger/Projects/kosmokrator/src/Session/SessionManager.php)
+- permission evaluation in [PermissionEvaluator.php](/Users/rutger/Projects/kosmokrator/src/Tool/Permission/PermissionEvaluator.php)
+- tool orchestration in [ToolExecutor.php](/Users/rutger/Projects/kosmokrator/src/Agent/ToolExecutor.php)
+- mode and permission controls through slash commands and settings
+
+What is missing is the gateway layer:
+
+- external event ingestion
+- external session routing
+- outbound chat rendering
+- async approval callbacks
+- platform transport lifecycle
+
+## Feature Map
+
+### Core Feature Map
+
+| Capability | Hermes | Kosmo Today | Proposed Kosmo Gateway |
+|---|---|---|---|
+| Platform adapters | Yes | No | Yes |
+| Telegram polling | Yes | No | V1 |
+| Telegram webhook | Yes | No | V2 |
+| Session routing by chat/thread/user | Yes | No | V1 |
+| Streamed response edits | Yes | No | V1 |
+| Inline approval flow | Yes | No | V2 |
+| Async approval timeout handling | Yes | No | V2 |
+| Reactions / typing indicators | Yes | No | V2 |
+| Media ingestion | Yes | No | V2 |
+| Forum topic routing | Yes | No | V3 |
+| Multi-platform gateway | Yes | No | V3+ |
+| Gateway model picker | Yes | No | V2 |
+| Gateway command registry | Yes | No | V1 |
+
+### Telegram v1 Feature Map
+
+| Feature | v1 | Notes |
+|---|---|---|
+| Bot token config | Yes | Stored in Kosmo settings |
+| Allowed users/chats | Yes | Required for first release |
+| Polling worker | Yes | Simplest reliable start |
+| DM support | Yes | One session per user chat |
+| Group mention gating | Yes | Mention or reply-to-bot |
+| Thread/topic-aware routing | Partial | Support message threads when present |
+| Streamed response editing | Yes | One visible in-progress message |
+| Plain text attachments summary | Yes | Text-first fallback |
+| Approval via text commands | Yes | `/approve`, `/deny`, `/status` |
+| Inline keyboards | No | V2 |
+| Reactions | No | V2 |
+| Voice/audio input | No | V3 |
+| Photos/files upload handling | Limited | Metadata in v1, rich handling later |
+
+### Telegram v2 Feature Map
+
+| Feature | v2 | Notes |
+|---|---|---|
+| Webhook mode | Yes | Optional deployment optimization |
+| Inline approval buttons | Yes | Bridges to permission requests |
+| Typing indicators | Yes | While agent is thinking or streaming |
+| Rich media sending | Yes | Files, images, rendered artifacts |
+| Gateway model selector | Yes | Limited to safe per-session overrides |
+| Better group routing | Yes | Wake words, allowlisted free-response chats |
+| Approval expiration and fallback | Yes | Important for unattended sessions |
+
+## Architecture
+
+### New Boundary
+
+Introduce a gateway subsystem under a new namespace, for example:
+
+```text
+src/Gateway/
+├── GatewayManager.php
+├── GatewayRunner.php
+├── Event/
+│ ├── GatewayEvent.php
+│ ├── MessageEvent.php
+│ ├── ActionEvent.php
+│ └── ApprovalEvent.php
+├── Session/
+│ ├── GatewaySessionKey.php
+│ ├── GatewaySessionRouter.php
+│ └── GatewaySessionContext.php
+├── Platform/
+│ ├── PlatformAdapterInterface.php
+│ └── Telegram/
+│ ├── TelegramAdapter.php
+│ ├── TelegramClient.php
+│ ├── TelegramUpdateNormalizer.php
+│ ├── TelegramOutboundRenderer.php
+│ └── TelegramPollingWorker.php
+└── Approval/
+ ├── GatewayApprovalBridge.php
+ ├── PendingApprovalStore.php
+ └── ApprovalResolution.php
+```
+
+The gateway should feed normalized events into the existing agent runtime rather than creating a new execution path.
+
+### Session Routing
+
+Kosmo needs deterministic external session keys. A good key shape is:
+
+```text
+telegram:{chat_id}
+telegram:{chat_id}:{thread_id}
+telegram:{chat_id}:{thread_id}:{user_id}
+```
+
+Routing policy depends on chat type:
+
+- private chat: `telegram:{user_chat_id}`
+- group with reply/mention mode: shared thread session by `chat_id`
+- forum topics: `chat_id:thread_id`
+- if we later need per-user isolation in shared groups, add `user_id`
+
+These keys should map onto Kosmo's existing session persistence, not bypass it.
+
+### Gateway Conversation Flow
+
+1. Telegram update arrives.
+2. Adapter validates source and normalizes it to `MessageEvent`.
+3. Session router computes a gateway session key.
+4. Gateway runner loads or creates the linked Kosmo session.
+5. Gateway outbound renderer posts an initial placeholder message.
+6. AgentLoop runs using the same session, tools, permissions, and settings as terminal Kosmo.
+7. Stream chunks update the Telegram message in place.
+8. Tool approvals route through the approval bridge.
+9. Final response is committed to session history and rendered as a stable Telegram message.
+
+## Telegram-Specific Design
+
+### v1 Inbound Rules
+
+- private chats are accepted if the sender is allowlisted
+- groups require either:
+ - direct mention of the bot, or
+ - reply to a bot message
+- commands are parsed first and may short-circuit the agent loop
+
+### v1 Commands
+
+The gateway should not duplicate the full slash command surface. Start with a small command registry:
+
+| Command | Purpose |
+|---|---|
+| `/help` | Gateway-specific help |
+| `/new` | Start a fresh linked Kosmo session |
+| `/resume` | Reuse the current session |
+| `/approve` | Approve the latest pending tool request |
+| `/deny` | Deny the latest pending tool request |
+| `/status` | Show current mode, model, session id, pending approvals |
+| `/cancel` | Cancel the active run for this session |
+
+This registry should be separate from CLI slash commands, but conceptually similar.
+
+### Outbound Rendering
+
+Telegram should get a gateway-specific renderer, not reused ANSI/TUI output.
+
+The renderer should support:
+
+- one editable in-progress message for streamed content
+- compact tool status lines for long operations
+- completion summary for long-running tasks
+- approval prompts rendered as plain text in v1
+- artifact/file uploads later in v2
+
+The renderer should compress noisy terminal-only details. Telegram is not the place to mirror raw TUI phase state or verbose tool banners.
+
+## Permissions and Approvals
+
+The gateway must reuse Kosmo permission modes, not invent a separate one.
+
+Rules:
+
+- `guardian`: deny or require explicit user approval via chat
+- `argus`: same underlying behavior as terminal Argus, but approval resolved over Telegram
+- `prometheus`: auto-allow where Kosmo already would auto-allow
+
+Required bridge behavior:
+
+- when a tool requires approval, pause the gateway run
+- persist the pending approval with session and message linkage
+- post a concise approval request into Telegram
+- resume the waiting run when approval is granted or denied
+
+This is the main runtime difference between terminal and gateway surfaces.
+
+## Settings and Config
+
+Gateway configuration should live in Kosmo settings, not a separate YAML silo.
+
+Suggested top-level settings:
+
+```yaml
+gateway:
+ telegram:
+ enabled: false
+ token: null
+ polling: true
+ webhook_url: null
+ allowed_users: []
+ allowed_chats: []
+ require_mention: true
+ free_response_chats: []
+ home_chat_id: null
+```
+
+These should be surfaced in `/settings`, likely in a new `Gateway` category rather than hidden under `Integrations`.
+
+## Data Model
+
+Minimal additional persistence:
+
+- gateway session linkage
+- pending approval records
+- outbound message mapping for edit-in-place
+- optional platform checkpoint/cursor state
+
+Suggested new tables:
+
+| Table | Purpose |
+|---|---|
+| `gateway_sessions` | maps platform/chat/thread identity to Kosmo session ids |
+| `gateway_messages` | tracks outbound platform message ids for updates |
+| `gateway_approvals` | pending and resolved approval requests |
+| `gateway_checkpoints` | polling offsets or webhook bookkeeping if needed |
+
+## Implementation Plan
+
+### Phase 0: Preparation
+
+- Introduce `src/Gateway/` namespace and service provider wiring.
+- Define normalized gateway event types.
+- Define gateway session key and persistence model.
+- Add settings schema for Telegram configuration.
+- Add a small internal command registry for gateway commands.
+
+### Phase 1: Telegram MVP
+
+- Polling worker for Telegram updates.
+- Allowlist and mention/reply gating.
+- Session router backed by SQLite.
+- Gateway renderer with streamed text updates.
+- `/help`, `/new`, `/status`, `/cancel`.
+- Reuse existing session, mode, model, and permission defaults.
+
+Exit criteria:
+
+- a user can converse with Kosmo from Telegram
+- responses stream cleanly into one message
+- chat sessions resume correctly
+- no unsafe tool bypass exists
+
+### Phase 2: Approval Bridge and Better Controls
+
+- Pending approval persistence and resume.
+- `/approve` and `/deny`.
+- Inline approval buttons.
+- Better progress/status updates for long tool runs.
+- Model override selection per gateway session.
+- Better command routing and per-chat policy configuration.
+
+Exit criteria:
+
+- guarded tools can be approved asynchronously from Telegram
+- resumed runs continue in the same agent turn cleanly
+- gateway sessions can be used without the terminal open
+
+### Phase 3: Rich Telegram Surface
+
+- file and image uploads
+- generated artifact sending
+- typing indicators and reactions
+- better thread/topic support
+- voice note ingestion
+- home-chat notifications for long-running tasks
+
+### Phase 4: Generalized Platform Layer
+
+- make the adapter contract stable enough for Discord/Slack/Signal later
+- move Telegram-specific assumptions out of shared gateway code
+- keep transport-specific rendering logic inside the adapter package
+
+## Testing Strategy
+
+### Unit Tests
+
+- session key generation
+- mention/reply gating rules
+- command parsing
+- approval state transitions
+- Telegram update normalization
+- outbound renderer chunk/edit behavior
+
+### Integration Tests
+
+- one full gateway conversation against fake Telegram updates
+- approval-required tool run pause/resume
+- session reuse across multiple inbound messages
+- cancel and timeout handling
+
+### Manual Validation
+
+- DM conversation
+- group mention flow
+- approval flow in Guardian/Argus
+- long streamed response editing
+- process restart with resumed session routing
+
+## Risks
+
+| Risk | Why it matters | Mitigation |
+|---|---|---|
+| Blocking agent runs tie up poll loop | Polling worker can stall | Separate transport loop from run execution |
+| Chat flooding during long streams | Telegram edit limits and UX | Throttle edits and coalesce chunks |
+| Approval deadlocks | External runs may wait forever | Persist approval state with timeout and cancellation |
+| Session confusion in groups | Wrong replies in shared chats | Deterministic session key strategy and explicit routing rules |
+| Leaking terminal-oriented output | Telegram UX becomes noisy | Dedicated gateway renderer |
+
+## Not In v1
+
+- multi-platform support beyond Telegram
+- forum-topic automation rules
+- complex pairing flows
+- gateway-side OAuth credential management
+- advanced media understanding
+- bot-driven swarm dashboards
+
+## Recommendation
+
+Build Kosmo's first gateway the Hermes way:
+
+- adapter-based
+- polling-first
+- session-routed
+- streamed
+- approval-aware
+
+Do not center the implementation on Chatogrator. If we reuse anything from that package, it should be narrow Telegram transport or rendering logic only, not the package architecture.
diff --git a/docs/proposals/lazy-integration-boot.md b/docs/proposals/lazy-integration-boot.md
new file mode 100644
index 0000000..6db9478
--- /dev/null
+++ b/docs/proposals/lazy-integration-boot.md
@@ -0,0 +1,181 @@
+# Lazy Integration Boot
+
+> Status: Proposal
+
+## Problem
+
+`IntegrationServiceProvider::boot()` eagerly discovers and boots all ~444 integration packages at startup. Each package's ServiceProvider is instantiated, `register()` and `boot()` are called, and the ToolProvider is constructed with all its tool class imports.
+
+**Measured cost: 17.2 MB** — the single largest allocation after autoload. For sessions that never touch integrations (pure coding tasks), this is pure waste.
+
+### Current boot flow
+
+```
+IntegrationServiceProvider::boot()
+ └─ discoverIntegrations()
+ ├─ parse composer.lock (fast, ~1ms)
+ ├─ parse local monorepo composer.json files (fast)
+ └─ for each of ~444 matching packages:
+ ├─ new XxxServiceProvider($container) ← autoloads class + deps
+ ├─ ->register() ← binds singleton closure (cheap)
+ └─ ->boot() ← new XxxToolProvider() + registers
+ into ToolProviderRegistry (expensive:
+ imports all tool classes via use statements)
+```
+
+### Memory profile
+
+```
+Before IntegrationServiceProvider::boot(): 15.6 MB
+After IntegrationServiceProvider::boot(): 32.8 MB
+ -------
+Cost: 17.2 MB
+```
+
+## Proposed solution
+
+Split boot into two phases: **discover** (cheap) and **materialize** (expensive, on-demand).
+
+### Phase 1: Discover at boot — build manifest only
+
+At boot, parse composer.lock + monorepo to collect a lightweight manifest:
+
+```php
+// ~50-100 KB instead of 17 MB
+$manifest = [
+ 'opencompanyapp/integration-slack' => [
+ 'providers' => ['OpenCompany\Integrations\Slack\SlackServiceProvider'],
+ 'dir' => null, // non-null for local monorepo packages
+ ],
+ 'opencompanyapp/integration-github' => [
+ 'providers' => ['OpenCompany\Integrations\Github\GithubServiceProvider'],
+ 'dir' => '/Users/.../integrations/packages/github',
+ ],
+ // ... ~442 more entries
+];
+```
+
+This reuses the existing `discoverIntegrations()` logic but stops before `new $providerClass()`. The manifest captures package name, provider class FQCNs, and optional local directory (for monorepo autoload registration).
+
+### Phase 2: Materialize on first access
+
+The first call to any of these triggers full boot of all pending packages:
+
+- `IntegrationManager::getAllProviders()`
+- `IntegrationManager::getActiveProviders()`
+- `IntegrationManager::getToolCatalog()`
+- `ToolProviderRegistry::all()`
+- `LuaDocService` catalog building
+
+### Where to put the lazy gate
+
+`IntegrationManager` is the natural boundary — every runtime consumer goes through it. Add an `ensureBooted()` guard:
+
+```php
+class IntegrationManager
+{
+ private bool $booted = false;
+
+ /** @var null|array, dir: ?string}> */
+ private ?array $pendingManifest = null;
+
+ public function setPendingManifest(array $manifest): void
+ {
+ $this->pendingManifest = $manifest;
+ }
+
+ private function ensureBooted(): void
+ {
+ if ($this->booted || $this->pendingManifest === null) {
+ return;
+ }
+
+ $this->booted = true;
+
+ foreach ($this->pendingManifest as $name => $entry) {
+ // Register local package autoload if needed
+ if ($entry['dir'] !== null) {
+ $this->registerLocalAutoload($name, $entry['dir']);
+ }
+
+ foreach ($entry['providers'] as $providerClass) {
+ if (!class_exists($providerClass)) {
+ continue;
+ }
+ try {
+ $provider = new $providerClass($this->container);
+ $provider->register();
+ $provider->boot();
+ } catch (\Throwable) {
+ continue;
+ }
+ }
+ }
+
+ $this->pendingManifest = null;
+ }
+
+ public function getAllProviders(): array
+ {
+ $this->ensureBooted();
+ return $this->providers->all();
+ }
+
+ // Same guard in getActiveProviders(), getToolCatalog(), etc.
+}
+```
+
+### Changes to IntegrationServiceProvider
+
+```php
+public function boot(): void
+{
+ // Build manifest (cheap: JSON parse + string matching, no class loading)
+ $manifest = $this->buildManifest();
+
+ // Hand it to IntegrationManager for lazy materialization
+ $this->container->resolving(IntegrationManager::class, function (IntegrationManager $mgr) use ($manifest) {
+ $mgr->setPendingManifest($manifest);
+ });
+}
+
+private function buildManifest(): array
+{
+ // Same discovery logic as current discoverIntegrations(),
+ // but returns array instead of calling registerIntegrationPackage()
+ $manifest = [];
+
+ // ... parse composer.lock, filter by prefix, skip redundant, etc.
+ // For each matching package:
+ // $manifest[$name] = ['providers' => $providerClasses, 'dir' => $packageDir];
+
+ return $manifest;
+}
+```
+
+## What doesn't change
+
+- Integration packages themselves are untouched
+- `ToolProviderRegistry` contract unchanged
+- Settings UI, credential resolver, permission system — all unchanged
+- Sessions that DO use integrations pay the same cost, just deferred to first use
+
+## Expected impact
+
+- **Startup: -17 MB** for sessions that never touch integrations
+- **First integration access: +17 MB** (same cost, just deferred)
+- **Manifest overhead: ~50-100 KB** (444 entries × ~100 bytes)
+- No behavioral change — integrations work identically once booted
+
+## Edge cases
+
+- **/settings listing integrations**: Triggers boot. This is fine — the user explicitly asked to see integrations.
+- **System prompt building**: If the system prompt references active integrations (tool catalog), this triggers boot during the first LLM call. Acceptable — the cost is paid once.
+- **Subagents**: If subagents share the same container, integrations are booted once globally. If they create fresh containers, each would pay the boot cost. Current architecture shares the container, so no issue.
+- **Local monorepo autoload**: The custom PSR-4 autoloader for local packages must be registered lazily too (inside `ensureBooted`), not at manifest-build time.
+
+## Alternatives considered
+
+**Per-integration lazy loading** (only boot slack when slack tools are invoked): More granular savings but significantly more complex. Would require a proxy ToolProvider that defers to the real one. The all-or-nothing approach is simpler and captures 95%+ of the benefit since most sessions either use integrations or don't.
+
+**Cache the manifest to disk**: Not worth it — building the manifest from composer.lock is fast (~5ms). The expensive part is instantiating 444 ServiceProviders, which can't be cached.
diff --git a/src/Agent/AgentLoop.php b/src/Agent/AgentLoop.php
index 57d2b98..f1b2dbd 100644
--- a/src/Agent/AgentLoop.php
+++ b/src/Agent/AgentLoop.php
@@ -22,6 +22,7 @@
use Kosmokrator\UI\AgentTreeBuilder;
use Kosmokrator\UI\RendererInterface;
use Kosmokrator\UI\SafeDisplay;
+use Kosmokrator\Web\Cache\WebTransientCache;
use Prism\Prism\Contracts\Message;
use Prism\Prism\Enums\FinishReason;
use Prism\Prism\Tool;
@@ -85,6 +86,7 @@ public function __construct(
private readonly int $memoryWarningThreshold = 50 * 1024 * 1024,
private readonly ?Dispatcher $events = null,
private readonly AgentTreeBuilder $treeBuilder = new AgentTreeBuilder,
+ private readonly ?WebTransientCache $webCache = null,
) {
$this->history = new ConversationHistory;
$this->tokens = new SessionTokenTracker;
@@ -206,6 +208,7 @@ private function applyModeFilter(): void
*/
public function run(string $userInput): void
{
+ $this->webCache?->advanceTurn();
$this->log->debug('User input', ['input' => $userInput]);
$this->history->addUser($userInput);
$this->persistMessage($this->history->messages()[array_key_last($this->history->messages())]);
diff --git a/src/Agent/AgentMode.php b/src/Agent/AgentMode.php
index 0dbd307..05f4887 100644
--- a/src/Agent/AgentMode.php
+++ b/src/Agent/AgentMode.php
@@ -36,6 +36,8 @@ public function label(): string
private const MEMORY_WRITE_TOOLS = ['memory_save'];
+ private const WEB_TOOLS = ['web_search', 'web_fetch'];
+
private const LUA_TOOLS = ['lua_list_docs', 'lua_search_docs', 'lua_read_doc', 'execute_lua'];
/**
@@ -46,9 +48,9 @@ public function label(): string
public function allowedTools(): array
{
return match ($this) {
- self::Edit => ['file_read', 'file_write', 'file_edit', 'apply_patch', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', ...self::TASK_TOOLS, ...self::ASK_TOOLS, ...self::MEMORY_READ_TOOLS, ...self::MEMORY_WRITE_TOOLS, ...self::LUA_TOOLS],
- self::Plan => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', ...self::TASK_TOOLS, ...self::ASK_TOOLS, ...self::MEMORY_READ_TOOLS, ...self::LUA_TOOLS],
- self::Ask => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', ...self::TASK_TOOLS, ...self::ASK_TOOLS, ...self::MEMORY_READ_TOOLS, 'lua_list_docs', 'lua_search_docs', 'lua_read_doc'],
+ self::Edit => ['file_read', 'file_write', 'file_edit', 'apply_patch', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', ...self::TASK_TOOLS, ...self::ASK_TOOLS, ...self::MEMORY_READ_TOOLS, ...self::MEMORY_WRITE_TOOLS, ...self::WEB_TOOLS, ...self::LUA_TOOLS],
+ self::Plan => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', ...self::TASK_TOOLS, ...self::ASK_TOOLS, ...self::MEMORY_READ_TOOLS, ...self::WEB_TOOLS, ...self::LUA_TOOLS],
+ self::Ask => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', ...self::TASK_TOOLS, ...self::ASK_TOOLS, ...self::MEMORY_READ_TOOLS, ...self::WEB_TOOLS, 'lua_list_docs', 'lua_search_docs', 'lua_read_doc'],
};
}
diff --git a/src/Agent/AgentSessionBuilder.php b/src/Agent/AgentSessionBuilder.php
index 0bcfcc7..726b335 100644
--- a/src/Agent/AgentSessionBuilder.php
+++ b/src/Agent/AgentSessionBuilder.php
@@ -19,7 +19,9 @@
use Kosmokrator\Tool\ToolRegistry;
use Kosmokrator\UI\HeadlessRenderer;
use Kosmokrator\UI\OutputFormat;
+use Kosmokrator\UI\RendererInterface;
use Kosmokrator\UI\UIManager;
+use Kosmokrator\Web\Cache\WebTransientCache;
use OpenCompany\PrismRelay\Registry\RelayRegistry;
use OpenCompany\PrismRelay\Relay;
use Psr\Log\LoggerInterface;
@@ -91,6 +93,7 @@ public function build(string $rendererPref, bool $animated): AgentSession
// Append Lua integration docs if available
$baseSystemPrompt .= $this->buildLuaDocsSuffix();
+ $baseSystemPrompt .= $this->buildWebToolsSuffix($toolRegistry);
// Task store
$taskStore = $this->container->make(TaskStore::class);
@@ -112,7 +115,7 @@ public function build(string $rendererPref, bool $animated): AgentSession
$llm, $ui, $log, $baseSystemPrompt, $permissions, $models, $taskStore, $sessionManager,
$contextPipeline->compactor, $contextPipeline->truncator, $contextPipeline->pruner,
$contextPipeline->deduplicator, $contextPipeline->budget, $contextPipeline->protectedContextBuilder,
- $memoryWarningThreshold, $events,
+ $memoryWarningThreshold, $events, webCache: $this->container->make(WebTransientCache::class),
);
// Subagent pipeline (orchestrator, root context, factory)
@@ -166,7 +169,10 @@ public function buildHeadless(OutputFormat $format = OutputFormat::Text, array $
// Create LLM client (always use sync/prism for headless)
$llmFactory = new LlmClientFactory($this->container);
- $llm = $llmFactory->create('ansi', $ui);
+ // Gateway surfaces need the same async-capable LLM path as TUI sessions.
+ // Some providers, notably Z.AI coding, work correctly via the async transport
+ // but fail on the sync Prism path used for plain ANSI/headless mode.
+ $llm = $llmFactory->create('tui', $ui);
// Apply model override if specified
if (! empty($options['model'])) {
@@ -218,6 +224,7 @@ public function buildHeadless(OutputFormat $format = OutputFormat::Text, array $
// Append Lua integration docs if available
$baseSystemPrompt .= $this->buildLuaDocsSuffix();
+ $baseSystemPrompt .= $this->buildWebToolsSuffix($toolRegistry);
// Task store
$taskStore = $this->container->make(TaskStore::class);
@@ -239,7 +246,7 @@ public function buildHeadless(OutputFormat $format = OutputFormat::Text, array $
$persistSession ? $sessionManager : null,
$contextPipeline->compactor, $contextPipeline->truncator, $contextPipeline->pruner,
$contextPipeline->deduplicator, $contextPipeline->budget, $contextPipeline->protectedContextBuilder,
- $memoryWarningThreshold, $events,
+ $memoryWarningThreshold, $events, webCache: $this->container->make(WebTransientCache::class),
);
// Apply guardrails
@@ -282,6 +289,114 @@ public function buildHeadless(OutputFormat $format = OutputFormat::Text, array $
return new AgentSession($ui, $agentLoop, $llm, $permissions, $sessionManager, $subagentPipeline->orchestrator);
}
+ /**
+ * Build an agent session for a non-terminal surface such as the Telegram gateway.
+ *
+ * Uses a caller-supplied renderer while keeping normal session persistence,
+ * permissions, tool wiring, and subagent support intact.
+ *
+ * @param array{model?: string, permission_mode?: string, agent_mode?: string, system_prompt?: string, append_system_prompt?: string, max_turns?: int, timeout?: int} $options
+ */
+ public function buildGateway(RendererInterface $ui, array $options = []): AgentSession
+ {
+ $config = $this->container->make('config');
+
+ $ui->initialize();
+
+ $llmFactory = new LlmClientFactory($this->container);
+ // Gateway surfaces need the same async-capable client selection as TUI.
+ $llm = $llmFactory->create('tui', $ui);
+
+ if (! empty($options['model'])) {
+ $llm->setModel($options['model']);
+ }
+
+ $log = $this->container->make(LoggerInterface::class);
+
+ $toolRegistry = $this->container->make(ToolRegistry::class);
+ $toolRegistry->register(new AskUserTool($ui));
+ $toolRegistry->register(new AskChoiceTool($ui));
+ $permissions = $this->container->make(PermissionEvaluator::class);
+ $models = $this->container->make(ModelCatalog::class);
+
+ $sessionManager = $this->container->make(SessionManager::class);
+ $project = InstructionLoader::gitRoot() ?? getcwd();
+ $sessionManager->setProject($project);
+
+ $kosmokratorConfig = $config->get('kosmokrator', []);
+ $settingsApplier = new SessionSettingsApplier($sessionManager, $kosmokratorConfig);
+ $settingsApplier->apply($llm, $permissions);
+
+ if (! empty($options['permission_mode'])) {
+ $permissions->setPermissionMode(PermissionMode::from((string) $options['permission_mode']));
+ }
+
+ $baseSystemPrompt = $config->get('kosmokrator.agent.system_prompt', 'You are a helpful coding assistant.')
+ .InstructionLoader::gather()
+ .EnvironmentContext::gather();
+
+ if (! empty($options['system_prompt'])) {
+ $baseSystemPrompt = (string) $options['system_prompt'];
+ }
+ if (! empty($options['append_system_prompt'])) {
+ $baseSystemPrompt .= "\n\n".(string) $options['append_system_prompt'];
+ }
+
+ $baseSystemPrompt .= $this->buildLuaDocsSuffix();
+ $baseSystemPrompt .= $this->buildWebToolsSuffix($toolRegistry);
+
+ $taskStore = $this->container->make(TaskStore::class);
+ $contextFactory = new ContextPipelineFactory($sessionManager, $models, $taskStore, $log, $kosmokratorConfig);
+ $contextPipeline = $contextFactory->create($llm);
+ $memoryWarningThreshold = (int) $config->get('kosmokrator.context.memory_warning_mb', 50) * 1024 * 1024;
+ $events = $this->container->bound(Dispatcher::class)
+ ? $this->container->make(Dispatcher::class)
+ : null;
+
+ $agentLoop = new AgentLoop(
+ $llm, $ui, $log, $baseSystemPrompt, $permissions, $models, $taskStore, $sessionManager,
+ $contextPipeline->compactor, $contextPipeline->truncator, $contextPipeline->pruner,
+ $contextPipeline->deduplicator, $contextPipeline->budget, $contextPipeline->protectedContextBuilder,
+ $memoryWarningThreshold, $events, webCache: $this->container->make(WebTransientCache::class),
+ );
+
+ if (! empty($options['max_turns'])) {
+ $agentLoop->setMaxTurns((int) $options['max_turns']);
+ }
+
+ if (! empty($options['timeout'])) {
+ $agentLoop->setTimeout((int) $options['timeout']);
+ }
+
+ if (! empty($options['agent_mode'])) {
+ $agentLoop->setMode(AgentMode::from((string) $options['agent_mode']));
+ }
+
+ $prismProviders = $config->get('prism.providers', []);
+ $subagentConfig = array_merge($kosmokratorConfig, ['prism_providers' => $prismProviders]);
+ $subagentPipelineFactory = new SubagentPipelineFactory(
+ $sessionManager,
+ $this->container->make(ProviderCatalog::class),
+ $this->container->make(RelayRegistry::class),
+ $models,
+ $this->container->make(Relay::class),
+ $log,
+ $subagentConfig,
+ );
+ $subagentPipeline = $subagentPipelineFactory->create(
+ $llm, $toolRegistry, $permissions, $ui, $contextPipeline, 'ansi',
+ );
+
+ $toolRegistry->register(new SubagentTool(
+ $subagentPipeline->rootContext,
+ fn (AgentContext $ctx, string $task) => $subagentPipeline->factory->createAndRunAgent($ctx, $task),
+ ));
+ $agentLoop->setAgentContext($subagentPipeline->rootContext);
+ $agentLoop->setTools($toolRegistry->toPrismTools());
+
+ return new AgentSession($ui, $agentLoop, $llm, $permissions, $sessionManager, $subagentPipeline->orchestrator);
+ }
+
/**
* Build the Lua integration docs suffix for the system prompt.
*/
@@ -313,4 +428,19 @@ private function buildLuaDocsSuffix(): string
return '';
}
}
+
+ private function buildWebToolsSuffix(ToolRegistry $toolRegistry): string
+ {
+ if ($toolRegistry->get('web_search') === null && $toolRegistry->get('web_fetch') === null) {
+ return '';
+ }
+
+ return "\n\n# Web Research\n\n"
+ .'Web tools are available: `web_search` for discovery and `web_fetch` for reading pages. '
+ .'For large pages, prefer `web_fetch` in `metadata` or `outline` mode first, then fetch only the relevant '
+ ."section, match, or chunk.\n\n"
+ .'For multi-source research, prefer subagents so different sources can be searched and inspected in parallel. '
+ .'Have subagents return concise findings with URLs, then synthesize in the parent agent instead of loading many '
+ .'full pages into the main conversation context.';
+ }
}
diff --git a/src/Agent/AgentType.php b/src/Agent/AgentType.php
index 49b2aa3..9081c89 100644
--- a/src/Agent/AgentType.php
+++ b/src/Agent/AgentType.php
@@ -43,11 +43,12 @@ public function allowedChildTypes(): array
public function allowedTools(): array
{
$luaTools = ['lua_list_docs', 'lua_search_docs', 'lua_read_doc', 'execute_lua'];
+ $webTools = ['web_search', 'web_fetch'];
return match ($this) {
- self::General => ['file_read', 'file_write', 'file_edit', 'apply_patch', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', 'memory_save', ...$luaTools],
- self::Explore => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', ...$luaTools],
- self::Plan => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', ...$luaTools],
+ self::General => ['file_read', 'file_write', 'file_edit', 'apply_patch', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', 'session_read', 'memory_save', ...$webTools, ...$luaTools],
+ self::Explore => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', 'session_read', ...$webTools, ...$luaTools],
+ self::Plan => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', 'session_read', ...$webTools, ...$luaTools],
};
}
diff --git a/src/Command/AgentCommand.php b/src/Command/AgentCommand.php
index db32c10..3a462cf 100644
--- a/src/Command/AgentCommand.php
+++ b/src/Command/AgentCommand.php
@@ -12,6 +12,7 @@
use Kosmokrator\LLM\ModelCatalog;
use Kosmokrator\LLM\ProviderCatalog;
use Kosmokrator\Session\SettingsRepositoryInterface;
+use Kosmokrator\Setup\SetupFlowInterface;
use Kosmokrator\Skill\SkillDispatcher;
use Kosmokrator\Skill\SkillLoader;
use Kosmokrator\Skill\SkillRegistry;
@@ -222,11 +223,25 @@ private function runHeadless(InputInterface $input, OutputInterface $output): in
*/
private function runInteractive(InputInterface $input, OutputInterface $output): int
{
- // Build the agent session (UI + LLM + permissions) from container bindings.
- // Falls back to a friendly error when the provider is not yet configured.
$config = $this->container->make('config');
$rendererPref = $input->getOption('renderer') ?: $config->get('kosmokrator.ui.renderer', 'auto');
$animated = ! $input->getOption('no-animation') && $config->get('kosmokrator.ui.intro_animated', true);
+ $setup = $this->container->make(SetupFlowInterface::class);
+
+ if ($setup->needsProviderSetup()) {
+ $completed = $setup->open(
+ rendererPref: (string) $rendererPref,
+ animated: $animated,
+ showIntro: false,
+ notice: 'No provider is configured yet. Finish setup first, then KosmoKrator will continue.',
+ );
+
+ if (! $completed) {
+ return Command::FAILURE;
+ }
+
+ $animated = false;
+ }
$builder = new AgentSessionBuilder($this->container);
@@ -266,7 +281,7 @@ private function runInteractive(InputInterface $input, OutputInterface $output):
$currentVersion = $this->getApplication()?->getVersion() ?? 'dev';
$updateAvailable = (new UpdateChecker($currentVersion))->check();
if ($updateAvailable !== null) {
- $session->ui->showNotice("Update available: v{$updateAvailable} (current: v{$currentVersion}). Run /update to install.");
+ $session->ui->showNotice("Update available: v{$updateAvailable} (current: v{$currentVersion}). Run `kosmokrator update` to install.");
}
}
@@ -502,39 +517,9 @@ private function repl(AgentSession $session): int
*/
private function buildSlashCommandRegistry(): SlashCommandRegistry
{
- $registry = new SlashCommandRegistry;
-
- // Core commands
- $registry->register(new Slash\QuitCommand);
- // Session management commands
- $registry->register(new Slash\ClearCommand);
- $registry->register(new Slash\SeedCommand);
- $registry->register(new Slash\TheogonyCommand);
- $registry->register(new Slash\CompactCommand);
- $registry->register(new Slash\TasksClearCommand);
- $registry->register(new Slash\MemoriesCommand);
- $registry->register(new Slash\SessionsCommand);
- $registry->register(new Slash\ForgetCommand);
-
- // Agent mode switches
- $registry->register(new Slash\GuardianCommand);
- $registry->register(new Slash\ArgusCommand);
- $registry->register(new Slash\PrometheusCommand);
- $registry->register(new Slash\ModeCommand(AgentMode::Edit));
- $registry->register(new Slash\ModeCommand(AgentMode::Plan));
- $registry->register(new Slash\ModeCommand(AgentMode::Ask));
-
- // Utility commands
$version = $this->getApplication()?->getVersion() ?? 'dev';
- $registry->register(new Slash\NewCommand);
- $registry->register(new Slash\ResumeCommand);
- $registry->register(new Slash\SettingsCommand($this->container));
- $registry->register(new Slash\AgentsCommand);
- $registry->register(new Slash\UpdateCommand($version));
- $registry->register(new Slash\FeedbackCommand($version));
- $registry->register(new Slash\RenameCommand);
- return $registry;
+ return SlashCommandRegistryFactory::build($this->container, $version);
}
/**
diff --git a/src/Command/SetupCommand.php b/src/Command/SetupCommand.php
index d761054..100042a 100644
--- a/src/Command/SetupCommand.php
+++ b/src/Command/SetupCommand.php
@@ -1,23 +1,18 @@
container->make(SettingsRepositoryInterface::class);
- $configSettings = $this->container->make(SettingsManager::class);
- $configSettings->setProjectRoot(InstructionLoader::gitRoot() ?? getcwd());
- $providers = $this->container->make(ProviderCatalog::class);
- $codexAuth = $this->container->make(CodexAuthFlow::class);
-
- $r = Theme::reset();
- $white = Theme::white();
- $dim = Theme::text();
- $accent = Theme::accent();
- $primary = Theme::primary();
-
- echo "\n{$accent} ⚡ KosmoKrator Setup{$r}\n\n";
-
- $currentProvider = $configSettings->get('agent.default_provider')
- ?? (string) $this->container->make('config')->get('kosmokrator.agent.default_provider', 'z');
-
- echo "{$dim} Available providers:{$r}\n";
- foreach ($providers->providers() as $definition) {
- $marker = $definition->id === $currentProvider ? "{$accent}→{$r}" : ' ';
- $status = $providers->authStatus($definition->id);
- echo " {$marker} {$white}{$definition->id}{$r} — {$dim}{$definition->description} · {$status}{$r}\n";
- }
- echo "\n";
+ $this
+ ->addOption('renderer', null, InputOption::VALUE_REQUIRED, 'Force renderer (tui or ansi)', 'auto')
+ ->addOption('no-animation', null, InputOption::VALUE_NONE, 'Skip any intro animation before opening setup');
+ }
- $provider = readline("{$dim} Provider [{$currentProvider}]: {$r}") ?: $currentProvider;
- $provider = trim($provider);
- $definition = $providers->provider($provider);
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $completed = $this->container->make(SetupFlowInterface::class)->open(
+ rendererPref: (string) $input->getOption('renderer'),
+ animated: ! $input->getOption('no-animation'),
+ showIntro: false,
+ notice: 'Open settings to configure your default provider, model, and credentials.',
+ );
- if ($definition === null) {
- echo "\n{$primary} ✗ Unknown provider: {$provider}{$r}\n\n";
+ if (! $completed) {
+ $output->writeln('Setup incomplete. Configure a provider before continuing. ');
return Command::FAILURE;
}
- echo "{$dim} {$definition->label}: {$definition->description}{$r}\n";
- echo "{$dim} Endpoint: {$definition->url}{$r}\n";
- echo "{$dim} Auth: {$providers->authStatus($provider)}{$r}\n\n";
-
- $providerModels = array_map(static fn ($model) => $model->id, $definition->models);
- echo "{$dim} Available models for {$white}{$provider}{$r}{$dim}:{$r}\n";
- foreach ($definition->models as $model) {
- $thinking = $model->thinking ? ' · thinking' : '';
- echo " {$white}{$model->id}{$r}{$dim} — {$model->displayName} · {$model->contextWindow} ctx · {$model->maxOutput} out{$thinking}{$r}\n";
- }
- echo "\n";
-
- $savedModel = $configSettings->get('agent.default_model');
- $providerSavedModel = $configSettings->getProviderLastModel($provider);
- $currentModel = $providerSavedModel ?? $savedModel;
- if ($currentModel === null || ! $providers->supportsModel($provider, $currentModel)) {
- $currentModel = $definition->defaultModel !== '' ? $definition->defaultModel : ($providerModels[0] ?? '');
- }
- $model = readline("{$dim} Model [{$currentModel}]: {$r}") ?: $currentModel;
- $model = trim($model);
-
- $configSettings->set('agent.default_provider', $provider, 'global');
- $configSettings->set('agent.default_model', $model, 'global');
- $configSettings->setProviderLastModel($provider, $model, 'global');
-
- if ($definition->authMode === 'oauth') {
- echo "{$dim} Codex uses your ChatGPT login, not an API key.{$r}\n";
- $action = strtolower(trim(readline("{$dim} Authenticate now? [browser/device/skip] {$r}")));
-
- try {
- if ($action === 'browser' || $action === '') {
- $token = $codexAuth->browserLogin(fn (string $message) => $output->writeln(" {$message}"));
- echo "\n{$accent} ✓ Codex authenticated for {$white}".($token->email ?? 'your ChatGPT account')."{$r}\n";
- } elseif ($action === 'device') {
- $token = $codexAuth->deviceLogin(fn (string $message) => $output->writeln(" {$message}"));
- echo "\n{$accent} ✓ Codex authenticated for {$white}".($token->email ?? 'your ChatGPT account')."{$r}\n";
- } else {
- echo "{$dim} Skipped authentication. Run {$white}kosmokrator codex:login{$dim} later if needed.{$r}\n";
- }
- } catch (\Throwable $e) {
- echo "\n{$primary} ✗ {$e->getMessage()}{$r}\n";
- echo "{$dim} You can retry later with {$white}kosmokrator codex:login{$dim}.{$r}\n";
- }
-
- echo "\n{$accent} ✓ Settings saved.{$r}\n";
- echo "{$dim} Run {$white}php bin/kosmokrator{$dim} to start.{$r}\n\n";
-
- return Command::SUCCESS;
- }
-
- if ($definition->authMode === 'api_key') {
- $currentKey = $providers->apiKey($provider);
- $maskedKey = $currentKey !== '' ? substr($currentKey, 0, 8).'...'.substr($currentKey, -4) : '';
- $keyPrompt = $maskedKey !== '' ? " [{$maskedKey}]" : '';
-
- $apiKey = readline("{$dim} API key{$keyPrompt}: {$r}") ?: $currentKey;
- $apiKey = trim($apiKey);
-
- if ($apiKey === '') {
- echo "\n{$primary} ✗ API key is required.{$r}\n\n";
-
- return Command::FAILURE;
- }
-
- $settings->set('global', "provider.{$provider}.api_key", $apiKey);
- }
-
- echo "\n{$accent} ✓ Settings saved.{$r}\n";
- echo "{$dim} Run {$white}php bin/kosmokrator{$dim} to start.{$r}\n\n";
+ $output->writeln('Setup complete. Run `kosmokrator` to start. ');
return Command::SUCCESS;
}
diff --git a/src/Command/Slash/ModelsCommand.php b/src/Command/Slash/ModelsCommand.php
new file mode 100644
index 0000000..f32e134
--- /dev/null
+++ b/src/Command/Slash/ModelsCommand.php
@@ -0,0 +1,379 @@
+providers ?? $this->container->make(ProviderCatalog::class);
+ $models = $ctx->models ?? $this->container->make(ModelCatalog::class);
+ $registry = $this->container->make(RelayRegistry::class);
+ $settings = $this->container->make(SettingsManager::class);
+ $settings->setProjectRoot($ctx->sessionManager->getProject() ?? getcwd());
+ $history = new ModelSwitcherHistory($ctx->settings, $settings);
+
+ $currentProvider = $ctx->llm->getProvider();
+ $currentModel = $ctx->llm->getModel();
+
+ $args = trim($args);
+ if ($args === '') {
+ $suggestions = $this->buildSuggestions($catalog, $history, $currentProvider, $currentModel);
+ $choice = $ctx->ui->askChoice('Switch model', array_map(
+ static fn (array $item): array => [
+ 'label' => $item['label'],
+ 'detail' => $item['detail'],
+ 'recommended' => (bool) ($item['recommended'] ?? false),
+ ],
+ $suggestions['choices'],
+ ));
+
+ if ($choice !== 'dismissed') {
+ foreach ($suggestions['choices'] as $item) {
+ if ($item['label'] === $choice) {
+ $this->switchSelection(
+ $ctx,
+ $settings,
+ $history,
+ $catalog,
+ $models,
+ $registry,
+ $item['provider'],
+ $item['model'],
+ );
+
+ return SlashCommandResult::continue();
+ }
+ }
+ }
+
+ $ctx->ui->showNotice($suggestions['notice']);
+
+ return SlashCommandResult::continue();
+ }
+
+ $selection = $this->resolveSelection($args, $catalog, $history, $currentProvider);
+ if ($selection === null) {
+ $ctx->ui->showNotice("Unknown model or provider: {$args}\nUse /models to see likely choices. Full inventory lives in /settings.");
+
+ return SlashCommandResult::continue();
+ }
+
+ $this->switchSelection(
+ $ctx,
+ $settings,
+ $history,
+ $catalog,
+ $models,
+ $registry,
+ $selection['provider'],
+ $selection['model'],
+ );
+
+ return SlashCommandResult::continue();
+ }
+
+ /**
+ * @return array{notice: string, choices: list}
+ */
+ private function buildSuggestions(
+ ProviderCatalog $catalog,
+ ModelSwitcherHistory $history,
+ string $currentProvider,
+ string $currentModel,
+ ): array {
+ $choices = [];
+ $choiceKeys = [];
+ $lines = [
+ "Current: {$this->selectionLabel($catalog, $currentProvider, $currentModel)}",
+ '',
+ ];
+
+ $recentModels = array_values(array_filter(
+ $history->recentModels($catalog),
+ static fn (array $entry): bool => ! ($entry['provider'] === $currentProvider && $entry['model'] === $currentModel),
+ ));
+
+ $lines[] = 'Recent used models:';
+ if ($recentModels === []) {
+ $lines[] = ' none yet';
+ } else {
+ foreach ($recentModels as $entry) {
+ $lines[] = ' '.$this->selectionLabel($catalog, $entry['provider'], $entry['model']);
+ $this->pushChoice($choices, $choiceKeys, $catalog, $entry['provider'], $entry['model'], 'Recent selection');
+ }
+ }
+
+ $currentProviderModels = $this->likelyModelsForProvider(
+ $catalog,
+ $history,
+ $currentProvider,
+ self::CURRENT_PROVIDER_LIMIT,
+ );
+
+ $lines[] = '';
+ $lines[] = 'Current provider:';
+ foreach ($currentProviderModels as $model) {
+ $suffix = $model === $currentModel ? ' ← current' : '';
+ $lines[] = ' '.$this->selectionLabel($catalog, $currentProvider, $model).$suffix;
+ $this->pushChoice(
+ $choices,
+ $choiceKeys,
+ $catalog,
+ $currentProvider,
+ $model,
+ 'Current provider',
+ $model === $currentModel,
+ );
+ }
+
+ $recentProvider = null;
+ foreach ($history->recentProviders($catalog) as $provider) {
+ if ($provider !== $currentProvider) {
+ $recentProvider = $provider;
+
+ break;
+ }
+ }
+
+ if ($recentProvider !== null) {
+ $lines[] = '';
+ $lines[] = 'Recent provider:';
+ foreach ($this->likelyModelsForProvider($catalog, $history, $recentProvider, self::RECENT_PROVIDER_LIMIT) as $model) {
+ $lines[] = ' '.$this->selectionLabel($catalog, $recentProvider, $model);
+ $this->pushChoice($choices, $choiceKeys, $catalog, $recentProvider, $model, 'Recent provider');
+ }
+ }
+
+ $lines[] = '';
+ $lines[] = 'Use /models , /models , or /models .';
+ $lines[] = 'Full provider and model inventory stays in /settings.';
+
+ return [
+ 'notice' => implode("\n", $lines),
+ 'choices' => $choices,
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function likelyModelsForProvider(
+ ProviderCatalog $catalog,
+ ModelSwitcherHistory $history,
+ string $provider,
+ int $limit,
+ ): array {
+ $definition = $catalog->provider($provider);
+ if ($definition === null) {
+ return [];
+ }
+
+ $ordered = [];
+ $push = static function (array &$items, string $model) use ($catalog, $provider): void {
+ if ($model === '' || ! $catalog->supportsModel($provider, $model) || in_array($model, $items, true)) {
+ return;
+ }
+
+ $items[] = $model;
+ };
+
+ $push($ordered, $history->lastModelForProvider($provider, $catalog) ?? '');
+ $push($ordered, $definition->defaultModel);
+
+ foreach ($definition->models as $model) {
+ $push($ordered, $model->id);
+ if (count($ordered) >= $limit) {
+ break;
+ }
+ }
+
+ return array_slice($ordered, 0, $limit);
+ }
+
+ /**
+ * @return array{provider: string, model: string}|null
+ */
+ private function resolveSelection(
+ string $raw,
+ ProviderCatalog $catalog,
+ ModelSwitcherHistory $history,
+ string $currentProvider,
+ ): ?array {
+ $raw = trim($raw);
+ if ($raw === '') {
+ return null;
+ }
+
+ if (str_contains($raw, ':')) {
+ [$provider, $model] = array_map('trim', explode(':', $raw, 2));
+ if ($provider !== '' && $model !== '' && $catalog->supportsModel($provider, $model)) {
+ return ['provider' => $provider, 'model' => $model];
+ }
+
+ return null;
+ }
+
+ if ($catalog->provider($raw) !== null) {
+ $model = $history->lastModelForProvider($raw, $catalog);
+
+ return $model !== null ? ['provider' => $raw, 'model' => $model] : null;
+ }
+
+ if ($catalog->supportsModel($currentProvider, $raw)) {
+ return ['provider' => $currentProvider, 'model' => $raw];
+ }
+
+ $matches = [];
+ foreach ($catalog->providers() as $provider) {
+ if ($catalog->supportsModel($provider->id, $raw)) {
+ $matches[] = ['provider' => $provider->id, 'model' => $raw];
+ }
+ }
+
+ return count($matches) === 1 ? $matches[0] : null;
+ }
+
+ private function switchSelection(
+ SlashCommandContext $ctx,
+ SettingsManager $settings,
+ ModelSwitcherHistory $history,
+ ProviderCatalog $catalog,
+ ModelCatalog $models,
+ RelayRegistry $registry,
+ string $provider,
+ string $model,
+ ): void {
+ if ($ctx->llm->getProvider() === $provider && $ctx->llm->getModel() === $model) {
+ $ctx->ui->showNotice("Already using {$this->selectionLabel($catalog, $provider, $model)}.");
+
+ return;
+ }
+
+ $settings->set('agent.default_provider', $provider, 'global');
+ $settings->set('agent.default_model', $model, 'global');
+ $settings->setProviderLastModel($provider, $model, 'global');
+ $history->record($provider, $model);
+
+ if ($this->requiresRestart($ctx->llm, $registry, $provider)) {
+ $ctx->ui->showNotice("Saved {$this->selectionLabel($catalog, $provider, $model)}. Restart the session to switch runtime.");
+
+ return;
+ }
+
+ $ctx->llm->setProvider($provider);
+ $inner = self::innerClient($ctx->llm);
+
+ if ($provider !== 'codex' && method_exists($inner, 'setBaseUrl')) {
+ $inner->setBaseUrl(rtrim($registry->url($provider), '/'));
+ }
+
+ if (method_exists($inner, 'setApiKey')) {
+ $inner->setApiKey($provider === 'codex' ? '' : $catalog->apiKey($provider));
+ }
+
+ $ctx->llm->setModel($model);
+ $ctx->ui->refreshRuntimeSelection($provider, $model, $models->contextWindow($model));
+ $ctx->ui->showNotice("Switched to {$this->selectionLabel($catalog, $provider, $model)}.");
+ }
+
+ private function selectionLabel(ProviderCatalog $catalog, string $provider, string $model): string
+ {
+ $providerLabel = $catalog->provider($provider)?->label ?? $provider;
+
+ return "{$providerLabel} · {$model}";
+ }
+
+ /**
+ * @param list $choices
+ * @param array $seen
+ */
+ private function pushChoice(
+ array &$choices,
+ array &$seen,
+ ProviderCatalog $catalog,
+ string $provider,
+ string $model,
+ string $reason,
+ bool $recommended = false,
+ ): void {
+ $key = strtolower($provider."\0".$model);
+ if (isset($seen[$key])) {
+ return;
+ }
+
+ $seen[$key] = true;
+ $choices[] = [
+ 'label' => $this->selectionLabel($catalog, $provider, $model),
+ 'detail' => "{$reason}\n\nProvider: {$provider}\nModel: {$model}",
+ 'provider' => $provider,
+ 'model' => $model,
+ 'recommended' => $recommended,
+ ];
+ }
+
+ private function requiresRestart(LlmClientInterface $llm, RelayRegistry $registry, string $provider): bool
+ {
+ $inner = self::innerClient($llm);
+
+ return $inner instanceof AsyncLlmClient && ! $registry->supportsAsync($provider);
+ }
+
+ private static function innerClient(LlmClientInterface $llm): LlmClientInterface
+ {
+ return $llm instanceof RetryableLlmClient ? $llm->inner() : $llm;
+ }
+}
diff --git a/src/Command/Slash/SettingsCommand.php b/src/Command/Slash/SettingsCommand.php
index 619e92e..1c85435 100644
--- a/src/Command/Slash/SettingsCommand.php
+++ b/src/Command/Slash/SettingsCommand.php
@@ -15,12 +15,15 @@
use Kosmokrator\LLM\Codex\CodexAuthFlow;
use Kosmokrator\LLM\LlmClientInterface;
use Kosmokrator\LLM\ModelCatalog;
+use Kosmokrator\LLM\ModelSwitcherHistory;
use Kosmokrator\LLM\ProviderCatalog;
use Kosmokrator\LLM\ProviderDefinition;
use Kosmokrator\LLM\RetryableLlmClient;
use Kosmokrator\Settings\SettingsManager;
use Kosmokrator\Settings\SettingsSchema;
use Kosmokrator\Tool\Permission\PermissionMode;
+use Kosmokrator\Web\Provider\WebFetchProviderManager;
+use Kosmokrator\Web\Provider\WebSearchProviderManager;
use OpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
use OpenCompany\IntegrationCore\Contracts\ToolProvider;
use OpenCompany\PrismRelay\Registry\RelayRegistry;
@@ -58,21 +61,43 @@ public function immediate(): bool
public function execute(string $args, SlashCommandContext $ctx): SlashCommandResult
{
- // Resolve catalogs and settings manager, then render the settings UI
+ $this->openWorkspace($ctx);
+ return SlashCommandResult::continue();
+ }
+
+ /**
+ * @param array $viewOverrides
+ */
+ public function openWorkspace(SlashCommandContext $ctx, array $viewOverrides = []): bool
+ {
$catalog = $ctx->providers ?? $this->container->make(ProviderCatalog::class);
$registry = $this->container->make(RelayRegistry::class);
$settings = $this->container->make(SettingsManager::class);
$settings->setProjectRoot($ctx->sessionManager->getProject() ?? getcwd());
- $view = $this->buildSettingsView($ctx, $catalog, $settings);
+ $view = array_replace($this->buildSettingsView($ctx, $catalog, $settings), $viewOverrides);
$result = $ctx->ui->showSettings($view);
- // No changes submitted
if ($result === [] || ! is_array($result)) {
- return SlashCommandResult::continue();
+ return false;
}
+ $this->applyWorkspaceResult($ctx, $catalog, $registry, $settings, $result);
+
+ return true;
+ }
+
+ /**
+ * @param array $result
+ */
+ private function applyWorkspaceResult(
+ SlashCommandContext $ctx,
+ ProviderCatalog $catalog,
+ RelayRegistry $registry,
+ SettingsManager $settings,
+ array $result,
+ ): void {
$scope = (string) ($result['scope'] ?? 'project');
$changes = is_array($result['changes'] ?? null) ? $result['changes'] : [];
@@ -81,7 +106,6 @@ public function execute(string $args, SlashCommandContext $ctx): SlashCommandRes
? trim((string) $result['delete_custom_provider'])
: '';
- // Determine which provider the setup panel is configuring (may be custom)
$setupProvider = trim((string) ($changes['provider.setup_provider'] ?? $ctx->llm->getProvider()));
if ($setupProvider === '__custom__') {
$setupProvider = trim((string) ($customProvider['id'] ?? ''));
@@ -103,7 +127,6 @@ public function execute(string $args, SlashCommandContext $ctx): SlashCommandRes
$targetProvider = (string) ($changes['agent.default_provider'] ?? $ctx->llm->getProvider());
$targetModel = (string) ($changes['agent.default_model'] ?? $ctx->llm->getModel());
- // Fall back to the provider's default model if the selected one isn't supported
if (! $catalog->supportsModel($targetProvider, $targetModel)) {
$fallbackModel = $catalog->defaultModel($targetProvider) ?? ($catalog->modelIds($targetProvider)[0] ?? $targetModel);
$changes['agent.default_model'] = $fallbackModel;
@@ -114,7 +137,6 @@ public function execute(string $args, SlashCommandContext $ctx): SlashCommandRes
foreach ($changes as $id => $value) {
$stringValue = is_scalar($value) || $value === null ? (string) $value : '';
- // Dispatch each setting change to the appropriate handler
match ($id) {
'agent.mode' => $this->applyMode($ctx, $stringValue, $scope),
'tools.default_permission_mode' => $this->applyPermissionMode($ctx, $stringValue, $scope),
@@ -124,6 +146,8 @@ public function execute(string $args, SlashCommandContext $ctx): SlashCommandRes
'agent.default_provider' => $this->applyProvider($ctx, $catalog, $registry, $settings, $stringValue, $scope),
'agent.default_model' => $this->applyModel($ctx, $settings, $targetProvider, $stringValue, $scope),
'provider.secret.api_key' => $this->storeApiKey($ctx, $catalog, $setupProvider !== '' ? $setupProvider : $targetProvider, $stringValue),
+ 'gateway.telegram.secret.token' => $this->storeGatewayTelegramToken($ctx, $stringValue),
+ 'gateway.telegram.token_action' => $this->handleGatewayTelegramTokenAction($ctx, $stringValue),
'provider.auth_action' => $this->handleAuthAction($ctx, $catalog, $setupProvider !== '' ? $setupProvider : $targetProvider, $stringValue),
'provider.auth_status',
'provider.setup_provider',
@@ -148,13 +172,12 @@ public function execute(string $args, SlashCommandContext $ctx): SlashCommandRes
};
}
- // Refresh the status bar immediately so the user sees the new model/provider
if (isset($changes['agent.default_provider']) || isset($changes['agent.default_model'])) {
+ (new ModelSwitcherHistory($ctx->settings, $settings))->record($targetProvider, $targetModel);
$modelCatalog = $ctx->models ?? $this->container->make(ModelCatalog::class);
$ctx->ui->refreshRuntimeSelection($targetProvider, $targetModel, $modelCatalog->contextWindow($targetModel));
}
- // Codex requires OAuth; prompt the user to authenticate if credentials are missing
if (($changes['agent.default_provider'] ?? null) === 'codex' && ! isset($changes['provider.auth_action'])) {
$flow = $this->container->make(CodexAuthFlow::class);
if ($flow->current() === null) {
@@ -183,8 +206,6 @@ public function execute(string $args, SlashCommandContext $ctx): SlashCommandRes
if ($updatedKeys !== []) {
$ctx->ui->showNotice('Settings updated: '.implode(', ', $updatedKeys));
}
-
- return SlashCommandResult::continue();
}
/**
@@ -223,6 +244,19 @@ private function buildSettingsView(SlashCommandContext $ctx, ProviderCatalog $ca
];
}
+ foreach ($fields as &$field) {
+ if (($field['id'] ?? null) === 'web.search.default_provider'
+ && $this->container->bound(WebSearchProviderManager::class)) {
+ $field['options'] = $this->container->make(WebSearchProviderManager::class)->availableProviderIds();
+ }
+
+ if (($field['id'] ?? null) === 'web.fetch.default_provider'
+ && $this->container->bound(WebFetchProviderManager::class)) {
+ $field['options'] = $this->container->make(WebFetchProviderManager::class)->availableProviderIds();
+ }
+ }
+ unset($field);
+
if ($categoryId === 'models') {
$providerId = (string) ($fields[0]['value'] ?? $currentProvider);
$providerDef = $catalog->provider($providerId);
@@ -490,6 +524,10 @@ private function buildSettingsView(SlashCommandContext $ctx, ProviderCatalog $ca
$fields = array_merge($fields, $integrationView['fields']);
}
+ if ($categoryId === 'gateway') {
+ $fields = array_merge($fields, $this->gatewayFields($ctx));
+ }
+
$categories[] = [
'id' => $categoryId,
'label' => $label,
@@ -634,6 +672,43 @@ private function storeApiKey(SlashCommandContext $ctx, ProviderCatalog $catalog,
}
}
+ private function storeGatewayTelegramToken(SlashCommandContext $ctx, string $value): void
+ {
+ if ($value === '' || str_starts_with($value, '(')) {
+ return;
+ }
+
+ $ctx->settings->set('global', 'gateway.telegram.token', $value);
+ }
+
+ private function handleGatewayTelegramTokenAction(SlashCommandContext $ctx, string $action): void
+ {
+ if ($action === '') {
+ return;
+ }
+
+ if ($action === 'clear_token') {
+ $ctx->settings->delete('global', 'gateway.telegram.token');
+ $ctx->ui->showNotice('Cleared Telegram gateway token.');
+
+ return;
+ }
+
+ if ($action === 'edit_token') {
+ $token = trim($ctx->ui->askUser('Enter Telegram bot token:'));
+ if ($token !== '') {
+ $ctx->settings->set('global', 'gateway.telegram.token', $token);
+ $ctx->ui->showNotice('Stored Telegram gateway token.');
+ }
+
+ return;
+ }
+
+ if ($action === 'status') {
+ $ctx->ui->showNotice($this->gatewayTelegramTokenStatus($ctx));
+ }
+ }
+
/**
* Dispatches provider-specific auth workflows: API key management, OAuth browser/device
* login flows, and credential status inspection.
@@ -765,6 +840,47 @@ private function providerApiKeyDisplay(ProviderCatalog $catalog): array
return $values;
}
+ /**
+ * @return list>
+ */
+ private function gatewayFields(SlashCommandContext $ctx): array
+ {
+ $value = $ctx->settings->get('global', 'gateway.telegram.token');
+ $masked = $value !== null && $value !== '' ? $this->maskSecret($value) : '';
+
+ return [
+ [
+ 'id' => 'gateway.telegram.secret.token',
+ 'label' => 'Telegram bot token',
+ 'value' => $masked,
+ 'source' => 'secret_store',
+ 'effect' => 'applies_now',
+ 'type' => 'text',
+ 'options' => [],
+ 'description' => 'Bot token stored separately from YAML config.',
+ ],
+ [
+ 'id' => 'gateway.telegram.token_action',
+ 'label' => 'Token action',
+ 'value' => '',
+ 'source' => 'runtime',
+ 'effect' => 'applies_now',
+ 'type' => 'choice',
+ 'options' => ['status', 'edit_token', 'clear_token'],
+ 'description' => 'Inspect, replace, or clear the stored Telegram bot token.',
+ ],
+ ];
+ }
+
+ private function gatewayTelegramTokenStatus(SlashCommandContext $ctx): string
+ {
+ $value = $ctx->settings->get('global', 'gateway.telegram.token');
+
+ return ($value !== null && $value !== '')
+ ? 'Telegram bot token is configured.'
+ : 'Telegram bot token is not configured.';
+ }
+
/**
* @return array
*/
@@ -892,10 +1008,35 @@ private function runtimeValue(SlashCommandContext $ctx, string $id, mixed $fallb
'context.compact_threshold' => (string) ($ctx->agentLoop->getCompactor()?->getCompactThresholdPercent() ?? $fallback ?? 60),
'context.prune_protect' => (string) ($ctx->agentLoop->getPruner()?->getProtectTokens() ?? $fallback ?? 40000),
'context.prune_min_savings' => (string) ($ctx->agentLoop->getPruner()?->getMinSavings() ?? $fallback ?? 20000),
- default => $fallback === null ? '' : (string) $fallback,
+ default => $this->stringifySettingValue($fallback),
};
}
+ private function stringifySettingValue(mixed $value): string
+ {
+ if ($value === null) {
+ return '';
+ }
+
+ if (is_bool($value)) {
+ return $value ? 'on' : 'off';
+ }
+
+ if (is_array($value)) {
+ $items = array_values(array_filter(array_map(static function (mixed $item): string {
+ if (is_scalar($item) || $item === null) {
+ return trim((string) $item);
+ }
+
+ return '';
+ }, $value), static fn (string $item): bool => $item !== ''));
+
+ return implode(', ', $items);
+ }
+
+ return (string) $value;
+ }
+
/**
* Build dynamic fields for the Integrations settings category.
*
diff --git a/src/Command/SlashCommandRegistryFactory.php b/src/Command/SlashCommandRegistryFactory.php
new file mode 100644
index 0000000..e8adefa
--- /dev/null
+++ b/src/Command/SlashCommandRegistryFactory.php
@@ -0,0 +1,51 @@
+register(new Slash\QuitCommand);
+ $registry->register(new Slash\ClearCommand);
+ $registry->register(new Slash\SeedCommand);
+ $registry->register(new Slash\TheogonyCommand);
+ $registry->register(new Slash\CompactCommand);
+ $registry->register(new Slash\ModelsCommand($container));
+ $registry->register(new Slash\TasksClearCommand);
+ $registry->register(new Slash\MemoriesCommand);
+ $registry->register(new Slash\SessionsCommand);
+ $registry->register(new Slash\ForgetCommand);
+
+ $registry->register(new Slash\GuardianCommand);
+ $registry->register(new Slash\ArgusCommand);
+ $registry->register(new Slash\PrometheusCommand);
+ $registry->register(new Slash\ModeCommand(AgentMode::Edit));
+ $registry->register(new Slash\ModeCommand(AgentMode::Plan));
+ $registry->register(new Slash\ModeCommand(AgentMode::Ask));
+
+ $registry->register(new Slash\NewCommand);
+ $registry->register(new Slash\ResumeCommand);
+ $registry->register(new Slash\SettingsCommand($container));
+ $registry->register(new Slash\AgentsCommand);
+ $registry->register(new Slash\FeedbackCommand($version));
+ $registry->register(new Slash\RenameCommand);
+
+ if ($includeHelp) {
+ $registry->register(new Slash\HelpCommand($registry, $powerRegistry ?? new PowerCommandRegistry));
+ }
+
+ return $registry;
+ }
+}
diff --git a/src/Command/TelegramGatewayCommand.php b/src/Command/TelegramGatewayCommand.php
new file mode 100644
index 0000000..92fa790
--- /dev/null
+++ b/src/Command/TelegramGatewayCommand.php
@@ -0,0 +1,102 @@
+container->make(SettingsManager::class);
+ $settings->setProjectRoot(InstructionLoader::gitRoot() ?? getcwd());
+ $settingsRepository = $this->container->make(SettingsRepositoryInterface::class);
+ $config = TelegramGatewayConfig::fromSettings($settings, $this->container->make('config'), $settingsRepository);
+ $secretToken = $settingsRepository->get('global', 'gateway.telegram.token');
+ if (is_string($secretToken) && $secretToken !== '') {
+ $config = new TelegramGatewayConfig(
+ enabled: $config->enabled,
+ token: $secretToken,
+ sessionMode: $config->sessionMode,
+ allowedUsers: $config->allowedUsers,
+ allowedChats: $config->allowedChats,
+ requireMention: $config->requireMention,
+ freeResponseChats: $config->freeResponseChats,
+ pollTimeoutSeconds: $config->pollTimeoutSeconds,
+ );
+ }
+
+ try {
+ $config->validate();
+ } catch (\RuntimeException $e) {
+ $output->writeln(''.$e->getMessage().' ');
+
+ return Command::FAILURE;
+ }
+
+ try {
+ $lock = TelegramPollerLock::acquire($config->token);
+ } catch (\RuntimeException $e) {
+ $output->writeln(''.$e->getMessage().' ');
+
+ return Command::FAILURE;
+ }
+
+ $client = new TelegramClient($this->container->make('http'), $config->token);
+ $me = $client->getMe();
+ $botUsername = ltrim((string) ($me['username'] ?? ''), '@');
+ $checkpoint = $this->container->make(GatewayCheckpointStore::class)->get('telegram', 'last_update_id') ?? 'none';
+
+ $output->writeln('Starting Telegram gateway… ');
+ $output->writeln(sprintf(' Bot: @%s', $botUsername !== '' ? $botUsername : 'unknown'));
+ $output->writeln(sprintf(' Session mode: %s', $config->sessionMode));
+ $output->writeln(sprintf(' Mention gating: %s', $config->requireMention ? 'required in groups' : 'off'));
+ $output->writeln(sprintf(' Allowed users: %s', $config->allowedUsers === [] ? 'all' : (string) count($config->allowedUsers)));
+ $output->writeln(sprintf(' Allowed chats: %s', $config->allowedChats === [] ? 'all' : (string) count($config->allowedChats)));
+ $output->writeln(sprintf(' Checkpoint: %s', $checkpoint));
+
+ $runtime = new TelegramGatewayRuntime(
+ container: $this->container,
+ client: $client,
+ config: $config,
+ sessionLinks: $this->container->make(GatewaySessionStore::class),
+ messages: $this->container->make(GatewayMessageStore::class),
+ approvals: $this->container->make(GatewayApprovalStore::class),
+ checkpoints: $this->container->make(GatewayCheckpointStore::class),
+ pendingInputs: $this->container->make(GatewayPendingInputStore::class),
+ log: $this->container->make(LoggerInterface::class),
+ launcher: new SymfonyProcessTelegramWorkerLauncher(InstructionLoader::gitRoot() ?? getcwd()),
+ );
+ $runtime->setBotUsername($botUsername);
+ $runtime->registerBotCommands();
+
+ return $runtime->run();
+ }
+}
diff --git a/src/Command/TelegramGatewayWorkerCommand.php b/src/Command/TelegramGatewayWorkerCommand.php
new file mode 100644
index 0000000..66570f3
--- /dev/null
+++ b/src/Command/TelegramGatewayWorkerCommand.php
@@ -0,0 +1,253 @@
+addOption('event', null, InputOption::VALUE_REQUIRED, 'Base64-encoded gateway event payload');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $encoded = $input->getOption('event');
+ if (! is_string($encoded) || $encoded === '') {
+ $output->writeln('Missing --event payload. ');
+
+ return Command::FAILURE;
+ }
+
+ try {
+ $decoded = base64_decode($encoded, true);
+ if ($decoded === false) {
+ throw new \RuntimeException('Invalid event payload encoding.');
+ }
+
+ $payload = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR);
+ if (! is_array($payload)) {
+ throw new \RuntimeException('Invalid event payload.');
+ }
+
+ $event = GatewayMessageEvent::fromArray($payload);
+ } catch (\Throwable $e) {
+ $output->writeln(''.$e->getMessage().' ');
+
+ return Command::FAILURE;
+ }
+
+ $this->writeWorkerLog(sprintf(
+ 'start route=%s chat=%s user=%s',
+ $event->routeKey,
+ $event->chatId,
+ $event->userId ?? 'unknown',
+ ));
+
+ $settings = $this->container->make(SettingsManager::class);
+ $settings->setProjectRoot(InstructionLoader::gitRoot() ?? getcwd());
+ $settingsRepository = $this->container->make(SettingsRepositoryInterface::class);
+ $config = TelegramGatewayConfig::fromSettings($settings, $this->container->make('config'), $settingsRepository);
+ $secretToken = $settingsRepository->get('global', 'gateway.telegram.token');
+ if (is_string($secretToken) && $secretToken !== '') {
+ $config = new TelegramGatewayConfig(
+ enabled: $config->enabled,
+ token: $secretToken,
+ sessionMode: $config->sessionMode,
+ allowedUsers: $config->allowedUsers,
+ allowedChats: $config->allowedChats,
+ requireMention: $config->requireMention,
+ freeResponseChats: $config->freeResponseChats,
+ pollTimeoutSeconds: $config->pollTimeoutSeconds,
+ );
+ }
+
+ $cancellation = new DeferredCancellation;
+ $cancelled = false;
+ /** @var AgentSession|null $session */
+ $session = null;
+
+ if (function_exists('pcntl_async_signals')) {
+ pcntl_async_signals(true);
+ $handler = function () use (&$cancelled, $cancellation, &$session): void {
+ $cancelled = true;
+ $cancellation->cancel(new \RuntimeException('Telegram gateway run cancelled.'));
+ if ($session instanceof AgentSession) {
+ $session->orchestrator?->cancelAll();
+ }
+ $this->container->make(ShellSessionManager::class)->killAll();
+ };
+ pcntl_signal(SIGTERM, $handler);
+ pcntl_signal(SIGINT, $handler);
+ }
+
+ $sessionLinks = $this->container->make(GatewaySessionStore::class);
+ $link = $sessionLinks->find('telegram', $event->routeKey);
+ $renderer = new TelegramGatewayRenderer(
+ client: new TelegramClient($this->container->make('http'), $config->token),
+ messages: $this->container->make(GatewayMessageStore::class),
+ approvals: $this->container->make(GatewayApprovalStore::class),
+ routeKey: $event->routeKey,
+ sessionId: $link?->sessionId ?? '',
+ chatId: $event->chatId,
+ threadId: $event->threadId,
+ approvalCallback: function (int $approvalId): string {
+ return $this->awaitApprovalDecision($approvalId);
+ },
+ cancellation: fn () => $cancellation->getCancellation(),
+ );
+
+ $builder = $this->container->make(AgentSessionBuilder::class);
+ $session = $builder->buildGateway($renderer, [
+ 'append_system_prompt' => GatewaySessionContextPromptBuilder::build($event, $link?->sessionId),
+ ]);
+
+ try {
+ if ($link !== null) {
+ $renderer->setSessionId($link->sessionId);
+ $session->sessionManager->setCurrentSession($link->sessionId);
+ $history = $session->sessionManager->loadHistory($link->sessionId);
+ if ($history->count() > 0) {
+ $session->agentLoop->setHistory($history);
+ }
+ }
+
+ $slashResult = (new TelegramSlashCommandBridge(
+ $this->container,
+ $this->getApplication()?->getVersion() ?? 'dev',
+ ))->dispatch($event->text, new SlashCommandContext(
+ $renderer,
+ $session->agentLoop,
+ $session->permissions,
+ $session->sessionManager,
+ $session->llm,
+ $this->container->make(TaskStore::class),
+ $this->container->make('config'),
+ $this->container->make(SettingsRepositoryInterface::class),
+ $session->orchestrator,
+ $this->container->make(ModelCatalog::class),
+ $this->container->make(ProviderCatalog::class),
+ ));
+
+ if ($slashResult !== null) {
+ if ($slashResult !== '') {
+ if ($link === null) {
+ $sessionId = $session->sessionManager->createSession($session->llm->getProvider().'/'.$session->llm->getModel());
+ $sessionLinks->save('telegram', $event->routeKey, $sessionId, $event->chatId, $event->threadId, $event->userId, [
+ 'username' => $event->username,
+ ]);
+ $renderer->setSessionId($sessionId);
+ $session->sessionManager->setCurrentSession($sessionId);
+ }
+
+ $session->agentLoop->run($slashResult);
+ }
+
+ return Command::SUCCESS;
+ }
+
+ if ($link === null) {
+ $sessionId = $session->sessionManager->createSession($session->llm->getProvider().'/'.$session->llm->getModel());
+ $sessionLinks->save('telegram', $event->routeKey, $sessionId, $event->chatId, $event->threadId, $event->userId, [
+ 'username' => $event->username,
+ ]);
+ $renderer->setSessionId($sessionId);
+ $session->sessionManager->setCurrentSession($sessionId);
+ }
+
+ $session->agentLoop->run($event->text);
+
+ if ($cancelled) {
+ $renderer->showNotice('Cancelled.');
+ }
+ } catch (\Throwable $e) {
+ $this->writeWorkerLog(sprintf(
+ 'error route=%s session=%s %s: %s',
+ $event->routeKey,
+ $session?->sessionManager->currentSessionId() ?? ($link?->sessionId ?? 'unlinked'),
+ get_class($e),
+ $e->getMessage(),
+ ));
+
+ throw $e;
+ } finally {
+ $this->writeWorkerLog(sprintf(
+ 'finish route=%s session=%s cancelled=%s',
+ $event->routeKey,
+ $session?->sessionManager->currentSessionId() ?? ($link?->sessionId ?? 'unlinked'),
+ $cancelled ? 'yes' : 'no',
+ ));
+ $session?->orchestrator?->cancelAll();
+ $this->container->make(ShellSessionManager::class)->killAll();
+ }
+
+ return Command::SUCCESS;
+ }
+
+ private function awaitApprovalDecision(int $approvalId, int $timeoutSeconds = 300): string
+ {
+ $approvals = $this->container->make(GatewayApprovalStore::class);
+ $deadline = microtime(true) + $timeoutSeconds;
+
+ while (microtime(true) < $deadline) {
+ $approval = $approvals->find($approvalId);
+ $status = $approval?->status ?? 'denied';
+
+ if ($status !== 'pending') {
+ return match ($status) {
+ 'approved', 'allow' => 'allow',
+ 'always' => 'always',
+ 'guardian' => 'guardian',
+ 'prometheus' => 'prometheus',
+ default => 'deny',
+ };
+ }
+
+ usleep(200_000);
+ }
+
+ $this->writeWorkerLog(sprintf('approval timeout id=%d', $approvalId));
+ $approvals->resolve($approvalId, 'denied');
+
+ return 'deny';
+ }
+
+ private function writeWorkerLog(string $message): void
+ {
+ file_put_contents('php://stderr', sprintf("[%s] %s\n", date(DATE_ATOM), $message), FILE_APPEND);
+ }
+}
diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php
new file mode 100644
index 0000000..2ca5117
--- /dev/null
+++ b/src/Command/UpdateCommand.php
@@ -0,0 +1,120 @@
+addOption('check', null, InputOption::VALUE_NONE, 'Only check whether an update is available')
+ ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Apply the update without interactive confirmation');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $checker = $this->makeChecker();
+ $updater = $this->makeUpdater();
+ $method = $updater->installationMethod();
+
+ $io->writeln(sprintf(
+ 'Install method: %s ',
+ match ($method) {
+ 'binary' => 'static binary',
+ 'phar' => 'PHAR',
+ 'source' => 'source checkout',
+ default => 'unknown',
+ },
+ ));
+
+ $checker->clearCache();
+ $latest = $checker->fetchLatestVersion();
+ if ($latest === null) {
+ $io->error('Could not reach GitHub. Try again later.');
+
+ return Command::FAILURE;
+ }
+
+ $currentNormalized = ltrim($this->currentVersion, 'v');
+ $latestNormalized = ltrim($latest, 'v');
+
+ if (! version_compare($latestNormalized, $currentNormalized, '>')) {
+ $io->success("Already on the latest version (v{$currentNormalized}).");
+
+ return Command::SUCCESS;
+ }
+
+ $io->note("Update available: v{$latestNormalized} (current: v{$currentNormalized})");
+
+ if ($input->getOption('check')) {
+ return Command::SUCCESS;
+ }
+
+ if ($method === 'source') {
+ $io->warning("Source installs are updated manually.\n\n".$updater->sourceUpdateInstructions());
+
+ return Command::SUCCESS;
+ }
+
+ if (! $input->getOption('yes') && (! $input->isInteractive() || ! $io->confirm("Install v{$latestNormalized} now?", true))) {
+ $io->text('Skipped.');
+
+ return Command::SUCCESS;
+ }
+
+ try {
+ $io->text('Downloading and replacing the current executable...');
+ $message = $updater->update($latestNormalized);
+ $io->success($message);
+
+ return Command::SUCCESS;
+ } catch (\Throwable $e) {
+ $io->error('Update failed: '.$e->getMessage());
+
+ return Command::FAILURE;
+ }
+ }
+
+ protected function makeChecker(): UpdateCheckerInterface
+ {
+ return $this->checkerFactory !== null
+ ? ($this->checkerFactory)($this->currentVersion)
+ : new UpdateChecker($this->currentVersion);
+ }
+
+ protected function makeUpdater(): SelfUpdaterInterface
+ {
+ return $this->updaterFactory !== null
+ ? ($this->updaterFactory)()
+ : new SelfUpdater;
+ }
+}
diff --git a/src/Gateway/GatewayApproval.php b/src/Gateway/GatewayApproval.php
new file mode 100644
index 0000000..8015dd6
--- /dev/null
+++ b/src/Gateway/GatewayApproval.php
@@ -0,0 +1,24 @@
+ $arguments
+ */
+ public function __construct(
+ public int $id,
+ public string $platform,
+ public string $routeKey,
+ public string $sessionId,
+ public string $toolName,
+ public array $arguments,
+ public string $status,
+ public string $chatId,
+ public ?string $threadId,
+ public ?int $requestMessageId = null,
+ ) {}
+}
diff --git a/src/Gateway/GatewayApprovalStore.php b/src/Gateway/GatewayApprovalStore.php
new file mode 100644
index 0000000..32105bb
--- /dev/null
+++ b/src/Gateway/GatewayApprovalStore.php
@@ -0,0 +1,142 @@
+ $arguments
+ */
+ public function createPending(
+ string $platform,
+ string $routeKey,
+ string $sessionId,
+ string $toolName,
+ array $arguments,
+ string $chatId,
+ ?string $threadId = null,
+ ?int $requestMessageId = null,
+ ): GatewayApproval {
+ $now = (new \DateTimeImmutable)->format(DATE_ATOM);
+ $stmt = $this->database->connection()->prepare(
+ 'INSERT INTO gateway_approvals (
+ platform, route_key, session_id, tool_name, arguments_json, status, chat_id, thread_id, request_message_id, created_at
+ ) VALUES (
+ :platform, :route_key, :session_id, :tool_name, :arguments_json, :status, :chat_id, :thread_id, :request_message_id, :created_at
+ )'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ 'session_id' => $sessionId,
+ 'tool_name' => $toolName,
+ 'arguments_json' => json_encode($arguments, JSON_THROW_ON_ERROR),
+ 'status' => 'pending',
+ 'chat_id' => $chatId,
+ 'thread_id' => $threadId,
+ 'request_message_id' => $requestMessageId,
+ 'created_at' => $now,
+ ]);
+
+ return new GatewayApproval(
+ id: (int) $this->database->connection()->lastInsertId(),
+ platform: $platform,
+ routeKey: $routeKey,
+ sessionId: $sessionId,
+ toolName: $toolName,
+ arguments: $arguments,
+ status: 'pending',
+ chatId: $chatId,
+ threadId: $threadId,
+ requestMessageId: $requestMessageId,
+ );
+ }
+
+ public function latestPending(string $platform, string $routeKey): ?GatewayApproval
+ {
+ return $this->findLatestByStatus($platform, $routeKey, 'pending');
+ }
+
+ public function resolve(int $id, string $status): void
+ {
+ $stmt = $this->database->connection()->prepare(
+ 'UPDATE gateway_approvals SET status = :status, resolved_at = :resolved_at WHERE id = :id'
+ );
+ $stmt->execute([
+ 'status' => $status,
+ 'resolved_at' => (new \DateTimeImmutable)->format(DATE_ATOM),
+ 'id' => $id,
+ ]);
+ }
+
+ public function find(int $id): ?GatewayApproval
+ {
+ $stmt = $this->database->connection()->prepare(
+ 'SELECT * FROM gateway_approvals WHERE id = :id LIMIT 1'
+ );
+ $stmt->execute(['id' => $id]);
+ $row = $stmt->fetch();
+
+ return is_array($row) ? $this->hydrate($row) : null;
+ }
+
+ public function resolveLatestPending(string $platform, string $routeKey, string $status): ?GatewayApproval
+ {
+ $approval = $this->latestPending($platform, $routeKey);
+ if ($approval === null) {
+ return null;
+ }
+
+ $this->resolve($approval->id, $status);
+
+ return $this->find($approval->id);
+ }
+
+ /**
+ * @param array $row
+ */
+ private function hydrate(array $row): GatewayApproval
+ {
+ $arguments = json_decode((string) $row['arguments_json'], true);
+
+ return new GatewayApproval(
+ id: (int) $row['id'],
+ platform: (string) $row['platform'],
+ routeKey: (string) $row['route_key'],
+ sessionId: (string) $row['session_id'],
+ toolName: (string) $row['tool_name'],
+ arguments: is_array($arguments) ? $arguments : [],
+ status: (string) $row['status'],
+ chatId: (string) $row['chat_id'],
+ threadId: $row['thread_id'] !== null ? (string) $row['thread_id'] : null,
+ requestMessageId: $row['request_message_id'] !== null ? (int) $row['request_message_id'] : null,
+ );
+ }
+
+ private function findLatestByStatus(string $platform, string $routeKey, string $status): ?GatewayApproval
+ {
+ $stmt = $this->database->connection()->prepare(
+ 'SELECT * FROM gateway_approvals
+ WHERE platform = :platform AND route_key = :route_key AND status = :status
+ ORDER BY id DESC
+ LIMIT 1'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ 'status' => $status,
+ ]);
+
+ $row = $stmt->fetch();
+
+ return is_array($row) ? $this->hydrate($row) : null;
+ }
+}
diff --git a/src/Gateway/GatewayCheckpointStore.php b/src/Gateway/GatewayCheckpointStore.php
new file mode 100644
index 0000000..43b7dbd
--- /dev/null
+++ b/src/Gateway/GatewayCheckpointStore.php
@@ -0,0 +1,45 @@
+database->connection()->prepare(
+ 'SELECT value FROM gateway_checkpoints WHERE platform = :platform AND checkpoint = :checkpoint LIMIT 1'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'checkpoint' => $checkpoint,
+ ]);
+ $row = $stmt->fetch();
+
+ return is_array($row) ? (string) $row['value'] : null;
+ }
+
+ public function set(string $platform, string $checkpoint, ?string $value): void
+ {
+ $stmt = $this->database->connection()->prepare(
+ 'INSERT INTO gateway_checkpoints (platform, checkpoint, value, updated_at)
+ VALUES (:platform, :checkpoint, :value, :updated_at)
+ ON CONFLICT(platform, checkpoint) DO UPDATE SET
+ value = excluded.value,
+ updated_at = excluded.updated_at'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'checkpoint' => $checkpoint,
+ 'value' => $value,
+ 'updated_at' => (new \DateTimeImmutable)->format(DATE_ATOM),
+ ]);
+ }
+}
diff --git a/src/Gateway/GatewayMessageEvent.php b/src/Gateway/GatewayMessageEvent.php
new file mode 100644
index 0000000..6842505
--- /dev/null
+++ b/src/Gateway/GatewayMessageEvent.php
@@ -0,0 +1,92 @@
+text)) === 1;
+ }
+
+ public function withRouteKey(string $routeKey): self
+ {
+ return new self(
+ updateId: $this->updateId,
+ platform: $this->platform,
+ chatId: $this->chatId,
+ threadId: $this->threadId,
+ routeKey: $routeKey,
+ text: $this->text,
+ userId: $this->userId,
+ username: $this->username,
+ isPrivate: $this->isPrivate,
+ isReplyToBot: $this->isReplyToBot,
+ mentionsBot: $this->mentionsBot,
+ messageId: $this->messageId,
+ callbackQueryId: $this->callbackQueryId,
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'update_id' => $this->updateId,
+ 'platform' => $this->platform,
+ 'chat_id' => $this->chatId,
+ 'thread_id' => $this->threadId,
+ 'route_key' => $this->routeKey,
+ 'text' => $this->text,
+ 'user_id' => $this->userId,
+ 'username' => $this->username,
+ 'is_private' => $this->isPrivate,
+ 'is_reply_to_bot' => $this->isReplyToBot,
+ 'mentions_bot' => $this->mentionsBot,
+ 'message_id' => $this->messageId,
+ 'callback_query_id' => $this->callbackQueryId,
+ ];
+ }
+
+ /**
+ * @param array $payload
+ */
+ public static function fromArray(array $payload): self
+ {
+ return new self(
+ updateId: (int) ($payload['update_id'] ?? 0),
+ platform: (string) ($payload['platform'] ?? 'telegram'),
+ chatId: (string) ($payload['chat_id'] ?? ''),
+ threadId: isset($payload['thread_id']) ? (string) $payload['thread_id'] : null,
+ routeKey: (string) ($payload['route_key'] ?? ''),
+ text: (string) ($payload['text'] ?? ''),
+ userId: isset($payload['user_id']) ? (string) $payload['user_id'] : null,
+ username: isset($payload['username']) ? (string) $payload['username'] : null,
+ isPrivate: (bool) ($payload['is_private'] ?? false),
+ isReplyToBot: (bool) ($payload['is_reply_to_bot'] ?? false),
+ mentionsBot: (bool) ($payload['mentions_bot'] ?? false),
+ messageId: isset($payload['message_id']) ? (int) $payload['message_id'] : null,
+ callbackQueryId: isset($payload['callback_query_id']) ? (string) $payload['callback_query_id'] : null,
+ );
+ }
+}
diff --git a/src/Gateway/GatewayMessagePointer.php b/src/Gateway/GatewayMessagePointer.php
new file mode 100644
index 0000000..0578050
--- /dev/null
+++ b/src/Gateway/GatewayMessagePointer.php
@@ -0,0 +1,17 @@
+database->connection()->prepare(
+ 'SELECT * FROM gateway_messages WHERE platform = :platform AND route_key = :route_key AND message_kind = :message_kind LIMIT 1'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ 'message_kind' => $messageKind,
+ ]);
+
+ $row = $stmt->fetch();
+ if (! is_array($row)) {
+ return null;
+ }
+
+ return new GatewayMessagePointer(
+ platform: (string) $row['platform'],
+ routeKey: (string) $row['route_key'],
+ messageKind: (string) $row['message_kind'],
+ chatId: (string) $row['chat_id'],
+ messageId: (int) $row['message_id'],
+ threadId: $row['thread_id'] !== null ? (string) $row['thread_id'] : null,
+ );
+ }
+
+ public function save(
+ string $platform,
+ string $routeKey,
+ string $messageKind,
+ string $chatId,
+ int $messageId,
+ ?string $threadId = null,
+ ): void {
+ $stmt = $this->database->connection()->prepare(
+ 'INSERT INTO gateway_messages (platform, route_key, message_kind, chat_id, message_id, thread_id, updated_at)
+ VALUES (:platform, :route_key, :message_kind, :chat_id, :message_id, :thread_id, :updated_at)
+ ON CONFLICT(platform, route_key, message_kind) DO UPDATE SET
+ chat_id = excluded.chat_id,
+ message_id = excluded.message_id,
+ thread_id = excluded.thread_id,
+ updated_at = excluded.updated_at'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ 'message_kind' => $messageKind,
+ 'chat_id' => $chatId,
+ 'message_id' => $messageId,
+ 'thread_id' => $threadId,
+ 'updated_at' => (new \DateTimeImmutable)->format(DATE_ATOM),
+ ]);
+ }
+
+ public function delete(string $platform, string $routeKey, string $messageKind): void
+ {
+ $stmt = $this->database->connection()->prepare(
+ 'DELETE FROM gateway_messages WHERE platform = :platform AND route_key = :route_key AND message_kind = :message_kind'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ 'message_kind' => $messageKind,
+ ]);
+ }
+}
diff --git a/src/Gateway/GatewayPendingInput.php b/src/Gateway/GatewayPendingInput.php
new file mode 100644
index 0000000..a77dc4f
--- /dev/null
+++ b/src/Gateway/GatewayPendingInput.php
@@ -0,0 +1,19 @@
+ $payload
+ */
+ public function __construct(
+ public int $id,
+ public string $platform,
+ public string $routeKey,
+ public array $payload,
+ public string $createdAt,
+ ) {}
+}
diff --git a/src/Gateway/GatewayPendingInputStore.php b/src/Gateway/GatewayPendingInputStore.php
new file mode 100644
index 0000000..6db4987
--- /dev/null
+++ b/src/Gateway/GatewayPendingInputStore.php
@@ -0,0 +1,85 @@
+database->connection()->prepare(
+ 'INSERT INTO gateway_pending_inputs (platform, route_key, payload_json, created_at)
+ VALUES (:platform, :route_key, :payload_json, :created_at)'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ 'payload_json' => json_encode($event->toArray(), JSON_THROW_ON_ERROR),
+ 'created_at' => (new \DateTimeImmutable)->format(DATE_ATOM),
+ ]);
+ }
+
+ public function count(string $platform, string $routeKey): int
+ {
+ $stmt = $this->database->connection()->prepare(
+ 'SELECT COUNT(*) AS cnt FROM gateway_pending_inputs WHERE platform = :platform AND route_key = :route_key'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ ]);
+
+ $row = $stmt->fetch();
+
+ return is_array($row) ? (int) ($row['cnt'] ?? 0) : 0;
+ }
+
+ public function dequeueNext(string $platform, string $routeKey): ?GatewayPendingInput
+ {
+ $stmt = $this->database->connection()->prepare(
+ 'SELECT * FROM gateway_pending_inputs WHERE platform = :platform AND route_key = :route_key ORDER BY id ASC LIMIT 1'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ ]);
+
+ $row = $stmt->fetch();
+ if (! is_array($row)) {
+ return null;
+ }
+
+ $delete = $this->database->connection()->prepare(
+ 'DELETE FROM gateway_pending_inputs WHERE id = :id'
+ );
+ $delete->execute(['id' => $row['id']]);
+
+ $payload = json_decode((string) $row['payload_json'], true);
+
+ return new GatewayPendingInput(
+ id: (int) $row['id'],
+ platform: (string) $row['platform'],
+ routeKey: (string) $row['route_key'],
+ payload: is_array($payload) ? $payload : [],
+ createdAt: (string) $row['created_at'],
+ );
+ }
+
+ public function clear(string $platform, string $routeKey): void
+ {
+ $stmt = $this->database->connection()->prepare(
+ 'DELETE FROM gateway_pending_inputs WHERE platform = :platform AND route_key = :route_key'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ ]);
+ }
+}
diff --git a/src/Gateway/GatewaySessionContextPromptBuilder.php b/src/Gateway/GatewaySessionContextPromptBuilder.php
new file mode 100644
index 0000000..50389c9
--- /dev/null
+++ b/src/Gateway/GatewaySessionContextPromptBuilder.php
@@ -0,0 +1,48 @@
+isPrivate ? 'private chat' : 'group or channel thread'),
+ '- Route key: '.$event->routeKey,
+ '- Chat ID: '.$event->chatId,
+ ];
+
+ if ($event->threadId !== null) {
+ $lines[] = '- Thread ID: '.$event->threadId;
+ }
+
+ if ($event->userId !== null) {
+ $lines[] = '- User ID: '.$event->userId;
+ }
+
+ if ($event->username !== null && $event->username !== '') {
+ $lines[] = '- Username: @'.ltrim($event->username, '@');
+ }
+
+ if ($sessionId !== null && $sessionId !== '') {
+ $lines[] = '- Linked Kosmo session: '.$sessionId;
+ }
+
+ $lines[] = '';
+ $lines[] = 'Gateway notes:';
+ $lines[] = '- Inline approval buttons may appear for dangerous operations.';
+ $lines[] = '- The user can also reply with /approve, /approve always, /approve guardian, /approve prometheus, /deny, or /cancel.';
+ $lines[] = '- Native Telegram attachments can be sent when the final text includes MEDIA:/absolute/path tags.';
+
+ return implode("\n", $lines);
+ }
+}
diff --git a/src/Gateway/GatewaySessionLink.php b/src/Gateway/GatewaySessionLink.php
new file mode 100644
index 0000000..2527708
--- /dev/null
+++ b/src/Gateway/GatewaySessionLink.php
@@ -0,0 +1,21 @@
+ $metadata
+ */
+ public function __construct(
+ public string $platform,
+ public string $routeKey,
+ public string $sessionId,
+ public string $chatId,
+ public ?string $threadId,
+ public ?string $userId,
+ public array $metadata = [],
+ ) {}
+}
diff --git a/src/Gateway/GatewaySessionStore.php b/src/Gateway/GatewaySessionStore.php
new file mode 100644
index 0000000..241480a
--- /dev/null
+++ b/src/Gateway/GatewaySessionStore.php
@@ -0,0 +1,99 @@
+database->connection()->prepare(
+ 'SELECT * FROM gateway_sessions WHERE platform = :platform AND route_key = :route_key LIMIT 1'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ ]);
+
+ $row = $stmt->fetch();
+ if (! is_array($row)) {
+ return null;
+ }
+
+ return new GatewaySessionLink(
+ platform: (string) $row['platform'],
+ routeKey: (string) $row['route_key'],
+ sessionId: (string) $row['session_id'],
+ chatId: (string) $row['chat_id'],
+ threadId: $row['thread_id'] !== null ? (string) $row['thread_id'] : null,
+ userId: $row['user_id'] !== null ? (string) $row['user_id'] : null,
+ metadata: $this->decodeJsonMap($row['metadata'] ?? null),
+ );
+ }
+
+ /**
+ * @param array $metadata
+ */
+ public function save(
+ string $platform,
+ string $routeKey,
+ string $sessionId,
+ string $chatId,
+ ?string $threadId = null,
+ ?string $userId = null,
+ array $metadata = [],
+ ): void {
+ $now = (new \DateTimeImmutable)->format(DATE_ATOM);
+ $stmt = $this->database->connection()->prepare(
+ 'INSERT INTO gateway_sessions (platform, route_key, session_id, chat_id, thread_id, user_id, metadata, created_at, updated_at)
+ VALUES (:platform, :route_key, :session_id, :chat_id, :thread_id, :user_id, :metadata, :created_at, :updated_at)
+ ON CONFLICT(platform, route_key) DO UPDATE SET
+ session_id = excluded.session_id,
+ chat_id = excluded.chat_id,
+ thread_id = excluded.thread_id,
+ user_id = excluded.user_id,
+ metadata = excluded.metadata,
+ updated_at = excluded.updated_at'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ 'session_id' => $sessionId,
+ 'chat_id' => $chatId,
+ 'thread_id' => $threadId,
+ 'user_id' => $userId,
+ 'metadata' => $metadata !== [] ? json_encode($metadata, JSON_THROW_ON_ERROR) : null,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ public function delete(string $platform, string $routeKey): void
+ {
+ $stmt = $this->database->connection()->prepare(
+ 'DELETE FROM gateway_sessions WHERE platform = :platform AND route_key = :route_key'
+ );
+ $stmt->execute([
+ 'platform' => $platform,
+ 'route_key' => $routeKey,
+ ]);
+ }
+
+ private function decodeJsonMap(mixed $value): array
+ {
+ if (! is_string($value) || $value === '') {
+ return [];
+ }
+
+ $decoded = json_decode($value, true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+}
diff --git a/src/Gateway/Telegram/SymfonyProcessTelegramWorkerHandle.php b/src/Gateway/Telegram/SymfonyProcessTelegramWorkerHandle.php
new file mode 100644
index 0000000..f64b86c
--- /dev/null
+++ b/src/Gateway/Telegram/SymfonyProcessTelegramWorkerHandle.php
@@ -0,0 +1,37 @@
+process->getPid();
+
+ return is_int($pid) && $pid > 0 ? $pid : null;
+ }
+
+ public function isRunning(): bool
+ {
+ return $this->process->isRunning();
+ }
+
+ public function terminate(int $signal = SIGTERM): bool
+ {
+ if (! $this->process->isRunning()) {
+ return false;
+ }
+
+ $this->process->signal($signal);
+
+ return true;
+ }
+}
diff --git a/src/Gateway/Telegram/SymfonyProcessTelegramWorkerLauncher.php b/src/Gateway/Telegram/SymfonyProcessTelegramWorkerLauncher.php
new file mode 100644
index 0000000..cb92b9f
--- /dev/null
+++ b/src/Gateway/Telegram/SymfonyProcessTelegramWorkerLauncher.php
@@ -0,0 +1,50 @@
+find(false) ?: PHP_BINARY;
+ $console = $this->projectRoot.'/bin/kosmokrator';
+ $payload = base64_encode(json_encode($event->toArray(), JSON_THROW_ON_ERROR));
+ $logPath = $this->projectRoot.'/storage/logs/telegram-gateway-worker.log';
+
+ if (! is_dir(dirname($logPath))) {
+ mkdir(dirname($logPath), 0777, true);
+ }
+
+ $process = new Process([
+ $phpBinary,
+ $console,
+ 'gateway:telegram:worker',
+ '--event='.$payload,
+ ], $this->projectRoot);
+ $process->setTimeout(null);
+ $process->start(function (string $type, string $buffer) use ($logPath): void {
+ if ($buffer === '') {
+ return;
+ }
+
+ $prefix = $type === Process::ERR ? '[stderr] ' : '[stdout] ';
+ file_put_contents(
+ $logPath,
+ sprintf('[%s] %s%s', date(DATE_ATOM), $prefix, $buffer),
+ FILE_APPEND,
+ );
+ });
+
+ return new SymfonyProcessTelegramWorkerHandle($process);
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramBotCommandCatalog.php b/src/Gateway/Telegram/TelegramBotCommandCatalog.php
new file mode 100644
index 0000000..9732a56
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramBotCommandCatalog.php
@@ -0,0 +1,85 @@
+
+ */
+ public static function nativeCommands(): array
+ {
+ return [
+ ['command' => 'help', 'description' => 'Show gateway help'],
+ ['command' => 'status', 'description' => 'Show linked session status'],
+ ['command' => 'new', 'description' => 'Start a fresh chat session'],
+ ['command' => 'resume', 'description' => 'Resume the linked session'],
+ ['command' => 'approve', 'description' => 'Approve the latest tool request'],
+ ['command' => 'deny', 'description' => 'Deny the latest tool request'],
+ ['command' => 'cancel', 'description' => 'Cancel the active run'],
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ public static function systemCommands(): array
+ {
+ return [
+ ['command' => 'compact', 'description' => 'Force context compaction'],
+ ['command' => 'edit', 'description' => 'Switch to edit mode'],
+ ['command' => 'plan', 'description' => 'Switch to plan mode'],
+ ['command' => 'ask', 'description' => 'Switch to ask mode'],
+ ['command' => 'guardian', 'description' => 'Switch to Guardian mode'],
+ ['command' => 'argus', 'description' => 'Switch to Argus mode'],
+ ['command' => 'prometheus', 'description' => 'Switch to Prometheus mode'],
+ ['command' => 'memories', 'description' => 'List stored memories'],
+ ['command' => 'sessions', 'description' => 'List recent sessions'],
+ ['command' => 'agents', 'description' => 'Show swarm summary'],
+ ['command' => 'rename', 'description' => 'Rename the current session'],
+ ['command' => 'forget', 'description' => 'Delete a memory by ID'],
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ public static function commands(): array
+ {
+ return [...self::nativeCommands(), ...self::systemCommands()];
+ }
+
+ /**
+ * @return list
+ */
+ public static function supportedSlashCommands(): array
+ {
+ return array_map(
+ static fn (array $command): string => '/'.$command['command'],
+ self::systemCommands(),
+ );
+ }
+
+ public static function helpText(): string
+ {
+ $lines = ['KosmoKrator Telegram gateway', '', 'Telegram commands:'];
+
+ foreach (self::nativeCommands() as $command) {
+ $lines[] = sprintf('/%s — %s', $command['command'], $command['description']);
+ }
+
+ $lines[] = '';
+ $lines[] = 'Kosmo commands:';
+ foreach (self::systemCommands() as $command) {
+ $lines[] = sprintf('/%s — %s', $command['command'], $command['description']);
+ }
+
+ $lines[] = '';
+ $lines[] = 'Approval shortcuts: /approve, /approve always, /approve guardian, /approve prometheus, /deny';
+ $lines[] = 'Inline buttons are also available on approval and status messages.';
+
+ return implode("\n", $lines);
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramClient.php b/src/Gateway/Telegram/TelegramClient.php
new file mode 100644
index 0000000..8547457
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramClient.php
@@ -0,0 +1,254 @@
+baseUrl = 'https://api.telegram.org/bot'.$this->token;
+ }
+
+ public function setMyCommands(array $commands): void
+ {
+ $this->request('setMyCommands', ['commands' => $commands]);
+ }
+
+ /**
+ * @return array
+ */
+ public function getMe(): array
+ {
+ return $this->request('getMe');
+ }
+
+ /**
+ * @return list>
+ */
+ public function getUpdates(?int $offset, int $timeout): array
+ {
+ $payload = ['timeout' => $timeout];
+ if ($offset !== null) {
+ $payload['offset'] = $offset;
+ }
+
+ $result = $this->request('getUpdates', $payload);
+
+ return is_array($result) ? array_values(array_filter($result, 'is_array')) : [];
+ }
+
+ /**
+ * @return array
+ */
+ public function sendMessage(string $chatId, string $text, ?string $threadId = null, ?int $replyToMessageId = null, ?array $replyMarkup = null, ?string $parseMode = null): array
+ {
+ $payload = [
+ 'chat_id' => $chatId,
+ 'text' => $text,
+ 'disable_web_page_preview' => true,
+ ];
+
+ if ($threadId !== null) {
+ $payload['message_thread_id'] = (int) $threadId;
+ }
+
+ if ($replyToMessageId !== null) {
+ $payload['reply_parameters'] = ['message_id' => $replyToMessageId];
+ }
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ if ($parseMode !== null && $parseMode !== '') {
+ $payload['parse_mode'] = $parseMode;
+ }
+
+ return $this->request('sendMessage', $payload);
+ }
+
+ /**
+ * @return array
+ */
+ public function editMessageText(string $chatId, int $messageId, string $text, ?array $replyMarkup = null, ?string $parseMode = null): array
+ {
+ $payload = [
+ 'chat_id' => $chatId,
+ 'message_id' => $messageId,
+ 'text' => $text,
+ 'disable_web_page_preview' => true,
+ ];
+
+ if ($replyMarkup !== null) {
+ $payload['reply_markup'] = $replyMarkup;
+ }
+
+ if ($parseMode !== null && $parseMode !== '') {
+ $payload['parse_mode'] = $parseMode;
+ }
+
+ return $this->request('editMessageText', $payload);
+ }
+
+ public function sendPhoto(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array
+ {
+ $payload = ['chat_id' => $chatId];
+ if ($threadId !== null) {
+ $payload['message_thread_id'] = (int) $threadId;
+ }
+ if ($caption !== null && $caption !== '') {
+ $payload['caption'] = $caption;
+ }
+ if ($parseMode !== null && $parseMode !== '') {
+ $payload['parse_mode'] = $parseMode;
+ }
+
+ if (filter_var($path, FILTER_VALIDATE_URL)) {
+ $payload['photo'] = $path;
+
+ return $this->request('sendPhoto', $payload);
+ }
+
+ return $this->upload('sendPhoto', 'photo', $path, $payload);
+ }
+
+ public function sendDocument(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array
+ {
+ $payload = ['chat_id' => $chatId];
+ if ($threadId !== null) {
+ $payload['message_thread_id'] = (int) $threadId;
+ }
+ if ($caption !== null && $caption !== '') {
+ $payload['caption'] = $caption;
+ }
+ if ($parseMode !== null && $parseMode !== '') {
+ $payload['parse_mode'] = $parseMode;
+ }
+
+ if (filter_var($path, FILTER_VALIDATE_URL)) {
+ $payload['document'] = $path;
+
+ return $this->request('sendDocument', $payload);
+ }
+
+ return $this->upload('sendDocument', 'document', $path, $payload);
+ }
+
+ public function sendVoice(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array
+ {
+ $payload = ['chat_id' => $chatId];
+ if ($threadId !== null) {
+ $payload['message_thread_id'] = (int) $threadId;
+ }
+ if ($caption !== null && $caption !== '') {
+ $payload['caption'] = $caption;
+ }
+ if ($parseMode !== null && $parseMode !== '') {
+ $payload['parse_mode'] = $parseMode;
+ }
+
+ return $this->upload('sendVoice', 'voice', $path, $payload);
+ }
+
+ public function sendChatAction(string $chatId, string $action = 'typing', ?string $threadId = null): void
+ {
+ $payload = [
+ 'chat_id' => $chatId,
+ 'action' => $action,
+ ];
+
+ if ($threadId !== null) {
+ $payload['message_thread_id'] = (int) $threadId;
+ }
+
+ $this->request('sendChatAction', $payload);
+ }
+
+ public function answerCallbackQuery(string $callbackQueryId, ?string $text = null): void
+ {
+ $payload = ['callback_query_id' => $callbackQueryId];
+ if ($text !== null && $text !== '') {
+ $payload['text'] = $text;
+ }
+
+ $this->request('answerCallbackQuery', $payload);
+ }
+
+ public function deleteWebhook(bool $dropPendingUpdates = false): void
+ {
+ $this->request('deleteWebhook', ['drop_pending_updates' => $dropPendingUpdates]);
+ }
+
+ /**
+ * @param array $payload
+ * @return array|list>
+ */
+ private function request(string $method, array $payload = []): array
+ {
+ $response = $this->requestClient()->asJson()->post($this->baseUrl.'/'.$method, $payload);
+ $result = $this->parseResponse($response);
+
+ return is_array($result) ? $result : [];
+ }
+
+ /**
+ * @param array $payload
+ */
+ private function upload(string $method, string $field, string $path, array $payload): array
+ {
+ if (! is_file($path)) {
+ throw new \RuntimeException("Telegram media file not found: {$path}");
+ }
+
+ $handle = fopen($path, 'rb');
+ if ($handle === false) {
+ throw new \RuntimeException("Unable to open Telegram media file: {$path}");
+ }
+
+ try {
+ $response = $this->requestClient()
+ ->attach($field, $handle, basename($path))
+ ->post($this->baseUrl.'/'.$method, $payload);
+
+ $result = $this->parseResponse($response);
+
+ return is_array($result) ? $result : [];
+ } finally {
+ fclose($handle);
+ }
+ }
+
+ private function requestClient(): PendingRequest
+ {
+ return $this->http->timeout(90);
+ }
+
+ /**
+ * @return array|list>
+ */
+ private function parseResponse(Response $response): array
+ {
+ if (! $response->successful()) {
+ throw new \RuntimeException("Telegram API error ({$response->status()}): ".$response->body());
+ }
+
+ $json = $response->json();
+ if (! is_array($json) || ! ($json['ok'] ?? false)) {
+ throw new \RuntimeException('Telegram API returned an invalid payload.');
+ }
+
+ $result = $json['result'] ?? [];
+
+ return is_array($result) ? $result : [];
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramClientInterface.php b/src/Gateway/Telegram/TelegramClientInterface.php
new file mode 100644
index 0000000..f16ac26
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramClientInterface.php
@@ -0,0 +1,54 @@
+ $commands
+ */
+ public function setMyCommands(array $commands): void;
+
+ /**
+ * @return array
+ */
+ public function getMe(): array;
+
+ /**
+ * @return list>
+ */
+ public function getUpdates(?int $offset, int $timeout): array;
+
+ /**
+ * @return array
+ */
+ public function sendMessage(string $chatId, string $text, ?string $threadId = null, ?int $replyToMessageId = null, ?array $replyMarkup = null, ?string $parseMode = null): array;
+
+ /**
+ * @return array
+ */
+ public function editMessageText(string $chatId, int $messageId, string $text, ?array $replyMarkup = null, ?string $parseMode = null): array;
+
+ /**
+ * @return array
+ */
+ public function sendPhoto(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array;
+
+ /**
+ * @return array
+ */
+ public function sendDocument(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array;
+
+ /**
+ * @return array
+ */
+ public function sendVoice(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array;
+
+ public function sendChatAction(string $chatId, string $action = 'typing', ?string $threadId = null): void;
+
+ public function answerCallbackQuery(string $callbackQueryId, ?string $text = null): void;
+
+ public function deleteWebhook(bool $dropPendingUpdates = false): void;
+}
diff --git a/src/Gateway/Telegram/TelegramGatewayConfig.php b/src/Gateway/Telegram/TelegramGatewayConfig.php
new file mode 100644
index 0000000..45c636c
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramGatewayConfig.php
@@ -0,0 +1,146 @@
+ $allowedUsers
+ * @param list $allowedChats
+ * @param list $freeResponseChats
+ */
+ public function __construct(
+ public bool $enabled,
+ public string $token,
+ public string $sessionMode,
+ public array $allowedUsers,
+ public array $allowedChats,
+ public bool $requireMention,
+ public array $freeResponseChats,
+ public int $pollTimeoutSeconds,
+ ) {}
+
+ public static function fromSettings(SettingsManager $settings, Repository $config, ?SettingsRepositoryInterface $repository = null): self
+ {
+ $enabled = self::toBool(($repository?->get('global', 'kosmokrator.gateway.telegram.enabled'))
+ ?? $settings->getRaw('kosmokrator.gateway.telegram.enabled')
+ ?? $config->get('kosmokrator.gateway.telegram.enabled', false));
+
+ $token = trim((string) (
+ $repository?->get('global', 'kosmokrator.gateway.telegram.token')
+ ?? $settings->getRaw('kosmokrator.gateway.telegram.token')
+ ?? $config->get('kosmokrator.gateway.telegram.token', '')
+ ?? getenv('KOSMOKRATOR_TELEGRAM_BOT_TOKEN')
+ ?: ''
+ ));
+
+ return new self(
+ enabled: $enabled,
+ token: $token,
+ sessionMode: (string) (
+ ($repository?->get('global', 'kosmokrator.gateway.telegram.session_mode'))
+ ?? $settings->getRaw('kosmokrator.gateway.telegram.session_mode')
+ ?? $config->get('kosmokrator.gateway.telegram.session_mode', 'thread')
+ ),
+ allowedUsers: self::toList(
+ ($repository?->get('global', 'kosmokrator.gateway.telegram.allowed_users'))
+ ?? $settings->getRaw('kosmokrator.gateway.telegram.allowed_users')
+ ?? $config->get('kosmokrator.gateway.telegram.allowed_users', [])
+ ),
+ allowedChats: self::toList(
+ ($repository?->get('global', 'kosmokrator.gateway.telegram.allowed_chats'))
+ ?? $settings->getRaw('kosmokrator.gateway.telegram.allowed_chats')
+ ?? $config->get('kosmokrator.gateway.telegram.allowed_chats', [])
+ ),
+ requireMention: self::toBool(
+ ($repository?->get('global', 'kosmokrator.gateway.telegram.require_mention'))
+ ?? $settings->getRaw('kosmokrator.gateway.telegram.require_mention')
+ ?? $config->get('kosmokrator.gateway.telegram.require_mention', true)
+ ),
+ freeResponseChats: self::toList(
+ ($repository?->get('global', 'kosmokrator.gateway.telegram.free_response_chats'))
+ ?? $settings->getRaw('kosmokrator.gateway.telegram.free_response_chats')
+ ?? $config->get('kosmokrator.gateway.telegram.free_response_chats', [])
+ ),
+ pollTimeoutSeconds: max(1, (int) (
+ ($repository?->get('global', 'kosmokrator.gateway.telegram.poll_timeout_seconds'))
+ ?? $settings->getRaw('kosmokrator.gateway.telegram.poll_timeout_seconds')
+ ?? $config->get('kosmokrator.gateway.telegram.poll_timeout_seconds', 20)
+ )),
+ );
+ }
+
+ public function validate(): void
+ {
+ if (! $this->enabled) {
+ throw new \RuntimeException('Telegram gateway is disabled. Set kosmokrator.gateway.telegram.enabled to true.');
+ }
+
+ if ($this->token === '') {
+ throw new \RuntimeException('Telegram gateway token is not configured. Set kosmokrator.gateway.telegram.token or KOSMOKRATOR_TELEGRAM_BOT_TOKEN.');
+ }
+
+ if (! in_array($this->sessionMode, ['chat', 'chat_user', 'thread', 'thread_user'], true)) {
+ throw new \RuntimeException('Telegram gateway session mode must be one of: chat, chat_user, thread, thread_user.');
+ }
+ }
+
+ public function allowsChat(string $chatId): bool
+ {
+ return $this->allowedChats === [] || in_array($chatId, $this->allowedChats, true);
+ }
+
+ public function allowsUser(?string $userId, ?string $username): bool
+ {
+ if ($this->allowedUsers === []) {
+ return true;
+ }
+
+ if ($userId !== null && in_array($userId, $this->allowedUsers, true)) {
+ return true;
+ }
+
+ return $username !== null && $username !== '' && in_array(ltrim($username, '@'), $this->allowedUsers, true);
+ }
+
+ public function isFreeResponseChat(string $chatId): bool
+ {
+ return in_array($chatId, $this->freeResponseChats, true);
+ }
+
+ /**
+ * @return list
+ */
+ private static function toList(mixed $value): array
+ {
+ if (is_array($value)) {
+ $items = array_map(static fn ($item): string => trim((string) $item), $value);
+
+ return array_values(array_filter($items, static fn (string $item): bool => $item !== ''));
+ }
+
+ if (! is_string($value)) {
+ return [];
+ }
+
+ $parts = preg_split('/[\s,]+/', $value) ?: [];
+ $items = array_map(static fn ($item): string => trim((string) $item), $parts);
+
+ return array_values(array_filter($items, static fn (string $item): bool => $item !== ''));
+ }
+
+ private static function toBool(mixed $value): bool
+ {
+ if (is_bool($value)) {
+ return $value;
+ }
+
+ return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true);
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramGatewayRenderer.php b/src/Gateway/Telegram/TelegramGatewayRenderer.php
new file mode 100644
index 0000000..a31c783
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramGatewayRenderer.php
@@ -0,0 +1,611 @@
+ []];
+
+ private const TELEGRAM_PARSE_MODE = 'HTML';
+
+ /** @var list */
+ private array $answerSegments = [''];
+
+ private string $placeholderText = 'Thinking…';
+
+ private ?string $statusNotice = null;
+
+ private ?string $activeToolName = null;
+
+ private ?int $statusMessageId = null;
+
+ /** @var list */
+ private array $answerMessageIds = [];
+
+ private ?int $toolMessageId = null;
+
+ private float $lastFlushAt = 0.0;
+
+ private float $lastTypingAt = 0.0;
+
+ /**
+ * @param \Closure(int, string, array): string $approvalCallback
+ */
+ public function __construct(
+ private readonly TelegramClientInterface $client,
+ private readonly GatewayMessageStore $messages,
+ private readonly GatewayApprovalStore $approvals,
+ private readonly string $routeKey,
+ private string $sessionId,
+ private readonly string $chatId,
+ private readonly ?string $threadId,
+ private readonly \Closure $approvalCallback,
+ private readonly \Closure|Cancellation|null $cancellation = null,
+ ) {}
+
+ public function setSessionId(string $sessionId): void
+ {
+ $this->sessionId = $sessionId;
+ }
+
+ public function initialize(): void {}
+
+ public function renderIntro(bool $animated): void {}
+
+ public function prompt(): string
+ {
+ return '';
+ }
+
+ public function showUserMessage(string $text): void {}
+
+ public function setPhase(AgentPhase $phase): void
+ {
+ $this->maybeSendTyping();
+ if ($phase === AgentPhase::Thinking) {
+ $this->updateStatusMessage($this->composeStatusText());
+ } elseif ($phase === AgentPhase::Tools) {
+ $this->updateStatusMessage(
+ $this->activeToolName !== null && $this->activeToolName !== ''
+ ? "Using tool: {$this->activeToolName}"
+ : 'Using tools…',
+ );
+ } elseif ($phase === AgentPhase::Idle) {
+ $this->updateStatusMessage('Done');
+ }
+ }
+
+ public function showThinking(): void {}
+
+ public function clearThinking(): void {}
+
+ public function showCompacting(): void
+ {
+ $this->appendNotice('Compacting context…');
+ }
+
+ public function clearCompacting(): void {}
+
+ public function getCancellation(): ?Cancellation
+ {
+ if ($this->cancellation instanceof \Closure) {
+ return ($this->cancellation)();
+ }
+
+ return $this->cancellation;
+ }
+
+ public function showReasoningContent(string $content): void {}
+
+ public function streamChunk(string $text): void
+ {
+ $this->maybeSendTyping();
+ $this->answerSegments[array_key_last($this->answerSegments)] .= $text;
+ $this->flushBufferedText(false);
+ }
+
+ public function streamComplete(): void
+ {
+ $this->flushBufferedText(true);
+ $this->deliverMediaAttachments();
+ $this->updateStatusMessage('Done');
+ }
+
+ public function showError(string $message): void
+ {
+ $this->statusNotice = null;
+ $this->updateStatusMessage("Error: {$message}");
+ }
+
+ public function showNotice(string $message): void
+ {
+ $this->appendNotice($message);
+ }
+
+ public function showMode(string $label, string $color = ''): void {}
+
+ public function setPermissionMode(string $label, string $color): void {}
+
+ public function showStatus(string $model, int $tokensIn, int $tokensOut, float $cost, int $maxContext): void {}
+
+ public function refreshRuntimeSelection(string $provider, string $model, int $maxContext): void {}
+
+ public function consumeQueuedMessage(): ?string
+ {
+ return null;
+ }
+
+ public function setImmediateCommandHandler(?\Closure $handler): void {}
+
+ public function teardown(): void {}
+
+ public function showWelcome(): void {}
+
+ public function setTaskStore(TaskStore $store): void {}
+
+ public function refreshTaskBar(): void {}
+
+ public function playTheogony(): void {}
+
+ public function playPrometheus(): void {}
+
+ public function playUnleash(): void {}
+
+ public function playAnimation(AnsiAnimation $animation): void {}
+
+ public function setSkillCompletions(array $completions): void {}
+
+ public function showSettings(array $currentSettings): array
+ {
+ return [];
+ }
+
+ public function pickSession(array $items): ?string
+ {
+ return null;
+ }
+
+ public function approvePlan(string $currentPermissionMode): ?array
+ {
+ return null;
+ }
+
+ public function askUser(string $question): string
+ {
+ return '';
+ }
+
+ public function askChoice(string $question, array $choices): string
+ {
+ return 'dismissed';
+ }
+
+ public function clearConversation(): void {}
+
+ public function replayHistory(array $messages): void {}
+
+ public function showSubagentStatus(array $stats): void {}
+
+ public function clearSubagentStatus(): void {}
+
+ public function showSubagentRunning(array $entries): void {}
+
+ public function showSubagentSpawn(array $entries): void {}
+
+ public function showSubagentBatch(array $entries): void {}
+
+ public function refreshSubagentTree(array $tree): void {}
+
+ public function setAgentTreeProvider(?\Closure $provider): void {}
+
+ public function showAgentsDashboard(array $summary, array $allStats, ?\Closure $refresh = null): void
+ {
+ $lines = [
+ 'Swarm summary',
+ sprintf(
+ 'Total: %d · Done: %d · Running: %d · Queued: %d · Failed: %d',
+ (int) ($summary['total'] ?? 0),
+ (int) ($summary['done'] ?? 0),
+ (int) ($summary['running'] ?? 0),
+ (int) ($summary['queued'] ?? 0),
+ (int) ($summary['failed'] ?? 0),
+ ),
+ ];
+
+ $active = $summary['active'] ?? [];
+ if (is_array($active) && $active !== []) {
+ $lines[] = sprintf('Active agents: %d', count($active));
+ }
+
+ $this->appendNotice(implode("\n", $lines));
+ }
+
+ public function showToolCall(string $name, array $args): void
+ {
+ $this->activeToolName = $name;
+ $this->maybeSendTyping();
+ $this->startNewAnswerSegment();
+ $this->toolMessageId = null;
+ $this->toolMessageId = $this->sendOrEditToolMessage(
+ $this->toolMessageId,
+ TelegramTextFormatter::formatToolSummary($name, $args),
+ );
+ $this->updateStatusMessage("Preparing tool: {$name}");
+ }
+
+ public function showToolResult(string $name, string $output, bool $success): void
+ {
+ $this->toolMessageId = $this->sendOrEditToolMessage(
+ $this->toolMessageId,
+ TelegramTextFormatter::formatToolResult($name, $output, $success),
+ );
+ }
+
+ public function askToolPermission(string $toolName, array $args): string
+ {
+ $approval = $this->approvals->createPending(
+ platform: 'telegram',
+ routeKey: $this->routeKey,
+ sessionId: $this->sessionId,
+ toolName: $toolName,
+ arguments: $args,
+ chatId: $this->chatId,
+ threadId: $this->threadId,
+ requestMessageId: $this->answerMessageIds === [] ? null : $this->answerMessageIds[array_key_last($this->answerMessageIds)],
+ );
+
+ $lines = [
+ 'Approval required '.htmlspecialchars($toolName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'',
+ 'Use the buttons below or reply with /approve, /approve always, /approve guardian, /approve prometheus, or /deny.',
+ ];
+ $json = json_encode($args, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
+ if (is_string($json) && $json !== '') {
+ if (mb_strlen($json) > 2400) {
+ $json = mb_substr($json, 0, 2397).'...';
+ }
+ $lines[] = ''.htmlspecialchars($json, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').' ';
+ }
+ $this->sendFormattedMessage(
+ $this->chatId,
+ implode("\n", $lines),
+ $this->threadId,
+ replyMarkup: [
+ 'inline_keyboard' => [
+ [
+ ['text' => 'Approve', 'callback_data' => 'ga:allow:'.$approval->id],
+ ['text' => 'Always', 'callback_data' => 'ga:always:'.$approval->id],
+ ],
+ [
+ ['text' => 'Guardian', 'callback_data' => 'ga:guardian:'.$approval->id],
+ ['text' => 'Prometheus', 'callback_data' => 'ga:prometheus:'.$approval->id],
+ ],
+ [
+ ['text' => 'Deny', 'callback_data' => 'ga:deny:'.$approval->id],
+ ],
+ ],
+ ],
+ );
+
+ $decision = ($this->approvalCallback)($approval->id, $toolName, $args);
+
+ return $decision;
+ }
+
+ public function showAutoApproveIndicator(string $toolName): void {}
+
+ public function showToolExecuting(string $name): void
+ {
+ $this->activeToolName = $name;
+ $this->maybeSendTyping();
+ $this->toolMessageId = $this->sendOrEditToolMessage(
+ $this->toolMessageId,
+ 'Running tool '.htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'',
+ );
+ $this->updateStatusMessage($name === 'concurrent' ? 'Running tools…' : "Using tool: {$name}");
+ }
+
+ public function updateToolExecuting(string $output): void
+ {
+ $output = trim($output);
+ if ($output !== '') {
+ $this->maybeSendTyping();
+ $summary = $this->activeToolName !== null && $this->activeToolName !== ''
+ ? 'Running tool '.htmlspecialchars($this->activeToolName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')."\n".htmlspecialchars($this->limitToolOutput($output), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').' '
+ : ''.htmlspecialchars($this->limitToolOutput($output), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').' ';
+ $this->toolMessageId = $this->sendOrEditToolMessage($this->toolMessageId, $summary);
+ $this->updateStatusMessage($output);
+ }
+ }
+
+ public function clearToolExecuting(): void
+ {
+ $this->activeToolName = null;
+ $this->toolMessageId = null;
+ }
+
+ private function ensureStatusMessage(string $text): void
+ {
+ if ($this->statusMessageId !== null) {
+ return;
+ }
+
+ $message = $this->client->sendMessage($this->chatId, $this->limit($text), $this->threadId);
+ $messageId = (int) ($message['message_id'] ?? 0);
+ if ($messageId > 0) {
+ $this->statusMessageId = $messageId;
+ $this->messages->save('telegram', $this->routeKey, 'status', $this->chatId, $messageId, $this->threadId);
+ }
+ }
+
+ private function flushBufferedText(bool $force): void
+ {
+ $display = $this->visibleText();
+ if ($display === '') {
+ return;
+ }
+
+ $now = microtime(true);
+ if (! $force && ($now - $this->lastFlushAt) < 0.75) {
+ return;
+ }
+
+ $chunks = $this->answerChunks();
+ foreach ($chunks as $index => $chunk) {
+ $messageId = $this->answerMessageIds[$index] ?? null;
+ $formatted = TelegramTextFormatter::formatHtml($chunk);
+ if ($messageId === null) {
+ $message = $this->sendFormattedMessage(
+ $this->chatId,
+ $formatted,
+ $this->threadId,
+ );
+ $messageId = (int) ($message['message_id'] ?? 0);
+ if ($messageId > 0) {
+ $this->answerMessageIds[$index] = $messageId;
+ if ($index === 0) {
+ $this->messages->save('telegram', $this->routeKey, 'response', $this->chatId, $messageId, $this->threadId);
+ }
+ }
+ } elseif ($messageId > 0) {
+ $this->editFormattedMessage(
+ $this->chatId,
+ $messageId,
+ $formatted,
+ );
+ }
+ }
+
+ $this->lastFlushAt = $now;
+ }
+
+ private function appendNotice(string $message): void
+ {
+ $this->statusNotice = $message;
+ $this->updateStatusMessage($this->composeStatusText());
+ }
+
+ private function limit(string $text): string
+ {
+ $normalized = trim($text);
+ if ($normalized === '') {
+ return '…';
+ }
+
+ if (mb_strlen($normalized) <= 3900) {
+ return $normalized;
+ }
+
+ return rtrim(mb_substr($normalized, 0, 3897)).'...';
+ }
+
+ private function limitToolOutput(string $text): string
+ {
+ $normalized = trim($text);
+ if (mb_strlen($normalized) <= 2200) {
+ return $normalized;
+ }
+
+ return rtrim(mb_substr($normalized, 0, 2197)).'...';
+ }
+
+ private function visibleText(): string
+ {
+ return implode("\n\n", array_values(array_filter(
+ array_map(fn (string $segment): string => $this->extractMediaPayload($segment)['text'], $this->answerSegments),
+ static fn (string $segment): bool => $segment !== '',
+ )));
+ }
+
+ private function composeStatusText(): string
+ {
+ if ($this->statusNotice !== null && $this->statusNotice !== '') {
+ return $this->placeholderText."\n\n".$this->statusNotice;
+ }
+
+ return $this->placeholderText;
+ }
+
+ private function updateStatusMessage(string $text): void
+ {
+ $this->maybeSendTyping();
+ $this->ensureStatusMessage($text);
+
+ if ($this->statusMessageId !== null) {
+ $this->client->editMessageText($this->chatId, $this->statusMessageId, $this->limit($text));
+ }
+ }
+
+ private function deliverMediaAttachments(): void
+ {
+ foreach ($this->answerSegments as $segment) {
+ $payload = $this->extractMediaPayload($segment);
+ foreach ($payload['media'] as $item) {
+ $path = $item['path'];
+ if ($path === '' || ! is_file($path)) {
+ continue;
+ }
+
+ $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
+ if (in_array($extension, ['png', 'jpg', 'jpeg', 'gif', 'webp'], true)) {
+ $this->client->sendPhoto($this->chatId, $path, $this->threadId);
+
+ continue;
+ }
+
+ if ($item['voice'] && in_array($extension, ['ogg', 'opus', 'mp3', 'm4a', 'wav'], true)) {
+ $this->client->sendVoice($this->chatId, $path, $this->threadId);
+
+ continue;
+ }
+
+ $this->client->sendDocument($this->chatId, $path, $this->threadId);
+ }
+ }
+ }
+
+ /**
+ * @return array{text: string, media: list}
+ */
+ private function extractMediaPayload(string $text): array
+ {
+ $voice = str_contains($text, '[[audio_as_voice]]');
+ $cleaned = str_replace('[[audio_as_voice]]', '', $text);
+ preg_match_all('/[`"\']?MEDIA:\s*([^\s`"\']+)[`"\']?/', $cleaned, $matches);
+
+ $media = [];
+ foreach ($matches[1] ?? [] as $path) {
+ if (trim($path) === '') {
+ continue;
+ }
+
+ $media[] = [
+ 'path' => trim($path),
+ 'voice' => $voice,
+ ];
+ }
+
+ $display = preg_replace('/[`"\']?MEDIA:\s*([^\s`"\']+)[`"\']?/', '', $cleaned) ?? $cleaned;
+ $display = preg_replace("/\n{3,}/", "\n\n", $display) ?? $display;
+
+ return [
+ 'text' => trim($display),
+ 'media' => $media,
+ ];
+ }
+
+ /**
+ * @return list
+ */
+ private function answerChunks(): array
+ {
+ $chunks = [];
+ foreach ($this->answerSegments as $segment) {
+ foreach (TelegramTextFormatter::splitPlainText($this->extractMediaPayload($segment)['text']) as $chunk) {
+ $chunks[] = $chunk;
+ }
+ }
+
+ return $chunks;
+ }
+
+ private function startNewAnswerSegment(): void
+ {
+ $current = $this->answerSegments[array_key_last($this->answerSegments)] ?? '';
+ if (trim($this->extractMediaPayload($current)['text']) !== '') {
+ $this->answerSegments[] = '';
+ }
+ }
+
+ private function sendOrEditToolMessage(?int $messageId, string $html): int
+ {
+ if ($messageId !== null) {
+ $this->editFormattedMessage(
+ $this->chatId,
+ $messageId,
+ $html,
+ );
+
+ return $messageId;
+ }
+
+ $message = $this->sendFormattedMessage(
+ $this->chatId,
+ $html,
+ $this->threadId,
+ );
+
+ return (int) ($message['message_id'] ?? 0);
+ }
+
+ private function sendFormattedMessage(
+ string $chatId,
+ string $html,
+ ?string $threadId = null,
+ ?int $replyToMessageId = null,
+ ?array $replyMarkup = null,
+ ): array {
+ try {
+ return $this->client->sendMessage(
+ $chatId,
+ $html,
+ $threadId,
+ $replyToMessageId,
+ $replyMarkup,
+ self::TELEGRAM_PARSE_MODE,
+ );
+ } catch (\Throwable) {
+ return $this->client->sendMessage(
+ $chatId,
+ $this->limit(TelegramTextFormatter::stripHtml($html)),
+ $threadId,
+ $replyToMessageId,
+ $replyMarkup,
+ );
+ }
+ }
+
+ private function editFormattedMessage(
+ string $chatId,
+ int $messageId,
+ string $html,
+ ?array $replyMarkup = null,
+ ): array {
+ try {
+ return $this->client->editMessageText(
+ $chatId,
+ $messageId,
+ $html,
+ $replyMarkup,
+ self::TELEGRAM_PARSE_MODE,
+ );
+ } catch (\Throwable) {
+ return $this->client->editMessageText(
+ $chatId,
+ $messageId,
+ $this->limit(TelegramTextFormatter::stripHtml($html)),
+ $replyMarkup,
+ );
+ }
+ }
+
+ private function maybeSendTyping(): void
+ {
+ $now = microtime(true);
+ if (($now - $this->lastTypingAt) < 4.0) {
+ return;
+ }
+
+ $this->client->sendChatAction($this->chatId, 'typing', $this->threadId);
+ $this->lastTypingAt = $now;
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramGatewayRuntime.php b/src/Gateway/Telegram/TelegramGatewayRuntime.php
new file mode 100644
index 0000000..369f7a8
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramGatewayRuntime.php
@@ -0,0 +1,473 @@
+> */
+ private array $backlog = [];
+
+ private string $botUsername = '';
+
+ private readonly TelegramSessionRouter $router;
+
+ /**
+ * @var array
+ */
+ private array $activeRoutes = [];
+
+ private int $pollFailureCount = 0;
+
+ public function __construct(
+ private readonly Container $container,
+ private readonly TelegramClientInterface $client,
+ private readonly TelegramGatewayConfig $config,
+ private readonly GatewaySessionStore $sessionLinks,
+ private readonly GatewayMessageStore $messages,
+ private readonly GatewayApprovalStore $approvals,
+ private readonly GatewayCheckpointStore $checkpoints,
+ private readonly GatewayPendingInputStore $pendingInputs,
+ private readonly LoggerInterface $log,
+ private readonly ?TelegramGatewayWorkerLauncherInterface $launcher = null,
+ ) {
+ $this->router = new TelegramSessionRouter($config->sessionMode);
+ }
+
+ public function setBotUsername(string $botUsername): void
+ {
+ $this->botUsername = ltrim($botUsername, '@');
+ }
+
+ public function registerBotCommands(): void
+ {
+ $this->client->setMyCommands(TelegramBotCommandCatalog::commands());
+ }
+
+ public function run(): never
+ {
+ if ($this->botUsername === '') {
+ $me = $this->client->getMe();
+ $this->botUsername = (string) ($me['username'] ?? '');
+ }
+ $this->client->deleteWebhook();
+
+ $offset = $this->loadOffset();
+ $normalizer = new TelegramUpdateNormalizer($this->botUsername);
+
+ while (true) {
+ $this->syncActiveRoutes();
+ try {
+ $updates = $this->nextBatch($offset);
+ $this->pollFailureCount = 0;
+ } catch (\Throwable $e) {
+ $this->pollFailureCount++;
+ $delay = min(self::MAX_POLL_BACKOFF_SECONDS, max(1, 2 ** min($this->pollFailureCount, 4)));
+ $this->log->warning('Telegram polling failed', [
+ 'attempt' => $this->pollFailureCount,
+ 'delay_seconds' => $delay,
+ 'error' => $e->getMessage(),
+ ]);
+ sleep($delay);
+
+ continue;
+ }
+
+ foreach ($updates as $update) {
+ $offset = ((int) ($update['update_id'] ?? 0)) + 1;
+ $this->storeOffset($offset);
+
+ $event = $normalizer->normalize($update);
+ if ($event === null) {
+ continue;
+ }
+
+ $event = $event->withRouteKey($this->router->routeKeyFor($event));
+ $this->handleEvent($event);
+ }
+ }
+ }
+
+ /**
+ * @param list> $updates
+ */
+ public function processUpdates(array $updates): void
+ {
+ $this->syncActiveRoutes();
+ $normalizer = new TelegramUpdateNormalizer($this->botUsername !== '' ? $this->botUsername : 'bot');
+
+ foreach ($updates as $update) {
+ $event = $normalizer->normalize($update);
+ if ($event === null) {
+ continue;
+ }
+
+ $event = $event->withRouteKey($this->router->routeKeyFor($event));
+ $this->handleEvent($event);
+ }
+ }
+
+ /**
+ * @return list>
+ */
+ private function nextBatch(?int $offset): array
+ {
+ if ($this->backlog !== []) {
+ $batch = $this->backlog;
+ $this->backlog = [];
+
+ return $batch;
+ }
+
+ return $this->client->getUpdates($offset, $this->config->pollTimeoutSeconds);
+ }
+
+ private function handleEvent(GatewayMessageEvent $event): void
+ {
+ if (! $this->config->allowsChat($event->chatId)) {
+ return;
+ }
+
+ if (! $this->config->allowsUser($event->userId, $event->username)) {
+ return;
+ }
+
+ if ($event->callbackQueryId !== null) {
+ $this->handleCallbackQuery($event);
+
+ return;
+ }
+
+ if ($event->isCommand('/help')) {
+ $this->client->sendMessage($event->chatId, $this->helpText(), $event->threadId, replyMarkup: $this->controlKeyboard());
+
+ return;
+ }
+
+ if ($event->isCommand('/status')) {
+ $this->syncActiveRoutes();
+ $link = $this->sessionLinks->find('telegram', $event->routeKey);
+ $active = $this->activeRoutes[$event->routeKey] ?? null;
+ $checkpoint = $this->checkpoints->get('telegram', 'last_update_id') ?? 'none';
+ $queued = $this->pendingInputs->count('telegram', $event->routeKey);
+ $text = implode("\n", array_filter([
+ 'Telegram gateway status',
+ $this->botUsername !== '' ? 'Bot: @'.$this->botUsername : null,
+ 'Session mode: '.$this->config->sessionMode,
+ 'Mention gating: '.($this->config->requireMention ? 'required in groups' : 'off'),
+ 'Checkpoint: '.$checkpoint,
+ $link === null ? 'Session: none linked yet' : 'Session: '.$link->sessionId,
+ 'Route: '.$event->routeKey,
+ 'Running: '.($active !== null ? 'yes' : 'no'),
+ $active !== null && $active['pid'] !== null ? 'Worker PID: '.$active['pid'] : null,
+ 'Queued inputs: '.$queued,
+ 'Active routes: '.count($this->activeRoutes),
+ ]));
+ $this->client->sendMessage($event->chatId, $text, $event->threadId, replyMarkup: $this->controlKeyboard());
+
+ return;
+ }
+
+ if ($event->isCommand('/new')) {
+ $this->sessionLinks->delete('telegram', $event->routeKey);
+ $this->messages->delete('telegram', $event->routeKey, 'response');
+ $this->pendingInputs->clear('telegram', $event->routeKey);
+ $this->client->sendMessage($event->chatId, 'Started a fresh session for this chat. Your next message will create a new Kosmo session.', $event->threadId);
+
+ return;
+ }
+
+ if ($event->isCommand('/resume')) {
+ $link = $this->sessionLinks->find('telegram', $event->routeKey);
+ if ($link !== null) {
+ $this->client->sendMessage($event->chatId, "Resuming linked session {$link->sessionId}.", $event->threadId);
+
+ return;
+ }
+
+ $this->client->sendMessage($event->chatId, 'No linked session exists yet for this chat. Send a message to start one.', $event->threadId);
+
+ return;
+ }
+
+ if ($event->isCommand('/approve') || $event->isCommand('/deny')) {
+ [$status, $message] = $this->resolveApprovalCommand($event);
+ $pending = $status !== null
+ ? $this->approvals->resolveLatestPending('telegram', $event->routeKey, $status)
+ : null;
+ $this->client->sendMessage(
+ $event->chatId,
+ $pending === null
+ ? 'No pending approval for this chat.'
+ : $message,
+ $event->threadId,
+ );
+
+ return;
+ }
+
+ if ($event->isCommand('/cancel')) {
+ $pending = $this->approvals->resolveLatestPending('telegram', $event->routeKey, 'denied');
+ $message = null;
+ if ($pending !== null) {
+ $message = 'Cancelled the pending approval request.';
+ } else {
+ $active = $this->activeRoutes[$event->routeKey] ?? null;
+ if ($active !== null && $active['handle']->terminate()) {
+ $message = 'Cancelling the active run…';
+ } else {
+ unset($this->activeRoutes[$event->routeKey]);
+ $message = 'No pending approval or active run to cancel.';
+ }
+ }
+ $this->client->sendMessage($event->chatId, $message, $event->threadId);
+
+ return;
+ }
+
+ if (! $event->isPrivate && ! $this->config->isFreeResponseChat($event->chatId) && $this->config->requireMention) {
+ if (! $event->mentionsBot && ! $event->isReplyToBot) {
+ return;
+ }
+ }
+
+ $this->runAgentForEvent($event);
+ }
+
+ private function runAgentForEvent(GatewayMessageEvent $event): void
+ {
+ $this->syncActiveRoutes();
+ if (isset($this->activeRoutes[$event->routeKey])) {
+ $this->pendingInputs->enqueue('telegram', $event->routeKey, $event);
+ $this->client->sendMessage(
+ $event->chatId,
+ 'Queued for the next turn in this chat.',
+ $event->threadId,
+ );
+
+ return;
+ }
+
+ $this->launchEvent($event);
+ }
+
+ private function launchEvent(GatewayMessageEvent $event): void
+ {
+ $handle = ($this->launcher ?? $this->defaultLauncher())->launch($event);
+ $link = $this->sessionLinks->find('telegram', $event->routeKey);
+
+ $this->activeRoutes[$event->routeKey] = [
+ 'sessionId' => $link?->sessionId ?? '',
+ 'pid' => $handle->pid(),
+ 'startedAt' => microtime(true),
+ 'handle' => $handle,
+ ];
+ }
+
+ private function helpText(): string
+ {
+ return TelegramBotCommandCatalog::helpText();
+ }
+
+ private function loadOffset(): ?int
+ {
+ $value = $this->checkpoints->get('telegram', 'last_update_id');
+
+ return $value !== null ? (int) $value : null;
+ }
+
+ private function storeOffset(int $offset): void
+ {
+ $this->checkpoints->set('telegram', 'last_update_id', (string) $offset);
+ }
+
+ private function defaultLauncher(): TelegramGatewayWorkerLauncherInterface
+ {
+ $projectRoot = InstructionLoader::gitRoot() ?? getcwd();
+
+ return new SymfonyProcessTelegramWorkerLauncher($projectRoot);
+ }
+
+ private function syncActiveRoutes(): void
+ {
+ foreach ($this->activeRoutes as $routeKey => $active) {
+ if (! $active['handle']->isRunning()) {
+ unset($this->activeRoutes[$routeKey]);
+
+ $pending = $this->pendingInputs->dequeueNext('telegram', $routeKey);
+ if ($pending !== null) {
+ $this->launchEvent(GatewayMessageEvent::fromArray($pending->payload));
+ }
+ }
+ }
+ }
+
+ private function handleCallbackQuery(GatewayMessageEvent $event): void
+ {
+ if ($event->callbackQueryId === null) {
+ return;
+ }
+
+ if (preg_match('/^gc:cmd:(.+)$/', $event->text, $matches) === 1) {
+ $this->handleControlCallback($event, '/'.ltrim((string) $matches[1], '/'));
+
+ return;
+ }
+
+ if (preg_match('/^ga:(allow|deny|always|guardian|prometheus):(\d+)$/', $event->text, $matches) !== 1) {
+ $this->client->answerCallbackQuery($event->callbackQueryId, 'Unsupported action.');
+
+ return;
+ }
+
+ $approvalId = (int) $matches[2];
+ $approval = $this->approvals->find($approvalId);
+ if ($approval === null || $approval->routeKey !== $event->routeKey) {
+ $this->client->answerCallbackQuery($event->callbackQueryId, 'Approval not found.');
+
+ return;
+ }
+
+ $status = match ($matches[1]) {
+ 'allow' => 'approved',
+ 'always' => 'always',
+ 'guardian' => 'guardian',
+ 'prometheus' => 'prometheus',
+ default => 'denied',
+ };
+ $this->approvals->resolve($approvalId, $status);
+ $label = match ($status) {
+ 'approved' => 'Approved',
+ 'always' => 'Approved Always',
+ 'guardian' => 'Switched To Guardian',
+ 'prometheus' => 'Switched To Prometheus',
+ default => 'Denied',
+ };
+ $this->client->answerCallbackQuery($event->callbackQueryId, $label.'.');
+
+ if ($event->messageId !== null) {
+ $this->client->editMessageText(
+ $event->chatId,
+ $event->messageId,
+ ''.$label.' '.htmlspecialchars($approval->toolName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'.',
+ ['inline_keyboard' => []],
+ 'HTML',
+ );
+ }
+ }
+
+ /**
+ * @return array{0: string|null, 1: string}
+ */
+ private function resolveApprovalCommand(GatewayMessageEvent $event): array
+ {
+ if ($event->isCommand('/deny')) {
+ return ['denied', 'Denied.'];
+ }
+
+ $args = strtolower(trim(substr($event->text, strlen('/approve'))));
+
+ return match (true) {
+ str_contains($args, 'guardian') => ['guardian', 'Switched to Guardian mode and approved.'],
+ str_contains($args, 'prometheus') => ['prometheus', 'Switched to Prometheus mode and approved.'],
+ str_contains($args, 'always') => ['always', 'Approved for the rest of this session.'],
+ default => ['approved', 'Approved.'],
+ };
+ }
+
+ /**
+ * @return array{inline_keyboard: list>}
+ */
+ private function controlKeyboard(): array
+ {
+ return [
+ 'inline_keyboard' => [
+ [
+ ['text' => 'Edit', 'callback_data' => 'gc:cmd:edit'],
+ ['text' => 'Plan', 'callback_data' => 'gc:cmd:plan'],
+ ['text' => 'Ask', 'callback_data' => 'gc:cmd:ask'],
+ ],
+ [
+ ['text' => 'Guardian', 'callback_data' => 'gc:cmd:guardian'],
+ ['text' => 'Argus', 'callback_data' => 'gc:cmd:argus'],
+ ['text' => 'Prometheus', 'callback_data' => 'gc:cmd:prometheus'],
+ ],
+ [
+ ['text' => 'Compact', 'callback_data' => 'gc:cmd:compact'],
+ ['text' => 'Status', 'callback_data' => 'gc:cmd:status'],
+ ['text' => 'Cancel', 'callback_data' => 'gc:cmd:cancel'],
+ ],
+ [
+ ['text' => 'New', 'callback_data' => 'gc:cmd:new'],
+ ['text' => 'Resume', 'callback_data' => 'gc:cmd:resume'],
+ ],
+ ],
+ ];
+ }
+
+ private function handleControlCallback(GatewayMessageEvent $event, string $command): void
+ {
+ if ($event->callbackQueryId === null) {
+ return;
+ }
+
+ $this->client->answerCallbackQuery($event->callbackQueryId, 'Working…');
+
+ if (in_array($command, ['/help', '/status', '/new', '/resume', '/cancel'], true)) {
+ $synthetic = new GatewayMessageEvent(
+ updateId: $event->updateId,
+ platform: $event->platform,
+ chatId: $event->chatId,
+ threadId: $event->threadId,
+ routeKey: $event->routeKey,
+ text: $command,
+ userId: $event->userId,
+ username: $event->username,
+ isPrivate: $event->isPrivate,
+ isReplyToBot: $event->isReplyToBot,
+ mentionsBot: $event->mentionsBot,
+ messageId: $event->messageId,
+ callbackQueryId: null,
+ );
+ $this->handleEvent($synthetic);
+
+ return;
+ }
+
+ $synthetic = new GatewayMessageEvent(
+ updateId: $event->updateId,
+ platform: $event->platform,
+ chatId: $event->chatId,
+ threadId: $event->threadId,
+ routeKey: $event->routeKey,
+ text: $command,
+ userId: $event->userId,
+ username: $event->username,
+ isPrivate: $event->isPrivate,
+ isReplyToBot: $event->isReplyToBot,
+ mentionsBot: $event->mentionsBot,
+ messageId: $event->messageId,
+ callbackQueryId: null,
+ );
+
+ $this->runAgentForEvent($synthetic);
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramGatewayWorkerHandleInterface.php b/src/Gateway/Telegram/TelegramGatewayWorkerHandleInterface.php
new file mode 100644
index 0000000..cd7ded4
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramGatewayWorkerHandleInterface.php
@@ -0,0 +1,14 @@
+handle = $handle;
+ }
+
+ public static function acquire(string $token): self
+ {
+ $home = getenv('HOME') ?: getenv('USERPROFILE') ?: sys_get_temp_dir();
+ $dir = rtrim($home, '/').'/.kosmokrator/data';
+ if (! is_dir($dir)) {
+ mkdir($dir, 0700, true);
+ }
+
+ $path = $dir.'/telegram-gateway-'.hash('sha256', $token).'.lock';
+ $handle = fopen($path, 'c+');
+ if ($handle === false) {
+ throw new \RuntimeException("Unable to open Telegram gateway lock file: {$path}");
+ }
+
+ if (! flock($handle, LOCK_EX | LOCK_NB)) {
+ fclose($handle);
+ throw new \RuntimeException('Another Telegram gateway worker is already polling this bot token.');
+ }
+
+ ftruncate($handle, 0);
+ fwrite($handle, (string) getmypid());
+ fflush($handle);
+
+ return new self($path, $handle);
+ }
+
+ public function __destruct()
+ {
+ if (is_resource($this->handle)) {
+ flock($this->handle, LOCK_UN);
+ fclose($this->handle);
+ }
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramSessionRouter.php b/src/Gateway/Telegram/TelegramSessionRouter.php
new file mode 100644
index 0000000..66de031
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramSessionRouter.php
@@ -0,0 +1,28 @@
+isPrivate) {
+ return 'telegram:'.$event->chatId;
+ }
+
+ return match ($this->mode) {
+ 'chat' => 'telegram:'.$event->chatId,
+ 'chat_user' => 'telegram:'.$event->chatId.':user:'.($event->userId ?? 'anon'),
+ 'thread_user' => 'telegram:'.$event->chatId.':'.($event->threadId ?? 'main').':user:'.($event->userId ?? 'anon'),
+ default => 'telegram:'.$event->chatId.($event->threadId !== null ? ':'.$event->threadId : ''),
+ };
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramSlashCommandBridge.php b/src/Gateway/Telegram/TelegramSlashCommandBridge.php
new file mode 100644
index 0000000..cee31a8
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramSlashCommandBridge.php
@@ -0,0 +1,51 @@
+container, $this->version);
+ $command = $registry->resolve($trimmed);
+
+ if ($command === null) {
+ $name = preg_split('/\s+/', $trimmed, 2)[0] ?: $trimmed;
+ $ctx->ui->showNotice("Unknown command: {$name}");
+
+ return '';
+ }
+
+ if (! in_array($command->name(), TelegramBotCommandCatalog::supportedSlashCommands(), true)) {
+ $ctx->ui->showNotice("{$command->name()} is not available in Telegram.");
+
+ return '';
+ }
+
+ $args = $registry->extractArgs($trimmed, $command);
+ $result = $command->execute($args, $ctx);
+
+ return match ($result->action) {
+ SlashCommandAction::Continue => '',
+ SlashCommandAction::Inject => $result->input ?? '',
+ SlashCommandAction::Quit => '',
+ };
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramTextFormatter.php b/src/Gateway/Telegram/TelegramTextFormatter.php
new file mode 100644
index 0000000..3b181de
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramTextFormatter.php
@@ -0,0 +1,169 @@
+
+ */
+ public static function splitPlainText(string $text, int $limit = 3600): array
+ {
+ $normalized = trim(str_replace("\r\n", "\n", $text));
+ if ($normalized === '') {
+ return [];
+ }
+
+ $chunks = [];
+ $remaining = $normalized;
+
+ while (mb_strlen($remaining) > $limit) {
+ $slice = mb_substr($remaining, 0, $limit);
+ $splitAt = max(
+ (int) mb_strrpos($slice, "\n\n"),
+ (int) mb_strrpos($slice, "\n"),
+ (int) mb_strrpos($slice, ' '),
+ );
+
+ if ($splitAt < (int) ($limit / 2)) {
+ $splitAt = $limit;
+ }
+
+ $chunks[] = trim(mb_substr($remaining, 0, $splitAt));
+ $remaining = ltrim(mb_substr($remaining, $splitAt));
+ }
+
+ if ($remaining !== '') {
+ $chunks[] = trim($remaining);
+ }
+
+ return array_values(array_filter($chunks, static fn (string $chunk): bool => $chunk !== ''));
+ }
+
+ public static function formatHtml(string $text): string
+ {
+ $source = str_replace("\r\n", "\n", trim($text));
+ if ($source === '') {
+ return '…';
+ }
+
+ $placeholders = [];
+ $index = 0;
+
+ $source = self::protectMarkdownTables($source, $placeholders, $index);
+
+ $source = preg_replace_callback('/```([a-zA-Z0-9_+-]*)\n(.*?)```/s', static function (array $matches) use (&$placeholders, &$index): string {
+ $token = "@@KOSMO_BLOCK_{$index}@@";
+ $code = htmlspecialchars(rtrim($matches[2]), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ $placeholders[$token] = ''.$code.' ';
+ $index++;
+
+ return $token;
+ }, $source) ?? $source;
+
+ $source = preg_replace_callback('/`([^`\n]+)`/', static function (array $matches) use (&$placeholders, &$index): string {
+ $token = "@@KOSMO_INLINE_{$index}@@";
+ $code = htmlspecialchars($matches[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ $placeholders[$token] = ''.$code.'';
+ $index++;
+
+ return $token;
+ }, $source) ?? $source;
+
+ $escaped = htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ $escaped = preg_replace('/^#{1,6}\s+(.+)$/m', '$1 ', $escaped) ?? $escaped;
+ $escaped = preg_replace('/\*\*(.+?)\*\*/s', '$1 ', $escaped) ?? $escaped;
+ $escaped = preg_replace('/\*(.+?)\*/s', '$1 ', $escaped) ?? $escaped;
+
+ return strtr($escaped, $placeholders);
+ }
+
+ public static function stripHtml(string $html): string
+ {
+ $text = str_replace(
+ ['', ' ', '', '', '', ' ', '', ' '],
+ ['', '', '`', '`', '', '', '', ''],
+ $html,
+ );
+ $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+
+ return trim($text);
+ }
+
+ public static function formatToolSummary(string $name, array $args): string
+ {
+ $lines = [
+ 'Tool '.htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'',
+ ];
+
+ if ($args !== []) {
+ $json = json_encode($args, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
+ if (is_string($json)) {
+ if (mb_strlen($json) > 2800) {
+ $json = mb_substr($json, 0, 2797).'...';
+ }
+ $lines[] = ''.htmlspecialchars($json, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').' ';
+ }
+ }
+
+ return implode("\n", $lines);
+ }
+
+ public static function formatToolResult(string $name, string $output, bool $success): string
+ {
+ $label = $success ? 'done' : 'failed';
+ $lines = [
+ 'Tool '.$label.' '.htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'',
+ ];
+
+ $trimmed = trim($output);
+ if ($trimmed !== '') {
+ if (mb_strlen($trimmed) > 2800) {
+ $trimmed = mb_substr($trimmed, 0, 2797).'...';
+ }
+ $lines[] = ''.htmlspecialchars($trimmed, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').' ';
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * @param array $placeholders
+ */
+ private static function protectMarkdownTables(string $source, array &$placeholders, int &$index): string
+ {
+ $lines = explode("\n", $source);
+ $result = [];
+ $count = count($lines);
+
+ for ($i = 0; $i < $count; $i++) {
+ $line = $lines[$i];
+ if (
+ isset($lines[$i + 1])
+ && str_contains($line, '|')
+ && preg_match('/^\s*\|?[\s:-]+\|[\s|:-]*$/', $lines[$i + 1]) === 1
+ ) {
+ $tableLines = [$line, $lines[$i + 1]];
+ $i += 2;
+
+ while ($i < $count && str_contains($lines[$i], '|')) {
+ $tableLines[] = $lines[$i];
+ $i++;
+ }
+
+ $i--;
+ $token = "@@KOSMO_TABLE_{$index}@@";
+ $placeholders[$token] = ''.htmlspecialchars(implode("\n", $tableLines), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').' ';
+ $result[] = $token;
+
+ continue;
+ }
+
+ $result[] = $line;
+ }
+
+ return implode("\n", $result);
+ }
+}
diff --git a/src/Gateway/Telegram/TelegramUpdateNormalizer.php b/src/Gateway/Telegram/TelegramUpdateNormalizer.php
new file mode 100644
index 0000000..bc62a52
--- /dev/null
+++ b/src/Gateway/Telegram/TelegramUpdateNormalizer.php
@@ -0,0 +1,104 @@
+isReplyToBot($message),
+ mentionsBot: $this->mentionsBot($message, $text),
+ messageId: isset($message['message_id']) ? (int) $message['message_id'] : null,
+ callbackQueryId: $callbackQueryId,
+ );
+ }
+
+ private function isReplyToBot(array $message): bool
+ {
+ $reply = is_array($message['reply_to_message'] ?? null) ? $message['reply_to_message'] : null;
+ $replyFrom = is_array($reply['from'] ?? null) ? $reply['from'] : null;
+
+ return is_array($replyFrom) && (($replyFrom['username'] ?? null) === $this->botUsername);
+ }
+
+ private function mentionsBot(array $message, string $text): bool
+ {
+ $entities = is_array($message['entities'] ?? null) ? $message['entities'] : [];
+ foreach ($entities as $entity) {
+ if (! is_array($entity)) {
+ continue;
+ }
+
+ if (($entity['type'] ?? null) !== 'mention') {
+ continue;
+ }
+
+ $offset = (int) ($entity['offset'] ?? 0);
+ $length = (int) ($entity['length'] ?? 0);
+ $mention = mb_substr($text, $offset, $length);
+ if (strcasecmp(ltrim($mention, '@'), $this->botUsername) === 0) {
+ return true;
+ }
+ }
+
+ return stripos($text, '@'.$this->botUsername) !== false;
+ }
+}
diff --git a/src/Kernel.php b/src/Kernel.php
index 16e3a7a..c7f1153 100644
--- a/src/Kernel.php
+++ b/src/Kernel.php
@@ -18,6 +18,7 @@
use Kosmokrator\Provider\LoggingServiceProvider;
use Kosmokrator\Provider\SessionServiceProvider;
use Kosmokrator\Provider\ToolServiceProvider;
+use Kosmokrator\Provider\WebServiceProvider;
use Revolt\EventLoop;
use Symfony\Component\Console\Application;
@@ -55,6 +56,7 @@ public function boot(): void
new CoreServiceProvider($this->container, $this->basePath),
new LlmServiceProvider($this->container),
new IntegrationServiceProvider($this->container, $this->basePath),
+ new WebServiceProvider($this->container),
new ToolServiceProvider($this->container),
new SessionServiceProvider($this->container),
new EventServiceProvider($this->container),
diff --git a/src/LLM/ModelSwitcherHistory.php b/src/LLM/ModelSwitcherHistory.php
new file mode 100644
index 0000000..f88b7ec
--- /dev/null
+++ b/src/LLM/ModelSwitcherHistory.php
@@ -0,0 +1,172 @@
+
+ */
+ public function recentModels(ProviderCatalog $catalog): array
+ {
+ $decoded = json_decode($this->settings->get('global', self::RECENT_MODELS_KEY) ?? '[]', true);
+ if (! is_array($decoded)) {
+ return [];
+ }
+
+ $recent = [];
+ foreach ($decoded as $entry) {
+ if (! is_array($entry)) {
+ continue;
+ }
+
+ $provider = trim((string) ($entry['provider'] ?? ''));
+ $model = trim((string) ($entry['model'] ?? ''));
+
+ if ($provider === '' || $model === '' || ! $catalog->supportsModel($provider, $model)) {
+ continue;
+ }
+
+ $key = strtolower($provider."\0".$model);
+ if (isset($recent[$key])) {
+ continue;
+ }
+
+ $recent[$key] = ['provider' => $provider, 'model' => $model];
+ }
+
+ return array_values($recent);
+ }
+
+ /**
+ * @return list
+ */
+ public function recentProviders(ProviderCatalog $catalog): array
+ {
+ $decoded = json_decode($this->settings->get('global', self::RECENT_PROVIDERS_KEY) ?? '[]', true);
+ if (! is_array($decoded)) {
+ return [];
+ }
+
+ $recent = [];
+ foreach ($decoded as $provider) {
+ $provider = trim((string) $provider);
+ if ($provider === '' || $catalog->provider($provider) === null || in_array($provider, $recent, true)) {
+ continue;
+ }
+
+ $recent[] = $provider;
+ }
+
+ return $recent;
+ }
+
+ public function lastModelForProvider(string $provider, ProviderCatalog $catalog): ?string
+ {
+ foreach ($this->recentModels($catalog) as $entry) {
+ if ($entry['provider'] === $provider) {
+ return $entry['model'];
+ }
+ }
+
+ $configured = $this->configSettings?->getProviderLastModel($provider);
+ if ($configured !== null && $catalog->supportsModel($provider, $configured)) {
+ return $configured;
+ }
+
+ $defaultModel = $catalog->defaultModel($provider);
+ if ($defaultModel !== null && $defaultModel !== '') {
+ return $defaultModel;
+ }
+
+ return $catalog->modelIds($provider)[0] ?? null;
+ }
+
+ public function record(string $provider, string $model): void
+ {
+ $recentModels = $this->recentModelsFromStore();
+ array_unshift($recentModels, ['provider' => $provider, 'model' => $model]);
+ $recentModels = $this->dedupeRecentModels($recentModels);
+ $recentModels = array_slice($recentModels, 0, self::RECENT_MODELS_LIMIT);
+
+ $recentProviders = $this->recentProvidersFromStore();
+ array_unshift($recentProviders, $provider);
+ $recentProviders = array_values(array_unique(array_filter(array_map(
+ static fn (mixed $value): string => trim((string) $value),
+ $recentProviders,
+ ))));
+ $recentProviders = array_slice($recentProviders, 0, self::RECENT_PROVIDERS_LIMIT);
+
+ $this->settings->set('global', self::RECENT_MODELS_KEY, (string) json_encode($recentModels, JSON_THROW_ON_ERROR));
+ $this->settings->set('global', self::RECENT_PROVIDERS_KEY, (string) json_encode($recentProviders, JSON_THROW_ON_ERROR));
+ }
+
+ /**
+ * @return list
+ */
+ private function recentModelsFromStore(): array
+ {
+ $decoded = json_decode($this->settings->get('global', self::RECENT_MODELS_KEY) ?? '[]', true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * @return list
+ */
+ private function recentProvidersFromStore(): array
+ {
+ $decoded = json_decode($this->settings->get('global', self::RECENT_PROVIDERS_KEY) ?? '[]', true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * @param list $entries
+ * @return list
+ */
+ private function dedupeRecentModels(array $entries): array
+ {
+ $deduped = [];
+
+ foreach ($entries as $entry) {
+ $provider = trim((string) ($entry['provider'] ?? ''));
+ $model = trim((string) ($entry['model'] ?? ''));
+
+ if ($provider === '' || $model === '') {
+ continue;
+ }
+
+ $key = strtolower($provider."\0".$model);
+ if (isset($deduped[$key])) {
+ continue;
+ }
+
+ $deduped[$key] = ['provider' => $provider, 'model' => $model];
+ }
+
+ return array_values($deduped);
+ }
+}
diff --git a/src/Provider/CoreServiceProvider.php b/src/Provider/CoreServiceProvider.php
index f606061..c9f8e94 100644
--- a/src/Provider/CoreServiceProvider.php
+++ b/src/Provider/CoreServiceProvider.php
@@ -14,6 +14,8 @@
use Kosmokrator\Settings\SettingsManager;
use Kosmokrator\Settings\SettingsSchema;
use Kosmokrator\Settings\YamlConfigStore;
+use Kosmokrator\Setup\SetupFlowInterface;
+use Kosmokrator\Setup\SetupSettingsFlow;
use Kosmokrator\UI\AgentDisplayFormatter;
use Kosmokrator\UI\AgentTreeBuilder;
use OpenCompany\PrismCodex\Contracts\CodexTokenStore as CodexTokenStoreContract;
@@ -99,6 +101,9 @@ public function register(): void
$this->container->make('config'),
$this->container->make(CodexTokenStoreContract::class),
));
+ $this->container->singleton(SetupFlowInterface::class, fn () => new SetupSettingsFlow(
+ $this->container,
+ ));
// UI display utilities (stateless singletons)
$this->container->singleton(AgentDisplayFormatter::class, fn () => new AgentDisplayFormatter);
diff --git a/src/Provider/ToolServiceProvider.php b/src/Provider/ToolServiceProvider.php
index 7612b88..04404c7 100644
--- a/src/Provider/ToolServiceProvider.php
+++ b/src/Provider/ToolServiceProvider.php
@@ -12,7 +12,9 @@
use Kosmokrator\Session\SessionManager;
use Kosmokrator\Session\Tool\MemorySaveTool;
use Kosmokrator\Session\Tool\MemorySearchTool;
+use Kosmokrator\Session\Tool\SessionReadTool;
use Kosmokrator\Session\Tool\SessionSearchTool;
+use Kosmokrator\Settings\SettingsManager;
use Kosmokrator\Task\TaskStore;
use Kosmokrator\Task\Tool\TaskCreateTool;
use Kosmokrator\Task\Tool\TaskGetTool;
@@ -43,6 +45,10 @@
use Kosmokrator\Tool\Permission\PermissionMode;
use Kosmokrator\Tool\Permission\SessionGrants;
use Kosmokrator\Tool\ToolRegistry;
+use Kosmokrator\Tool\Web\WebFetchTool;
+use Kosmokrator\Tool\Web\WebSearchTool;
+use Kosmokrator\Web\Provider\WebFetchProviderManager;
+use Kosmokrator\Web\Provider\WebSearchProviderManager;
use Lua\Sandbox;
use OpenCompany\IntegrationCore\Contracts\LuaToolInvoker;
use Psr\Log\LoggerInterface;
@@ -124,6 +130,14 @@ function () use (&$evaluator) {
));
$registry->register(new GlobTool);
$registry->register(new GrepTool);
+ $registry->register(new WebSearchTool(
+ $this->container->make(WebSearchProviderManager::class),
+ $this->container->make(SettingsManager::class),
+ ));
+ $registry->register(new WebFetchTool(
+ $this->container->make(WebFetchProviderManager::class),
+ $this->container->make(SettingsManager::class),
+ ));
$registry->register(new BashTool($bashTimeout));
$registry->register(new ShellStartTool(
$this->container->make(ShellSessionManager::class),
@@ -149,6 +163,7 @@ function () use (&$evaluator) {
$registry->register(new MemorySaveTool($sessionManager));
$registry->register(new MemorySearchTool($sessionManager));
$registry->register(new SessionSearchTool($sessionManager));
+ $registry->register(new SessionReadTool($sessionManager));
// Lua integration tools — only if Lua extension is available
if (class_exists(Sandbox::class) && $this->container->bound(LuaDocService::class)) {
diff --git a/src/Provider/WebServiceProvider.php b/src/Provider/WebServiceProvider.php
new file mode 100644
index 0000000..0387a8c
--- /dev/null
+++ b/src/Provider/WebServiceProvider.php
@@ -0,0 +1,99 @@
+container->make('config');
+
+ $this->container->singleton(WebTransientCache::class, fn () => new WebTransientCache(
+ keepTurns: max(1, (int) $config->get('kosmokrator.web.cache.keep_turns', 2)),
+ maxEntries: max(16, (int) $config->get('kosmokrator.web.cache.max_entries', 128)),
+ ));
+ $this->container->singleton(WebRequestGuard::class);
+ $this->container->singleton(HtmlPageExtractor::class);
+ $this->container->singleton(MarkdownPageExtractor::class);
+ $this->container->singleton(McpToolInvokerInterface::class, fn () => new StreamableMcpToolInvoker);
+
+ $this->container->singleton(TavilySearchProvider::class, function () use ($config) {
+ return new TavilySearchProvider(
+ apiKey: $config->get('kosmokrator.web.search.providers.tavily.api_key'),
+ );
+ });
+
+ $this->container->singleton(ZaiMcpSearchProvider::class, function () use ($config) {
+ return new ZaiMcpSearchProvider(
+ invoker: $this->container->make(McpToolInvokerInterface::class),
+ auth: $this->container->make(ProviderAuthService::class),
+ apiKeyOverride: $config->get('kosmokrator.web.search.providers.zai.api_key'),
+ remoteUrl: (string) $config->get('kosmokrator.web.search.providers.zai.remote_url', 'https://api.z.ai/api/mcp/web_search_prime/mcp'),
+ );
+ });
+
+ $this->container->singleton(DirectFetchProvider::class, function () use ($config) {
+ return new DirectFetchProvider(
+ $this->container->make(WebRequestGuard::class),
+ $this->container->make(HtmlPageExtractor::class),
+ (int) $config->get('kosmokrator.web.fetch.timeout', 20),
+ (int) $config->get('kosmokrator.web.fetch.max_bytes', 10_485_760),
+ );
+ });
+
+ $this->container->singleton(ZaiReaderFetchProvider::class, function () use ($config) {
+ return new ZaiReaderFetchProvider(
+ auth: $this->container->make(ProviderAuthService::class),
+ guard: $this->container->make(WebRequestGuard::class),
+ extractor: $this->container->make(MarkdownPageExtractor::class),
+ baseUrl: (string) $config->get('kosmokrator.web.fetch.providers.zai.base_url', 'https://api.z.ai/api/coding/paas/v4'),
+ apiKeyOverride: $config->get('kosmokrator.web.fetch.providers.zai.api_key'),
+ defaultTimeout: (int) $config->get('kosmokrator.web.fetch.timeout', 20),
+ );
+ });
+
+ $this->container->singleton(WebSearchProviderManager::class, function () {
+ return new WebSearchProviderManager(
+ providers: [
+ $this->container->make(TavilySearchProvider::class),
+ $this->container->make(ZaiMcpSearchProvider::class),
+ ],
+ settings: $this->container->make(SettingsManager::class),
+ cache: $this->container->make(WebTransientCache::class),
+ );
+ });
+
+ $this->container->singleton(WebFetchProviderManager::class, function () {
+ return new WebFetchProviderManager(
+ providers: [
+ $this->container->make(DirectFetchProvider::class),
+ $this->container->make(ZaiReaderFetchProvider::class),
+ ],
+ settings: $this->container->make(SettingsManager::class),
+ cache: $this->container->make(WebTransientCache::class),
+ );
+ });
+
+ $this->container->alias(TavilySearchProvider::class, WebSearchProvider::class);
+ $this->container->alias(DirectFetchProvider::class, WebFetchProvider::class);
+ }
+}
diff --git a/src/Session/Database.php b/src/Session/Database.php
index c22971f..4ce26ae 100644
--- a/src/Session/Database.php
+++ b/src/Session/Database.php
@@ -12,7 +12,7 @@ class Database
{
private \PDO $pdo;
- private const SCHEMA_VERSION = 5;
+ private const SCHEMA_VERSION = 7;
/**
* @param string|null $path Absolute path to the SQLite database file, or ':memory:' for an ephemeral db.
@@ -167,6 +167,77 @@ private function createInitialSchema(): void
$this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_memories_memory_class ON memories(memory_class)');
// Index for session listing by project
$this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_updated ON sessions(project, updated_at DESC)');
+
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_sessions (
+ platform TEXT NOT NULL,
+ route_key TEXT NOT NULL,
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ chat_id TEXT NOT NULL,
+ thread_id TEXT,
+ user_id TEXT,
+ metadata TEXT,
+ created_at TEXT,
+ updated_at TEXT,
+ PRIMARY KEY (platform, route_key)
+ )
+ ');
+
+ $this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_gateway_sessions_session_id ON gateway_sessions(session_id)');
+
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_messages (
+ platform TEXT NOT NULL,
+ route_key TEXT NOT NULL,
+ message_kind TEXT NOT NULL,
+ chat_id TEXT NOT NULL,
+ message_id INTEGER NOT NULL,
+ thread_id TEXT,
+ updated_at TEXT,
+ PRIMARY KEY (platform, route_key, message_kind)
+ )
+ ');
+
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_approvals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ platform TEXT NOT NULL,
+ route_key TEXT NOT NULL,
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ tool_name TEXT NOT NULL,
+ arguments_json TEXT NOT NULL,
+ status TEXT NOT NULL,
+ chat_id TEXT NOT NULL,
+ thread_id TEXT,
+ request_message_id INTEGER,
+ created_at TEXT,
+ resolved_at TEXT
+ )
+ ');
+
+ $this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_gateway_approvals_route_status ON gateway_approvals(platform, route_key, status, created_at DESC)');
+
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_checkpoints (
+ platform TEXT NOT NULL,
+ checkpoint TEXT NOT NULL,
+ value TEXT,
+ updated_at TEXT,
+ PRIMARY KEY (platform, checkpoint)
+ )
+ ');
+
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_pending_inputs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ platform TEXT NOT NULL,
+ route_key TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ created_at TEXT
+ )
+ ');
+
+ $this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_gateway_pending_inputs_route ON gateway_pending_inputs(platform, route_key, id)');
}
/** Runs incremental schema migrations starting from the given version. */
@@ -198,6 +269,75 @@ private function migrate(int $from): void
$this->createMessagesFtsSchema();
$this->rebuildMessagesFtsIndex();
}
+
+ if ($from < 6) {
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_sessions (
+ platform TEXT NOT NULL,
+ route_key TEXT NOT NULL,
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ chat_id TEXT NOT NULL,
+ thread_id TEXT,
+ user_id TEXT,
+ metadata TEXT,
+ created_at TEXT,
+ updated_at TEXT,
+ PRIMARY KEY (platform, route_key)
+ )
+ ');
+ $this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_gateway_sessions_session_id ON gateway_sessions(session_id)');
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_messages (
+ platform TEXT NOT NULL,
+ route_key TEXT NOT NULL,
+ message_kind TEXT NOT NULL,
+ chat_id TEXT NOT NULL,
+ message_id INTEGER NOT NULL,
+ thread_id TEXT,
+ updated_at TEXT,
+ PRIMARY KEY (platform, route_key, message_kind)
+ )
+ ');
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_approvals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ platform TEXT NOT NULL,
+ route_key TEXT NOT NULL,
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ tool_name TEXT NOT NULL,
+ arguments_json TEXT NOT NULL,
+ status TEXT NOT NULL,
+ chat_id TEXT NOT NULL,
+ thread_id TEXT,
+ request_message_id INTEGER,
+ created_at TEXT,
+ resolved_at TEXT
+ )
+ ');
+ $this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_gateway_approvals_route_status ON gateway_approvals(platform, route_key, status, created_at DESC)');
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_checkpoints (
+ platform TEXT NOT NULL,
+ checkpoint TEXT NOT NULL,
+ value TEXT,
+ updated_at TEXT,
+ PRIMARY KEY (platform, checkpoint)
+ )
+ ');
+ }
+
+ if ($from < 7) {
+ $this->pdo->exec('
+ CREATE TABLE IF NOT EXISTS gateway_pending_inputs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ platform TEXT NOT NULL,
+ route_key TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ created_at TEXT
+ )
+ ');
+ $this->pdo->exec('CREATE INDEX IF NOT EXISTS idx_gateway_pending_inputs_route ON gateway_pending_inputs(platform, route_key, id)');
+ }
}
/** Adds a column to a table only if it does not already exist. */
diff --git a/src/Session/MessageRepository.php b/src/Session/MessageRepository.php
index 7c06a70..8320f0e 100644
--- a/src/Session/MessageRepository.php
+++ b/src/Session/MessageRepository.php
@@ -394,6 +394,151 @@ private function fallbackTerms(string $query): array
return array_values(array_unique($terms));
}
+ /**
+ * FTS5 search grouped by session — returns per-session match info with context.
+ *
+ * For each matched session, returns the best-matching message and up to 2
+ * surrounding messages for context, plus a count of total matches in that session.
+ *
+ * @param string $project Project path to scope the search
+ * @param string $query Free-text query
+ * @param string|null $excludeSessionId Optional session to exclude
+ * @param int $limit Maximum number of unique sessions to return
+ * @return array}>
+ */
+ public function searchProjectHistoryGrouped(string $project, string $query, ?string $excludeSessionId = null, int $limit = 5): array
+ {
+ $ftsQuery = $this->buildFtsQuery($query);
+ if ($ftsQuery === null) {
+ return [];
+ }
+
+ // Fetch more results than needed so we can group and rank by session
+ $fetchLimit = $limit * 10;
+
+ $sql = '
+ SELECT m.id, m.session_id, m.role, m.content, m.created_at,
+ s.title, s.updated_at, bm25(messages_fts) AS rank
+ FROM messages_fts
+ INNER JOIN messages m ON m.id = messages_fts.rowid
+ INNER JOIN sessions s ON s.id = m.session_id
+ WHERE s.project = :project
+ AND m.compacted = 0
+ AND m.content IS NOT NULL
+ AND messages_fts MATCH :query
+ ';
+ $params = ['project' => $project, 'query' => $ftsQuery];
+
+ if ($excludeSessionId !== null) {
+ $sql .= ' AND m.session_id != :exclude_session_id';
+ $params['exclude_session_id'] = $excludeSessionId;
+ }
+
+ $sql .= ' ORDER BY rank ASC, s.updated_at DESC LIMIT :fetch_limit';
+ $params['fetch_limit'] = $fetchLimit;
+
+ $stmt = $this->db->connection()->prepare($sql);
+ $stmt->execute($params);
+ $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
+
+ // Group by session, keeping the best match (lowest rank = most relevant)
+ $sessions = [];
+ foreach ($rows as $row) {
+ $sid = $row['session_id'];
+ if (! isset($sessions[$sid])) {
+ $sessions[$sid] = [
+ 'session_id' => $sid,
+ 'title' => $row['title'],
+ 'updated_at' => $row['updated_at'],
+ 'match_count' => 0,
+ 'best_match_id' => (int) $row['id'],
+ 'best_match' => [
+ 'role' => $row['role'],
+ 'content' => $row['content'],
+ 'created_at' => $row['created_at'],
+ ],
+ ];
+ }
+ $sessions[$sid]['match_count']++;
+
+ if (count($sessions) >= $limit && ! isset($sessions[$sid])) {
+ break;
+ }
+ }
+
+ // Fetch context (surrounding messages) for each session's best match
+ $result = [];
+ foreach (array_slice(array_values($sessions), 0, $limit) as $entry) {
+ $entry['context'] = $this->loadContextAroundMessage($entry['session_id'], $entry['best_match_id']);
+ unset($entry['best_match_id']);
+ $result[] = $entry;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Load 1 message before and 1 after the given message ID within the same session.
+ *
+ * @return list
+ */
+ private function loadContextAroundMessage(string $sessionId, int $messageId): array
+ {
+ $context = [];
+
+ // Message before
+ $stmt = $this->db->connection()->prepare('
+ SELECT role, content FROM messages
+ WHERE session_id = :sid AND id < :mid AND compacted = 0 AND content IS NOT NULL
+ ORDER BY id DESC LIMIT 1
+ ');
+ $stmt->execute(['sid' => $sessionId, 'mid' => $messageId]);
+ $before = $stmt->fetch(\PDO::FETCH_ASSOC);
+ if ($before) {
+ $context[] = ['role' => $before['role'], 'content' => $before['content']];
+ }
+
+ // Message after
+ $stmt = $this->db->connection()->prepare('
+ SELECT role, content FROM messages
+ WHERE session_id = :sid AND id > :mid AND compacted = 0 AND content IS NOT NULL
+ ORDER BY id ASC LIMIT 1
+ ');
+ $stmt->execute(['sid' => $sessionId, 'mid' => $messageId]);
+ $after = $stmt->fetch(\PDO::FETCH_ASSOC);
+ if ($after) {
+ $context[] = ['role' => $after['role'], 'content' => $after['content']];
+ }
+
+ return $context;
+ }
+
+ /**
+ * Load a session's messages formatted as a readable transcript.
+ *
+ * @param string $sessionId Session to load
+ * @param int $limit Maximum messages to return (0 = all)
+ * @return list
+ */
+ public function loadTranscript(string $sessionId, int $limit = 0): array
+ {
+ $sql = 'SELECT role, content, tool_calls, created_at
+ FROM messages
+ WHERE session_id = :sid AND compacted = 0
+ ORDER BY id ASC';
+ $params = ['sid' => $sessionId];
+
+ if ($limit > 0) {
+ $sql .= ' LIMIT :lim';
+ $params['lim'] = $limit;
+ }
+
+ $stmt = $this->db->connection()->prepare($sql);
+ $stmt->execute($params);
+
+ return $stmt->fetchAll(\PDO::FETCH_ASSOC);
+ }
+
private function looksIdentifierLike(string $query): bool
{
return strpbrk($query, '/._-') !== false;
diff --git a/src/Session/MessageRepositoryInterface.php b/src/Session/MessageRepositoryInterface.php
index c813a89..c4a7cc9 100644
--- a/src/Session/MessageRepositoryInterface.php
+++ b/src/Session/MessageRepositoryInterface.php
@@ -105,4 +105,24 @@ public function sumTokens(string $sessionId): array;
* @return array> Matching message rows with session metadata
*/
public function searchProjectHistory(string $project, string $query, ?string $excludeSessionId = null, int $limit = 5): array;
+
+ /**
+ * FTS5 search grouped by session — returns per-session match info with context.
+ *
+ * @param string $project Project path to scope the search
+ * @param string $query Free-text query
+ * @param string|null $excludeSessionId Optional session to exclude
+ * @param int $limit Maximum number of unique sessions to return
+ * @return array}>
+ */
+ public function searchProjectHistoryGrouped(string $project, string $query, ?string $excludeSessionId = null, int $limit = 5): array;
+
+ /**
+ * Load a session's messages formatted as a readable transcript.
+ *
+ * @param string $sessionId Session to load
+ * @param int $limit Maximum messages to return (0 = all)
+ * @return list
+ */
+ public function loadTranscript(string $sessionId, int $limit = 0): array;
}
diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php
index e05b21a..d944c5f 100644
--- a/src/Session/SessionManager.php
+++ b/src/Session/SessionManager.php
@@ -422,6 +422,34 @@ public function searchSessionHistory(string $query, int $limit = 5): array
return $this->messages->searchProjectHistory($this->project, $query, $this->currentSessionId, $limit);
}
+ /**
+ * FTS5 search grouped by session — returns per-session match info with context.
+ *
+ * @param string $query Search terms
+ * @param int $limit Maximum unique sessions to return
+ * @return array>
+ */
+ public function searchSessionHistoryGrouped(string $query, int $limit = 5): array
+ {
+ if ($this->project === null || trim($query) === '') {
+ return [];
+ }
+
+ return $this->messages->searchProjectHistoryGrouped($this->project, $query, $this->currentSessionId, $limit);
+ }
+
+ /**
+ * Load a session's messages as a readable transcript.
+ *
+ * @param string $sessionId Session to load
+ * @param int $limit Maximum messages (0 = all)
+ * @return list
+ */
+ public function loadSessionTranscript(string $sessionId, int $limit = 0): array
+ {
+ return $this->messages->loadTranscript($sessionId, $limit);
+ }
+
/**
* Remove expired and excess compaction memories for the current project.
*
diff --git a/src/Session/Tool/SessionReadTool.php b/src/Session/Tool/SessionReadTool.php
new file mode 100644
index 0000000..c377820
--- /dev/null
+++ b/src/Session/Tool/SessionReadTool.php
@@ -0,0 +1,120 @@
+ ['type' => 'string', 'description' => 'Session ID or unique prefix (at least 8 characters). Shown in session_search results as [xxxxxxxx].'],
+ 'limit' => ['type' => 'integer', 'description' => 'Maximum messages to return. Defaults to 50, max 200.'],
+ ];
+ }
+
+ public function requiredParameters(): array
+ {
+ return ['session_id'];
+ }
+
+ /**
+ * @param array{session_id?:string, limit?:int|string} $args
+ */
+ protected function handle(array $args): ToolResult
+ {
+ $idOrPrefix = trim((string) ($args['session_id'] ?? ''));
+ if ($idOrPrefix === '') {
+ return ToolResult::error('session_id is required.');
+ }
+
+ $rawLimit = $args['limit'] ?? 50;
+ $limit = is_numeric((string) $rawLimit) ? (int) $rawLimit : 50;
+ $limit = max(1, min(200, $limit));
+
+ $session = $this->session->findSession($idOrPrefix);
+ if ($session === null) {
+ return ToolResult::error("No session found matching \"{$idOrPrefix}\".");
+ }
+
+ $sessionId = (string) $session['id'];
+ $title = (string) ($session['title'] ?? 'untitled');
+ $model = (string) ($session['model'] ?? 'unknown');
+ $date = isset($session['created_at']) ? $this->formatDate((string) $session['created_at']) : 'unknown';
+
+ $messages = $this->session->loadSessionTranscript($sessionId, $limit);
+ if ($messages === []) {
+ return ToolResult::success("Session \"{$title}\" exists but has no messages.");
+ }
+
+ $lines = [
+ "# Session: {$title}",
+ "ID: {$sessionId} | Model: {$model} | Date: {$date} | Messages: ".count($messages),
+ '',
+ ];
+
+ foreach ($messages as $msg) {
+ $role = strtoupper((string) ($msg['role'] ?? 'unknown'));
+ $content = (string) ($msg['content'] ?? '');
+ $toolCalls = (string) ($msg['tool_calls'] ?? '');
+
+ if ($content !== '') {
+ // Truncate very long messages to avoid blowing up context
+ $display = mb_strlen($content) > 2000
+ ? mb_substr($content, 0, 2000).'... [truncated]'
+ : $content;
+ $lines[] = "[{$role}]: {$display}";
+ } elseif ($toolCalls !== '') {
+ $calls = json_decode($toolCalls, true);
+ if (is_array($calls)) {
+ $names = array_map(fn (array $c) => (string) ($c['name'] ?? '?'), $calls);
+ $lines[] = "[{$role}]: [Called: ".implode(', ', $names).']';
+ }
+ }
+
+ $lines[] = '';
+ }
+
+ if (count($messages) === $limit) {
+ $lines[] = "(Showing first {$limit} messages. Use a higher limit to see more.)";
+ }
+
+ return ToolResult::success(implode("\n", $lines));
+ }
+
+ private function formatDate(string $timestamp): string
+ {
+ if (is_numeric($timestamp)) {
+ return date('Y-m-d', (int) ((float) $timestamp));
+ }
+
+ $time = strtotime($timestamp);
+
+ return $time !== false ? date('Y-m-d', $time) : substr($timestamp, 0, 10);
+ }
+}
diff --git a/src/Session/Tool/SessionSearchTool.php b/src/Session/Tool/SessionSearchTool.php
index c9ddf81..9572a81 100644
--- a/src/Session/Tool/SessionSearchTool.php
+++ b/src/Session/Tool/SessionSearchTool.php
@@ -9,8 +9,11 @@
use Kosmokrator\Tool\ToolResult;
/**
- * Searches prior session messages in the current project using the session database.
- * Exposed separately from memory_search so the agent can explicitly recall old conversations.
+ * Searches prior session history or lists recent sessions in the current project.
+ *
+ * Two modes:
+ * - Browse (empty query): returns recent sessions with titles, dates, and previews
+ * - Search (with query): FTS5 search grouped by session with surrounding context
*/
class SessionSearchTool extends AbstractTool
{
@@ -25,20 +28,34 @@ public function name(): string
public function description(): string
{
- return 'Search prior session history in this project by keywords, phrases, file paths, or command names.';
+ return <<<'DESC'
+Search prior sessions or browse recent ones in this project.
+
+Two modes:
+1. Browse (no query): Returns recent sessions with titles, dates, message counts, and preview of the last user message. Zero cost, instant.
+2. Search (with query): FTS5 keyword search across all past sessions. Returns results grouped by session with match counts and surrounding context.
+
+Use this proactively when:
+- The user says "we did this before", "remember when", "last time", "as I mentioned"
+- The user references a topic worked on before but not in current context
+- You want to check if a similar problem was solved before
+- The user asks "what did we do about X?" or "how did we fix Y?"
+
+Search syntax: keywords (joined with AND by default), "exact phrase" in quotes, path/identifiers like src/Foo.php.
+DESC;
}
public function parameters(): array
{
return [
- 'query' => ['type' => 'string', 'description' => 'Search terms or an exact phrase in quotes.'],
- 'limit' => ['type' => 'integer', 'description' => 'Maximum number of matches to return. Defaults to 8, max 20.'],
+ 'query' => ['type' => 'string', 'description' => 'Search terms, exact phrases in quotes, or file paths. Omit to browse recent sessions.'],
+ 'limit' => ['type' => 'integer', 'description' => 'Maximum results. Defaults to 5 (browse) or 5 (search), max 10.'],
];
}
public function requiredParameters(): array
{
- return ['query'];
+ return [];
}
/**
@@ -47,38 +64,104 @@ public function requiredParameters(): array
protected function handle(array $args): ToolResult
{
$query = trim((string) ($args['query'] ?? ''));
+ $rawLimit = $args['limit'] ?? 5;
+ $limit = is_numeric((string) $rawLimit) ? (int) $rawLimit : 5;
+ $limit = max(1, min(10, $limit));
+
if ($query === '') {
- return ToolResult::error('Query is required.');
+ return $this->browseRecent($limit);
}
- $rawLimit = $args['limit'] ?? 8;
- $limit = is_numeric((string) $rawLimit) ? (int) $rawLimit : 8;
- $limit = max(1, min(20, $limit));
+ return $this->searchGrouped($query, $limit);
+ }
- $rows = $this->session->searchSessionHistory($query, $limit);
- if ($rows === []) {
- return ToolResult::success('No session history matches found.');
+ private function browseRecent(int $limit): ToolResult
+ {
+ $sessions = $this->session->listSessions($limit);
+ if ($sessions === []) {
+ return ToolResult::success('No prior sessions found in this project.');
}
- $lines = ['Found '.count($rows).' session history matches:', ''];
+ $lines = ['Recent sessions ('.count($sessions).'):', ''];
- foreach ($rows as $row) {
- $title = (string) ($row['title'] ?? $row['session_id'] ?? 'session');
- $role = (string) ($row['role'] ?? 'message');
- $date = isset($row['updated_at']) ? substr((string) $row['updated_at'], 0, 10) : '';
- $datePart = $date !== '' ? " ({$date})" : '';
- $lines[] = '- '.$title.$datePart.' ['.$role.']: '.$this->truncate((string) ($row['content'] ?? ''), 280);
+ foreach ($sessions as $s) {
+ $title = (string) ($s['title'] ?? 'untitled');
+ $date = isset($s['updated_at']) ? $this->formatDate((string) $s['updated_at']) : '';
+ $count = (int) ($s['message_count'] ?? 0);
+ $preview = $this->truncate((string) ($s['last_user_message'] ?? ''), 120);
+ $id = substr((string) $s['id'], 0, 8);
+
+ $lines[] = "- [{$id}] {$title} ({$date}, {$count} msgs)";
+ if ($preview !== '') {
+ $lines[] = " > {$preview}";
+ }
}
+ $lines[] = '';
+ $lines[] = 'Use session_search with a query to search across sessions, or session_read with a session ID to view a full transcript.';
+
+ return ToolResult::success(implode("\n", $lines));
+ }
+
+ private function searchGrouped(string $query, int $limit): ToolResult
+ {
+ $results = $this->session->searchSessionHistoryGrouped($query, $limit);
+ if ($results === []) {
+ return ToolResult::success("No session history matches for \"{$query}\".");
+ }
+
+ $lines = ['Found matches in '.count($results)." session(s) for \"{$query}\":", ''];
+
+ foreach ($results as $entry) {
+ $title = (string) ($entry['title'] ?? 'untitled');
+ $date = isset($entry['updated_at']) ? $this->formatDate((string) $entry['updated_at']) : '';
+ $matchCount = (int) ($entry['match_count'] ?? 1);
+ $id = substr((string) $entry['session_id'], 0, 8);
+
+ $lines[] = "## [{$id}] {$title} ({$date}, {$matchCount} matches)";
+ $lines[] = '';
+
+ // Show context messages before the match
+ foreach ($entry['context'] ?? [] as $ctx) {
+ $role = strtoupper((string) ($ctx['role'] ?? 'unknown'));
+ $content = $this->truncate((string) ($ctx['content'] ?? ''), 200);
+ if ($content !== '') {
+ $lines[] = " [{$role}]: {$content}";
+ }
+ }
+
+ // Show the best match
+ $best = $entry['best_match'] ?? [];
+ $bestRole = strtoupper((string) ($best['role'] ?? 'unknown'));
+ $bestContent = $this->truncate((string) ($best['content'] ?? ''), 400);
+ $lines[] = " **[{$bestRole}]**: {$bestContent}";
+ $lines[] = '';
+ }
+
+ $lines[] = 'Use session_read with a session ID prefix to view the full transcript.';
+
return ToolResult::success(implode("\n", $lines));
}
private function truncate(string $text, int $limit): string
{
+ $text = str_replace(["\r\n", "\r", "\n"], ' ', $text);
if (mb_strlen($text) <= $limit) {
return $text;
}
return mb_substr($text, 0, $limit).'...';
}
+
+ private function formatDate(string $timestamp): string
+ {
+ // Handle both ISO-8601 and Unix float timestamps
+ if (is_numeric($timestamp)) {
+ return date('Y-m-d', (int) ((float) $timestamp));
+ }
+
+ $time = strtotime($timestamp);
+
+ return $time !== false ? date('Y-m-d', $time) : substr($timestamp, 0, 10);
+ }
}
diff --git a/src/Settings/SettingsSchema.php b/src/Settings/SettingsSchema.php
index 1f54d3d..3f24917 100644
--- a/src/Settings/SettingsSchema.php
+++ b/src/Settings/SettingsSchema.php
@@ -59,6 +59,7 @@ public function categories(): array
'context_memory',
'agent',
'permissions',
+ 'gateway',
'integrations',
'subagents',
'advanced',
@@ -79,6 +80,7 @@ public function categoryLabels(): array
'context_memory' => 'Context & Memory',
'agent' => 'Agent',
'permissions' => 'Permissions',
+ 'gateway' => 'Gateway',
'integrations' => 'Integrations',
'subagents' => 'Subagents',
'advanced' => 'Advanced',
@@ -285,6 +287,111 @@ private function buildDefinitions(): array
effect: 'applies_now',
default: 'guardian',
),
+ new SettingDefinition(
+ id: 'gateway.telegram.enabled',
+ path: 'kosmokrator.gateway.telegram.enabled',
+ label: 'Telegram gateway',
+ description: 'Enable the Telegram gateway worker.',
+ category: 'gateway',
+ type: 'toggle',
+ options: ['on', 'off'],
+ effect: 'next_session',
+ default: 'off',
+ ),
+ new SettingDefinition(
+ id: 'gateway.telegram.session_mode',
+ path: 'kosmokrator.gateway.telegram.session_mode',
+ label: 'Session routing',
+ description: 'How Telegram group messages map to Kosmo sessions.',
+ category: 'gateway',
+ type: 'choice',
+ options: ['thread', 'thread_user', 'chat', 'chat_user'],
+ effect: 'next_session',
+ default: 'thread',
+ ),
+ new SettingDefinition(
+ id: 'gateway.telegram.allowed_users',
+ path: 'kosmokrator.gateway.telegram.allowed_users',
+ label: 'Allowed users',
+ description: 'Space- or comma-separated Telegram user IDs or usernames. Leave empty to allow any user that can reach the bot.',
+ category: 'gateway',
+ type: 'text',
+ effect: 'next_session',
+ default: '',
+ ),
+ new SettingDefinition(
+ id: 'gateway.telegram.allowed_chats',
+ path: 'kosmokrator.gateway.telegram.allowed_chats',
+ label: 'Allowed chats',
+ description: 'Space- or comma-separated chat IDs allowed to talk to the bot. Leave empty to allow all chats.',
+ category: 'gateway',
+ type: 'text',
+ effect: 'next_session',
+ default: '',
+ ),
+ new SettingDefinition(
+ id: 'gateway.telegram.require_mention',
+ path: 'kosmokrator.gateway.telegram.require_mention',
+ label: 'Require mention in groups',
+ description: 'In non-private chats, only respond when mentioned or replied to unless the chat is marked as free-response.',
+ category: 'gateway',
+ type: 'toggle',
+ options: ['on', 'off'],
+ effect: 'next_session',
+ default: 'on',
+ ),
+ new SettingDefinition(
+ id: 'gateway.telegram.free_response_chats',
+ path: 'kosmokrator.gateway.telegram.free_response_chats',
+ label: 'Free-response chats',
+ description: 'Space- or comma-separated chat IDs where the bot may answer without an explicit mention.',
+ category: 'gateway',
+ type: 'text',
+ effect: 'next_session',
+ default: '',
+ ),
+ new SettingDefinition(
+ id: 'gateway.telegram.poll_timeout_seconds',
+ path: 'kosmokrator.gateway.telegram.poll_timeout_seconds',
+ label: 'Poll timeout',
+ description: 'Long-poll timeout in seconds for Telegram update fetches.',
+ category: 'gateway',
+ type: 'number',
+ effect: 'next_session',
+ default: 20,
+ ),
+ new SettingDefinition(
+ id: 'web.search.default_provider',
+ path: 'kosmokrator.web.search.default_provider',
+ label: 'Web search provider',
+ description: 'Default provider used by web_search when no provider override is passed.',
+ category: 'advanced',
+ type: 'choice',
+ options: ['tavily', 'zai', 'exa', 'google_custom_search'],
+ effect: 'next_session',
+ default: 'tavily',
+ ),
+ new SettingDefinition(
+ id: 'web.fetch.default_provider',
+ path: 'kosmokrator.web.fetch.default_provider',
+ label: 'Web fetch provider',
+ description: 'Default provider used by web_fetch when no provider override is passed.',
+ category: 'advanced',
+ type: 'choice',
+ options: ['direct', 'zai', 'firecrawl'],
+ effect: 'next_session',
+ default: 'direct',
+ ),
+ new SettingDefinition(
+ id: 'web.fetch.max_chars',
+ path: 'kosmokrator.web.fetch.max_chars',
+ label: 'Web fetch max chars',
+ description: 'Default maximum number of characters returned by web_fetch before chunking.',
+ category: 'advanced',
+ type: 'number',
+ effect: 'next_session',
+ default: 12000,
+ ),
new SettingDefinition(
id: 'context.memories',
path: 'kosmokrator.context.memories',
diff --git a/src/Setup/SetupFlowInterface.php b/src/Setup/SetupFlowInterface.php
new file mode 100644
index 0000000..bf13b57
--- /dev/null
+++ b/src/Setup/SetupFlowInterface.php
@@ -0,0 +1,12 @@
+container->make(SettingsManager::class);
+ $settings->setProjectRoot(InstructionLoader::gitRoot() ?? getcwd());
+ $providers = $this->container->make(ProviderCatalog::class);
+ $config = $this->container->make('config');
+
+ $provider = trim((string) ($settings->get('agent.default_provider')
+ ?? $config->get('kosmokrator.agent.default_provider', 'z')));
+
+ if ($provider === '' || $providers->provider($provider) === null) {
+ return true;
+ }
+
+ return match ($providers->authMode($provider)) {
+ 'none' => false,
+ 'oauth' => ! str_starts_with($providers->authStatus($provider), 'Authenticated'),
+ default => trim($providers->apiKey($provider)) === '',
+ };
+ }
+
+ public function open(string $rendererPref = 'auto', bool $animated = false, bool $showIntro = false, ?string $notice = null): bool
+ {
+ $ui = new UIManager($rendererPref);
+ $ui->initialize();
+
+ try {
+ if ($showIntro) {
+ $ui->renderIntro($animated);
+ }
+
+ if ($notice !== null && trim($notice) !== '') {
+ $ui->showNotice(trim($notice));
+ }
+
+ $ctx = $this->makeContext($ui);
+ $settings = new SettingsCommand($this->container);
+ $settings->openWorkspace($ctx, [
+ 'title' => 'Setup',
+ 'scope' => 'global',
+ 'initial_category' => 'provider_setup',
+ ]);
+
+ return ! $this->needsProviderSetup();
+ } finally {
+ $ui->teardown();
+ }
+ }
+
+ private function makeContext(UIManager $ui): SlashCommandContext
+ {
+ $settings = $this->container->make(SettingsManager::class);
+ $settings->setProjectRoot(InstructionLoader::gitRoot() ?? getcwd());
+ $providers = $this->container->make(ProviderCatalog::class);
+ $models = $this->container->make(ModelCatalog::class);
+ $provider = trim((string) ($settings->get('agent.default_provider')
+ ?? $this->container->make('config')->get('kosmokrator.agent.default_provider', 'z')));
+ $model = trim((string) ($settings->getProviderLastModel($provider)
+ ?? $settings->get('agent.default_model')
+ ?? $providers->defaultModel($provider)
+ ?? ($providers->modelIds($provider)[0] ?? '')));
+
+ $llm = new class($provider !== '' ? $provider : 'z', $model) implements LlmClientInterface
+ {
+ public function __construct(
+ private string $provider,
+ private string $model,
+ ) {}
+
+ public function chat(array $messages, array $tools = [], ?Cancellation $cancellation = null): LlmResponse
+ {
+ throw new \RuntimeException('Setup flow does not execute LLM requests.');
+ }
+
+ public function stream(array $messages, array $tools = [], ?Cancellation $cancellation = null): Generator
+ {
+ yield from [];
+ }
+
+ public function supportsStreaming(): bool
+ {
+ return false;
+ }
+
+ public function setSystemPrompt(string $prompt): void {}
+
+ public function getProvider(): string
+ {
+ return $this->provider;
+ }
+
+ public function setProvider(string $provider): void
+ {
+ $this->provider = $provider;
+ }
+
+ public function getModel(): string
+ {
+ return $this->model;
+ }
+
+ public function setModel(string $model): void
+ {
+ $this->model = $model;
+ }
+
+ public function getTemperature(): int|float|null
+ {
+ return null;
+ }
+
+ public function setTemperature(int|float|null $temperature): void {}
+
+ public function getMaxTokens(): ?int
+ {
+ return null;
+ }
+
+ public function setMaxTokens(?int $maxTokens): void {}
+
+ public function getReasoningEffort(): string
+ {
+ return 'off';
+ }
+
+ public function setReasoningEffort(string $effort): void {}
+
+ public function setApiKey(string $apiKey): void {}
+
+ public function setBaseUrl(string $baseUrl): void {}
+ };
+
+ $sessionManager = $this->container->make(SessionManager::class);
+ $sessionManager->setProject(InstructionLoader::gitRoot() ?? getcwd());
+ $permissions = $this->container->make(PermissionEvaluator::class);
+ $taskStore = $this->container->make(TaskStore::class);
+
+ $agentLoop = new AgentLoop(
+ $llm,
+ $ui,
+ $this->container->make(LoggerInterface::class),
+ 'Setup settings flow.',
+ $permissions,
+ $models,
+ $taskStore,
+ $sessionManager,
+ );
+
+ return new SlashCommandContext(
+ ui: $ui,
+ agentLoop: $agentLoop,
+ permissions: $permissions,
+ sessionManager: $sessionManager,
+ llm: $llm,
+ taskStore: $taskStore,
+ config: $this->container->make('config'),
+ settings: $this->container->make(SettingsRepositoryInterface::class),
+ providers: $providers,
+ models: $models,
+ );
+ }
+}
diff --git a/src/Tool/Permission/PermissionConfigParser.php b/src/Tool/Permission/PermissionConfigParser.php
index f404753..cc8b953 100644
--- a/src/Tool/Permission/PermissionConfigParser.php
+++ b/src/Tool/Permission/PermissionConfigParser.php
@@ -25,6 +25,7 @@ class PermissionConfigParser
'memory_save', 'memory_search',
'ask_user', 'ask_choice',
'subagent',
+ 'web_search', 'web_fetch',
];
public function parse(Repository $config): array
diff --git a/src/Tool/Web/WebFetchTool.php b/src/Tool/Web/WebFetchTool.php
new file mode 100644
index 0000000..dfbf1d9
--- /dev/null
+++ b/src/Tool/Web/WebFetchTool.php
@@ -0,0 +1,434 @@
+providers->availableProviderIds();
+
+ return [
+ 'url' => ['type' => 'string', 'description' => 'The page URL to fetch.'],
+ 'provider' => ['type' => 'enum', 'description' => 'Optional fetch provider override.', 'options' => $availableProviders],
+ 'mode' => ['type' => 'enum', 'description' => 'Fetch mode. Defaults to main.', 'options' => ['metadata', 'outline', 'main', 'full', 'section', 'match', 'chunk']],
+ 'format' => ['type' => 'enum', 'description' => 'Preferred output format. Defaults to markdown.', 'options' => ['markdown', 'text', 'html']],
+ 'max_chars' => ['type' => 'integer', 'description' => 'Maximum content characters to return before chunking. Defaults to 12000.'],
+ 'summarize' => ['type' => 'boolean', 'description' => 'Reserved flag for future summarization. Currently ignored.'],
+ 'prompt' => ['type' => 'string', 'description' => 'Optional focus note for future summarization/extraction flows.'],
+ 'heading' => ['type' => 'string', 'description' => 'Section heading to fetch when mode=section.'],
+ 'section_id' => ['type' => 'string', 'description' => 'Section id to fetch when mode=section. Use ids returned by outline mode.'],
+ 'match' => ['type' => 'string', 'description' => 'Phrase to match when mode=match.'],
+ 'start_after' => ['type' => 'string', 'description' => 'Reserved for future boundary targeting.'],
+ 'end_before' => ['type' => 'string', 'description' => 'Reserved for future boundary targeting.'],
+ 'chunk_token' => ['type' => 'string', 'description' => 'Opaque token from a previous truncated response when mode=chunk.'],
+ 'timeout' => ['type' => 'integer', 'description' => 'Optional request timeout in seconds.'],
+ 'strategy' => ['type' => 'enum', 'description' => 'Provider selection strategy. Defaults to auto.', 'options' => ['auto', 'direct_only', 'provider_only']],
+ 'include_metadata' => ['type' => 'boolean', 'description' => 'Include page metadata in the response. Defaults to true.'],
+ 'include_outline' => ['type' => 'boolean', 'description' => 'Include outline information when available. Defaults to true.'],
+ ];
+ }
+
+ public function requiredParameters(): array
+ {
+ return ['url'];
+ }
+
+ /**
+ * @param array $args
+ */
+ protected function handle(array $args): ToolResult
+ {
+ $request = new WebFetchRequest(
+ url: trim((string) ($args['url'] ?? '')),
+ provider: $this->nullableString($args['provider'] ?? null),
+ mode: $this->enumValue((string) ($args['mode'] ?? 'main'), ['metadata', 'outline', 'main', 'full', 'section', 'match', 'chunk'], 'main'),
+ format: $this->enumValue((string) ($args['format'] ?? 'markdown'), ['markdown', 'text', 'html'], 'markdown'),
+ maxChars: max(50, min(50_000, (int) ($args['max_chars'] ?? ($this->settings->getRaw('kosmokrator.web.fetch.max_chars') ?? 12_000)))),
+ summarize: filter_var($args['summarize'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
+ prompt: $this->nullableString($args['prompt'] ?? null),
+ heading: $this->nullableString($args['heading'] ?? null),
+ sectionId: $this->nullableString($args['section_id'] ?? null),
+ match: $this->nullableString($args['match'] ?? null),
+ startAfter: $this->nullableString($args['start_after'] ?? null),
+ endBefore: $this->nullableString($args['end_before'] ?? null),
+ chunkToken: $this->nullableString($args['chunk_token'] ?? null),
+ timeout: isset($args['timeout']) ? max(5, min(60, (int) $args['timeout'])) : null,
+ strategy: $this->enumValue((string) ($args['strategy'] ?? 'auto'), ['auto', 'direct_only', 'provider_only'], 'auto'),
+ includeMetadata: filter_var($args['include_metadata'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
+ includeOutline: filter_var($args['include_outline'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
+ );
+
+ if ($request->url === '') {
+ return ToolResult::error('URL is required.');
+ }
+
+ $baseResponse = $this->providers->fetch($request);
+ $selected = $this->selectContent($baseResponse, $request);
+ $selectedContent = $selected['content'];
+
+ if ($request->format === 'text' && $selected['source'] !== 'metadata' && $selected['source'] !== 'outline') {
+ $selectedContent = $this->markdownToPlainText($selectedContent);
+ }
+
+ [$content, $truncated, $nextChunkToken] = $this->sliceContent(
+ $selectedContent,
+ $request->maxChars,
+ $request,
+ $selected['source'],
+ );
+
+ $response = new WebFetchResponse(
+ provider: $baseResponse->provider,
+ url: $baseResponse->url,
+ finalUrl: $baseResponse->finalUrl,
+ statusCode: $baseResponse->statusCode,
+ contentType: $baseResponse->contentType,
+ format: $baseResponse->format,
+ title: $baseResponse->title,
+ metadata: $baseResponse->metadata,
+ outline: $baseResponse->outline,
+ sections: $baseResponse->sections,
+ content: $request->format === 'html' && $selected['source'] === 'content'
+ ? ($baseResponse->rawHtml ?? $content)
+ : $content,
+ rawHtml: $baseResponse->rawHtml,
+ truncated: $truncated,
+ nextChunkToken: $nextChunkToken,
+ extractionMethod: $baseResponse->extractionMethod,
+ meta: array_merge($baseResponse->meta, ['selected_source' => $selected['source']]),
+ );
+
+ return ToolResult::successWithMetadata(
+ $this->renderResponse($response, $request->mode, $request->includeMetadata, $request->includeOutline),
+ [
+ 'provider' => $response->provider,
+ 'url' => $response->url,
+ 'final_url' => $response->finalUrl,
+ 'status_code' => $response->statusCode,
+ 'content_type' => $response->contentType,
+ 'title' => $response->title,
+ 'metadata' => $response->metadata,
+ 'outline' => $response->outline,
+ 'content' => $response->content,
+ 'truncated' => $response->truncated,
+ 'next_chunk_token' => $response->nextChunkToken,
+ ],
+ );
+ }
+
+ /**
+ * @return array{content: string, source: string}
+ */
+ private function selectContent(WebFetchResponse $response, WebFetchRequest $request): array
+ {
+ return match ($request->mode) {
+ 'metadata' => ['content' => '', 'source' => 'metadata'],
+ 'outline' => ['content' => '', 'source' => 'outline'],
+ 'main', 'full' => ['content' => $this->baseContentForFormat($response, $request), 'source' => 'content'],
+ 'section' => $this->selectSection($response, $request),
+ 'match' => $this->selectMatches($response, $request),
+ 'chunk' => $this->selectChunkSource($response, $request),
+ default => ['content' => $response->content, 'source' => 'content'],
+ };
+ }
+
+ /**
+ * @return array{content: string, source: string}
+ */
+ private function selectSection(WebFetchResponse $response, WebFetchRequest $request): array
+ {
+ if ($request->sectionId !== null && isset($response->sections[$request->sectionId])) {
+ return ['content' => $response->sections[$request->sectionId], 'source' => 'section:'.$request->sectionId];
+ }
+
+ if ($request->sectionId !== null) {
+ $normalizedRequestedId = $this->slugify($request->sectionId);
+
+ foreach (array_keys($response->sections) as $sectionId) {
+ if ($this->slugify($sectionId) === $normalizedRequestedId) {
+ return ['content' => $response->sections[$sectionId], 'source' => 'section:'.$sectionId];
+ }
+ }
+ }
+
+ if ($request->heading !== null) {
+ foreach ($response->outline as $entry) {
+ if (strcasecmp($entry['title'], $request->heading) === 0 && isset($response->sections[$entry['id']])) {
+ return ['content' => $response->sections[$entry['id']], 'source' => 'section:'.$entry['id']];
+ }
+ }
+ }
+
+ $availableIds = array_keys($response->sections);
+ $suffix = $availableIds === [] ? '' : ' Available section ids: '.implode(', ', array_slice($availableIds, 0, 12));
+
+ throw new WebFetchPermanentException('Requested section was not found. Use outline mode first to inspect available section ids and headings.'.$suffix);
+ }
+
+ /**
+ * @return array{content: string, source: string}
+ */
+ private function selectMatches(WebFetchResponse $response, WebFetchRequest $request): array
+ {
+ if ($request->match === null || trim($request->match) === '') {
+ throw new WebFetchPermanentException('match is required when mode=match.');
+ }
+
+ $blocks = [];
+ foreach ($response->sections as $sectionId => $content) {
+ if (stripos($content, $request->match) !== false) {
+ $heading = $this->sectionHeading($response, $sectionId) ?? $sectionId;
+ $blocks[] = "## {$heading}\n\n".$content;
+ }
+ }
+
+ if ($blocks === []) {
+ throw new WebFetchPermanentException("No matching content found for '{$request->match}'.");
+ }
+
+ return ['content' => implode("\n\n", $blocks), 'source' => 'match:'.$request->match];
+ }
+
+ /**
+ * @return array{content: string, source: string}
+ */
+ private function selectChunkSource(WebFetchResponse $response, WebFetchRequest $request): array
+ {
+ if ($request->chunkToken === null) {
+ throw new WebFetchPermanentException('chunk_token is required when mode=chunk.');
+ }
+
+ $token = $this->decodeChunkToken($request->chunkToken);
+ $source = (string) ($token['source'] ?? 'content');
+
+ if ($source === 'content') {
+ return ['content' => $this->baseContentForFormat($response, $request), 'source' => 'content'];
+ }
+
+ if (str_starts_with($source, 'section:')) {
+ $sectionId = substr($source, strlen('section:'));
+ if (isset($response->sections[$sectionId])) {
+ return ['content' => $response->sections[$sectionId], 'source' => $source];
+ }
+ }
+
+ if (str_starts_with($source, 'match:')) {
+ return $this->selectMatches($response, new WebFetchRequest(
+ url: $request->url,
+ provider: $request->provider,
+ mode: 'match',
+ format: $request->format,
+ maxChars: $request->maxChars,
+ summarize: $request->summarize,
+ prompt: $request->prompt,
+ match: substr($source, strlen('match:')),
+ timeout: $request->timeout,
+ strategy: $request->strategy,
+ includeMetadata: $request->includeMetadata,
+ includeOutline: $request->includeOutline,
+ ));
+ }
+
+ throw new WebFetchPermanentException('Chunk token could not be resolved against the current page content.');
+ }
+
+ /**
+ * @return array{0: string, 1: bool, 2: ?string}
+ */
+ private function sliceContent(string $content, int $maxChars, WebFetchRequest $request, string $source): array
+ {
+ if ($request->mode === 'metadata' || $request->mode === 'outline') {
+ return ['', false, null];
+ }
+
+ $offset = 0;
+ if ($request->mode === 'chunk' && $request->chunkToken !== null) {
+ $decoded = $this->decodeChunkToken($request->chunkToken);
+ $offset = max(0, (int) ($decoded['offset'] ?? 0));
+ }
+
+ if (mb_strlen($content) <= $offset + $maxChars) {
+ return [mb_substr($content, $offset), false, null];
+ }
+
+ $slice = mb_substr($content, $offset, $maxChars);
+ $nextToken = $this->encodeChunkToken([
+ 'source' => $source,
+ 'offset' => $offset + $maxChars,
+ ]);
+
+ return [$slice, true, $nextToken];
+ }
+
+ private function renderResponse(WebFetchResponse $response, string $mode, bool $includeMetadata, bool $includeOutline): string
+ {
+ $lines = [
+ "Provider: {$response->provider}",
+ "Mode: {$mode}",
+ "URL: {$response->url}",
+ ];
+
+ if ($response->finalUrl !== null && $response->finalUrl !== $response->url) {
+ $lines[] = "Final URL: {$response->finalUrl}";
+ }
+
+ if ($response->title !== null && $response->title !== '') {
+ $lines[] = "Title: {$response->title}";
+ }
+
+ if ($includeMetadata && $response->metadata !== []) {
+ $lines[] = '';
+ $lines[] = 'Metadata:';
+ foreach ($response->metadata as $key => $value) {
+ if (is_scalar($value)) {
+ $lines[] = "- {$key}: {$value}";
+ }
+ }
+ }
+
+ if ($includeOutline && $response->outline !== []) {
+ $lines[] = '';
+ $lines[] = 'Outline:';
+ foreach ($response->outline as $entry) {
+ $indent = str_repeat(' ', max(0, $entry['level'] - 1));
+ $lines[] = sprintf('%s- %s [id: %s]', $indent, $entry['title'], $entry['id']);
+ }
+ }
+
+ if ($mode !== 'metadata' && $mode !== 'outline') {
+ $lines[] = '';
+ $lines[] = 'Content:';
+ $lines[] = $response->content !== '' ? $response->content : '[No content extracted]';
+ }
+
+ if ($response->truncated && $response->nextChunkToken !== null) {
+ $lines[] = '';
+ $lines[] = 'More content is available.';
+ $lines[] = 'Next chunk token: '.$response->nextChunkToken;
+ $lines[] = 'Use web_fetch with mode="chunk" and this chunk_token to continue from the current position.';
+ }
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * @return array
+ */
+ private function decodeChunkToken(string $token): array
+ {
+ $normalized = strtr($token, '-_', '+/');
+ $padding = strlen($normalized) % 4;
+ if ($padding !== 0) {
+ $normalized .= str_repeat('=', 4 - $padding);
+ }
+
+ $decoded = base64_decode($normalized, true);
+ if (! is_string($decoded)) {
+ throw new \RuntimeException('Invalid chunk token.');
+ }
+
+ $data = json_decode($decoded, true);
+ if (! is_array($data)) {
+ throw new \RuntimeException('Invalid chunk token.');
+ }
+
+ return $data;
+ }
+
+ private function baseContentForFormat(WebFetchResponse $response, WebFetchRequest $request): string
+ {
+ if ($request->format === 'html' && is_string($response->rawHtml) && $response->rawHtml !== '') {
+ return $response->rawHtml;
+ }
+
+ if ($request->format === 'text') {
+ return $this->markdownToPlainText($response->content);
+ }
+
+ return $response->content;
+ }
+
+ private function markdownToPlainText(string $content): string
+ {
+ $content = str_replace(["\r\n", "\r"], "\n", $content);
+ $content = preg_replace('/```[^\n]*\n(.*?)```/s', "$1\n", $content) ?? $content;
+ $content = preg_replace('/`([^`]+)`/', '$1', $content) ?? $content;
+ $content = preg_replace('/!\[([^\]]*)\]\(([^)]+)\)/', '$1', $content) ?? $content;
+ $content = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '$1', $content) ?? $content;
+ $content = preg_replace('/^(#{1,6})\s*/m', '', $content) ?? $content;
+ $content = preg_replace('/^\s*>\s?/m', '', $content) ?? $content;
+ $content = preg_replace('/^\s*[-*+]\s+/m', '- ', $content) ?? $content;
+ $content = strip_tags($content);
+ $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $content = preg_replace("/\n{3,}/", "\n\n", $content) ?? $content;
+
+ return trim($content);
+ }
+
+ /**
+ * @param array $payload
+ */
+ private function encodeChunkToken(array $payload): string
+ {
+ return rtrim(strtr(base64_encode(json_encode($payload, JSON_THROW_ON_ERROR)), '+/', '-_'), '=');
+ }
+
+ private function nullableString(mixed $value): ?string
+ {
+ return is_string($value) && trim($value) !== '' ? trim($value) : null;
+ }
+
+ private function enumValue(string $value, array $allowed, string $fallback): string
+ {
+ return in_array($value, $allowed, true) ? $value : $fallback;
+ }
+
+ private function sectionHeading(WebFetchResponse $response, string $sectionId): ?string
+ {
+ foreach ($response->outline as $entry) {
+ if ($entry['id'] === $sectionId) {
+ return $entry['title'];
+ }
+ }
+
+ return null;
+ }
+
+ private function slugify(string $value): string
+ {
+ $value = mb_strtolower(trim($value));
+ $value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? $value;
+
+ return trim($value, '-');
+ }
+}
diff --git a/src/Tool/Web/WebSearchTool.php b/src/Tool/Web/WebSearchTool.php
new file mode 100644
index 0000000..d8950bf
--- /dev/null
+++ b/src/Tool/Web/WebSearchTool.php
@@ -0,0 +1,152 @@
+providers->availableProviderIds();
+
+ return [
+ 'query' => ['type' => 'string', 'description' => 'Search query.'],
+ 'provider' => ['type' => 'enum', 'description' => 'Optional provider override.', 'options' => $availableProviders],
+ 'max_results' => ['type' => 'integer', 'description' => 'Maximum number of search results to return. Defaults to 5.'],
+ 'allowed_domains' => ['type' => 'array', 'description' => 'Optional domain allowlist, e.g. ["docs.python.org", "developer.mozilla.org"].'],
+ 'blocked_domains' => ['type' => 'array', 'description' => 'Optional domain blocklist.'],
+ 'search_depth' => ['type' => 'enum', 'description' => 'Provider-specific depth hint. Defaults to basic.', 'options' => ['basic', 'advanced']],
+ 'include_snippets' => ['type' => 'boolean', 'description' => 'Include result snippets when the provider supports them. Defaults to true.'],
+ 'include_answer' => ['type' => 'boolean', 'description' => 'Request an answer/summary from the provider when supported. Defaults to false.'],
+ ];
+ }
+
+ public function requiredParameters(): array
+ {
+ return ['query'];
+ }
+
+ /**
+ * @param array $args
+ */
+ protected function handle(array $args): ToolResult
+ {
+ $request = new WebSearchRequest(
+ query: trim((string) ($args['query'] ?? '')),
+ provider: $this->nullableString($args['provider'] ?? null),
+ maxResults: max(1, min(10, (int) ($args['max_results'] ?? ($this->settings->getRaw('kosmokrator.web.search.max_results') ?? 5)))),
+ allowedDomains: $this->stringList($args['allowed_domains'] ?? []),
+ blockedDomains: $this->stringList($args['blocked_domains'] ?? []),
+ searchDepth: in_array((string) ($args['search_depth'] ?? 'basic'), ['basic', 'advanced'], true) ? (string) ($args['search_depth'] ?? 'basic') : 'basic',
+ includeSnippets: filter_var($args['include_snippets'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true,
+ includeAnswer: filter_var($args['include_answer'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false,
+ );
+
+ if ($request->query === '') {
+ return ToolResult::error('Search query is required.');
+ }
+
+ $response = $this->providers->search($request);
+
+ $lines = [
+ "Provider: {$response->provider}",
+ "Query: {$response->query}",
+ 'Results: '.count($response->results),
+ ];
+
+ if ($response->answer !== null && trim($response->answer) !== '') {
+ $lines[] = '';
+ $lines[] = 'Answer:';
+ $lines[] = trim($response->answer);
+ }
+
+ if ($response->results !== []) {
+ $lines[] = '';
+ $lines[] = 'Top results:';
+
+ foreach ($response->results as $index => $hit) {
+ $lines[] = sprintf('%d. %s', $index + 1, $hit->title);
+ $lines[] = " URL: {$hit->url}";
+ if ($hit->snippet !== '') {
+ $lines[] = ' Snippet: '.$this->truncate($hit->snippet, 280);
+ }
+ }
+ }
+
+ $lines[] = '';
+ $lines[] = 'Use web_fetch in metadata, outline, or section mode to inspect only the relevant parts of a result page.';
+
+ return ToolResult::successWithMetadata(
+ implode("\n", $lines),
+ [
+ 'provider' => $response->provider,
+ 'query' => $response->query,
+ 'answer' => $response->answer,
+ 'results' => array_map(static fn ($hit): array => [
+ 'title' => $hit->title,
+ 'url' => $hit->url,
+ 'snippet' => $hit->snippet,
+ 'score' => $hit->score,
+ 'published_at' => $hit->publishedAt,
+ ], $response->results),
+ ],
+ );
+ }
+
+ /**
+ * @return list
+ */
+ private function stringList(mixed $value): array
+ {
+ if (! is_array($value)) {
+ return [];
+ }
+
+ $items = [];
+ foreach ($value as $item) {
+ if (is_string($item) && trim($item) !== '') {
+ $items[] = trim($item);
+ }
+ }
+
+ return array_values(array_unique($items));
+ }
+
+ private function nullableString(mixed $value): ?string
+ {
+ return is_string($value) && trim($value) !== '' ? trim($value) : null;
+ }
+
+ private function truncate(string $text, int $limit): string
+ {
+ return mb_strlen($text) <= $limit ? $text : mb_substr($text, 0, $limit).'...';
+ }
+}
diff --git a/src/UI/Ansi/AnsiCoreRenderer.php b/src/UI/Ansi/AnsiCoreRenderer.php
index 84f2734..312288a 100644
--- a/src/UI/Ansi/AnsiCoreRenderer.php
+++ b/src/UI/Ansi/AnsiCoreRenderer.php
@@ -19,6 +19,31 @@
*/
final class AnsiCoreRenderer implements CoreRendererInterface
{
+ private const THINKING_PHRASES = [
+ '◈ Consulting the Oracle at Delphi...',
+ '♃ Aligning the celestial spheres...',
+ '⚡ Channeling Prometheus\' fire...',
+ '♄ Weaving the threads of Fate...',
+ '☽ Reading the astral charts...',
+ '♂ Invoking the nine Muses...',
+ '♆ Traversing the Aether...',
+ '♅ Deciphering cosmic glyphs...',
+ '⚡ Summoning Athena\'s wisdom...',
+ '☉ Attuning to the Music of the Spheres...',
+ '♃ Gazing into the cosmic void...',
+ '◈ Unraveling the Labyrinth...',
+ '♆ Communing with the Titans...',
+ '♄ Forging in Hephaestus\' workshop...',
+ '☽ Scrying the heavens...',
+ ];
+
+ private const COMPACTION_PHRASES = [
+ '⧫ Condensing the cosmic record...',
+ '⧫ Distilling the essence of memory...',
+ '⧫ Weaving threads of context...',
+ '⧫ Forging a compact chronicle...',
+ ];
+
private readonly AnsiIntro $intro;
private string $streamBuffer = '';
@@ -87,7 +112,15 @@ public function prompt(): string
$r = Theme::reset();
$red = Theme::primary();
- $input = readline($red.' ⟡ '.$r);
+ if (\function_exists('readline')) {
+ $input = \readline($red.' ⟡ '.$r);
+ } else {
+ echo $red.' ⟡ '.$r;
+ $input = fgets(STDIN);
+ if (is_string($input)) {
+ $input = rtrim($input, "\r\n");
+ }
+ }
if ($input === false) {
return '/quit';
@@ -124,8 +157,9 @@ public function showThinking(): void
$r = Theme::reset();
$dim = Theme::dim();
$blue = Theme::rgb(112, 160, 208);
+ $phrase = self::THINKING_PHRASES[array_rand(self::THINKING_PHRASES)];
- echo "\n{$dim} ┌ {$blue}⚡ Thinking...{$r}\n";
+ echo "\n{$dim} ┌ {$blue}{$phrase}{$r}\n";
}
/** No-op: ANSI thinking indicator is static text. */
@@ -138,7 +172,8 @@ public function showCompacting(): void
{
$r = Theme::reset();
$red = Theme::rgb(208, 64, 64);
- echo "\n{$red} ⧫ Compacting context...{$r}\n";
+ $phrase = self::COMPACTION_PHRASES[array_rand(self::COMPACTION_PHRASES)];
+ echo "\n{$red} {$phrase}{$r}\n";
}
/** No-op: ANSI compacting indicator is static text. */
@@ -196,7 +231,18 @@ public function showError(string $message): void
($this->flushQuestionRecapCallback)();
$r = Theme::reset();
$err = Theme::error();
- echo "\n{$err} ✗ Error: {$message}{$r}\n\n";
+ $dim = Theme::dim();
+
+ if (str_contains($message, "\n")) {
+ $border = Theme::borderTask();
+ echo "\n{$border} ┌ {$err}✗ Error{$r}\n";
+ foreach (explode("\n", $message) as $line) {
+ echo "{$border} │{$r} {$dim}{$line}{$r}\n";
+ }
+ echo "{$border} └{$r}\n\n";
+ } else {
+ echo "\n{$err} ✗ Error: {$message}{$r}\n\n";
+ }
}
public function showNotice(string $message): void
diff --git a/src/UI/Ansi/AnsiDialogRenderer.php b/src/UI/Ansi/AnsiDialogRenderer.php
index a8d42f7..635acc9 100644
--- a/src/UI/Ansi/AnsiDialogRenderer.php
+++ b/src/UI/Ansi/AnsiDialogRenderer.php
@@ -29,15 +29,17 @@ public function showSettings(array $currentSettings): array
$dim = Theme::dim();
$accent = Theme::warning();
$white = "\033[1;37m";
+ $title = (string) ($currentSettings['title'] ?? 'Settings');
- echo "\n{$accent} ⚙ Settings{$r}\n";
+ echo "\n{$accent} ⚙ {$title}{$r}\n";
echo "{$dim} Separate settings workspace. Press Enter to keep a value unchanged.{$r}\n\n";
- $scope = strtolower(trim(readline(' Scope [project/global, default project]: ')));
- $scope = $scope === 'global' ? 'global' : 'project';
+ $defaultScope = (string) ($currentSettings['scope'] ?? 'project');
+ $scope = strtolower(trim($this->prompt(" Scope [project/global, default {$defaultScope}]: ")));
+ $scope = $scope === '' ? $defaultScope : ($scope === 'global' ? 'global' : 'project');
$changes = [];
- $categories = is_array($currentSettings['categories'] ?? null) ? $currentSettings['categories'] : [];
+ $categories = $this->orderedCategories($currentSettings);
foreach ($categories as $category) {
echo "{$white} {$category['label']}{$r}\n";
@@ -45,7 +47,7 @@ public function showSettings(array $currentSettings): array
foreach ($category['fields'] ?? [] as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
- $current = (string) ($field['value'] ?? '');
+ $current = $this->stringifySettingValue($field['value'] ?? '');
if ($type === 'readonly') {
echo "{$dim} {$field['label']}: {$current}{$r}\n";
@@ -54,12 +56,12 @@ public function showSettings(array $currentSettings): array
}
$hint = '';
- $options = is_array($field['options'] ?? null) ? $field['options'] : [];
+ $options = $this->stringifySettingOptions($field['options'] ?? []);
if ($options !== []) {
$hint = ' ['.implode('/', $options).']';
}
- $answer = readline(" {$field['label']}{$hint} [{$current}]: ");
+ $answer = $this->prompt(" {$field['label']}{$hint} [{$current}]: ");
$answer = trim($answer);
if ($answer === '') {
@@ -243,7 +245,7 @@ private function pickSessionReadline(array $items): ?string
}
echo "{$dim} [0] Cancel{$r}\n";
- $choice = (int) readline(' > ');
+ $choice = (int) $this->prompt(' > ');
if ($choice < 1 || $choice > count($items)) {
return null;
}
@@ -251,10 +253,82 @@ private function pickSessionReadline(array $items): ?string
return $items[$choice - 1]['value'];
}
- /** No-op: ANSI mode has no interactive plan approval dialog. */
public function approvePlan(string $currentPermissionMode): ?array
{
- return null;
+ $r = Theme::reset();
+ $dim = Theme::dim();
+ $gold = Theme::accent();
+ $white = "\033[1;37m";
+ $border = Theme::borderTask();
+
+ $permissions = [
+ 'g' => ['id' => 'guardian', 'label' => 'Guardian ◈'],
+ 'a' => ['id' => 'argus', 'label' => 'Argus ◉'],
+ 'p' => ['id' => 'prometheus', 'label' => 'Prometheus ⚡'],
+ ];
+
+ $contexts = [
+ 'k' => ['id' => 'keep', 'label' => 'keep context'],
+ 'c' => ['id' => 'compact', 'label' => 'compact'],
+ 'r' => ['id' => 'clear', 'label' => 'clear'],
+ ];
+
+ $permissionId = $currentPermissionMode;
+ $contextId = 'keep';
+
+ echo "\n{$border} ┌ {$gold}Plan Complete{$r}\n";
+ echo "{$border} │{$r}\n";
+
+ // Permission selection
+ $permHints = [];
+ foreach ($permissions as $key => $perm) {
+ $marker = $perm['id'] === $permissionId ? $white : $dim;
+ $permHints[] = "[{$key}]{$marker}{$perm['label']}{$r}";
+ }
+ echo "{$border} │{$r} {$dim}Permission:{$r} ".implode("{$dim} · {$r}", $permHints)."\n";
+
+ // Context selection
+ $ctxHints = [];
+ foreach ($contexts as $key => $ctx) {
+ $marker = $ctx['id'] === $contextId ? $white : $dim;
+ $ctxHints[] = "[{$key}]{$marker}{$ctx['label']}{$r}";
+ }
+ echo "{$border} │{$r} {$dim}Context: {$r} ".implode("{$dim} · {$r}", $ctxHints)."\n";
+ echo "{$border} │{$r}\n";
+
+ while (true) {
+ $answer = $this->prompt("{$border} └ {$gold}Enter{$r}{$dim} implement / {$r}{$gold}d{$r}{$dim} dismiss ▸{$r} ");
+
+ if ($answer === false) {
+ return null;
+ }
+
+ $char = strtolower(trim($answer));
+
+ // Accept with defaults
+ if ($char === '' || $char === 'i') {
+ return ['permission' => $permissionId, 'context' => $contextId];
+ }
+
+ // Dismiss
+ if ($char === 'd') {
+ return null;
+ }
+
+ // Permission change
+ if (isset($permissions[$char])) {
+ $permissionId = $permissions[$char]['id'];
+
+ return ['permission' => $permissionId, 'context' => $contextId];
+ }
+
+ // Context change
+ if (isset($contexts[$char])) {
+ $contextId = $contexts[$char]['id'];
+
+ return ['permission' => $permissionId, 'context' => $contextId];
+ }
+ }
}
public function askUser(string $question): string
@@ -262,7 +336,7 @@ public function askUser(string $question): string
$r = Theme::reset();
$accent = Theme::accent();
echo "\n{$accent}?{$r} {$question}\n";
- $answer = readline('> ') ?: '';
+ $answer = $this->prompt('> ');
$trimmed = trim($answer);
($this->queueQuestionRecapCallback)($question, $trimmed, $trimmed !== '', false);
@@ -285,7 +359,7 @@ public function askChoice(string $question, array $choices): string
}
echo " {$dim}".(count($choices) + 1).". Dismiss{$r}\n";
- $pick = (int) readline("{$dim}>{$r} ");
+ $pick = (int) $this->prompt("{$dim}>{$r} ");
if ($pick >= 1 && $pick <= count($choices)) {
$choice = $choices[$pick - 1];
($this->queueQuestionRecapCallback)($question, $choice['label'], true, (bool) ($choice['recommended'] ?? false));
@@ -297,4 +371,97 @@ public function askChoice(string $question, array $choices): string
return 'dismissed';
}
+
+ /**
+ * @return list>
+ */
+ private function orderedCategories(array $currentSettings): array
+ {
+ $categories = is_array($currentSettings['categories'] ?? null) ? $currentSettings['categories'] : [];
+ $initialCategory = trim((string) ($currentSettings['initial_category'] ?? ''));
+ if ($initialCategory === '') {
+ return $categories;
+ }
+
+ usort($categories, static function (array $a, array $b) use ($initialCategory): int {
+ $aId = (string) ($a['id'] ?? '');
+ $bId = (string) ($b['id'] ?? '');
+
+ if ($aId === $initialCategory) {
+ return -1;
+ }
+
+ if ($bId === $initialCategory) {
+ return 1;
+ }
+
+ return 0;
+ });
+
+ return $categories;
+ }
+
+ private function prompt(string $message): string
+ {
+ if (\function_exists('readline')) {
+ $input = \readline($message);
+
+ return $input === false ? '' : $input;
+ }
+
+ echo $message;
+ $input = fgets(STDIN);
+
+ return $input === false ? '' : rtrim($input, "\r\n");
+ }
+
+ private function stringifySettingValue(mixed $value): string
+ {
+ if (is_array($value)) {
+ $parts = [];
+
+ foreach ($value as $item) {
+ if (is_scalar($item)) {
+ $parts[] = (string) $item;
+ }
+ }
+
+ return implode(', ', $parts);
+ }
+
+ if (is_scalar($value) || $value === null) {
+ return (string) $value;
+ }
+
+ return '';
+ }
+
+ /**
+ * @return list
+ */
+ private function stringifySettingOptions(mixed $options): array
+ {
+ if (! is_array($options)) {
+ return [];
+ }
+
+ $labels = [];
+
+ foreach ($options as $option) {
+ if (is_scalar($option)) {
+ $labels[] = (string) $option;
+
+ continue;
+ }
+
+ if (is_array($option)) {
+ $label = $option['label'] ?? $option['value'] ?? null;
+ if (is_scalar($label)) {
+ $labels[] = (string) $label;
+ }
+ }
+ }
+
+ return $labels;
+ }
}
diff --git a/src/UI/Ansi/AnsiRenderer.php b/src/UI/Ansi/AnsiRenderer.php
index e5ad0c6..c2bd505 100644
--- a/src/UI/Ansi/AnsiRenderer.php
+++ b/src/UI/Ansi/AnsiRenderer.php
@@ -481,6 +481,8 @@ private function queueQuestionRecap(string $question, string $answer, bool $answ
/** Flushes all queued question/answer pairs as a formatted block before the next output. */
private function flushPendingQuestionRecap(): void
{
+ $this->tool->finalizeDiscoveryBatch();
+
if ($this->pendingQuestionRecap === []) {
return;
}
diff --git a/src/UI/Ansi/AnsiToolRenderer.php b/src/UI/Ansi/AnsiToolRenderer.php
index 4c86f56..357d0f2 100644
--- a/src/UI/Ansi/AnsiToolRenderer.php
+++ b/src/UI/Ansi/AnsiToolRenderer.php
@@ -9,6 +9,7 @@
use Kosmokrator\UI\Highlight\Lua\LuaLanguage;
use Kosmokrator\UI\Theme;
use Kosmokrator\UI\ToolRendererInterface;
+use Kosmokrator\UI\Tui\ExplorationClassifier;
use Tempest\Highlight\Highlighter;
/**
@@ -24,6 +25,13 @@ final class AnsiToolRenderer implements ToolRendererInterface
private ?TaskStore $taskStore = null;
+ private float $executingStartTime = 0.0;
+
+ /** @var array */
+ private array $discoveryBatch = [];
+
+ private bool $discoveryBatchOpen = false;
+
/** @var \Closure(): void */
private \Closure $flushQuestionRecapCallback;
@@ -49,7 +57,8 @@ public function getLastToolArgs(): array
public function showToolCall(string $name, array $args): void
{
- if (! in_array($name, ['ask_user', 'ask_choice'], true)) {
+ // Skip flush during active discovery batch — the batch manages its own output
+ if (! in_array($name, ['ask_user', 'ask_choice'], true) && ! $this->discoveryBatchOpen) {
($this->flushQuestionRecapCallback)();
}
@@ -62,6 +71,20 @@ public function showToolCall(string $name, array $args): void
$friendly = Theme::toolLabel($name);
$border = Theme::borderTask();
+ // Discovery batch: accumulate consecutive omens (read-only) tools
+ if (ExplorationClassifier::isOmensTool($name, $args)) {
+ if (! $this->discoveryBatchOpen) {
+ $this->discoveryBatchOpen = true;
+ echo "\n{$border} ┌ {$gold}☽ Reading the omens...{$r}\n";
+ }
+ $this->discoveryBatch[] = ['name' => $name, 'args' => $args, 'output' => null, 'success' => null];
+
+ return;
+ }
+
+ // Non-omens tool: finalize any open discovery batch before rendering
+ $this->finalizeDiscoveryBatch();
+
// Task tools: compact display, suppress noise
if ($this->isTaskTool($name)) {
$label = $this->formatTaskToolCallLabel($name, $args, $icon, $friendly, $dim, $r);
@@ -150,10 +173,25 @@ public function showToolCall(string $name, array $args): void
public function showToolResult(string $name, string $output, bool $success): void
{
- if (! in_array($name, ['ask_user', 'ask_choice'], true)) {
+ // Skip flush during active discovery batch — the batch manages its own output
+ if (! in_array($name, ['ask_user', 'ask_choice'], true) && ! $this->discoveryBatchOpen) {
($this->flushQuestionRecapCallback)();
}
+ // Discovery batch: fill in the result for the pending entry
+ if ($this->discoveryBatchOpen && $this->discoveryBatch !== []) {
+ $last = &$this->discoveryBatch[count($this->discoveryBatch) - 1];
+ if ($last['output'] === null && $last['name'] === $name) {
+ $last['output'] = $output;
+ $last['success'] = $success;
+ unset($last);
+ $this->echoDiscoveryResultLine($name, $this->discoveryBatch[count($this->discoveryBatch) - 1]);
+
+ return;
+ }
+ unset($last);
+ }
+
$r = Theme::reset();
$border = Theme::borderTask();
$text = Theme::text();
@@ -203,6 +241,23 @@ public function showToolResult(string $name, string $output, bool $success): voi
return;
}
+ // File write: compact creation notice
+ if ($name === 'file_write') {
+ $path = Theme::relativePath((string) ($this->lastToolArgs['path'] ?? ''));
+ $content = (string) ($this->lastToolArgs['content'] ?? $output);
+ $lineCount = substr_count($content, "\n") + 1;
+ echo "{$border} ┃ {$status} {$dim}Created{$r} {$path} {$dim}({$lineCount} lines){$r}\n";
+
+ return;
+ }
+
+ // Apply patch: compact summary
+ if ($name === 'apply_patch') {
+ echo "{$border} ┃ {$status} {$dim}{$friendly}{$r} {$dim}{$output}{$r}\n";
+
+ return;
+ }
+
// File edit: show diff view
if ($name === 'file_edit' && $success && isset($this->lastToolArgs['old_string'])) {
$diffLines = $this->buildDiffLines(
@@ -265,9 +320,20 @@ public function askToolPermission(string $toolName, array $args): string
$r = Theme::reset();
$yellow = Theme::warning();
$dim = Theme::dim();
+ $border = Theme::borderTask();
+ $icon = Theme::toolIcon($toolName);
+ $friendly = Theme::toolLabel($toolName);
+
+ $context = $this->formatPermissionContext($toolName, $args);
+
+ echo "{$border} ┌ {$yellow}{$icon} {$friendly}{$r}\n";
+ if ($context !== '') {
+ echo "{$border} │{$r} {$context}\n";
+ }
+ echo "{$border} │{$r}\n";
while (true) {
- $answer = readline("{$yellow} ⟡ Allow?{$r} {$dim}[Y]es / [a]lways / [g]uardian / [p]rometheus / [n]o ▸{$r} ");
+ $answer = readline("{$border} └ {$yellow}Allow?{$r} {$dim}[Y]es / [a]lways / [g]uardian / [p]rometheus / [n]o ▸{$r} ");
if ($answer === false) {
return 'deny';
@@ -308,6 +374,7 @@ public function showToolExecuting(string $name): void
if ($this->isTaskTool($name) || in_array($name, ['ask_user', 'ask_choice', 'subagent'], true)) {
return;
}
+ $this->executingStartTime = microtime(true);
$dim = Theme::dim();
$r = Theme::reset();
$border = Theme::borderTask();
@@ -328,8 +395,11 @@ public function updateToolExecuting(string $output): void
$dim = Theme::dim();
$r = Theme::reset();
$border = Theme::borderTask();
- $preview = mb_strlen($last) > 80 ? mb_substr($last, 0, 80).'…' : $last;
- echo "\r{$border} ┃ {$dim}{$preview}{$r}\r";
+ $elapsed = $this->executingStartTime > 0 ? (int) (microtime(true) - $this->executingStartTime) : 0;
+ $elapsedStr = $elapsed > 0 ? " {$dim}({$elapsed}s){$r}" : '';
+ $maxPreview = 80 - ($elapsed > 0 ? strlen("({$elapsed}s) ") : 0);
+ $preview = mb_strlen($last) > $maxPreview ? mb_substr($last, 0, $maxPreview).'…' : $last;
+ echo "\r\033[2K{$border} ┃ {$dim}{$preview}{$elapsedStr}{$r}\r";
}
}
@@ -338,6 +408,76 @@ public function clearToolExecuting(): void
echo "\r\033[2K"; // Clear the running line
}
+ /** Closes any open discovery batch, printing the summary footer and resetting state. */
+ public function finalizeDiscoveryBatch(): void
+ {
+ if (! $this->discoveryBatchOpen) {
+ return;
+ }
+
+ $r = Theme::reset();
+ $border = Theme::borderTask();
+ $dim = Theme::dim();
+
+ $summary = $this->formatDiscoverySummary();
+ echo "{$border} └ {$dim}{$summary}{$r}\n";
+
+ $this->discoveryBatch = [];
+ $this->discoveryBatchOpen = false;
+
+ // Flush any pending question recaps that were deferred during the batch
+ ($this->flushQuestionRecapCallback)();
+ }
+
+ /** Prints a single compact result line inside the discovery batch. */
+ private function echoDiscoveryResultLine(string $name, array $entry): void
+ {
+ $r = Theme::reset();
+ $border = Theme::borderTask();
+ $dim = Theme::dim();
+ $status = $entry['success'] ? Theme::success().'✓' : Theme::error().'✗';
+ $args = $entry['args'];
+ $output = (string) $entry['output'];
+
+ $label = match ($name) {
+ 'file_read' => Theme::relativePath((string) ($args['path'] ?? ''))
+ ." {$dim}(".count(explode("\n", $output)).' lines)',
+ 'glob' => ($args['pattern'] ?? '?')
+ ." {$dim}(".count(array_filter(explode("\n", $output), fn (string $l) => trim($l) !== '')).' matches)',
+ 'grep' => '"'.mb_substr((string) ($args['pattern'] ?? ''), 0, 40).'"'
+ ." {$dim}(".count(array_filter(explode("\n", $output), fn (string $l) => trim($l) !== '')).' matches)',
+ 'memory_search' => '"'.mb_substr((string) ($args['query'] ?? $args['type'] ?? ''), 0, 40).'"',
+ 'bash' => $this->stripCwdPrefix(mb_substr(trim((string) ($args['command'] ?? '')), 0, 60)),
+ default => Theme::toolLabel($name),
+ };
+
+ echo "{$border} │ {$status}{$r} {$label}{$r}\n";
+ }
+
+ /** Summarizes the discovery batch as "3 reads · 2 globs · 1 search". */
+ private function formatDiscoverySummary(): string
+ {
+ $counts = [];
+ foreach ($this->discoveryBatch as $entry) {
+ $label = match ($entry['name']) {
+ 'file_read' => 'read',
+ 'glob' => 'glob',
+ 'grep' => 'search',
+ 'memory_search' => 'recall',
+ 'bash' => 'probe',
+ default => $entry['name'],
+ };
+ $counts[$label] = ($counts[$label] ?? 0) + 1;
+ }
+
+ $parts = [];
+ foreach ($counts as $label => $count) {
+ $parts[] = $count.' '.$label.($count > 1 ? 's' : '');
+ }
+
+ return implode(' · ', $parts);
+ }
+
/** Checks if a tool name is a task management tool. */
private function isTaskTool(string $name): bool
{
@@ -394,6 +534,30 @@ private function formatTaskToolCallLabel(string $name, array $args, string $icon
return null;
}
+ /** Extracts the primary context line for a permission prompt. */
+ private function formatPermissionContext(string $toolName, array $args): string
+ {
+ $dim = Theme::dim();
+ $r = Theme::reset();
+
+ return match ($toolName) {
+ 'bash' => $this->stripCwdPrefix(trim((string) ($args['command'] ?? ''))),
+ 'shell_start' => trim((string) ($args['command'] ?? '')),
+ 'shell_write' => "{$dim}input:{$r} ".trim((string) ($args['input'] ?? '')),
+ 'file_write', 'file_read' => Theme::relativePath((string) ($args['path'] ?? '')),
+ 'file_edit' => Theme::relativePath((string) ($args['path'] ?? '')),
+ 'apply_patch' => $this->countPatchFiles((string) ($args['patch'] ?? '')).' file(s)',
+ 'execute_lua' => substr_count((string) ($args['code'] ?? ''), "\n") + 1 .' lines of Lua',
+ default => '',
+ };
+ }
+
+ /** Count files mentioned in a patch block. */
+ private function countPatchFiles(string $patch): int
+ {
+ return max(1, preg_match_all('/^(Add|Update|Delete|Move) File:/m', $patch));
+ }
+
/**
* Strip leading `cd /absolute/path && ` prefix from a bash command for display.
*/
diff --git a/src/UI/Theme.php b/src/UI/Theme.php
index 1f7074d..44b43d0 100644
--- a/src/UI/Theme.php
+++ b/src/UI/Theme.php
@@ -309,6 +309,8 @@ public static function toolIcon(string $name): string
'shell_kill' => '✕', // Terminating a live session
'grep' => '⊛', // Astral search — seeking through the cosmos
'glob' => '✧', // Star cluster — surveying many points of light
+ 'web_search' => '⌕', // Search through the wider world
+ 'web_fetch' => '☍', // Pulling a distant page into view
'task_create' => '⊕', // Circled plus — bringing new labors into being
'task_update' => '⊙', // Circled dot — altering the fate of a labor
'task_list' => '☰', // Trigram — surveying all labors
@@ -343,6 +345,8 @@ public static function toolLabel(string $name): string
'shell_kill' => 'Shell',
'grep' => 'Search',
'glob' => 'Glob',
+ 'web_search' => 'Web Search',
+ 'web_fetch' => 'Web Fetch',
'task_create' => 'Task',
'task_update' => 'Task',
'task_list' => 'Tasks',
diff --git a/src/UI/Tui/ExplorationClassifier.php b/src/UI/Tui/ExplorationClassifier.php
index 2651e3b..2b66d81 100644
--- a/src/UI/Tui/ExplorationClassifier.php
+++ b/src/UI/Tui/ExplorationClassifier.php
@@ -19,6 +19,8 @@ final class ExplorationClassifier
'glob',
'grep',
'memory_search',
+ 'session_search',
+ 'session_read',
];
private const EXPLORATORY_BASH_PREFIXES = [
diff --git a/src/UI/Tui/Widget/SettingsWorkspaceWidget.php b/src/UI/Tui/Widget/SettingsWorkspaceWidget.php
index c949d38..ef396c1 100644
--- a/src/UI/Tui/Widget/SettingsWorkspaceWidget.php
+++ b/src/UI/Tui/Widget/SettingsWorkspaceWidget.php
@@ -98,13 +98,14 @@ public function __construct(
continue;
}
- $value = (string) ($field['value'] ?? '');
+ $value = $this->stringifyFieldValue($field['value'] ?? '');
$this->values[$id] = $value;
$this->originalValues[$id] = $value;
}
}
$this->syncProviderSetupListIndex();
+ $this->applyInitialCategory();
}
/** Register the callback invoked when the user saves changes. */
@@ -935,6 +936,7 @@ private function renderHeader(int $width): array
$accent = Theme::accent();
$white = Theme::white();
$dim = Theme::dim();
+ $heading = (string) ($this->view['title'] ?? 'Settings');
$provider = $this->values['agent.default_provider'] ?? '';
$model = $this->values['agent.default_model'] ?? '';
@@ -944,12 +946,36 @@ private function renderHeader(int $width): array
$modelLabel = $this->displayLabelForFieldValue('agent.default_model', $model);
return [
- "{$accent}⚙ Settings{$r} {$dim}scope{$r}: {$white}{$this->scope}{$r} {$dim}provider{$r}: {$white}{$providerLabel}{$r} {$dim}model{$r}: {$white}{$modelLabel}{$r} {$status}{$unsaved}{$r}",
+ "{$accent}⚙ {$heading}{$r} {$dim}scope{$r}: {$white}{$this->scope}{$r} {$dim}provider{$r}: {$white}{$providerLabel}{$r} {$dim}model{$r}: {$white}{$modelLabel}{$r} {$status}{$unsaved}{$r}",
"{$dim}Separate settings workspace. Save writes YAML-backed config; auth secrets remain managed separately.{$r}",
str_repeat('─', max(10, $width - 2)),
];
}
+ private function applyInitialCategory(): void
+ {
+ $initialCategory = trim((string) ($this->view['initial_category'] ?? ''));
+ if ($initialCategory === '') {
+ return;
+ }
+
+ foreach ($this->categories() as $index => $category) {
+ if ((string) ($category['id'] ?? '') !== $initialCategory) {
+ continue;
+ }
+
+ $this->categoryIndex = $index;
+ $this->fieldIndex = 0;
+
+ if ($initialCategory === 'provider_setup') {
+ $this->providerSetupEditing = false;
+ $this->syncProviderSetupListIndex();
+ }
+
+ return;
+ }
+ }
+
/**
* @return list
*/
@@ -1085,6 +1111,10 @@ private function renderDetails(int $width, int $height): array
return $this->renderIntegrationDetails($width, $height);
}
+ if ($this->isGatewayCategory()) {
+ return $this->renderGatewayDetails($width, $height);
+ }
+
$field = $this->selectedField();
$categoryId = (string) ($this->selectedCategory()['id'] ?? '');
$provider = $categoryId === 'provider_setup'
@@ -1257,6 +1287,89 @@ private function renderIntegrationDetails(int $width, int $height): array
return array_slice($lines, 0, $height);
}
+ /**
+ * @return list
+ */
+ private function renderGatewayDetails(int $width, int $height): array
+ {
+ $lines = [$this->boxHeader('Details', $width)];
+ $field = $this->selectedField();
+
+ if ($field !== null) {
+ if ($this->editing) {
+ $label = (string) ($field['label'] ?? $field['id']);
+ $lines[] = $this->boxLine('Editing: '.$label, $width, Theme::accent());
+ $lines[] = $this->boxLine('Enter saves · Esc cancels · paste supported', $width);
+ $lines[] = $this->boxLine('', $width);
+
+ foreach ($this->wrapForBox($this->editBuffer === '' ? ' ' : $this->editBuffer, max(8, $width - 2)) as $line) {
+ $lines[] = $this->boxLine($line, $width);
+ }
+
+ $lines[] = $this->boxLine('', $width);
+ }
+
+ foreach ($this->wrap((string) ($field['description'] ?? ''), $width - 2) as $line) {
+ $lines[] = $this->boxLine($line, $width);
+ }
+ $lines[] = $this->boxLine('', $width);
+ $lines[] = $this->boxLine('Source: '.($field['source'] ?? 'default'), $width);
+ $lines[] = $this->boxLine('Effect: '.($field['effect'] ?? 'next_session'), $width);
+ }
+
+ $enabled = $this->stringifyFieldValue($this->values['gateway.telegram.enabled'] ?? 'off');
+ $token = trim($this->stringifyFieldValue($this->values['gateway.telegram.secret.token'] ?? ''));
+ $sessionMode = $this->stringifyFieldValue($this->values['gateway.telegram.session_mode'] ?? 'thread');
+ $allowedUsers = $this->stringifyFieldValue($this->values['gateway.telegram.allowed_users'] ?? '');
+ $allowedChats = $this->stringifyFieldValue($this->values['gateway.telegram.allowed_chats'] ?? '');
+ $freeResponse = $this->stringifyFieldValue($this->values['gateway.telegram.free_response_chats'] ?? '');
+ $requireMention = $this->stringifyFieldValue($this->values['gateway.telegram.require_mention'] ?? 'on');
+
+ $lines[] = $this->boxLine('', $width);
+ $lines[] = $this->boxLine('Telegram Gateway', $width, Theme::accent());
+ $lines[] = $this->boxLine('Enabled: '.$enabled, $width);
+ $lines[] = $this->boxLine('Token: '.($token !== '' ? 'configured' : 'missing'), $width);
+ $lines[] = $this->boxLine('Session routing: '.$sessionMode, $width);
+ $lines[] = $this->boxLine('Require mention: '.$requireMention, $width);
+ $lines[] = $this->boxLine('Allowed users: '.($allowedUsers !== '' ? $allowedUsers : '(all)'), $width);
+ $lines[] = $this->boxLine('Allowed chats: '.($allowedChats !== '' ? $allowedChats : '(all)'), $width);
+ $lines[] = $this->boxLine('Free-response chats: '.($freeResponse !== '' ? $freeResponse : '(none)'), $width);
+ $lines[] = $this->boxLine('', $width);
+ $lines[] = $this->boxLine('Start with: php bin/kosmokrator gateway:telegram', $width);
+
+ while (count($lines) < $height - 1) {
+ $lines[] = $this->boxLine('', $width);
+ }
+ $lines[] = $this->boxFooter($width);
+
+ return array_slice($lines, 0, $height);
+ }
+
+ private function stringifyFieldValue(mixed $value): string
+ {
+ if ($value === null) {
+ return '';
+ }
+
+ if (is_bool($value)) {
+ return $value ? 'on' : 'off';
+ }
+
+ if (is_array($value)) {
+ $items = array_values(array_filter(array_map(static function (mixed $item): string {
+ if (is_scalar($item) || $item === null) {
+ return trim((string) $item);
+ }
+
+ return '';
+ }, $value), static fn (string $item): bool => $item !== ''));
+
+ return implode(', ', $items);
+ }
+
+ return (string) $value;
+ }
+
/**
* @return list
*/
@@ -1474,6 +1587,11 @@ private function isIntegrationsCategory(): bool
return (string) ($this->selectedCategory()['id'] ?? '') === 'integrations';
}
+ private function isGatewayCategory(): bool
+ {
+ return (string) ($this->selectedCategory()['id'] ?? '') === 'gateway';
+ }
+
/** Handle Up/Down/Enter/Right input when the models browser is active. */
private function handleModelsBrowserInput(string $data, object $kb): void
{
diff --git a/src/Update/SelfUpdater.php b/src/Update/SelfUpdater.php
index 5e12b27..939d92a 100644
--- a/src/Update/SelfUpdater.php
+++ b/src/Update/SelfUpdater.php
@@ -11,7 +11,7 @@
* binary type and downloads the matching asset from GitHub Releases.
* Source installations (git clone) are rejected with guidance.
*/
-final class SelfUpdater
+final class SelfUpdater implements SelfUpdaterInterface
{
private const GITHUB_REPO = 'OpenCompanyApp/kosmokrator';
@@ -58,6 +58,28 @@ public function update(string $targetVersion): string
return "Updated to v{$targetVersion}. Restart KosmoKrator to use the new version.";
}
+ public function installationMethod(): string
+ {
+ if (\Phar::running(false) !== '') {
+ return 'phar';
+ }
+
+ $path = realpath($_SERVER['argv'][0] ?? '');
+ if ($path === false || ! is_file($path)) {
+ return 'unknown';
+ }
+
+ return $this->isSourceInstallation($path) ? 'source' : 'binary';
+ }
+
+ public function sourceUpdateInstructions(): string
+ {
+ $path = realpath($_SERVER['argv'][0] ?? '');
+ $projectRoot = $path !== false ? dirname($path, 2) : getcwd();
+
+ return "cd {$projectRoot}\ngit pull\ncomposer install";
+ }
+
/**
* Determine the absolute path of the currently running binary.
*
@@ -136,10 +158,14 @@ private function download(string $url, string $destination): void
$data = @file_get_contents($url, false, $context);
- // Check HTTP status from response headers ($http_response_header is set by file_get_contents)
+ // Check HTTP status from response headers.
$status = 0;
- if ($http_response_header !== []) {
- foreach ($http_response_header as $header) {
+ $headers = function_exists('http_get_last_response_headers')
+ ? (http_get_last_response_headers() ?: [])
+ : [];
+
+ if ($headers !== []) {
+ foreach ($headers as $header) {
if (preg_match('/^HTTP\/[\d.]+ (\d{3})/', $header, $m)) {
$status = (int) $m[1];
}
diff --git a/src/Update/SelfUpdaterInterface.php b/src/Update/SelfUpdaterInterface.php
new file mode 100644
index 0000000..6354705
--- /dev/null
+++ b/src/Update/SelfUpdaterInterface.php
@@ -0,0 +1,14 @@
+ */
+ private array $entries = [];
+
+ private int $generation = 0;
+
+ public function __construct(
+ private readonly int $keepTurns = 2,
+ private readonly int $maxEntries = 128,
+ ) {}
+
+ public function advanceTurn(): void
+ {
+ $this->generation++;
+ $this->evictExpired();
+ }
+
+ public function has(string $key): bool
+ {
+ $this->evictExpired();
+
+ return array_key_exists($key, $this->entries);
+ }
+
+ public function get(string $key): mixed
+ {
+ $this->evictExpired();
+
+ return $this->entries[$key]['value'] ?? null;
+ }
+
+ public function put(string $key, mixed $value): void
+ {
+ $this->entries[$key] = [
+ 'value' => $value,
+ 'generation' => $this->generation,
+ ];
+
+ $this->evictOverflow();
+ }
+
+ public function remember(string $key, callable $resolver): mixed
+ {
+ if ($this->has($key)) {
+ return $this->get($key);
+ }
+
+ $value = $resolver();
+ $this->put($key, $value);
+
+ return $value;
+ }
+
+ private function evictExpired(): void
+ {
+ foreach ($this->entries as $key => $entry) {
+ if (($this->generation - $entry['generation']) > $this->keepTurns) {
+ unset($this->entries[$key]);
+ }
+ }
+ }
+
+ private function evictOverflow(): void
+ {
+ if (count($this->entries) <= $this->maxEntries) {
+ return;
+ }
+
+ uasort($this->entries, static fn (array $a, array $b): int => $a['generation'] <=> $b['generation']);
+ $overflow = count($this->entries) - $this->maxEntries;
+
+ foreach (array_keys($this->entries) as $key) {
+ unset($this->entries[$key]);
+ $overflow--;
+
+ if ($overflow <= 0) {
+ break;
+ }
+ }
+ }
+}
diff --git a/src/Web/Contracts/WebFetchProvider.php b/src/Web/Contracts/WebFetchProvider.php
new file mode 100644
index 0000000..f76f872
--- /dev/null
+++ b/src/Web/Contracts/WebFetchProvider.php
@@ -0,0 +1,17 @@
+\n]+>\s*)*(#{1,6})\s+(.+)$/m';
+
+ private readonly HtmlConverter $converter;
+
+ public function __construct()
+ {
+ $this->converter = new HtmlConverter([
+ 'header_style' => 'atx',
+ 'remove_nodes' => 'script style noscript template iframe svg canvas nav footer aside form button',
+ 'strip_tags' => false,
+ 'hard_break' => false,
+ 'list_item_style' => '-',
+ ]);
+ }
+
+ public function extract(string $html, string $url): ExtractedPage
+ {
+ $dom = new DOMDocument('1.0', 'UTF-8');
+ $previous = libxml_use_internal_errors(true);
+ @$dom->loadHTML($html, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET | LIBXML_COMPACT);
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+
+ $xpath = new DOMXPath($dom);
+ $this->stripNoise($xpath);
+
+ $title = $this->readTitle($xpath, $dom);
+ $metadata = $this->readMetadata($xpath, $url, $title);
+ $root = $this->pickContentRoot($xpath, $dom);
+ $outline = $this->extractOutline($xpath, $root);
+ $content = $this->normalizeMarkdown($this->converter->convert($this->innerHtml($root)));
+ $sections = $this->splitSections($content, $outline);
+
+ if ($sections === [] && $content !== '') {
+ $sections = ['content' => $content];
+ }
+
+ return new ExtractedPage(
+ title: $title,
+ metadata: $metadata,
+ outline: $outline,
+ fullContent: trim($content),
+ sections: $sections,
+ );
+ }
+
+ private function stripNoise(DOMXPath $xpath): void
+ {
+ $nodes = $xpath->query('//script|//style|//noscript|//template|//iframe|//svg|//canvas|//nav|//footer|//aside|//form|//button');
+ if ($nodes === false) {
+ return;
+ }
+
+ /** @var DOMNode $node */
+ foreach (iterator_to_array($nodes) as $node) {
+ $node->parentNode?->removeChild($node);
+ }
+ }
+
+ private function readTitle(DOMXPath $xpath, DOMDocument $dom): ?string
+ {
+ $title = trim($dom->getElementsByTagName('title')->item(0)?->textContent ?? '');
+ if ($title !== '') {
+ return $title;
+ }
+
+ foreach ([
+ "//meta[@property='og:title']/@content",
+ "//meta[@name='twitter:title']/@content",
+ ] as $query) {
+ $value = trim((string) $xpath->evaluate("string({$query})"));
+ if ($value !== '') {
+ return $value;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array
+ */
+ private function readMetadata(DOMXPath $xpath, string $url, ?string $title): array
+ {
+ $description = $this->firstMetaValue($xpath, [
+ "//meta[@name='description']/@content",
+ "//meta[@property='og:description']/@content",
+ "//meta[@name='twitter:description']/@content",
+ ]);
+
+ $canonical = trim((string) $xpath->evaluate("string(//link[@rel='canonical']/@href)"));
+ $author = $this->firstMetaValue($xpath, [
+ "//meta[@name='author']/@content",
+ "//meta[@property='article:author']/@content",
+ ]);
+ $publishedAt = $this->firstMetaValue($xpath, [
+ "//meta[@property='article:published_time']/@content",
+ "//meta[@name='pubdate']/@content",
+ '//time/@datetime',
+ ]);
+
+ return array_filter([
+ 'title' => $title,
+ 'description' => $description,
+ 'canonical_url' => $canonical !== '' ? $canonical : $url,
+ 'author' => $author,
+ 'published_at' => $publishedAt,
+ ], static fn (mixed $value): bool => $value !== null && $value !== '');
+ }
+
+ /**
+ * @param list $queries
+ */
+ private function firstMetaValue(DOMXPath $xpath, array $queries): ?string
+ {
+ foreach ($queries as $query) {
+ $value = trim((string) $xpath->evaluate("string({$query})"));
+ if ($value !== '') {
+ return $value;
+ }
+ }
+
+ return null;
+ }
+
+ private function pickContentRoot(DOMXPath $xpath, DOMDocument $dom): DOMNode
+ {
+ $queries = [
+ '//main',
+ '//*[@role="main"]',
+ '//article',
+ '//*[contains(@class, "content")]',
+ '//*[contains(@class, "article")]',
+ '//*[contains(@class, "markdown")]',
+ ];
+
+ foreach ($queries as $query) {
+ $queryResult = $xpath->query($query);
+ $node = $queryResult !== false ? $queryResult->item(0) : null;
+ if ($node instanceof DOMNode) {
+ return $node;
+ }
+ }
+
+ return $dom->getElementsByTagName('body')->item(0) ?? $dom;
+ }
+
+ /**
+ * @return list
+ */
+ private function extractOutline(DOMXPath $xpath, DOMNode $root): array
+ {
+ $outline = [];
+ $seenIds = [];
+
+ $headings = $xpath->query('.//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]', $root);
+ if ($headings === false) {
+ return [];
+ }
+
+ foreach ($headings as $headingNode) {
+ if (! $headingNode instanceof DOMElement) {
+ continue;
+ }
+
+ $heading = $this->normalizeWhitespace($headingNode->textContent ?? '');
+ if ($heading === '') {
+ continue;
+ }
+
+ $level = (int) substr(strtolower($headingNode->tagName), 1);
+ $outline[] = [
+ 'id' => $this->makeUniqueId($this->slugify($heading), $seenIds),
+ 'title' => $heading,
+ 'level' => $level,
+ ];
+ }
+
+ return $outline;
+ }
+
+ /**
+ * @param list $outline
+ * @return array
+ */
+ private function splitSections(string $markdown, array $outline): array
+ {
+ if ($markdown === '' || $outline === []) {
+ return [];
+ }
+
+ preg_match_all(self::HEADING_PATTERN, $markdown, $matches, PREG_OFFSET_CAPTURE);
+
+ if (($matches[0] ?? []) === []) {
+ return [];
+ }
+
+ $sections = [];
+ $count = count($matches[0]);
+
+ for ($i = 0; $i < $count; $i++) {
+ $offset = $matches[1][$i][1];
+ $nextOffset = $i + 1 < $count ? $matches[1][$i + 1][1] : mb_strlen($markdown);
+ $chunk = trim(mb_substr($markdown, $offset, $nextOffset - $offset));
+
+ $entry = $outline[$i] ?? null;
+ if ($entry !== null) {
+ $sections[$entry['id']] = $chunk;
+ }
+ }
+
+ return $sections;
+ }
+
+ private function normalizeMarkdown(string $markdown): string
+ {
+ $markdown = str_replace(["\r\n", "\r"], "\n", trim($markdown));
+ $markdown = preg_replace("/\n{3,}/", "\n\n", $markdown) ?? $markdown;
+
+ return trim($markdown);
+ }
+
+ private function innerHtml(DOMNode $node): string
+ {
+ $html = '';
+
+ foreach ($node->childNodes as $child) {
+ $html .= $node->ownerDocument?->saveHTML($child) ?: '';
+ }
+
+ return $html;
+ }
+
+ private function normalizeWhitespace(string $text): string
+ {
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ $text = preg_replace('/[ \t]+/u', ' ', $text) ?? $text;
+ $text = preg_replace('/\n{3,}/u', "\n\n", $text) ?? $text;
+
+ return trim($text);
+ }
+
+ /**
+ * @param array $seenIds
+ */
+ private function makeUniqueId(string $id, array &$seenIds): string
+ {
+ $candidate = $id !== '' ? $id : 'section';
+ $suffix = 2;
+
+ while (isset($seenIds[$candidate])) {
+ $candidate = $id.'-'.$suffix;
+ $suffix++;
+ }
+
+ $seenIds[$candidate] = true;
+
+ return $candidate;
+ }
+
+ private function slugify(string $text): string
+ {
+ $text = mb_strtolower($text);
+ $text = preg_replace('/[^a-z0-9]+/u', '-', $text) ?? $text;
+
+ return trim($text, '-');
+ }
+}
diff --git a/src/Web/Extract/MarkdownPageExtractor.php b/src/Web/Extract/MarkdownPageExtractor.php
new file mode 100644
index 0000000..b458a22
--- /dev/null
+++ b/src/Web/Extract/MarkdownPageExtractor.php
@@ -0,0 +1,146 @@
+\n]+>\s*)*(#{1,6})\s+(.+)$/m';
+
+ /**
+ * @param array $metadata
+ */
+ public function extract(string $markdown, ?string $title = null, array $metadata = []): ExtractedPage
+ {
+ $content = $this->normalizeMarkdown($markdown);
+ $outline = $this->extractOutline($content);
+ $sections = $this->splitSections($content, $outline);
+
+ if ($sections === [] && $content !== '') {
+ $sections = ['content' => $content];
+ }
+
+ return new ExtractedPage(
+ title: $title,
+ metadata: array_filter(
+ array_merge(['title' => $title], $metadata),
+ static fn (mixed $value): bool => $value !== null && $value !== ''
+ ),
+ outline: $outline,
+ fullContent: $content,
+ sections: $sections,
+ );
+ }
+
+ /**
+ * @return list
+ */
+ private function extractOutline(string $markdown): array
+ {
+ if ($markdown === '') {
+ return [];
+ }
+
+ preg_match_all(self::HEADING_PATTERN, $markdown, $matches);
+
+ if (($matches[0] ?? []) === []) {
+ return [];
+ }
+
+ $outline = [];
+ $seenIds = [];
+
+ foreach ($matches[2] as $index => $heading) {
+ $heading = $this->cleanHeading((string) $heading);
+ if ($heading === '') {
+ continue;
+ }
+
+ $outline[] = [
+ 'id' => $this->makeUniqueId($this->slugify($heading), $seenIds),
+ 'title' => $heading,
+ 'level' => strlen((string) ($matches[1][$index] ?? '#')),
+ ];
+ }
+
+ return $outline;
+ }
+
+ /**
+ * @param list $outline
+ * @return array
+ */
+ private function splitSections(string $markdown, array $outline): array
+ {
+ if ($markdown === '' || $outline === []) {
+ return [];
+ }
+
+ preg_match_all(self::HEADING_PATTERN, $markdown, $matches, PREG_OFFSET_CAPTURE);
+
+ if (($matches[0] ?? []) === []) {
+ return [];
+ }
+
+ $sections = [];
+ $count = count($matches[0]);
+
+ for ($i = 0; $i < $count; $i++) {
+ $offset = $matches[1][$i][1];
+ $nextOffset = $i + 1 < $count ? $matches[1][$i + 1][1] : mb_strlen($markdown);
+ $chunk = trim(mb_substr($markdown, $offset, $nextOffset - $offset));
+
+ $entry = $outline[$i] ?? null;
+ if ($entry !== null) {
+ $sections[$entry['id']] = $chunk;
+ }
+ }
+
+ return $sections;
+ }
+
+ private function normalizeMarkdown(string $markdown): string
+ {
+ $markdown = str_replace(["\r\n", "\r"], "\n", trim($markdown));
+ $markdown = preg_replace("/\n{3,}/", "\n\n", $markdown) ?? $markdown;
+
+ return trim($markdown);
+ }
+
+ private function cleanHeading(string $heading): string
+ {
+ $heading = strip_tags($heading);
+ $heading = html_entity_decode($heading, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ return trim($heading);
+ }
+
+ /**
+ * @param array $seenIds
+ */
+ private function makeUniqueId(string $id, array &$seenIds): string
+ {
+ $candidate = $id !== '' ? $id : 'section';
+ $suffix = 2;
+
+ while (isset($seenIds[$candidate])) {
+ $candidate = $id.'-'.$suffix;
+ $suffix++;
+ }
+
+ $seenIds[$candidate] = true;
+
+ return $candidate;
+ }
+
+ private function slugify(string $text): string
+ {
+ $text = mb_strtolower($text);
+ $text = preg_replace('/[^a-z0-9]+/u', '-', $text) ?? $text;
+
+ return trim($text, '-');
+ }
+}
diff --git a/src/Web/Mcp/McpToolInvokerInterface.php b/src/Web/Mcp/McpToolInvokerInterface.php
new file mode 100644
index 0000000..425c462
--- /dev/null
+++ b/src/Web/Mcp/McpToolInvokerInterface.php
@@ -0,0 +1,15 @@
+ $arguments
+ * @param array $headers
+ * @return array|list|string
+ */
+ public function call(string $remoteUrl, string $toolName, array $arguments, array $headers = []): array|string;
+}
diff --git a/src/Web/Mcp/StreamableMcpToolInvoker.php b/src/Web/Mcp/StreamableMcpToolInvoker.php
new file mode 100644
index 0000000..812364c
--- /dev/null
+++ b/src/Web/Mcp/StreamableMcpToolInvoker.php
@@ -0,0 +1,228 @@
+httpClient = $httpClient ?? HttpClientBuilder::buildDefault();
+ }
+
+ public function call(string $remoteUrl, string $toolName, array $arguments, array $headers = []): array|string
+ {
+ [$sessionId] = $this->send(
+ remoteUrl: $remoteUrl,
+ payload: [
+ 'jsonrpc' => '2.0',
+ 'id' => 0,
+ 'method' => 'initialize',
+ 'params' => [
+ 'protocolVersion' => '2025-11-25',
+ 'capabilities' => (object) [],
+ 'clientInfo' => [
+ 'name' => 'kosmokrator',
+ 'version' => '0.1',
+ ],
+ ],
+ ],
+ headers: $headers,
+ );
+
+ $this->send(
+ remoteUrl: $remoteUrl,
+ payload: [
+ 'jsonrpc' => '2.0',
+ 'method' => 'notifications/initialized',
+ ],
+ headers: $headers,
+ sessionId: $sessionId,
+ expectPayload: false,
+ );
+
+ [, $payload] = $this->send(
+ remoteUrl: $remoteUrl,
+ payload: [
+ 'jsonrpc' => '2.0',
+ 'id' => 1,
+ 'method' => 'tools/call',
+ 'params' => [
+ 'name' => $toolName,
+ 'arguments' => $arguments,
+ ],
+ ],
+ headers: $headers,
+ sessionId: $sessionId,
+ );
+
+ if (! is_array($payload)) {
+ throw new \RuntimeException('Remote MCP tool returned an invalid payload.');
+ }
+
+ if (($payload['isError'] ?? false) === true) {
+ $error = $this->extractToolText($payload);
+ throw new \RuntimeException($error !== '' ? $error : 'Remote MCP tool call failed.');
+ }
+
+ $text = $this->extractToolText($payload);
+
+ return $this->decodeEmbeddedPayload($text);
+ }
+
+ /**
+ * @param array $payload
+ * @param array $headers
+ * @return array{0: string, 1: array|null}
+ */
+ private function send(
+ string $remoteUrl,
+ array $payload,
+ array $headers,
+ string $sessionId = '',
+ bool $expectPayload = true,
+ ): array {
+ $request = new Request($remoteUrl, 'POST');
+ $request->setHeader('Content-Type', 'application/json');
+ $request->setHeader('Accept', 'application/json, text/event-stream');
+ foreach ($headers as $key => $value) {
+ $request->setHeader($key, $value);
+ }
+ if ($sessionId !== '') {
+ $request->setHeader('mcp-session-id', $sessionId);
+ }
+ $request->setTransferTimeout(30);
+ $request->setInactivityTimeout(30);
+ $request->setBody(json_encode($payload, JSON_THROW_ON_ERROR));
+
+ $response = $this->httpClient->request($request);
+ $status = $response->getStatus();
+
+ if ($status < 200 || $status >= 300) {
+ $body = $response->getBody()->buffer();
+ throw new \RuntimeException("Remote MCP request failed ({$status}).");
+ }
+
+ $resolvedSessionId = (string) ($response->getHeader('mcp-session-id') ?? $sessionId);
+ if (! $expectPayload) {
+ return [$resolvedSessionId, null];
+ }
+
+ $contentType = strtolower((string) ($response->getHeader('content-type') ?? ''));
+ $decoded = str_contains($contentType, 'text/event-stream')
+ ? $this->decodeSsePayload($response->getBody())
+ : json_decode($response->getBody()->buffer(), true);
+
+ if (! is_array($decoded)) {
+ throw new \RuntimeException('Remote MCP response was not valid JSON.');
+ }
+
+ $result = $decoded['result'] ?? $decoded;
+ if (! is_array($result)) {
+ throw new \RuntimeException('Remote MCP result payload was invalid.');
+ }
+
+ return [$resolvedSessionId, $result];
+ }
+
+ /**
+ * @return array|null
+ */
+ private function decodeSsePayload(ReadableStream $stream): ?array
+ {
+ $buffer = '';
+
+ while (($chunk = $stream->read()) !== null) {
+ $buffer .= str_replace(["\r\n", "\r"], "\n", $chunk);
+
+ while (($separatorPos = strpos($buffer, "\n\n")) !== false) {
+ $event = substr($buffer, 0, $separatorPos);
+ $buffer = substr($buffer, $separatorPos + 2);
+
+ $decoded = $this->decodeSseEvent($event);
+ if ($decoded !== null) {
+ return $decoded;
+ }
+ }
+ }
+
+ return $this->decodeSseEvent($buffer);
+ }
+
+ /**
+ * @return array|null
+ */
+ private function decodeSseEvent(string $event): ?array
+ {
+ $dataLines = [];
+
+ foreach (explode("\n", $event) as $line) {
+ if (str_starts_with($line, 'data:')) {
+ $dataLines[] = ltrim(substr($line, 5));
+ }
+ }
+
+ $payload = implode("\n", $dataLines);
+ if ($payload === '') {
+ return null;
+ }
+
+ $decoded = json_decode($payload, true);
+
+ return is_array($decoded) ? $decoded : null;
+ }
+
+ /**
+ * @param array $payload
+ */
+ private function extractToolText(array $payload): string
+ {
+ $content = $payload['content'] ?? null;
+ if (! is_array($content)) {
+ return '';
+ }
+
+ foreach ($content as $item) {
+ if (is_array($item) && ($item['type'] ?? null) === 'text' && is_string($item['text'] ?? null)) {
+ return $item['text'];
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * @return array|list|string
+ */
+ private function decodeEmbeddedPayload(string $text): array|string
+ {
+ $value = $text;
+
+ for ($i = 0; $i < 3; $i++) {
+ $decoded = json_decode($value, true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ break;
+ }
+
+ if (is_array($decoded)) {
+ return $decoded;
+ }
+
+ if (! is_string($decoded)) {
+ return $value;
+ }
+
+ $value = $decoded;
+ }
+
+ return $value;
+ }
+}
diff --git a/src/Web/Provider/Fetch/DirectFetchProvider.php b/src/Web/Provider/Fetch/DirectFetchProvider.php
new file mode 100644
index 0000000..62ca4dc
--- /dev/null
+++ b/src/Web/Provider/Fetch/DirectFetchProvider.php
@@ -0,0 +1,159 @@
+httpClient = $httpClient ?? HttpClientBuilder::buildDefault();
+ }
+
+ public function id(): string
+ {
+ return 'direct';
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ public function fetch(WebFetchRequest $request): WebFetchResponse
+ {
+ $this->guard->assertSafePublicUrl($request->url);
+
+ $httpRequest = new Request($request->url, 'GET');
+ $httpRequest->setHeader('User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36');
+ $httpRequest->setHeader('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7');
+ $httpRequest->setHeader('Accept-Language', 'en-US,en;q=0.9');
+ $httpRequest->setHeader('Accept-Encoding', 'gzip, deflate');
+ $httpRequest->setHeader('Cache-Control', 'no-cache');
+ $httpRequest->setHeader('Pragma', 'no-cache');
+ $httpRequest->setHeader('Upgrade-Insecure-Requests', '1');
+ $httpRequest->setTransferTimeout($request->timeout ?? $this->defaultTimeout);
+ $httpRequest->setInactivityTimeout($request->timeout ?? $this->defaultTimeout);
+
+ $response = $this->httpClient->request($httpRequest);
+ $status = $response->getStatus();
+ $body = $response->getBody()->buffer();
+ $contentType = strtolower(trim(explode(';', $response->getHeader('content-type') ?? 'text/plain')[0]));
+ $contentEncoding = strtolower(trim($response->getHeader('content-encoding') ?? ''));
+ $finalUrl = (string) $response->getRequest()->getUri();
+
+ if (strlen($body) > $this->maxBytes) {
+ throw new \RuntimeException('Fetched page exceeds the maximum allowed size.');
+ }
+
+ if ($status < 200 || $status >= 300) {
+ $exceptionClass = $this->isPermanentStatus($status)
+ ? WebFetchPermanentException::class
+ : \RuntimeException::class;
+
+ throw new $exceptionClass("Direct fetch failed ({$status}) for {$request->url}.");
+ }
+
+ $body = $this->decodeBody($body, $contentEncoding);
+
+ if (str_contains($contentType, 'html') || $contentType === 'application/xhtml+xml') {
+ $page = $this->extractor->extract($body, $finalUrl);
+
+ return new WebFetchResponse(
+ provider: $this->id(),
+ url: $request->url,
+ finalUrl: $finalUrl,
+ statusCode: $status,
+ contentType: $contentType,
+ format: $request->format,
+ title: $page->title,
+ metadata: $page->metadata,
+ outline: $page->outline,
+ sections: $page->sections,
+ content: $page->fullContent,
+ rawHtml: $body,
+ extractionMethod: 'html_dom',
+ meta: ['bytes' => strlen($body)],
+ );
+ }
+
+ if (str_starts_with($contentType, 'text/')) {
+ $content = trim(mb_convert_encoding($body, 'UTF-8', 'UTF-8'));
+
+ return new WebFetchResponse(
+ provider: $this->id(),
+ url: $request->url,
+ finalUrl: $finalUrl,
+ statusCode: $status,
+ contentType: $contentType,
+ format: 'text',
+ title: null,
+ metadata: [],
+ outline: [],
+ sections: ['full' => $content],
+ content: $content,
+ rawHtml: null,
+ extractionMethod: 'plain_text',
+ meta: ['bytes' => strlen($body)],
+ );
+ }
+
+ throw new \RuntimeException("Unsupported content type for direct fetch: {$contentType}");
+ }
+
+ private function decodeBody(string $body, string $contentEncoding): string
+ {
+ if ($contentEncoding === '' || $contentEncoding === 'identity') {
+ return $body;
+ }
+
+ return match ($contentEncoding) {
+ 'gzip', 'x-gzip' => $this->decodeGzip($body),
+ 'deflate' => $this->decodeDeflate($body),
+ default => throw new \RuntimeException("Unsupported content encoding for direct fetch: {$contentEncoding}"),
+ };
+ }
+
+ private function decodeGzip(string $body): string
+ {
+ $decoded = gzdecode($body);
+ if ($decoded === false) {
+ throw new \RuntimeException('Failed to decode gzip-compressed web response.');
+ }
+
+ return $decoded;
+ }
+
+ private function decodeDeflate(string $body): string
+ {
+ $decoded = zlib_decode($body);
+ if ($decoded === false) {
+ throw new \RuntimeException('Failed to decode deflate-compressed web response.');
+ }
+
+ return $decoded;
+ }
+
+ private function isPermanentStatus(int $status): bool
+ {
+ return $status >= 400 && $status < 500 && ! in_array($status, [408, 429], true);
+ }
+}
diff --git a/src/Web/Provider/Fetch/ZaiReaderFetchProvider.php b/src/Web/Provider/Fetch/ZaiReaderFetchProvider.php
new file mode 100644
index 0000000..28b77a9
--- /dev/null
+++ b/src/Web/Provider/Fetch/ZaiReaderFetchProvider.php
@@ -0,0 +1,136 @@
+httpClient = $httpClient ?? HttpClientBuilder::buildDefault();
+ }
+
+ public function id(): string
+ {
+ return 'zai';
+ }
+
+ public function isAvailable(): bool
+ {
+ return $this->resolveApiKey() !== '';
+ }
+
+ public function fetch(WebFetchRequest $request): WebFetchResponse
+ {
+ $apiKey = $this->resolveApiKey();
+ if ($apiKey === '') {
+ throw new \RuntimeException('Z.AI web reader is not configured.');
+ }
+
+ $this->guard->assertSafePublicUrl($request->url);
+
+ $payload = [
+ 'url' => $request->url,
+ 'timeout' => $request->timeout ?? $this->defaultTimeout,
+ 'return_format' => $request->format === 'text' ? 'text' : 'markdown',
+ 'no_cache' => false,
+ 'retain_images' => true,
+ 'with_links_summary' => true,
+ ];
+
+ $httpRequest = new Request(rtrim($this->baseUrl, '/').'/reader', 'POST');
+ $httpRequest->setHeader('Authorization', 'Bearer '.$apiKey);
+ $httpRequest->setHeader('Content-Type', 'application/json');
+ $httpRequest->setBody(json_encode($payload, JSON_THROW_ON_ERROR));
+ $httpRequest->setTransferTimeout($request->timeout ?? $this->defaultTimeout);
+ $httpRequest->setInactivityTimeout($request->timeout ?? $this->defaultTimeout);
+
+ $response = $this->httpClient->request($httpRequest);
+ $status = $response->getStatus();
+ $body = $response->getBody()->buffer();
+ $data = json_decode($body, true);
+
+ if ($status !== 200 || ! is_array($data)) {
+ $message = is_array($data) ? json_encode($data, JSON_UNESCAPED_SLASHES) : $body;
+ throw new \RuntimeException("Z.AI reader failed ({$status}): {$message}");
+ }
+
+ $reader = $data['reader_result'] ?? null;
+ if (! is_array($reader)) {
+ throw new \RuntimeException('Z.AI reader returned no reader_result payload.');
+ }
+
+ $content = trim((string) ($reader['content'] ?? ''));
+ $title = $this->nullableString($reader['title'] ?? null);
+ $finalUrl = $this->nullableString($reader['url'] ?? null) ?? $request->url;
+ $description = $this->nullableString($reader['description'] ?? null);
+ $metadata = is_array($reader['metadata'] ?? null) ? $reader['metadata'] : [];
+ if ($description !== null && ! isset($metadata['description'])) {
+ $metadata['description'] = $description;
+ }
+ if ($title !== null && ! isset($metadata['title'])) {
+ $metadata['title'] = $title;
+ }
+ if (! isset($metadata['canonical_url'])) {
+ $metadata['canonical_url'] = $finalUrl;
+ }
+
+ $page = $this->extractor->extract($content, $title, $metadata);
+
+ return new WebFetchResponse(
+ provider: $this->id(),
+ url: $request->url,
+ finalUrl: $finalUrl,
+ statusCode: $status,
+ contentType: 'text/markdown',
+ format: $request->format === 'text' ? 'text' : 'markdown',
+ title: $page->title,
+ metadata: $page->metadata,
+ outline: $page->outline,
+ sections: $page->sections,
+ content: $page->fullContent,
+ rawHtml: null,
+ extractionMethod: 'zai_reader',
+ meta: [
+ 'transport' => 'rest',
+ 'model' => $this->nullableString($data['model'] ?? null),
+ 'request_id' => $this->nullableString($data['request_id'] ?? null),
+ ],
+ );
+ }
+
+ private function resolveApiKey(): string
+ {
+ $key = $this->apiKeyOverride;
+ if (is_string($key) && trim($key) !== '') {
+ return trim($key);
+ }
+
+ return trim($this->auth->apiKey('z'));
+ }
+
+ private function nullableString(mixed $value): ?string
+ {
+ return is_string($value) && trim($value) !== '' ? trim($value) : null;
+ }
+}
diff --git a/src/Web/Provider/Search/TavilySearchProvider.php b/src/Web/Provider/Search/TavilySearchProvider.php
new file mode 100644
index 0000000..c46acb6
--- /dev/null
+++ b/src/Web/Provider/Search/TavilySearchProvider.php
@@ -0,0 +1,100 @@
+httpClient = HttpClientBuilder::buildDefault();
+ }
+
+ public function id(): string
+ {
+ return 'tavily';
+ }
+
+ public function isAvailable(): bool
+ {
+ return is_string($this->apiKey) && trim($this->apiKey) !== '';
+ }
+
+ public function search(WebSearchRequest $request): WebSearchResponse
+ {
+ if (! $this->isAvailable()) {
+ throw new \RuntimeException('Tavily is not configured.');
+ }
+
+ $payload = [
+ 'api_key' => $this->apiKey,
+ 'query' => $request->query,
+ 'max_results' => max(1, min(10, $request->maxResults)),
+ 'search_depth' => $request->searchDepth === 'advanced' ? 'advanced' : 'basic',
+ 'include_answer' => $request->includeAnswer,
+ 'include_raw_content' => false,
+ ];
+
+ if ($request->allowedDomains !== []) {
+ $payload['include_domains'] = $request->allowedDomains;
+ }
+
+ if ($request->blockedDomains !== []) {
+ $payload['exclude_domains'] = $request->blockedDomains;
+ }
+
+ $httpRequest = new Request('https://api.tavily.com/search', 'POST');
+ $httpRequest->setHeader('Content-Type', 'application/json');
+ $httpRequest->setBody(json_encode($payload, JSON_THROW_ON_ERROR));
+ $httpRequest->setTransferTimeout(30);
+ $httpRequest->setInactivityTimeout(30);
+
+ $response = $this->httpClient->request($httpRequest);
+ $status = $response->getStatus();
+ $body = $response->getBody()->buffer();
+ $data = json_decode($body, true);
+
+ if ($status !== 200 || ! is_array($data)) {
+ $message = is_array($data) ? ($data['error'] ?? $body) : $body;
+ throw new \RuntimeException("Tavily search failed ({$status}): {$message}");
+ }
+
+ $results = [];
+ foreach (($data['results'] ?? []) as $item) {
+ if (! is_array($item)) {
+ continue;
+ }
+
+ $results[] = new WebSearchHit(
+ title: (string) ($item['title'] ?? $item['url'] ?? 'Untitled result'),
+ url: (string) ($item['url'] ?? ''),
+ snippet: (string) ($item['content'] ?? ''),
+ score: isset($item['score']) ? (float) $item['score'] : null,
+ publishedAt: isset($item['published_date']) ? (string) $item['published_date'] : null,
+ source: 'tavily',
+ );
+ }
+
+ return new WebSearchResponse(
+ provider: $this->id(),
+ query: $request->query,
+ results: $results,
+ answer: isset($data['answer']) && is_string($data['answer']) ? $data['answer'] : null,
+ meta: [
+ 'cache_hit' => false,
+ ],
+ );
+ }
+}
diff --git a/src/Web/Provider/Search/ZaiMcpSearchProvider.php b/src/Web/Provider/Search/ZaiMcpSearchProvider.php
new file mode 100644
index 0000000..cb285f8
--- /dev/null
+++ b/src/Web/Provider/Search/ZaiMcpSearchProvider.php
@@ -0,0 +1,386 @@
+httpClient = $httpClient ?? HttpClientBuilder::buildDefault();
+ if ($sleep instanceof \Closure) {
+ $this->sleep = static function (int $seconds) use ($sleep): void {
+ $sleep($seconds);
+ };
+
+ return;
+ }
+
+ if ($sleep !== null) {
+ $callback = \Closure::fromCallable($sleep);
+ $this->sleep = static function (int $seconds) use ($callback): void {
+ $callback($seconds);
+ };
+
+ return;
+ }
+
+ $this->sleep = static function (int $seconds): void {
+ sleep($seconds);
+ };
+ }
+
+ public function id(): string
+ {
+ return 'zai';
+ }
+
+ public function isAvailable(): bool
+ {
+ return $this->resolveApiKey() !== '';
+ }
+
+ public function search(WebSearchRequest $request): WebSearchResponse
+ {
+ $apiKey = $this->resolveApiKey();
+ if ($apiKey === '') {
+ throw new \RuntimeException('Z.AI web search is not configured.');
+ }
+
+ $answer = null;
+ $transport = 'remote_mcp';
+ $results = $this->searchViaMcp($request, $apiKey);
+
+ if ($results === [] || $this->shouldPreferChatSearch($request) || ($request->includeAnswer && trim((string) $answer) === '')) {
+ ['results' => $chatResults, 'answer' => $chatAnswer] = $this->searchViaChatSearch($request, $apiKey);
+
+ if ($chatResults !== []) {
+ $results = $chatResults;
+ $transport = 'chat_search';
+ }
+
+ if (trim((string) $chatAnswer) !== '') {
+ $answer = $chatAnswer;
+ }
+ }
+
+ if ($request->maxResults > 0) {
+ $results = array_slice($results, 0, $request->maxResults);
+ }
+
+ return new WebSearchResponse(
+ provider: $this->id(),
+ query: $request->query,
+ results: $results,
+ answer: $answer,
+ meta: [
+ 'transport' => $transport,
+ 'remote_url' => $this->remoteUrl,
+ ],
+ );
+ }
+
+ private function resolveApiKey(): string
+ {
+ $key = $this->apiKeyOverride;
+ if (is_string($key) && trim($key) !== '') {
+ return trim($key);
+ }
+
+ return trim($this->auth->apiKey('z'));
+ }
+
+ /**
+ * @param list $blockedDomains
+ */
+ private function isBlockedDomain(string $url, array $blockedDomains): bool
+ {
+ $host = parse_url($url, PHP_URL_HOST);
+ if (! is_string($host) || $host === '') {
+ return false;
+ }
+
+ $host = strtolower($host);
+
+ foreach ($blockedDomains as $blockedDomain) {
+ $blockedDomain = strtolower(trim($blockedDomain));
+ if ($blockedDomain === '') {
+ continue;
+ }
+
+ if ($host === $blockedDomain || str_ends_with($host, '.'.$blockedDomain)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @return list
+ */
+ private function searchViaMcp(WebSearchRequest $request, string $apiKey): array
+ {
+ $attempt = 0;
+
+ while (true) {
+ try {
+ $arguments = [
+ 'search_query' => $request->query,
+ 'content_size' => $request->searchDepth === 'advanced' ? 'high' : 'medium',
+ ];
+
+ if ($request->allowedDomains !== []) {
+ $arguments['search_domain_filter'] = implode(',', $request->allowedDomains);
+ }
+
+ $payload = $this->invoker->call(
+ $this->remoteUrl,
+ 'web_search_prime',
+ $arguments,
+ ['Authorization' => 'Bearer '.$apiKey],
+ );
+
+ if (! is_array($payload)) {
+ return [];
+ }
+
+ $results = [];
+ foreach ($payload as $item) {
+ if (! is_array($item)) {
+ continue;
+ }
+
+ $url = (string) ($item['link'] ?? '');
+ if ($url === '') {
+ continue;
+ }
+
+ if ($request->blockedDomains !== [] && $this->isBlockedDomain($url, $request->blockedDomains)) {
+ continue;
+ }
+
+ $results[] = new WebSearchHit(
+ title: (string) ($item['title'] ?? $url),
+ url: $url,
+ snippet: $request->includeSnippets ? trim((string) ($item['content'] ?? '')) : '',
+ score: null,
+ publishedAt: isset($item['publish_date']) ? (string) $item['publish_date'] : null,
+ source: (string) ($item['media'] ?? 'zai'),
+ );
+ }
+
+ return $results;
+ } catch (\Throwable $e) {
+ if (! $this->isRateLimitError($e->getMessage())) {
+ return [];
+ }
+
+ if (! isset($this->rateLimitRetryDelays[$attempt])) {
+ throw new \RuntimeException('Z.AI web search is rate limited. Please retry shortly.', 0, $e);
+ }
+
+ ($this->sleep)((int) $this->rateLimitRetryDelays[$attempt]);
+ $attempt++;
+ }
+ }
+ }
+
+ /**
+ * @return list
+ */
+ /**
+ * @return array{results: list, answer: ?string}
+ */
+ private function searchViaChatSearch(WebSearchRequest $request, string $apiKey): array
+ {
+ $webSearch = [
+ 'enable' => true,
+ 'search_engine' => 'search-prime',
+ 'search_result' => true,
+ 'count' => max(1, min(10, $request->maxResults)),
+ 'content_size' => $request->searchDepth === 'advanced' ? 'high' : 'medium',
+ ];
+
+ if ($request->allowedDomains !== []) {
+ $webSearch['search_domain_filter'] = implode(',', $request->allowedDomains);
+ }
+
+ $instruction = sprintf(
+ 'Search the web for: %s. Return only valid JSON with keys "answer" and "results". '.
+ '"answer" should be a concise summary when requested, otherwise null. '.
+ '"results" should be an array of up to %d objects with keys: title, url, source, published_at, snippet. '.
+ 'Do not wrap the JSON in markdown fences.',
+ $request->query,
+ max(1, min(10, $request->maxResults))
+ );
+
+ $payload = [
+ 'model' => 'glm-5.1',
+ 'messages' => [
+ ['role' => 'user', 'content' => $instruction],
+ ],
+ 'tools' => [[
+ 'type' => 'web_search',
+ 'web_search' => $webSearch,
+ ]],
+ 'temperature' => 0,
+ ];
+
+ if (! $request->includeAnswer) {
+ $payload['messages'][0]['content'] .= ' Set "answer" to null.';
+ }
+
+ $httpRequest = new Request(rtrim($this->chatBaseUrl, '/').'/chat/completions', 'POST');
+ $httpRequest->setHeader('Authorization', 'Bearer '.$apiKey);
+ $httpRequest->setHeader('Content-Type', 'application/json');
+ $httpRequest->setBody(json_encode($payload, JSON_THROW_ON_ERROR));
+ $httpRequest->setTransferTimeout(60);
+ $httpRequest->setInactivityTimeout(60);
+
+ $response = $this->httpClient->request($httpRequest);
+ $status = $response->getStatus();
+ $body = $response->getBody()->buffer();
+ $data = json_decode($body, true);
+
+ if ($status !== 200 || ! is_array($data)) {
+ return ['results' => [], 'answer' => null];
+ }
+
+ $content = (string) ($data['choices'][0]['message']['content'] ?? '');
+ $parsed = $this->parseChatSearchPayload($content);
+ if ($parsed !== null) {
+ return $parsed;
+ }
+
+ $lines = preg_split("/\r\n|\n|\r/", trim($content)) ?: [];
+ $results = [];
+
+ foreach ($lines as $line) {
+ $line = trim($line);
+ if ($line === '' || ! str_contains($line, ' | ')) {
+ continue;
+ }
+
+ $parts = array_map('trim', explode(' | ', $line));
+ if (count($parts) < 4) {
+ continue;
+ }
+
+ [$title, $url, $source, $publishedAt] = array_pad($parts, 4, '');
+ if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
+ continue;
+ }
+
+ if ($request->blockedDomains !== [] && $this->isBlockedDomain($url, $request->blockedDomains)) {
+ continue;
+ }
+
+ $results[] = new WebSearchHit(
+ title: $title !== '' ? $title : $url,
+ url: $url,
+ snippet: '',
+ score: null,
+ publishedAt: $publishedAt !== '' ? $publishedAt : null,
+ source: $source !== '' ? $source : 'zai',
+ );
+ }
+
+ return ['results' => $results, 'answer' => null];
+ }
+
+ private function isRateLimitError(string $message): bool
+ {
+ $normalized = strtolower($message);
+
+ return str_contains($normalized, 'rate limit')
+ || str_contains($normalized, 'code":"1302')
+ || str_contains($normalized, 'code 1302')
+ || str_contains($normalized, 'mcp error -429')
+ || str_contains($normalized, '429');
+ }
+
+ private function shouldPreferChatSearch(WebSearchRequest $request): bool
+ {
+ $query = trim($request->query);
+ if ($query === '') {
+ return false;
+ }
+
+ $wordCount = count(array_filter(preg_split('/\s+/', $query) ?: [], static fn (string $part): bool => $part !== ''));
+
+ return $request->includeAnswer || $wordCount <= 1 || mb_strlen($query) < 16;
+ }
+
+ /**
+ * @return array{results: list, answer: ?string}|null
+ */
+ private function parseChatSearchPayload(string $content): ?array
+ {
+ $trimmed = trim($content);
+ if ($trimmed === '') {
+ return null;
+ }
+
+ if (preg_match('/```(?:json)?\s*(\{.*\})\s*```/is', $trimmed, $matches) === 1) {
+ $trimmed = trim((string) $matches[1]);
+ }
+
+ $data = json_decode($trimmed, true);
+ if (! is_array($data)) {
+ return null;
+ }
+
+ $results = [];
+ foreach (($data['results'] ?? []) as $item) {
+ if (! is_array($item)) {
+ continue;
+ }
+
+ $url = trim((string) ($item['url'] ?? ''));
+ if ($url === '' || ! filter_var($url, FILTER_VALIDATE_URL)) {
+ continue;
+ }
+
+ $results[] = new WebSearchHit(
+ title: trim((string) ($item['title'] ?? $url)) ?: $url,
+ url: $url,
+ snippet: trim((string) ($item['snippet'] ?? '')),
+ score: null,
+ publishedAt: ($publishedAt = trim((string) ($item['published_at'] ?? ''))) !== '' ? $publishedAt : null,
+ source: ($source = trim((string) ($item['source'] ?? ''))) !== '' ? $source : 'zai',
+ );
+ }
+
+ $answer = $data['answer'] ?? null;
+ if (! is_string($answer) || trim($answer) === '') {
+ $answer = null;
+ }
+
+ return ['results' => $results, 'answer' => $answer];
+ }
+}
diff --git a/src/Web/Provider/WebFetchProviderManager.php b/src/Web/Provider/WebFetchProviderManager.php
new file mode 100644
index 0000000..7fb01b9
--- /dev/null
+++ b/src/Web/Provider/WebFetchProviderManager.php
@@ -0,0 +1,143 @@
+ $providers */
+ public function __construct(
+ iterable $providers,
+ private readonly SettingsManager $settings,
+ private readonly WebTransientCache $cache,
+ ) {
+ foreach ($providers as $provider) {
+ $this->providers[$provider->id()] = $provider;
+ }
+ }
+
+ /** @var array */
+ private array $providers = [];
+
+ /**
+ * @return list
+ */
+ public function availableProviderIds(): array
+ {
+ $available = [];
+
+ foreach ($this->providers as $providerId => $provider) {
+ if ($provider->isAvailable()) {
+ $available[] = $providerId;
+ }
+ }
+
+ return $available;
+ }
+
+ public function fetch(WebFetchRequest $request): WebFetchResponse
+ {
+ $providerIds = $this->candidateProviders($request->provider, $request->strategy);
+ $errors = [];
+
+ if ($providerIds === []) {
+ throw new \RuntimeException('No available web fetch provider matches the requested strategy.');
+ }
+
+ foreach ($providerIds as $providerId) {
+ $provider = $this->providers[$providerId] ?? null;
+ if ($provider === null || ! $provider->isAvailable()) {
+ continue;
+ }
+
+ $cacheKey = 'web_fetch:'.$providerId.':'.hash('sha256', json_encode($request->toCachePayload(), JSON_THROW_ON_ERROR));
+
+ try {
+ /** @var WebFetchResponse $response */
+ $response = $this->cache->remember($cacheKey, fn () => $provider->fetch($request));
+
+ return new WebFetchResponse(
+ provider: $response->provider,
+ url: $response->url,
+ finalUrl: $response->finalUrl,
+ statusCode: $response->statusCode,
+ contentType: $response->contentType,
+ format: $response->format,
+ title: $response->title,
+ metadata: $response->metadata,
+ outline: $response->outline,
+ sections: $response->sections,
+ content: $response->content,
+ rawHtml: $response->rawHtml,
+ truncated: $response->truncated,
+ nextChunkToken: $response->nextChunkToken,
+ extractionMethod: $response->extractionMethod,
+ meta: array_merge($response->meta, ['cache_key' => $cacheKey]),
+ );
+ } catch (WebFetchPermanentException $e) {
+ throw $e;
+ } catch (\Throwable $e) {
+ $errors[] = "{$providerId}: {$e->getMessage()}";
+
+ if (($request->strategy === 'direct_only' || $request->strategy === 'provider_only') && $request->provider !== null) {
+ break;
+ }
+ }
+ }
+
+ $suffix = $errors === [] ? '' : ' Tried providers: '.implode(' | ', $errors);
+ throw new \RuntimeException('No available web fetch provider succeeded.'.$suffix);
+ }
+
+ /**
+ * @return list
+ */
+ private function candidateProviders(?string $explicitProvider, string $strategy): array
+ {
+ if (is_string($explicitProvider) && $explicitProvider !== '') {
+ return [$explicitProvider];
+ }
+
+ if ($strategy === 'direct_only') {
+ return isset($this->providers['direct']) ? ['direct'] : [];
+ }
+
+ $ordered = [];
+ $default = $this->settings->getRaw('kosmokrator.web.fetch.default_provider');
+ if (is_string($default) && $default !== '') {
+ $ordered[] = $default;
+ }
+
+ $fallbacks = $this->settings->getRaw('kosmokrator.web.fetch.fallback_providers');
+ if (is_array($fallbacks)) {
+ foreach ($fallbacks as $fallback) {
+ if (is_string($fallback) && $fallback !== '') {
+ $ordered[] = $fallback;
+ }
+ }
+ }
+
+ foreach (array_keys($this->providers) as $providerId) {
+ $ordered[] = $providerId;
+ }
+
+ $ordered = array_values(array_unique($ordered));
+
+ if ($strategy !== 'provider_only') {
+ return $ordered;
+ }
+
+ return array_values(array_filter(
+ $ordered,
+ static fn (string $providerId): bool => $providerId !== 'direct'
+ ));
+ }
+}
diff --git a/src/Web/Provider/WebSearchProviderManager.php b/src/Web/Provider/WebSearchProviderManager.php
new file mode 100644
index 0000000..9898017
--- /dev/null
+++ b/src/Web/Provider/WebSearchProviderManager.php
@@ -0,0 +1,164 @@
+ $providers */
+ public function __construct(
+ iterable $providers,
+ private readonly SettingsManager $settings,
+ private readonly WebTransientCache $cache,
+ ) {
+ foreach ($providers as $provider) {
+ $this->providers[$provider->id()] = $provider;
+ }
+ }
+
+ /** @var array */
+ private array $providers = [];
+
+ /**
+ * @return list
+ */
+ public function availableProviderIds(): array
+ {
+ $available = [];
+
+ foreach ($this->providers as $providerId => $provider) {
+ if ($provider->isAvailable()) {
+ $available[] = $providerId;
+ }
+ }
+
+ return $available;
+ }
+
+ public function search(WebSearchRequest $request): WebSearchResponse
+ {
+ $providerIds = $this->candidateProviders($request->provider);
+ $errors = [];
+
+ foreach ($providerIds as $providerId) {
+ $provider = $this->providers[$providerId] ?? null;
+ if ($provider === null || ! $provider->isAvailable()) {
+ continue;
+ }
+
+ $cacheKey = 'web_search:'.$providerId.':'.hash('sha256', json_encode($request->toCachePayload(), JSON_THROW_ON_ERROR));
+
+ try {
+ /** @var WebSearchResponse $response */
+ $response = $this->cache->remember($cacheKey, fn () => $provider->search($request));
+ $response = $this->normalizeResponse($response, $request, $cacheKey);
+
+ if ($response->results === [] && trim((string) $response->answer) === '') {
+ $errors[] = "{$providerId}: empty result set";
+
+ continue;
+ }
+
+ return $response;
+ } catch (\Throwable $e) {
+ $errors[] = "{$providerId}: {$e->getMessage()}";
+ }
+ }
+
+ $suffix = $errors === [] ? '' : ' Tried providers: '.implode(' | ', $errors);
+ throw new \RuntimeException('No available web search provider succeeded.'.$suffix);
+ }
+
+ /**
+ * @return list
+ */
+ private function candidateProviders(?string $explicitProvider): array
+ {
+ if (is_string($explicitProvider) && $explicitProvider !== '') {
+ return [$explicitProvider];
+ }
+
+ $ordered = [];
+ $default = $this->settings->getRaw('kosmokrator.web.search.default_provider');
+ if (is_string($default) && $default !== '') {
+ $ordered[] = $default;
+ }
+
+ $fallbacks = $this->settings->getRaw('kosmokrator.web.search.fallback_providers');
+ if (is_array($fallbacks)) {
+ foreach ($fallbacks as $fallback) {
+ if (is_string($fallback) && $fallback !== '') {
+ $ordered[] = $fallback;
+ }
+ }
+ }
+
+ foreach (array_keys($this->providers) as $providerId) {
+ $ordered[] = $providerId;
+ }
+
+ return array_values(array_unique($ordered));
+ }
+
+ private function normalizeResponse(WebSearchResponse $response, WebSearchRequest $request, string $cacheKey): WebSearchResponse
+ {
+ $results = [];
+
+ foreach ($response->results as $hit) {
+ $host = parse_url($hit->url, PHP_URL_HOST);
+ if (! is_string($host) || $host === '') {
+ continue;
+ }
+
+ if ($request->allowedDomains !== [] && ! $this->matchesAnyDomain($host, $request->allowedDomains)) {
+ continue;
+ }
+
+ if ($request->blockedDomains !== [] && $this->matchesAnyDomain($host, $request->blockedDomains)) {
+ continue;
+ }
+
+ $results[] = $hit;
+ }
+
+ if ($request->maxResults > 0) {
+ $results = array_slice($results, 0, $request->maxResults);
+ }
+
+ return new WebSearchResponse(
+ provider: $response->provider,
+ query: $response->query,
+ results: $results,
+ answer: $response->answer,
+ meta: array_merge($response->meta, ['cache_key' => $cacheKey]),
+ );
+ }
+
+ /**
+ * @param list $domains
+ */
+ private function matchesAnyDomain(string $host, array $domains): bool
+ {
+ $host = strtolower($host);
+
+ foreach ($domains as $domain) {
+ $domain = strtolower(trim($domain));
+ if ($domain === '') {
+ continue;
+ }
+
+ if ($host === $domain || str_ends_with($host, '.'.$domain)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Web/Safety/WebRequestGuard.php b/src/Web/Safety/WebRequestGuard.php
new file mode 100644
index 0000000..d49ff83
--- /dev/null
+++ b/src/Web/Safety/WebRequestGuard.php
@@ -0,0 +1,79 @@
+ 2048) {
+ throw new \RuntimeException('URL is too long.');
+ }
+
+ $parts = parse_url($url);
+ if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
+ throw new \RuntimeException('URL is invalid.');
+ }
+
+ $scheme = strtolower((string) $parts['scheme']);
+ if (! in_array($scheme, ['http', 'https'], true)) {
+ throw new \RuntimeException('Only http and https URLs are allowed.');
+ }
+
+ if (isset($parts['user']) || isset($parts['pass'])) {
+ throw new \RuntimeException('URLs with embedded credentials are not allowed.');
+ }
+
+ $host = strtolower((string) $parts['host']);
+ if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
+ throw new \RuntimeException('Localhost URLs are not allowed.');
+ }
+
+ $ips = $this->resolveIpAddresses($host);
+ if ($ips === []) {
+ throw new \RuntimeException("Could not resolve host '{$host}'.");
+ }
+
+ foreach ($ips as $ip) {
+ if ($this->isPrivateOrReservedIp($ip)) {
+ throw new \RuntimeException("Blocked private or reserved address for host '{$host}'.");
+ }
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private function resolveIpAddresses(string $host): array
+ {
+ $ips = [];
+
+ $v4 = gethostbynamel($host);
+ if (is_array($v4)) {
+ foreach ($v4 as $ip) {
+ if (filter_var($ip, FILTER_VALIDATE_IP)) {
+ $ips[] = $ip;
+ }
+ }
+ }
+
+ $records = dns_get_record($host, DNS_AAAA);
+ if (is_array($records)) {
+ foreach ($records as $record) {
+ $ipv6 = $record['ipv6'] ?? null;
+ if (is_string($ipv6) && filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+ $ips[] = $ipv6;
+ }
+ }
+ }
+
+ return array_values(array_unique($ips));
+ }
+
+ private function isPrivateOrReservedIp(string $ip): bool
+ {
+ return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
+ }
+}
diff --git a/src/Web/Value/ExtractedPage.php b/src/Web/Value/ExtractedPage.php
new file mode 100644
index 0000000..71f5417
--- /dev/null
+++ b/src/Web/Value/ExtractedPage.php
@@ -0,0 +1,21 @@
+ $metadata
+ * @param list $outline
+ * @param array $sections
+ */
+ public function __construct(
+ public ?string $title,
+ public array $metadata,
+ public array $outline,
+ public string $fullContent,
+ public array $sections,
+ ) {}
+}
diff --git a/src/Web/Value/WebFetchRequest.php b/src/Web/Value/WebFetchRequest.php
new file mode 100644
index 0000000..fae3c0b
--- /dev/null
+++ b/src/Web/Value/WebFetchRequest.php
@@ -0,0 +1,53 @@
+
+ */
+ public function toCachePayload(): array
+ {
+ return [
+ 'url' => $this->url,
+ 'provider' => $this->provider,
+ 'mode' => $this->mode,
+ 'format' => $this->format,
+ 'max_chars' => $this->maxChars,
+ 'summarize' => $this->summarize,
+ 'prompt' => $this->prompt,
+ 'heading' => $this->heading,
+ 'section_id' => $this->sectionId,
+ 'match' => $this->match,
+ 'start_after' => $this->startAfter,
+ 'end_before' => $this->endBefore,
+ 'chunk_token' => $this->chunkToken,
+ 'strategy' => $this->strategy,
+ 'include_metadata' => $this->includeMetadata,
+ 'include_outline' => $this->includeOutline,
+ ];
+ }
+}
diff --git a/src/Web/Value/WebFetchResponse.php b/src/Web/Value/WebFetchResponse.php
new file mode 100644
index 0000000..65f53fd
--- /dev/null
+++ b/src/Web/Value/WebFetchResponse.php
@@ -0,0 +1,33 @@
+ $metadata
+ * @param list $outline
+ * @param array $sections
+ * @param array $meta
+ */
+ public function __construct(
+ public string $provider,
+ public string $url,
+ public ?string $finalUrl,
+ public int $statusCode,
+ public string $contentType,
+ public string $format,
+ public ?string $title,
+ public array $metadata,
+ public array $outline,
+ public array $sections,
+ public string $content,
+ public ?string $rawHtml = null,
+ public bool $truncated = false,
+ public ?string $nextChunkToken = null,
+ public string $extractionMethod = 'direct',
+ public array $meta = [],
+ ) {}
+}
diff --git a/src/Web/Value/WebSearchHit.php b/src/Web/Value/WebSearchHit.php
new file mode 100644
index 0000000..8c1072b
--- /dev/null
+++ b/src/Web/Value/WebSearchHit.php
@@ -0,0 +1,17 @@
+ $allowedDomains
+ * @param list $blockedDomains
+ */
+ public function __construct(
+ public string $query,
+ public ?string $provider = null,
+ public int $maxResults = 5,
+ public array $allowedDomains = [],
+ public array $blockedDomains = [],
+ public string $searchDepth = 'basic',
+ public bool $includeSnippets = true,
+ public bool $includeAnswer = false,
+ ) {}
+
+ /**
+ * @return array
+ */
+ public function toCachePayload(): array
+ {
+ return [
+ 'query' => $this->query,
+ 'provider' => $this->provider,
+ 'max_results' => $this->maxResults,
+ 'allowed_domains' => $this->allowedDomains,
+ 'blocked_domains' => $this->blockedDomains,
+ 'search_depth' => $this->searchDepth,
+ 'include_snippets' => $this->includeSnippets,
+ 'include_answer' => $this->includeAnswer,
+ ];
+ }
+}
diff --git a/src/Web/Value/WebSearchResponse.php b/src/Web/Value/WebSearchResponse.php
new file mode 100644
index 0000000..6b8fa60
--- /dev/null
+++ b/src/Web/Value/WebSearchResponse.php
@@ -0,0 +1,20 @@
+ $results
+ * @param array $meta
+ */
+ public function __construct(
+ public string $provider,
+ public string $query,
+ public array $results,
+ public ?string $answer = null,
+ public array $meta = [],
+ ) {}
+}
diff --git a/storage/logs/audio.log b/storage/logs/audio.log
index ea341e8..6ca4df0 100644
--- a/storage/logs/audio.log
+++ b/storage/logs/audio.log
@@ -1249,3 +1249,63 @@
[2026-04-10 18:10:51] INFO: Completion sound: script composed successfully {"attempt":0,"script_length":858}
[2026-04-10 18:10:51] INFO: Completion sound: spawning playback process {"script_path":"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_sound_69d93d2b69e4e.py","instrument":"Flute"}
[2026-04-10 18:10:51] INFO: Worker finished
+[2026-04-10 23:19:41] INFO: Completion sound worker booting {"timeout_seconds":60}
+[2026-04-10 23:19:42] INFO: Completion sound worker booted
+[2026-04-10 23:19:42] INFO: Worker starting composition {"instrument":24,"message_preview":"Done. Here's the final summary:\n\n## Split complete — 9 umbrella issues → 58 new atomic issues\n\n| Umb"}
+[2026-04-10 23:20:42] WARNING: Completion sound: LLM call failed {"attempt":0,"error":"Completion sound LLM timed out after 60s"}
+[2026-04-10 23:20:43] ERROR: Completion sound: all attempts exhausted {"last_error":"Completion sound LLM timed out after 60s","attempts":2}
+[2026-04-10 23:20:43] WARNING: Completion sound: using fallback composition {"reason":"Completion sound LLM timed out after 60s"}
+[2026-04-10 23:20:43] INFO: Completion sound: spawning playback process {"script_path":"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_sound_69d985cb712ca.py","instrument":"Guitar"}
+[2026-04-10 23:20:43] INFO: Worker finished
+[2026-04-11 19:05:53] INFO: Completion sound worker booting {"timeout_seconds":60}
+[2026-04-11 19:05:53] INFO: Completion sound worker booted
+[2026-04-11 19:05:54] INFO: Worker starting composition {"instrument":105,"message_preview":"Good. Progress summary:\n\n**Stories: 528\/822 done (64%)**, 294 remaining. ~15 more waves needed.\n\nThe"}
+[2026-04-11 19:06:53] WARNING: Completion sound: script rejected — Python syntax error {"output":" File \"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_syntax_check_69da9bcd00462.py\", line 47\n Rising C-major arpeggios at 120 BPM that crest on a bright C6, dip briefly (work remaining), then climb back — steady momentum energy with rich major harmony underneath. 6 seconds, ends on an expectant high note to convey \"still going.\"\n ^\nSyntaxError: invalid character '—' (U+2014)"}
+[2026-04-11 19:06:53] WARNING: Completion sound: script validation failed {"attempt":0}
+[2026-04-11 19:06:53] INFO: Completion sound: retrying composition {"attempt":1}
+[2026-04-11 19:07:27] INFO: Completion sound: script composed successfully {"attempt":1,"script_length":2285}
+[2026-04-11 19:07:27] INFO: Completion sound: spawning playback process {"script_path":"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_sound_69da9befa4564.py","instrument":"Banjo"}
+[2026-04-11 19:07:27] INFO: Worker finished
+[2026-04-11 21:12:38] INFO: Completion sound worker booting {"timeout_seconds":60}
+[2026-04-11 21:12:38] INFO: Completion sound worker booted
+[2026-04-11 21:12:38] INFO: Worker starting composition {"instrument":105,"message_preview":"Progress update: **Stories: 692\/822 (84%)**, 130 remaining. ~7 more waves to finish stories.\n\nAll 10"}
+[2026-04-11 21:13:07] INFO: Completion sound: script composed successfully {"attempt":0,"script_length":987}
+[2026-04-11 21:13:07] INFO: Completion sound: spawning playback process {"script_path":"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_sound_69dab9633398d.py","instrument":"Banjo"}
+[2026-04-11 21:13:07] INFO: Worker finished
+[2026-04-12 00:30:23] INFO: Completion sound worker booting {"timeout_seconds":60}
+[2026-04-12 00:30:23] INFO: Completion sound worker booted
+[2026-04-12 00:30:23] INFO: Worker starting composition {"instrument":46,"message_preview":"Hey! What can I help you with today?"}
+[2026-04-12 00:30:47] INFO: Completion sound: script composed successfully {"attempt":0,"script_length":962}
+[2026-04-12 00:30:47] INFO: Completion sound: spawning playback process {"script_path":"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_sound_69dae7b7a14d9.py","instrument":"Harp"}
+[2026-04-12 00:30:47] INFO: Worker finished
+[2026-04-12 21:20:59] INFO: Completion sound worker booting {"timeout_seconds":60}
+[2026-04-12 21:21:00] INFO: Completion sound worker booted
+[2026-04-12 21:21:00] INFO: Worker starting composition {"instrument":112,"message_preview":"Here's a summary of the findings:\n\n---\n\n## UI Style Guide (`docs\/ui-design.md`)\n\nThe design doc defi"}
+[2026-04-12 21:21:36] WARNING: Completion sound: LLM call failed {"attempt":0,"error":"API error (429): Rate limit reached for requests"}
+[2026-04-12 21:21:36] INFO: Completion sound: retrying composition {"attempt":1}
+[2026-04-12 21:22:13] WARNING: Completion sound: LLM call failed {"attempt":1,"error":"API error (429): Rate limit reached for requests"}
+[2026-04-12 21:22:13] ERROR: Completion sound: all attempts exhausted {"last_error":"API error (429): Rate limit reached for requests","attempts":2}
+[2026-04-12 21:22:14] WARNING: Completion sound: using fallback composition {"reason":"API error (429): Rate limit reached for requests"}
+[2026-04-12 21:22:14] INFO: Completion sound: spawning playback process {"script_path":"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_sound_69dc0d0626695.py","instrument":"Fiddle"}
+[2026-04-12 21:22:14] INFO: Worker finished
+[2026-04-12 22:08:56] INFO: Completion sound worker booting {"timeout_seconds":60}
+[2026-04-12 22:08:57] INFO: Completion sound worker booted
+[2026-04-12 22:08:57] INFO: Worker starting composition {"instrument":112,"message_preview":"Done. `@tailwindcss\/typography` is now installed and registered via `@plugin` (Tailwind v4 syntax) i"}
+[2026-04-12 22:09:11] INFO: Completion sound: script composed successfully {"attempt":0,"script_length":807}
+[2026-04-12 22:09:11] INFO: Completion sound: spawning playback process {"script_path":"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_sound_69dc18075c79b.py","instrument":"Fiddle"}
+[2026-04-12 22:09:11] INFO: Worker finished
+[2026-04-12 22:29:38] INFO: Completion sound worker booting {"timeout_seconds":60}
+[2026-04-12 22:29:39] INFO: Completion sound worker booted
+[2026-04-12 22:29:39] INFO: Worker starting composition {"instrument":112,"message_preview":"Done. Switched from Instrument Sans to **Lexend** (weights 300–700) across the entire app:\n\n- `parti"}
+[2026-04-12 22:30:02] WARNING: Completion sound: script rejected — Python syntax error {"output":" File \"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_syntax_check_69dc1cea94871.py\", line 30\n A bright C-major fanfare (C5–E5–G5–C6) with major-triad bass support — short and satisfying for a clean, quick task completion.\n ^\nSyntaxError: invalid character '–' (U+2013)"}
+[2026-04-12 22:30:02] WARNING: Completion sound: script validation failed {"attempt":0}
+[2026-04-12 22:30:02] INFO: Completion sound: retrying composition {"attempt":1}
+[2026-04-12 22:30:15] INFO: Completion sound: script composed successfully {"attempt":1,"script_length":1021}
+[2026-04-12 22:30:15] INFO: Completion sound: spawning playback process {"script_path":"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_sound_69dc1cf7bc32a.py","instrument":"Fiddle"}
+[2026-04-12 22:30:15] INFO: Worker finished
+[2026-04-13 08:56:23] INFO: Completion sound worker booting {"timeout_seconds":60}
+[2026-04-13 08:56:24] INFO: Completion sound worker booted
+[2026-04-13 08:56:24] INFO: Worker starting composition {"instrument":112,"message_preview":"All clean. The UI transformation is well underway. Here's the current status:\n\n**Completed:**\n- All "}
+[2026-04-13 08:57:02] INFO: Completion sound: script composed successfully {"attempt":0,"script_length":1136}
+[2026-04-13 08:57:02] INFO: Completion sound: spawning playback process {"script_path":"\/var\/folders\/xq\/3h5kbr5550l9msblmc16334m0000gn\/T\/kosmokrator_sound_69dcafde79594.py","instrument":"Fiddle"}
+[2026-04-13 08:57:02] INFO: Worker finished
diff --git a/tests/Feature/AgentCommandTest.php b/tests/Feature/AgentCommandTest.php
index ab4900f..a163cfc 100644
--- a/tests/Feature/AgentCommandTest.php
+++ b/tests/Feature/AgentCommandTest.php
@@ -1,24 +1,60 @@
boot();
+ $flow = new class implements SetupFlowInterface
+ {
+ public bool $opened = false;
+
+ public function needsProviderSetup(): bool
+ {
+ return true;
+ }
+
+ public function open(string $rendererPref = 'auto', bool $animated = false, bool $showIntro = false, ?string $notice = null): bool
+ {
+ $this->opened = true;
+
+ return false;
+ }
+ };
+
+ $kernel->getContainer()->instance(SetupFlowInterface::class, $flow);
+
$command = new AgentCommand($kernel->getContainer());
- $tester = new CommandTester($command);
+ $input = new ArrayInput([
+ '--renderer' => 'ansi',
+ '--no-animation' => true,
+ ]);
+ $input->bind($command->getDefinition());
+
+ $invoke = \Closure::bind(
+ function (ArrayInput $input, BufferedOutput $output): int {
+ return $this->runInteractive($input, $output);
+ },
+ $command,
+ AgentCommand::class,
+ );
- $tester->execute(['--no-animation' => true, '--renderer' => 'ansi']);
+ $status = $invoke($input, new BufferedOutput);
- $this->assertSame(1, $tester->getStatusCode());
+ $this->assertSame(1, $status);
+ $this->assertTrue($flow->opened);
}
}
diff --git a/tests/Unit/Agent/AgentLoopTest.php b/tests/Unit/Agent/AgentLoopTest.php
index f2c3f8b..d55eed8 100644
--- a/tests/Unit/Agent/AgentLoopTest.php
+++ b/tests/Unit/Agent/AgentLoopTest.php
@@ -30,6 +30,7 @@
use Prism\Prism\ValueObjects\ToolCall;
use Psr\Log\NullLogger;
+#[AllowMockObjectsWithoutExpectations]
class AgentLoopTest extends TestCase
{
private LlmClientInterface&Stub $llm;
diff --git a/tests/Unit/Agent/AgentModeTest.php b/tests/Unit/Agent/AgentModeTest.php
index 5ccdb59..0d1fd11 100644
--- a/tests/Unit/Agent/AgentModeTest.php
+++ b/tests/Unit/Agent/AgentModeTest.php
@@ -32,7 +32,9 @@ public function test_edit_mode_has_all_tools(): void
$this->assertContains('session_search', $tools);
$this->assertContains('memory_save', $tools);
$this->assertContains('subagent', $tools);
- $this->assertCount(25, $tools);
+ $this->assertContains('web_search', $tools);
+ $this->assertContains('web_fetch', $tools);
+ $this->assertCount(27, $tools);
}
public function test_plan_mode_has_read_only_tools(): void
diff --git a/tests/Unit/Agent/AgentSessionBuilderTest.php b/tests/Unit/Agent/AgentSessionBuilderTest.php
index 096939a..5135cee 100644
--- a/tests/Unit/Agent/AgentSessionBuilderTest.php
+++ b/tests/Unit/Agent/AgentSessionBuilderTest.php
@@ -137,6 +137,7 @@ public function test_build_uses_ansi_renderer_for_ansi_preference(): void
$builder = new AgentSessionBuilder($container);
$session = $builder->build('ansi', false);
+ $this->assertInstanceOf(UIManager::class, $session->ui);
$this->assertSame('ansi', $session->ui->getActiveRenderer());
}
diff --git a/tests/Unit/Agent/ContextBudgetTest.php b/tests/Unit/Agent/ContextBudgetTest.php
index 5a247b7..9e56b1a 100644
--- a/tests/Unit/Agent/ContextBudgetTest.php
+++ b/tests/Unit/Agent/ContextBudgetTest.php
@@ -6,8 +6,10 @@
use Kosmokrator\Agent\ContextBudget;
use Kosmokrator\LLM\ModelCatalog;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
final class ContextBudgetTest extends TestCase
{
// ---------------------------------------------------------------
diff --git a/tests/Unit/Agent/ContextCompactorTest.php b/tests/Unit/Agent/ContextCompactorTest.php
index 9540c02..b7fc0cb 100644
--- a/tests/Unit/Agent/ContextCompactorTest.php
+++ b/tests/Unit/Agent/ContextCompactorTest.php
@@ -9,10 +9,12 @@
use Kosmokrator\LLM\LlmClientInterface;
use Kosmokrator\LLM\LlmResponse;
use Kosmokrator\LLM\ModelCatalog;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Prism\Prism\Enums\FinishReason;
use Psr\Log\NullLogger;
+#[AllowMockObjectsWithoutExpectations]
class ContextCompactorTest extends TestCase
{
private function makeCompactor(?LlmClientInterface $llm = null, int $thresholdPercent = 60): ContextCompactor
diff --git a/tests/Unit/Agent/ContextManagerTest.php b/tests/Unit/Agent/ContextManagerTest.php
index 40540a6..6ac3940 100644
--- a/tests/Unit/Agent/ContextManagerTest.php
+++ b/tests/Unit/Agent/ContextManagerTest.php
@@ -15,11 +15,13 @@
use Kosmokrator\LLM\ModelCatalog;
use Kosmokrator\Session\SessionManager;
use Kosmokrator\UI\NullRenderer;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Prism\Prism\Enums\FinishReason;
use Prism\Prism\ValueObjects\ToolResult;
use Psr\Log\NullLogger;
+#[AllowMockObjectsWithoutExpectations]
class ContextManagerTest extends TestCase
{
public function test_circuit_breaker_resets_after_context_pressure_drops(): void
diff --git a/tests/Unit/Audio/CompletionSoundTest.php b/tests/Unit/Audio/CompletionSoundTest.php
index 23b8e87..bd84d88 100644
--- a/tests/Unit/Audio/CompletionSoundTest.php
+++ b/tests/Unit/Audio/CompletionSoundTest.php
@@ -6,10 +6,12 @@
use Kosmokrator\Audio\CompletionSound;
use Kosmokrator\LLM\LlmClientInterface;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
+#[AllowMockObjectsWithoutExpectations]
class CompletionSoundTest extends TestCase
{
private LlmClientInterface&MockObject $llm;
diff --git a/tests/Unit/Command/CodexLogoutCommandTest.php b/tests/Unit/Command/CodexLogoutCommandTest.php
index eedc574..2ed24dc 100644
--- a/tests/Unit/Command/CodexLogoutCommandTest.php
+++ b/tests/Unit/Command/CodexLogoutCommandTest.php
@@ -8,11 +8,13 @@
use Kosmokrator\Command\CodexLogoutCommand;
use OpenCompany\PrismCodex\Contracts\CodexTokenStore;
use OpenCompany\PrismCodex\ValueObjects\CodexToken;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
+#[AllowMockObjectsWithoutExpectations]
class CodexLogoutCommandTest extends TestCase
{
private Container&MockObject $container;
diff --git a/tests/Unit/Command/CodexStatusCommandTest.php b/tests/Unit/Command/CodexStatusCommandTest.php
index 6d5a0ab..f99f822 100644
--- a/tests/Unit/Command/CodexStatusCommandTest.php
+++ b/tests/Unit/Command/CodexStatusCommandTest.php
@@ -8,11 +8,13 @@
use Kosmokrator\Command\CodexStatusCommand;
use OpenCompany\PrismCodex\Contracts\CodexTokenStore;
use OpenCompany\PrismCodex\ValueObjects\CodexToken;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
+#[AllowMockObjectsWithoutExpectations]
class CodexStatusCommandTest extends TestCase
{
private Container&MockObject $container;
diff --git a/tests/Unit/Command/SetupCommandTest.php b/tests/Unit/Command/SetupCommandTest.php
index c9f7771..a89b2b7 100644
--- a/tests/Unit/Command/SetupCommandTest.php
+++ b/tests/Unit/Command/SetupCommandTest.php
@@ -6,34 +6,101 @@
use Illuminate\Container\Container;
use Kosmokrator\Command\SetupCommand;
+use Kosmokrator\Setup\SetupFlowInterface;
use PHPUnit\Framework\TestCase;
-use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
-class SetupCommandTest extends TestCase
+final class SetupCommandTest extends TestCase
{
- private SetupCommand $command;
+ public function test_command_name_is_setup(): void
+ {
+ $command = new SetupCommand($this->makeContainer(new FakeSetupFlow(true)));
- private CommandTester $tester;
+ $this->assertSame('setup', $command->getName());
+ }
- protected function setUp(): void
+ public function test_command_has_correct_description(): void
{
- $container = new Container;
+ $command = new SetupCommand($this->makeContainer(new FakeSetupFlow(true)));
+
+ $this->assertSame(
+ 'Open setup-focused settings for provider and model configuration',
+ $command->getDescription(),
+ );
+ }
- $this->command = new SetupCommand($container);
+ public function test_setup_command_opens_setup_flow_and_succeeds_when_completed(): void
+ {
+ $flow = new FakeSetupFlow(true);
+ $tester = new CommandTester(new SetupCommand($this->makeContainer($flow)));
- $app = new Application;
- $app->addCommand($this->command);
- $this->tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['--renderer' => 'ansi', '--no-animation' => true]);
+
+ $this->assertSame(0, $exitCode);
+ $this->assertTrue($flow->opened);
+ $this->assertSame('ansi', $flow->rendererPref);
+ $this->assertFalse($flow->animated);
+ $this->assertFalse($flow->showIntro);
+ $this->assertSame(
+ 'Open settings to configure your default provider, model, and credentials.',
+ $flow->notice,
+ );
+ $this->assertStringContainsString('Setup complete. Run `kosmokrator` to start.', $tester->getDisplay());
}
- public function test_command_name_is_setup(): void
+ public function test_setup_command_fails_when_setup_is_incomplete(): void
{
- $this->assertSame('setup', $this->command->getName());
+ $flow = new FakeSetupFlow(false);
+ $tester = new CommandTester(new SetupCommand($this->makeContainer($flow)));
+
+ $exitCode = $tester->execute([]);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertTrue($flow->opened);
+ $this->assertStringContainsString(
+ 'Setup incomplete. Configure a provider before continuing.',
+ $tester->getDisplay(),
+ );
}
- public function test_command_has_correct_description(): void
+ private function makeContainer(SetupFlowInterface $flow): Container
+ {
+ $container = new Container;
+ $container->instance(SetupFlowInterface::class, $flow);
+
+ return $container;
+ }
+}
+
+final class FakeSetupFlow implements SetupFlowInterface
+{
+ public bool $opened = false;
+
+ public string $rendererPref = 'auto';
+
+ public bool $animated = false;
+
+ public bool $showIntro = false;
+
+ public ?string $notice = null;
+
+ public function __construct(
+ private readonly bool $completed,
+ ) {}
+
+ public function needsProviderSetup(): bool
{
- $this->assertSame('Configure KosmoKrator (API keys, provider, model)', $this->command->getDescription());
+ return true;
+ }
+
+ public function open(string $rendererPref = 'auto', bool $animated = false, bool $showIntro = false, ?string $notice = null): bool
+ {
+ $this->opened = true;
+ $this->rendererPref = $rendererPref;
+ $this->animated = $animated;
+ $this->showIntro = $showIntro;
+ $this->notice = $notice;
+
+ return $this->completed;
}
}
diff --git a/tests/Unit/Command/Slash/AgentsCommandTest.php b/tests/Unit/Command/Slash/AgentsCommandTest.php
index b954c10..9aed9c5 100644
--- a/tests/Unit/Command/Slash/AgentsCommandTest.php
+++ b/tests/Unit/Command/Slash/AgentsCommandTest.php
@@ -18,8 +18,10 @@
use Kosmokrator\Task\TaskStore;
use Kosmokrator\Tool\Permission\PermissionEvaluator;
use Kosmokrator\UI\UIManager;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class AgentsCommandTest extends TestCase
{
private AgentsCommand $command;
@@ -105,7 +107,7 @@ public function test_execute_with_stats_shows_dashboard(): void
->with(
$this->callback(fn (array $summary) => $summary['total'] === 1 && $summary['done'] === 1),
$this->identicalTo($stats),
- $this->isType('callable'),
+ $this->isCallable(),
);
$ctx = $this->makeContext(ui: $ui, orchestrator: $orchestrator, llm: $llm);
diff --git a/tests/Unit/Command/Slash/ClearCommandTest.php b/tests/Unit/Command/Slash/ClearCommandTest.php
index 5b11740..3ac2b88 100644
--- a/tests/Unit/Command/Slash/ClearCommandTest.php
+++ b/tests/Unit/Command/Slash/ClearCommandTest.php
@@ -8,8 +8,10 @@
use Kosmokrator\Command\SlashCommandAction;
use Kosmokrator\Command\SlashCommandContext;
use Kosmokrator\Command\SlashCommandResult;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class ClearCommandTest extends TestCase
{
private ClearCommand $command;
diff --git a/tests/Unit/Command/Slash/ModelsCommandTest.php b/tests/Unit/Command/Slash/ModelsCommandTest.php
new file mode 100644
index 0000000..bc83e7c
--- /dev/null
+++ b/tests/Unit/Command/Slash/ModelsCommandTest.php
@@ -0,0 +1,442 @@
+projectDir = sys_get_temp_dir().'/kosmokrator-models-project-'.bin2hex(random_bytes(4));
+ $this->homeDir = sys_get_temp_dir().'/kosmokrator-models-home-'.bin2hex(random_bytes(4));
+ mkdir($this->projectDir, 0777, true);
+ mkdir($this->homeDir.'/.kosmokrator', 0777, true);
+ $this->originalHome = (string) getenv('HOME');
+ putenv("HOME={$this->homeDir}");
+ }
+
+ protected function tearDown(): void
+ {
+ putenv("HOME={$this->originalHome}");
+ $this->removeDirectory($this->projectDir);
+ $this->removeDirectory($this->homeDir);
+ parent::tearDown();
+ }
+
+ public function test_execute_switches_runtime_and_persists_recent_selection(): void
+ {
+ [$container, $catalog, $settingsManager, $settingsRepo] = $this->makeEnvironment();
+ $renderer = new RecordingRenderer;
+ $llm = new FakeLlmClient('z', 'GLM-5.1');
+
+ $modelCatalog = $this->createMock(ModelCatalog::class);
+ $modelCatalog->method('contextWindow')
+ ->with('gpt-5.4')
+ ->willReturn(400000);
+
+ $ctx = $this->makeContext($renderer, $llm, $settingsRepo, $catalog, $modelCatalog);
+
+ $command = new ModelsCommand($container);
+ $command->execute('openai:gpt-5.4', $ctx);
+
+ $this->assertSame('openai', $llm->getProvider());
+ $this->assertSame('gpt-5.4', $llm->getModel());
+ $this->assertSame('https://api.openai.com/v1', $llm->baseUrl);
+ $this->assertSame('openai-test-key', $llm->apiKey);
+ $this->assertSame('openai', $settingsManager->get('agent.default_provider'));
+ $this->assertSame('gpt-5.4', $settingsManager->get('agent.default_model'));
+ $this->assertSame('gpt-5.4', $settingsManager->getProviderLastModel('openai'));
+ $this->assertSame(['provider' => 'openai', 'model' => 'gpt-5.4', 'maxContext' => 400000], $renderer->lastRefresh);
+ $this->assertStringContainsString('Switched to OpenAI · gpt-5.4.', end($renderer->notices));
+
+ $recentModels = json_decode((string) $settingsRepo->get('global', 'kosmokrator.model_switcher.recent_models'), true, 512, JSON_THROW_ON_ERROR);
+ $recentProviders = json_decode((string) $settingsRepo->get('global', 'kosmokrator.model_switcher.recent_providers'), true, 512, JSON_THROW_ON_ERROR);
+
+ $this->assertSame([['provider' => 'openai', 'model' => 'gpt-5.4']], $recentModels);
+ $this->assertSame(['openai'], $recentProviders);
+ }
+
+ public function test_execute_without_args_shows_curated_sections_in_order(): void
+ {
+ [$container, $catalog, , $settingsRepo] = $this->makeEnvironment();
+ $renderer = new RecordingRenderer('dismissed');
+ $llm = new FakeLlmClient('openai', 'gpt-5.4');
+
+ $settingsRepo->set('global', 'kosmokrator.model_switcher.recent_models', (string) json_encode([
+ ['provider' => 'anthropic', 'model' => 'claude-sonnet-4-20250514'],
+ ['provider' => 'openai', 'model' => 'gpt-5.4'],
+ ], JSON_THROW_ON_ERROR));
+ $settingsRepo->set('global', 'kosmokrator.model_switcher.recent_providers', (string) json_encode([
+ 'anthropic',
+ 'openai',
+ ], JSON_THROW_ON_ERROR));
+
+ $ctx = $this->makeContext(
+ $renderer,
+ $llm,
+ $settingsRepo,
+ $catalog,
+ $this->createStub(ModelCatalog::class),
+ );
+
+ $command = new ModelsCommand($container);
+ $command->execute('', $ctx);
+
+ $notice = end($renderer->notices);
+ self::assertIsString($notice);
+ $this->assertStringContainsString('Current: OpenAI · gpt-5.4', $notice);
+ $this->assertStringContainsString('Recent used models:', $notice);
+ $this->assertStringContainsString('Anthropic · claude-sonnet-4-20250514', $notice);
+ $this->assertStringContainsString('Current provider:', $notice);
+ $this->assertStringContainsString('Recent provider:', $notice);
+ $this->assertStringContainsString('Full provider and model inventory stays in /settings.', $notice);
+ $this->assertLessThan(
+ strpos($notice, 'Current provider:'),
+ strpos($notice, 'Recent used models:'),
+ );
+ $this->assertLessThan(
+ strpos($notice, 'Recent provider:'),
+ strpos($notice, 'Current provider:'),
+ );
+ }
+
+ public function test_execute_provider_only_uses_recent_model_for_that_provider(): void
+ {
+ [$container, $catalog, $settingsManager, $settingsRepo] = $this->makeEnvironment();
+ $renderer = new RecordingRenderer;
+ $llm = new FakeLlmClient('z', 'GLM-5.1');
+
+ $settingsRepo->set('global', 'kosmokrator.model_switcher.recent_models', (string) json_encode([
+ ['provider' => 'anthropic', 'model' => 'claude-sonnet-4-20250514'],
+ ], JSON_THROW_ON_ERROR));
+ $settingsRepo->set('global', 'kosmokrator.model_switcher.recent_providers', (string) json_encode([
+ 'anthropic',
+ ], JSON_THROW_ON_ERROR));
+
+ $modelCatalog = $this->createMock(ModelCatalog::class);
+ $modelCatalog->method('contextWindow')
+ ->with('claude-sonnet-4-20250514')
+ ->willReturn(200000);
+
+ $ctx = $this->makeContext($renderer, $llm, $settingsRepo, $catalog, $modelCatalog);
+
+ $command = new ModelsCommand($container);
+ $command->execute('anthropic', $ctx);
+
+ $this->assertSame('anthropic', $llm->getProvider());
+ $this->assertSame('claude-sonnet-4-20250514', $llm->getModel());
+ $this->assertSame('anthropic-test-key', $llm->apiKey);
+ $this->assertSame('anthropic', $settingsManager->get('agent.default_provider'));
+ $this->assertSame('claude-sonnet-4-20250514', $settingsManager->get('agent.default_model'));
+ }
+
+ /**
+ * @return array{0: Container, 1: ProviderCatalog, 2: SettingsManager, 3: InMemorySettingsRepository}
+ */
+ private function makeEnvironment(): array
+ {
+ $config = new Repository([
+ 'kosmokrator' => [
+ 'agent' => [
+ 'default_provider' => 'z',
+ 'default_model' => 'GLM-5.1',
+ ],
+ ],
+ ]);
+ $schema = new SettingsSchema;
+ $settingsManager = new SettingsManager(
+ config: $config,
+ schema: $schema,
+ store: new YamlConfigStore,
+ baseConfigPath: dirname(__DIR__, 4).'/config',
+ );
+ $registry = new RelayRegistry([
+ 'z' => [
+ 'label' => 'Z.AI',
+ 'auth' => 'api_key',
+ 'driver' => 'openai-compatible',
+ 'url' => 'https://z.example/v1',
+ 'default_model' => 'GLM-5.1',
+ 'models' => [
+ 'GLM-5.1' => ['display_name' => 'GLM-5.1', 'context' => 200000, 'max_output' => 8192],
+ 'GLM-4.5-Air' => ['display_name' => 'GLM-4.5 Air', 'context' => 128000, 'max_output' => 8192],
+ ],
+ ],
+ 'openai' => [
+ 'label' => 'OpenAI',
+ 'auth' => 'api_key',
+ 'driver' => 'openai-compatible',
+ 'url' => 'https://api.openai.com/v1',
+ 'default_model' => 'gpt-5.4',
+ 'models' => [
+ 'gpt-5.4' => ['display_name' => 'GPT-5.4', 'context' => 400000, 'max_output' => 128000],
+ 'gpt-5.4-mini' => ['display_name' => 'GPT-5.4 Mini', 'context' => 128000, 'max_output' => 32768],
+ ],
+ ],
+ 'anthropic' => [
+ 'label' => 'Anthropic',
+ 'auth' => 'api_key',
+ 'driver' => 'anthropic',
+ 'url' => 'https://api.anthropic.com',
+ 'default_model' => 'claude-sonnet-4-20250514',
+ 'models' => [
+ 'claude-sonnet-4-20250514' => ['display_name' => 'Claude Sonnet 4', 'context' => 200000, 'max_output' => 8192],
+ 'claude-opus-4-20250514' => ['display_name' => 'Claude Opus 4', 'context' => 200000, 'max_output' => 8192],
+ ],
+ ],
+ ]);
+
+ $settingsRepo = new InMemorySettingsRepository([
+ 'global' => [
+ 'provider.z.api_key' => 'z-test-key',
+ 'provider.openai.api_key' => 'openai-test-key',
+ 'provider.anthropic.api_key' => 'anthropic-test-key',
+ ],
+ ]);
+
+ $codexTokens = $this->createStub(CodexTokenStore::class);
+ $codexTokens->method('current')->willReturn(null);
+
+ $providerCatalog = new ProviderCatalog(
+ new ProviderMeta($registry),
+ $registry,
+ $config,
+ $settingsRepo,
+ $codexTokens,
+ );
+
+ $container = new Container;
+ $container->instance(SettingsManager::class, $settingsManager);
+ $container->instance(RelayRegistry::class, $registry);
+
+ return [$container, $providerCatalog, $settingsManager, $settingsRepo];
+ }
+
+ private function makeContext(
+ RecordingRenderer $renderer,
+ FakeLlmClient $llm,
+ InMemorySettingsRepository $settingsRepo,
+ ProviderCatalog $providerCatalog,
+ ModelCatalog $modelCatalog,
+ ): SlashCommandContext {
+ $sessionManager = $this->createStub(SessionManager::class);
+ $sessionManager->method('getProject')->willReturn($this->projectDir);
+
+ return new SlashCommandContext(
+ ui: $renderer,
+ agentLoop: $this->createStub(AgentLoop::class),
+ permissions: $this->createStub(PermissionEvaluator::class),
+ sessionManager: $sessionManager,
+ llm: $llm,
+ taskStore: $this->createStub(TaskStore::class),
+ config: new Repository([]),
+ settings: $settingsRepo,
+ providers: $providerCatalog,
+ models: $modelCatalog,
+ );
+ }
+
+ private function removeDirectory(string $path): void
+ {
+ if (! is_dir($path)) {
+ return;
+ }
+
+ $items = scandir($path);
+ if ($items === false) {
+ return;
+ }
+
+ foreach ($items as $item) {
+ if ($item === '.' || $item === '..') {
+ continue;
+ }
+
+ $child = $path.'/'.$item;
+ if (is_dir($child)) {
+ $this->removeDirectory($child);
+ } elseif (file_exists($child)) {
+ unlink($child);
+ }
+ }
+
+ rmdir($path);
+ }
+}
+
+final class InMemorySettingsRepository implements SettingsRepositoryInterface
+{
+ /**
+ * @param array> $data
+ */
+ public function __construct(private array $data = []) {}
+
+ public function get(string $scope, string $key): ?string
+ {
+ return $this->data[$scope][$key] ?? null;
+ }
+
+ public function set(string $scope, string $key, string $value): void
+ {
+ $this->data[$scope] ??= [];
+ $this->data[$scope][$key] = $value;
+ }
+
+ public function all(string $scope): array
+ {
+ return $this->data[$scope] ?? [];
+ }
+
+ public function delete(string $scope, string $key): void
+ {
+ unset($this->data[$scope][$key]);
+ }
+
+ public function resolve(string $key, string $projectScope): ?string
+ {
+ return $this->data[$projectScope][$key] ?? $this->data['global'][$key] ?? null;
+ }
+}
+
+final class RecordingRenderer extends NullRenderer
+{
+ /** @var list */
+ public array $notices = [];
+
+ /** @var array{provider: string, model: string, maxContext: int}|null */
+ public ?array $lastRefresh = null;
+
+ public function __construct(
+ private readonly string $choice = 'dismissed',
+ ) {}
+
+ public function showNotice(string $message): void
+ {
+ $this->notices[] = $message;
+ }
+
+ public function askChoice(string $question, array $choices): string
+ {
+ return $this->choice;
+ }
+
+ public function refreshRuntimeSelection(string $provider, string $model, int $maxContext): void
+ {
+ $this->lastRefresh = [
+ 'provider' => $provider,
+ 'model' => $model,
+ 'maxContext' => $maxContext,
+ ];
+ }
+}
+
+final class FakeLlmClient implements LlmClientInterface
+{
+ public string $apiKey = '';
+
+ public string $baseUrl = '';
+
+ public function __construct(
+ private string $provider,
+ private string $model,
+ ) {}
+
+ public function chat(array $messages, array $tools = [], ?Cancellation $cancellation = null): LlmResponse
+ {
+ throw new \RuntimeException('not used');
+ }
+
+ public function stream(array $messages, array $tools = [], ?Cancellation $cancellation = null): \Generator
+ {
+ yield from [];
+ }
+
+ public function supportsStreaming(): bool
+ {
+ return true;
+ }
+
+ public function setSystemPrompt(string $prompt): void {}
+
+ public function getProvider(): string
+ {
+ return $this->provider;
+ }
+
+ public function setProvider(string $provider): void
+ {
+ $this->provider = $provider;
+ }
+
+ public function getModel(): string
+ {
+ return $this->model;
+ }
+
+ public function setModel(string $model): void
+ {
+ $this->model = $model;
+ }
+
+ public function getTemperature(): int|float|null
+ {
+ return null;
+ }
+
+ public function setTemperature(int|float|null $temperature): void {}
+
+ public function getMaxTokens(): ?int
+ {
+ return null;
+ }
+
+ public function setMaxTokens(?int $maxTokens): void {}
+
+ public function getReasoningEffort(): string
+ {
+ return 'off';
+ }
+
+ public function setReasoningEffort(string $effort): void {}
+
+ public function setApiKey(string $apiKey): void
+ {
+ $this->apiKey = $apiKey;
+ }
+
+ public function setBaseUrl(string $baseUrl): void
+ {
+ $this->baseUrl = $baseUrl;
+ }
+}
diff --git a/tests/Unit/Command/Slash/SettingsCommandTest.php b/tests/Unit/Command/Slash/SettingsCommandTest.php
index 1697424..4d5e0b1 100644
--- a/tests/Unit/Command/Slash/SettingsCommandTest.php
+++ b/tests/Unit/Command/Slash/SettingsCommandTest.php
@@ -52,6 +52,18 @@ protected function tearDown(): void
parent::tearDown();
}
+ public function test_runtime_value_normalizes_array_fallbacks(): void
+ {
+ $command = new SettingsCommand(new Container);
+ $ctx = $this->createStub(SlashCommandContext::class);
+
+ $method = new \ReflectionMethod($command, 'runtimeValue');
+
+ $value = $method->invoke($command, $ctx, 'gateway.telegram.allowed_users', ['alice', 'bob']);
+
+ $this->assertSame('alice, bob', $value);
+ }
+
public function test_execute_switches_runtime_provider_and_model_and_refreshes_ui(): void
{
$config = new Repository([]);
diff --git a/tests/Unit/Command/SlashCommandContextTest.php b/tests/Unit/Command/SlashCommandContextTest.php
index 17935a4..beda88b 100644
--- a/tests/Unit/Command/SlashCommandContextTest.php
+++ b/tests/Unit/Command/SlashCommandContextTest.php
@@ -16,8 +16,10 @@
use Kosmokrator\Task\TaskStore;
use Kosmokrator\Tool\Permission\PermissionEvaluator;
use Kosmokrator\UI\UIManager;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
final class SlashCommandContextTest extends TestCase
{
private UIManager $ui;
diff --git a/tests/Unit/Command/UpdateCommandTest.php b/tests/Unit/Command/UpdateCommandTest.php
new file mode 100644
index 0000000..ab80e3e
--- /dev/null
+++ b/tests/Unit/Command/UpdateCommandTest.php
@@ -0,0 +1,133 @@
+makeTester(
+ checker: new FakeUpdateChecker('0.6.0'),
+ updater: new FakeSelfUpdater('binary'),
+ currentVersion: '0.6.0',
+ );
+
+ $exit = $tester->execute(['--check' => true]);
+
+ $this->assertSame(0, $exit);
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString('Install method: static binary', $display);
+ $this->assertStringContainsString('Already on the latest version (v0.6.0).', $display);
+ }
+
+ public function test_source_install_prints_manual_update_instructions(): void
+ {
+ $tester = $this->makeTester(
+ checker: new FakeUpdateChecker('0.6.2'),
+ updater: new FakeSelfUpdater('source', "cd /repo\n"."git pull\n".'composer install'),
+ currentVersion: '0.6.0',
+ );
+
+ $exit = $tester->execute([]);
+
+ $this->assertSame(0, $exit);
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString('source checkout', $display);
+ $this->assertStringContainsString('Update available: v0.6.2 (current: v0.6.0)', $display);
+ $this->assertStringContainsString('cd /repo', $display);
+ $this->assertStringContainsString('git pull', $display);
+ $this->assertStringContainsString('composer install', $display);
+ }
+
+ public function test_binary_install_updates_in_place_with_yes_flag(): void
+ {
+ $updater = new FakeSelfUpdater('binary', updateMessage: 'Updated to v0.6.2.');
+ $tester = $this->makeTester(
+ checker: new FakeUpdateChecker('0.6.2'),
+ updater: $updater,
+ currentVersion: '0.6.0',
+ );
+
+ $exit = $tester->execute(['--yes' => true]);
+
+ $this->assertSame(0, $exit);
+ $this->assertSame(['0.6.2'], $updater->updatedVersions);
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString('Downloading and replacing the current executable...', $display);
+ $this->assertStringContainsString('Updated to v0.6.2.', $display);
+ }
+
+ private function makeTester(
+ UpdateCheckerInterface $checker,
+ SelfUpdaterInterface $updater,
+ string $currentVersion,
+ ): CommandTester {
+ $app = new Application;
+ $app->addCommand(new UpdateCommand(
+ new Container,
+ $currentVersion,
+ checkerFactory: static fn (string $version): UpdateCheckerInterface => $checker,
+ updaterFactory: static fn (): SelfUpdaterInterface => $updater,
+ ));
+
+ return new CommandTester($app->get('update'));
+ }
+}
+
+final class FakeUpdateChecker implements UpdateCheckerInterface
+{
+ public bool $cacheCleared = false;
+
+ public function __construct(
+ private readonly ?string $latest,
+ ) {}
+
+ public function fetchLatestVersion(): ?string
+ {
+ return $this->latest;
+ }
+
+ public function clearCache(): void
+ {
+ $this->cacheCleared = true;
+ }
+}
+
+final class FakeSelfUpdater implements SelfUpdaterInterface
+{
+ /** @var list */
+ public array $updatedVersions = [];
+
+ public function __construct(
+ private readonly string $method,
+ private readonly string $sourceInstructions = '',
+ private readonly string $updateMessage = 'Updated.',
+ ) {}
+
+ public function installationMethod(): string
+ {
+ return $this->method;
+ }
+
+ public function sourceUpdateInstructions(): string
+ {
+ return $this->sourceInstructions;
+ }
+
+ public function update(string $targetVersion): string
+ {
+ $this->updatedVersions[] = $targetVersion;
+
+ return $this->updateMessage;
+ }
+}
diff --git a/tests/Unit/Gateway/GatewayApprovalStoreTest.php b/tests/Unit/Gateway/GatewayApprovalStoreTest.php
new file mode 100644
index 0000000..29e6b6e
--- /dev/null
+++ b/tests/Unit/Gateway/GatewayApprovalStoreTest.php
@@ -0,0 +1,26 @@
+connection()->exec("INSERT INTO sessions (id, project, model, created_at, updated_at) VALUES ('sess-1', '/project', 'z/GLM', '2026-04-11T00:00:00+00:00', '2026-04-11T00:00:00+00:00')");
+ $store = new GatewayApprovalStore($db);
+
+ $pending = $store->createPending('telegram', 'telegram:123', 'sess-1', 'bash', ['command' => 'git status'], '123');
+ $resolved = $store->resolveLatestPending('telegram', 'telegram:123', 'approved');
+
+ $this->assertNotNull($resolved);
+ $this->assertSame($pending->id, $resolved->id);
+ $this->assertSame('approved', $resolved->status);
+ }
+}
diff --git a/tests/Unit/Gateway/GatewayPendingInputStoreTest.php b/tests/Unit/Gateway/GatewayPendingInputStoreTest.php
new file mode 100644
index 0000000..157cc9e
--- /dev/null
+++ b/tests/Unit/Gateway/GatewayPendingInputStoreTest.php
@@ -0,0 +1,87 @@
+enqueue('telegram', 'telegram:123', $first);
+ $store->enqueue('telegram', 'telegram:123', $second);
+
+ $this->assertSame(2, $store->count('telegram', 'telegram:123'));
+
+ $next = $store->dequeueNext('telegram', 'telegram:123');
+ $this->assertNotNull($next);
+ $this->assertSame('first', (string) ($next->payload['text'] ?? null));
+ $this->assertSame(1, $store->count('telegram', 'telegram:123'));
+
+ $next = $store->dequeueNext('telegram', 'telegram:123');
+ $this->assertNotNull($next);
+ $this->assertSame('second', (string) ($next->payload['text'] ?? null));
+ $this->assertSame(0, $store->count('telegram', 'telegram:123'));
+ }
+
+ public function test_clear_removes_pending_inputs_for_route(): void
+ {
+ $store = new GatewayPendingInputStore($db = new Database(':memory:'));
+
+ $event = new GatewayMessageEvent(
+ updateId: 1,
+ platform: 'telegram',
+ chatId: '123',
+ threadId: null,
+ routeKey: 'telegram:123',
+ text: 'hello',
+ userId: '5',
+ username: 'rutger',
+ isPrivate: true,
+ isReplyToBot: false,
+ mentionsBot: false,
+ );
+
+ $store->enqueue('telegram', 'telegram:123', $event);
+ $store->clear('telegram', 'telegram:123');
+
+ $this->assertSame(0, $store->count('telegram', 'telegram:123'));
+ $this->assertNull($store->dequeueNext('telegram', 'telegram:123'));
+ }
+}
diff --git a/tests/Unit/Gateway/GatewaySessionStoreTest.php b/tests/Unit/Gateway/GatewaySessionStoreTest.php
new file mode 100644
index 0000000..ead6270
--- /dev/null
+++ b/tests/Unit/Gateway/GatewaySessionStoreTest.php
@@ -0,0 +1,40 @@
+connection()->exec("INSERT INTO sessions (id, project, model, created_at, updated_at) VALUES ('sess-1', '/project', 'z/GLM', '2026-04-11T00:00:00+00:00', '2026-04-11T00:00:00+00:00')");
+ $store = new GatewaySessionStore($db);
+
+ $store->save('telegram', 'telegram:123', 'sess-1', '123', metadata: ['username' => 'rutger']);
+
+ $link = $store->find('telegram', 'telegram:123');
+
+ $this->assertNotNull($link);
+ $this->assertSame('sess-1', $link->sessionId);
+ $this->assertSame('123', $link->chatId);
+ $this->assertSame(['username' => 'rutger'], $link->metadata);
+ }
+
+ public function test_delete_removes_route_link(): void
+ {
+ $db = new Database(':memory:');
+ $db->connection()->exec("INSERT INTO sessions (id, project, model, created_at, updated_at) VALUES ('sess-1', '/project', 'z/GLM', '2026-04-11T00:00:00+00:00', '2026-04-11T00:00:00+00:00')");
+ $store = new GatewaySessionStore($db);
+
+ $store->save('telegram', 'telegram:123', 'sess-1', '123');
+ $store->delete('telegram', 'telegram:123');
+
+ $this->assertNull($store->find('telegram', 'telegram:123'));
+ }
+}
diff --git a/tests/Unit/Gateway/Telegram/FakeTelegramClient.php b/tests/Unit/Gateway/Telegram/FakeTelegramClient.php
new file mode 100644
index 0000000..988e817
--- /dev/null
+++ b/tests/Unit/Gateway/Telegram/FakeTelegramClient.php
@@ -0,0 +1,119 @@
+ */
+ public array $botCommands = [];
+
+ /** @var list> */
+ public array $updates = [];
+
+ /** @var list> */
+ public array $sent = [];
+
+ /** @var list> */
+ public array $edited = [];
+
+ /** @var list> */
+ public array $photos = [];
+
+ /** @var list> */
+ public array $documents = [];
+
+ /** @var list> */
+ public array $voices = [];
+
+ /** @var list> */
+ public array $callbackAnswers = [];
+
+ /** @var list> */
+ public array $chatActions = [];
+
+ public function __construct() {}
+
+ public function setMyCommands(array $commands): void
+ {
+ $this->botCommands = $commands;
+ }
+
+ public function getMe(): array
+ {
+ return ['username' => 'kosmokrator_bot'];
+ }
+
+ public function getUpdates(?int $offset, int $timeout): array
+ {
+ $batch = $this->updates;
+ $this->updates = [];
+
+ return $batch;
+ }
+
+ public function sendMessage(string $chatId, string $text, ?string $threadId = null, ?int $replyToMessageId = null, ?array $replyMarkup = null, ?string $parseMode = null): array
+ {
+ $message = [
+ 'message_id' => count($this->sent) + 1,
+ 'chat_id' => $chatId,
+ 'text' => $text,
+ 'thread_id' => $threadId,
+ 'reply_to_message_id' => $replyToMessageId,
+ 'reply_markup' => $replyMarkup,
+ 'parse_mode' => $parseMode,
+ ];
+ $this->sent[] = $message;
+
+ return ['message_id' => $message['message_id']];
+ }
+
+ public function editMessageText(string $chatId, int $messageId, string $text, ?array $replyMarkup = null, ?string $parseMode = null): array
+ {
+ $this->edited[] = [
+ 'chat_id' => $chatId,
+ 'message_id' => $messageId,
+ 'text' => $text,
+ 'reply_markup' => $replyMarkup,
+ 'parse_mode' => $parseMode,
+ ];
+
+ return ['message_id' => $messageId];
+ }
+
+ public function sendPhoto(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array
+ {
+ $this->photos[] = compact('chatId', 'path', 'threadId', 'caption', 'parseMode');
+
+ return ['message_id' => count($this->photos) + 100];
+ }
+
+ public function sendDocument(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array
+ {
+ $this->documents[] = compact('chatId', 'path', 'threadId', 'caption', 'parseMode');
+
+ return ['message_id' => count($this->documents) + 200];
+ }
+
+ public function sendVoice(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array
+ {
+ $this->voices[] = compact('chatId', 'path', 'threadId', 'caption', 'parseMode');
+
+ return ['message_id' => count($this->voices) + 300];
+ }
+
+ public function sendChatAction(string $chatId, string $action = 'typing', ?string $threadId = null): void
+ {
+ $this->chatActions[] = compact('chatId', 'action', 'threadId');
+ }
+
+ public function answerCallbackQuery(string $callbackQueryId, ?string $text = null): void
+ {
+ $this->callbackAnswers[] = compact('callbackQueryId', 'text');
+ }
+
+ public function deleteWebhook(bool $dropPendingUpdates = false): void {}
+}
diff --git a/tests/Unit/Gateway/Telegram/FakeTelegramWorkerHandle.php b/tests/Unit/Gateway/Telegram/FakeTelegramWorkerHandle.php
new file mode 100644
index 0000000..b343721
--- /dev/null
+++ b/tests/Unit/Gateway/Telegram/FakeTelegramWorkerHandle.php
@@ -0,0 +1,36 @@
+pid;
+ }
+
+ public function isRunning(): bool
+ {
+ return $this->running;
+ }
+
+ public function terminate(int $signal = SIGTERM): bool
+ {
+ $this->terminated = true;
+ $this->running = false;
+
+ return true;
+ }
+}
diff --git a/tests/Unit/Gateway/Telegram/FakeTelegramWorkerLauncher.php b/tests/Unit/Gateway/Telegram/FakeTelegramWorkerLauncher.php
new file mode 100644
index 0000000..2f2880a
--- /dev/null
+++ b/tests/Unit/Gateway/Telegram/FakeTelegramWorkerLauncher.php
@@ -0,0 +1,32 @@
+ */
+ public array $launched = [];
+
+ public ?FakeTelegramWorkerHandle $lastHandle = null;
+
+ /** @var (\Closure(GatewayMessageEvent, FakeTelegramWorkerHandle): void)|null */
+ public ?\Closure $onLaunch = null;
+
+ public function launch(GatewayMessageEvent $event): TelegramGatewayWorkerHandleInterface
+ {
+ $this->launched[] = $event;
+ $this->lastHandle = new FakeTelegramWorkerHandle(4242 + count($this->launched));
+
+ if ($this->onLaunch !== null) {
+ ($this->onLaunch)($event, $this->lastHandle);
+ }
+
+ return $this->lastHandle;
+ }
+}
diff --git a/tests/Unit/Gateway/Telegram/TelegramGatewayConfigTest.php b/tests/Unit/Gateway/Telegram/TelegramGatewayConfigTest.php
new file mode 100644
index 0000000..a952fa8
--- /dev/null
+++ b/tests/Unit/Gateway/Telegram/TelegramGatewayConfigTest.php
@@ -0,0 +1,44 @@
+ [
+ 'gateway' => [
+ 'telegram' => [
+ 'enabled' => false,
+ 'token' => null,
+ 'session_mode' => 'thread',
+ ],
+ ],
+ ],
+ ]);
+ $manager = new SettingsManager($config, new SettingsSchema, new YamlConfigStore(new NullLogger), __DIR__.'/../../../../config');
+ $manager->setProjectRoot(null);
+ $manager->setRaw('kosmokrator.gateway.telegram.enabled', true, 'global');
+ $manager->setRaw('kosmokrator.gateway.telegram.token', 'abc123', 'global');
+ $manager->setRaw('kosmokrator.gateway.telegram.session_mode', 'chat_user', 'global');
+ $manager->setRaw('kosmokrator.gateway.telegram.allowed_users', '1,alice', 'global');
+
+ $gateway = TelegramGatewayConfig::fromSettings($manager, $config);
+
+ $this->assertTrue($gateway->enabled);
+ $this->assertSame('abc123', $gateway->token);
+ $this->assertSame('chat_user', $gateway->sessionMode);
+ $this->assertSame(['1', 'alice'], $gateway->allowedUsers);
+ }
+}
diff --git a/tests/Unit/Gateway/Telegram/TelegramGatewayRendererTest.php b/tests/Unit/Gateway/Telegram/TelegramGatewayRendererTest.php
new file mode 100644
index 0000000..1c9773e
--- /dev/null
+++ b/tests/Unit/Gateway/Telegram/TelegramGatewayRendererTest.php
@@ -0,0 +1,196 @@
+connection()->prepare('INSERT INTO sessions (id, project, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
+ ->execute(['sess-1', null, null, 'test/model', date(DATE_ATOM), date(DATE_ATOM)]);
+ $client = new FakeTelegramClient;
+ $renderer = new TelegramGatewayRenderer(
+ client: $client,
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ routeKey: 'telegram:123',
+ sessionId: 'sess-1',
+ chatId: '123',
+ threadId: null,
+ approvalCallback: static fn (): string => 'deny',
+ );
+
+ $renderer->setPhase(AgentPhase::Thinking);
+ $renderer->showNotice('Retrying in 5s (attempt 2)');
+
+ $this->assertCount(1, $client->sent);
+ $this->assertSame('Thinking…', $client->sent[0]['text']);
+ $this->assertCount(2, $client->edited);
+ $this->assertSame('Thinking…', $client->edited[0]['text']);
+ $this->assertSame("Thinking…\n\nRetrying in 5s (attempt 2)", $client->edited[1]['text']);
+ $this->assertNotEmpty($client->chatActions);
+ }
+
+ public function test_ask_tool_permission_sends_inline_buttons(): void
+ {
+ $db = new Database(':memory:');
+ $db->connection()->prepare('INSERT INTO sessions (id, project, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
+ ->execute(['sess-1', null, null, 'test/model', date(DATE_ATOM), date(DATE_ATOM)]);
+ $client = new FakeTelegramClient;
+ $renderer = new TelegramGatewayRenderer(
+ client: $client,
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ routeKey: 'telegram:123',
+ sessionId: 'sess-1',
+ chatId: '123',
+ threadId: null,
+ approvalCallback: static fn (): string => 'deny',
+ );
+
+ $renderer->askToolPermission('bash', ['command' => 'rm -rf /tmp/x']);
+
+ $this->assertCount(1, $client->sent);
+ $this->assertNotNull($client->sent[0]['reply_markup']);
+ $keyboard = $client->sent[0]['reply_markup']['inline_keyboard'] ?? [];
+ $this->assertSame('Approve', $keyboard[0][0]['text'] ?? null);
+ $this->assertSame('Always', $keyboard[0][1]['text'] ?? null);
+ $this->assertSame('Guardian', $keyboard[1][0]['text'] ?? null);
+ $this->assertSame('Prometheus', $keyboard[1][1]['text'] ?? null);
+ $this->assertSame('Deny', $keyboard[2][0]['text'] ?? null);
+ }
+
+ public function test_stream_complete_uses_separate_status_and_answer_messages(): void
+ {
+ $db = new Database(':memory:');
+ $db->connection()->prepare('INSERT INTO sessions (id, project, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
+ ->execute(['sess-1', null, null, 'test/model', date(DATE_ATOM), date(DATE_ATOM)]);
+ $client = new FakeTelegramClient;
+ $renderer = new TelegramGatewayRenderer(
+ client: $client,
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ routeKey: 'telegram:123',
+ sessionId: 'sess-1',
+ chatId: '123',
+ threadId: null,
+ approvalCallback: static fn (): string => 'deny',
+ );
+
+ $path = tempnam(sys_get_temp_dir(), 'kosmo-photo-');
+ $photo = $path.'.png';
+ rename($path, $photo);
+ file_put_contents($photo, 'png');
+
+ try {
+ $renderer->setPhase(AgentPhase::Thinking);
+ $renderer->streamChunk("See attached\nMEDIA:{$photo}");
+ $renderer->streamComplete();
+
+ $this->assertCount(1, $client->photos);
+ $this->assertSame($photo, $client->photos[0]['path']);
+ $this->assertCount(2, $client->sent);
+ $this->assertSame('Thinking…', $client->sent[0]['text']);
+ $this->assertSame('See attached', $client->sent[1]['text']);
+ $this->assertSame('HTML', $client->sent[1]['parse_mode']);
+ $answerEdit = $client->edited[1]['text'] ?? '';
+ $this->assertSame('See attached', $answerEdit);
+ $this->assertSame('HTML', $client->edited[1]['parse_mode']);
+ $this->assertSame('Done', $client->edited[array_key_last($client->edited)]['text']);
+ } finally {
+ @unlink($photo);
+ }
+ }
+
+ public function test_tool_execution_uses_separate_tool_message(): void
+ {
+ $db = new Database(':memory:');
+ $db->connection()->prepare('INSERT INTO sessions (id, project, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
+ ->execute(['sess-1', null, null, 'test/model', date(DATE_ATOM), date(DATE_ATOM)]);
+ $client = new FakeTelegramClient;
+ $renderer = new TelegramGatewayRenderer(
+ client: $client,
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ routeKey: 'telegram:123',
+ sessionId: 'sess-1',
+ chatId: '123',
+ threadId: null,
+ approvalCallback: static fn (): string => 'deny',
+ );
+
+ $renderer->setPhase(AgentPhase::Thinking);
+ $renderer->showToolCall('grep', ['pattern' => 'telegram']);
+ $renderer->showToolExecuting('grep');
+
+ $this->assertCount(2, $client->sent);
+ $this->assertSame('Thinking…', $client->sent[0]['text']);
+ $this->assertStringContainsString('Tool grep', $client->sent[1]['text']);
+ $this->assertSame('HTML', $client->sent[1]['parse_mode']);
+ $this->assertSame('Preparing tool: grep', $client->edited[1]['text']);
+ $this->assertStringContainsString('Running tool grep', $client->edited[2]['text']);
+ }
+
+ public function test_stream_complete_splits_large_messages_into_multiple_chunks(): void
+ {
+ $db = new Database(':memory:');
+ $db->connection()->prepare('INSERT INTO sessions (id, project, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
+ ->execute(['sess-1', null, null, 'test/model', date(DATE_ATOM), date(DATE_ATOM)]);
+ $client = new FakeTelegramClient;
+ $renderer = new TelegramGatewayRenderer(
+ client: $client,
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ routeKey: 'telegram:123',
+ sessionId: 'sess-1',
+ chatId: '123',
+ threadId: null,
+ approvalCallback: static fn (): string => 'deny',
+ );
+
+ $renderer->setPhase(AgentPhase::Thinking);
+ $renderer->streamChunk(str_repeat("alpha beta gamma\n", 350));
+ $renderer->streamComplete();
+
+ $this->assertGreaterThanOrEqual(3, count($client->sent));
+ $this->assertSame('HTML', $client->sent[1]['parse_mode']);
+ $this->assertSame('HTML', $client->sent[2]['parse_mode']);
+ }
+
+ public function test_stream_complete_formats_code_blocks_and_tables_as_html(): void
+ {
+ $db = new Database(':memory:');
+ $db->connection()->prepare('INSERT INTO sessions (id, project, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
+ ->execute(['sess-1', null, null, 'test/model', date(DATE_ATOM), date(DATE_ATOM)]);
+ $client = new FakeTelegramClient;
+ $renderer = new TelegramGatewayRenderer(
+ client: $client,
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ routeKey: 'telegram:123',
+ sessionId: 'sess-1',
+ chatId: '123',
+ threadId: null,
+ approvalCallback: static fn (): string => 'deny',
+ );
+
+ $renderer->setPhase(AgentPhase::Thinking);
+ $renderer->streamChunk("# Heading\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n```php\necho 'hi';\n```");
+ $renderer->streamComplete();
+
+ $payload = $client->sent[1]['text'] ?? '';
+ $this->assertStringContainsString('Heading ', $payload);
+ $this->assertStringContainsString('| A | B |', $payload);
+ $this->assertStringContainsString('echo', $payload);
+ }
+}
diff --git a/tests/Unit/Gateway/Telegram/TelegramGatewayRuntimeTest.php b/tests/Unit/Gateway/Telegram/TelegramGatewayRuntimeTest.php
new file mode 100644
index 0000000..c5b9122
--- /dev/null
+++ b/tests/Unit/Gateway/Telegram/TelegramGatewayRuntimeTest.php
@@ -0,0 +1,495 @@
+registerBotCommands();
+
+ $this->assertSame([
+ ['command' => 'help', 'description' => 'Show gateway help'],
+ ['command' => 'status', 'description' => 'Show linked session status'],
+ ['command' => 'new', 'description' => 'Start a fresh chat session'],
+ ['command' => 'resume', 'description' => 'Resume the linked session'],
+ ['command' => 'approve', 'description' => 'Approve the latest tool request'],
+ ['command' => 'deny', 'description' => 'Deny the latest tool request'],
+ ['command' => 'cancel', 'description' => 'Cancel the active run'],
+ ['command' => 'compact', 'description' => 'Force context compaction'],
+ ['command' => 'edit', 'description' => 'Switch to edit mode'],
+ ['command' => 'plan', 'description' => 'Switch to plan mode'],
+ ['command' => 'ask', 'description' => 'Switch to ask mode'],
+ ['command' => 'guardian', 'description' => 'Switch to Guardian mode'],
+ ['command' => 'argus', 'description' => 'Switch to Argus mode'],
+ ['command' => 'prometheus', 'description' => 'Switch to Prometheus mode'],
+ ['command' => 'memories', 'description' => 'List stored memories'],
+ ['command' => 'sessions', 'description' => 'List recent sessions'],
+ ['command' => 'agents', 'description' => 'Show swarm summary'],
+ ['command' => 'rename', 'description' => 'Rename the current session'],
+ ['command' => 'forget', 'description' => 'Delete a memory by ID'],
+ ], $client->botCommands);
+ }
+
+ public function test_process_updates_handles_help_command_without_running_agent(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $launcher = new FakeTelegramWorkerLauncher;
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: new GatewaySessionStore($db),
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ checkpoints: new GatewayCheckpointStore($db),
+ pendingInputs: new GatewayPendingInputStore($db),
+ log: new NullLogger,
+ launcher: $launcher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 1,
+ 'message' => [
+ 'message_id' => 1,
+ 'text' => '/help',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+
+ $this->assertCount(1, $client->sent);
+ $this->assertStringContainsString('KosmoKrator Telegram gateway', $client->sent[0]['text']);
+ $this->assertSame([], $launcher->launched);
+ }
+
+ public function test_process_updates_launches_worker_for_normal_message(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $launcher = new FakeTelegramWorkerLauncher;
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: new GatewaySessionStore($db),
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ checkpoints: new GatewayCheckpointStore($db),
+ pendingInputs: new GatewayPendingInputStore($db),
+ log: new NullLogger,
+ launcher: $launcher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 1,
+ 'message' => [
+ 'message_id' => 11,
+ 'text' => 'hello there',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+
+ $this->assertCount(1, $launcher->launched);
+ $this->assertSame('hello there', $launcher->launched[0]->text);
+ }
+
+ public function test_status_reports_active_run_details(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $launcher = new FakeTelegramWorkerLauncher;
+ $sessions = new GatewaySessionStore($db);
+ $db->connection()->prepare('INSERT INTO sessions (id, project, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
+ ->execute(['sess-123', null, null, 'test/model', date(DATE_ATOM), date(DATE_ATOM)]);
+ $sessions->save('telegram', 'telegram:123', 'sess-123', '123', null, '5');
+ $checkpoints = new GatewayCheckpointStore($db);
+ $checkpoints->set('telegram', 'last_update_id', '42');
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: $sessions,
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ checkpoints: $checkpoints,
+ pendingInputs: new GatewayPendingInputStore($db),
+ log: new NullLogger,
+ launcher: $launcher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 1,
+ 'message' => [
+ 'message_id' => 11,
+ 'text' => 'hello there',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+ $runtime->processUpdates([[
+ 'update_id' => 2,
+ 'message' => [
+ 'message_id' => 12,
+ 'text' => '/status',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+
+ $this->assertCount(1, $client->sent);
+ $this->assertStringContainsString('Bot: @kosmokrator_bot', $client->sent[0]['text']);
+ $this->assertStringContainsString('Session: sess-123', $client->sent[0]['text']);
+ $this->assertStringContainsString('Running: yes', $client->sent[0]['text']);
+ $this->assertStringContainsString('Worker PID: 4243', $client->sent[0]['text']);
+ $this->assertStringContainsString('Checkpoint: 42', $client->sent[0]['text']);
+ }
+
+ public function test_cancel_terminates_active_worker(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $launcher = new FakeTelegramWorkerLauncher;
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: new GatewaySessionStore($db),
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ checkpoints: new GatewayCheckpointStore($db),
+ pendingInputs: new GatewayPendingInputStore($db),
+ log: new NullLogger,
+ launcher: $launcher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 1,
+ 'message' => [
+ 'message_id' => 11,
+ 'text' => 'hello there',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+ $runtime->processUpdates([[
+ 'update_id' => 2,
+ 'message' => [
+ 'message_id' => 12,
+ 'text' => '/cancel',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+
+ $this->assertNotNull($launcher->lastHandle);
+ $this->assertTrue($launcher->lastHandle->terminated);
+ $this->assertCount(1, $client->sent);
+ $this->assertSame('Cancelling the active run…', $client->sent[0]['text']);
+ }
+
+ public function test_callback_query_approves_pending_request(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $approvals = new GatewayApprovalStore($db);
+ $db->connection()->prepare('INSERT INTO sessions (id, project, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
+ ->execute(['sess-1', null, null, 'test/model', date(DATE_ATOM), date(DATE_ATOM)]);
+ $approval = $approvals->createPending('telegram', 'telegram:123', 'sess-1', 'bash', ['command' => 'ls'], '123');
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: new GatewaySessionStore($db),
+ messages: new GatewayMessageStore($db),
+ approvals: $approvals,
+ checkpoints: new GatewayCheckpointStore($db),
+ pendingInputs: new GatewayPendingInputStore($db),
+ log: new NullLogger,
+ launcher: new FakeTelegramWorkerLauncher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 3,
+ 'callback_query' => [
+ 'id' => 'cbq-1',
+ 'data' => 'ga:allow:'.$approval->id,
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ 'message' => [
+ 'message_id' => 99,
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ ],
+ ],
+ ]]);
+
+ $resolved = $approvals->find($approval->id);
+ $this->assertSame('approved', $resolved?->status);
+ $this->assertCount(1, $client->callbackAnswers);
+ $this->assertSame('Approved.', $client->callbackAnswers[0]['text']);
+ $this->assertCount(1, $client->edited);
+ $this->assertSame('Approved bash.', $client->edited[0]['text']);
+ $this->assertSame('HTML', $client->edited[0]['parse_mode']);
+ }
+
+ public function test_callback_query_can_switch_to_prometheus_for_pending_request(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $approvals = new GatewayApprovalStore($db);
+ $db->connection()->prepare('INSERT INTO sessions (id, project, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
+ ->execute(['sess-1', null, null, 'test/model', date(DATE_ATOM), date(DATE_ATOM)]);
+ $approval = $approvals->createPending('telegram', 'telegram:123', 'sess-1', 'bash', ['command' => 'ls'], '123');
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: new GatewaySessionStore($db),
+ messages: new GatewayMessageStore($db),
+ approvals: $approvals,
+ checkpoints: new GatewayCheckpointStore($db),
+ pendingInputs: new GatewayPendingInputStore($db),
+ log: new NullLogger,
+ launcher: new FakeTelegramWorkerLauncher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 3,
+ 'callback_query' => [
+ 'id' => 'cbq-1',
+ 'data' => 'ga:prometheus:'.$approval->id,
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ 'message' => [
+ 'message_id' => 99,
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ ],
+ ],
+ ]]);
+
+ $resolved = $approvals->find($approval->id);
+ $this->assertSame('prometheus', $resolved?->status);
+ $this->assertSame('Switched To Prometheus.', $client->callbackAnswers[0]['text']);
+ }
+
+ public function test_status_includes_inline_control_keyboard(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: new GatewaySessionStore($db),
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ checkpoints: new GatewayCheckpointStore($db),
+ pendingInputs: new GatewayPendingInputStore($db),
+ log: new NullLogger,
+ launcher: new FakeTelegramWorkerLauncher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 2,
+ 'message' => [
+ 'message_id' => 12,
+ 'text' => '/status',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+
+ $keyboard = $client->sent[0]['reply_markup']['inline_keyboard'] ?? [];
+ $this->assertSame('Edit', $keyboard[0][0]['text'] ?? null);
+ $this->assertSame('Prometheus', $keyboard[1][2]['text'] ?? null);
+ $this->assertSame('Compact', $keyboard[2][0]['text'] ?? null);
+ }
+
+ public function test_control_callback_launches_slash_command_as_new_turn(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $launcher = new FakeTelegramWorkerLauncher;
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: new GatewaySessionStore($db),
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ checkpoints: new GatewayCheckpointStore($db),
+ pendingInputs: new GatewayPendingInputStore($db),
+ log: new NullLogger,
+ launcher: $launcher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 3,
+ 'callback_query' => [
+ 'id' => 'cbq-1',
+ 'data' => 'gc:cmd:edit',
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ 'message' => [
+ 'message_id' => 99,
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ ],
+ ],
+ ]]);
+
+ $this->assertCount(1, $launcher->launched);
+ $this->assertSame('/edit', $launcher->launched[0]->text);
+ $this->assertSame('Working…', $client->callbackAnswers[0]['text']);
+ }
+
+ public function test_second_message_is_queued_and_runs_after_active_route_finishes(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $launcher = new FakeTelegramWorkerLauncher;
+ $pendingInputs = new GatewayPendingInputStore($db);
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: new GatewaySessionStore($db),
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ checkpoints: new GatewayCheckpointStore($db),
+ pendingInputs: $pendingInputs,
+ log: new NullLogger,
+ launcher: $launcher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 1,
+ 'message' => [
+ 'message_id' => 11,
+ 'text' => 'first',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+ $runtime->processUpdates([[
+ 'update_id' => 2,
+ 'message' => [
+ 'message_id' => 12,
+ 'text' => 'second',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+
+ $this->assertCount(1, $launcher->launched);
+ $this->assertCount(1, $client->sent);
+ $this->assertSame('Queued for the next turn in this chat.', $client->sent[0]['text']);
+ $this->assertSame(1, $pendingInputs->count('telegram', 'telegram:123'));
+
+ $this->assertNotNull($launcher->lastHandle);
+ $launcher->lastHandle->running = false;
+
+ $runtime->processUpdates([]);
+
+ $this->assertCount(2, $launcher->launched);
+ $this->assertSame('second', $launcher->launched[1]->text);
+ $this->assertSame(0, $pendingInputs->count('telegram', 'telegram:123'));
+ }
+
+ public function test_new_clears_queued_inputs_for_route(): void
+ {
+ $container = new Container;
+ $db = new Database(':memory:');
+ $client = new FakeTelegramClient;
+ $launcher = new FakeTelegramWorkerLauncher;
+ $pendingInputs = new GatewayPendingInputStore($db);
+ $runtime = new TelegramGatewayRuntime(
+ container: $container,
+ client: $client,
+ config: new TelegramGatewayConfig(true, 'token', 'thread', [], [], true, [], 20),
+ sessionLinks: new GatewaySessionStore($db),
+ messages: new GatewayMessageStore($db),
+ approvals: new GatewayApprovalStore($db),
+ checkpoints: new GatewayCheckpointStore($db),
+ pendingInputs: $pendingInputs,
+ log: new NullLogger,
+ launcher: $launcher,
+ );
+ $runtime->setBotUsername('kosmokrator_bot');
+
+ $runtime->processUpdates([[
+ 'update_id' => 1,
+ 'message' => [
+ 'message_id' => 11,
+ 'text' => 'first',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+ $runtime->processUpdates([[
+ 'update_id' => 2,
+ 'message' => [
+ 'message_id' => 12,
+ 'text' => 'second',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+ $runtime->processUpdates([[
+ 'update_id' => 3,
+ 'message' => [
+ 'message_id' => 13,
+ 'text' => '/new',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 5, 'username' => 'rutger'],
+ ],
+ ]]);
+
+ $this->assertSame(0, $pendingInputs->count('telegram', 'telegram:123'));
+ $this->assertCount(2, $client->sent);
+ $this->assertSame('Started a fresh session for this chat. Your next message will create a new Kosmo session.', $client->sent[1]['text']);
+ }
+}
diff --git a/tests/Unit/Gateway/Telegram/TelegramPollerLockTest.php b/tests/Unit/Gateway/Telegram/TelegramPollerLockTest.php
new file mode 100644
index 0000000..2cac8c7
--- /dev/null
+++ b/tests/Unit/Gateway/Telegram/TelegramPollerLockTest.php
@@ -0,0 +1,25 @@
+expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Another Telegram gateway worker is already polling this bot token.');
+
+ try {
+ TelegramPollerLock::acquire('same-token');
+ } finally {
+ unset($lock);
+ }
+ }
+}
diff --git a/tests/Unit/Gateway/Telegram/TelegramSessionRouterTest.php b/tests/Unit/Gateway/Telegram/TelegramSessionRouterTest.php
new file mode 100644
index 0000000..e7a74ef
--- /dev/null
+++ b/tests/Unit/Gateway/Telegram/TelegramSessionRouterTest.php
@@ -0,0 +1,36 @@
+assertSame('telegram:-100:77', $router->routeKeyFor($event));
+ }
+
+ public function test_chat_user_mode_isolates_users_in_shared_chat(): void
+ {
+ $router = new TelegramSessionRouter('chat_user');
+ $event = new GatewayMessageEvent(1, 'telegram', '-100', null, 'telegram:-100', 'hi', '5', 'alice', false, false, false, 10);
+
+ $this->assertSame('telegram:-100:user:5', $router->routeKeyFor($event));
+ }
+
+ public function test_private_chats_always_use_chat_scope(): void
+ {
+ $router = new TelegramSessionRouter('thread_user');
+ $event = new GatewayMessageEvent(1, 'telegram', '123', null, 'telegram:123', 'hi', '5', 'alice', true, false, false, 10);
+
+ $this->assertSame('telegram:123', $router->routeKeyFor($event));
+ }
+}
diff --git a/tests/Unit/Gateway/Telegram/TelegramUpdateNormalizerTest.php b/tests/Unit/Gateway/Telegram/TelegramUpdateNormalizerTest.php
new file mode 100644
index 0000000..89476f2
--- /dev/null
+++ b/tests/Unit/Gateway/Telegram/TelegramUpdateNormalizerTest.php
@@ -0,0 +1,99 @@
+normalize([
+ 'update_id' => 42,
+ 'message' => [
+ 'message_id' => 5,
+ 'text' => 'hello',
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ 'from' => ['id' => 99, 'username' => 'rutger'],
+ ],
+ ]);
+
+ $this->assertNotNull($event);
+ $this->assertSame('telegram:123', $event->routeKey);
+ $this->assertTrue($event->isPrivate);
+ $this->assertFalse($event->mentionsBot);
+ }
+
+ public function test_detects_mentions_and_thread_keys(): void
+ {
+ $normalizer = new TelegramUpdateNormalizer('kosmokrator_bot');
+
+ $event = $normalizer->normalize([
+ 'update_id' => 43,
+ 'message' => [
+ 'message_id' => 6,
+ 'message_thread_id' => 777,
+ 'text' => '@kosmokrator_bot check this',
+ 'chat' => ['id' => -1001, 'type' => 'supergroup'],
+ 'from' => ['id' => 100, 'username' => 'alice'],
+ 'entities' => [
+ ['type' => 'mention', 'offset' => 0, 'length' => 17],
+ ],
+ ],
+ ]);
+
+ $this->assertNotNull($event);
+ $this->assertSame('telegram:-1001:777', $event->routeKey);
+ $this->assertTrue($event->mentionsBot);
+ $this->assertFalse($event->isPrivate);
+ }
+
+ public function test_detects_reply_to_bot(): void
+ {
+ $normalizer = new TelegramUpdateNormalizer('kosmokrator_bot');
+
+ $event = $normalizer->normalize([
+ 'update_id' => 44,
+ 'message' => [
+ 'message_id' => 7,
+ 'text' => 'follow up',
+ 'chat' => ['id' => -2001, 'type' => 'group'],
+ 'from' => ['id' => 101, 'username' => 'bob'],
+ 'reply_to_message' => [
+ 'from' => ['username' => 'kosmokrator_bot'],
+ ],
+ ],
+ ]);
+
+ $this->assertNotNull($event);
+ $this->assertTrue($event->isReplyToBot);
+ }
+
+ public function test_normalizes_callback_query_events(): void
+ {
+ $normalizer = new TelegramUpdateNormalizer('kosmokrator_bot');
+
+ $event = $normalizer->normalize([
+ 'update_id' => 45,
+ 'callback_query' => [
+ 'id' => 'cbq-1',
+ 'data' => 'ga:approve:12',
+ 'from' => ['id' => 101, 'username' => 'bob'],
+ 'message' => [
+ 'message_id' => 8,
+ 'chat' => ['id' => 123, 'type' => 'private'],
+ ],
+ ],
+ ]);
+
+ $this->assertNotNull($event);
+ $this->assertSame('ga:approve:12', $event->text);
+ $this->assertSame('cbq-1', $event->callbackQueryId);
+ $this->assertSame(8, $event->messageId);
+ }
+}
diff --git a/tests/Unit/LLM/Codex/CodexAuthFlowTest.php b/tests/Unit/LLM/Codex/CodexAuthFlowTest.php
index 28603b9..528452e 100644
--- a/tests/Unit/LLM/Codex/CodexAuthFlowTest.php
+++ b/tests/Unit/LLM/Codex/CodexAuthFlowTest.php
@@ -9,9 +9,11 @@
use OpenCompany\PrismCodex\CodexOAuthService;
use OpenCompany\PrismCodex\Contracts\CodexTokenStore;
use OpenCompany\PrismCodex\ValueObjects\CodexToken;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
final class CodexAuthFlowTest extends TestCase
{
private CodexOAuthService&MockObject $oauth;
diff --git a/tests/Unit/Session/DatabaseTest.php b/tests/Unit/Session/DatabaseTest.php
index 4eae703..7424e25 100644
--- a/tests/Unit/Session/DatabaseTest.php
+++ b/tests/Unit/Session/DatabaseTest.php
@@ -23,6 +23,11 @@ public function test_creates_schema_on_fresh_database(): void
$this->assertContains('messages', $tableNames);
$this->assertContains('messages_fts', $tableNames);
$this->assertContains('memories', $tableNames);
+ $this->assertContains('gateway_sessions', $tableNames);
+ $this->assertContains('gateway_messages', $tableNames);
+ $this->assertContains('gateway_approvals', $tableNames);
+ $this->assertContains('gateway_checkpoints', $tableNames);
+ $this->assertContains('gateway_pending_inputs', $tableNames);
$this->assertContains('schema_version', $tableNames);
}
@@ -33,7 +38,7 @@ public function test_schema_version_is_set(): void
$version = $pdo->query('SELECT version FROM schema_version LIMIT 1')->fetch();
$this->assertNotFalse($version);
- $this->assertEquals(5, $version['version']);
+ $this->assertEquals(7, $version['version']);
}
public function test_idempotent_schema_creation(): void
@@ -43,7 +48,7 @@ public function test_idempotent_schema_creation(): void
// Creating a second Database on the same connection shouldn't fail
$version = $pdo->query('SELECT version FROM schema_version LIMIT 1')->fetch();
- $this->assertEquals(5, $version['version']);
+ $this->assertEquals(7, $version['version']);
}
public function test_foreign_keys_enabled(): void
diff --git a/tests/Unit/Session/MessageRepositoryTest.php b/tests/Unit/Session/MessageRepositoryTest.php
index 8f2a443..3920e57 100644
--- a/tests/Unit/Session/MessageRepositoryTest.php
+++ b/tests/Unit/Session/MessageRepositoryTest.php
@@ -244,4 +244,105 @@ public function test_search_project_history_excludes_compacted_messages(): void
$this->assertSame([], $results);
}
+
+ public function test_search_grouped_returns_per_session_results(): void
+ {
+ $sessions = new SessionRepository($this->db);
+ $s1 = $sessions->create('/project', 'model-1');
+ $s2 = $sessions->create('/project', 'model-1');
+
+ $this->messages->append($s1, 'user', 'How does JWT auth work?');
+ $this->messages->append($s1, 'assistant', 'JWT tokens are signed credentials');
+ $this->messages->append($s2, 'user', 'Fix the JWT refresh bug');
+ $this->messages->append($s2, 'assistant', 'The JWT refresh endpoint was broken');
+
+ $results = $this->messages->searchProjectHistoryGrouped('/project', 'JWT', $this->sessionId, 5);
+
+ $this->assertCount(2, $results);
+ // Each result has required fields
+ foreach ($results as $r) {
+ $this->assertArrayHasKey('session_id', $r);
+ $this->assertArrayHasKey('match_count', $r);
+ $this->assertArrayHasKey('best_match', $r);
+ $this->assertArrayHasKey('context', $r);
+ $this->assertGreaterThanOrEqual(1, $r['match_count']);
+ }
+ }
+
+ public function test_search_grouped_excludes_current_session(): void
+ {
+ $this->messages->append($this->sessionId, 'user', 'JWT auth in current session');
+
+ $results = $this->messages->searchProjectHistoryGrouped('/project', 'JWT', $this->sessionId, 5);
+
+ $this->assertSame([], $results);
+ }
+
+ public function test_search_grouped_limits_unique_sessions(): void
+ {
+ $sessions = new SessionRepository($this->db);
+ for ($i = 0; $i < 5; $i++) {
+ $sid = $sessions->create('/project', 'model-1');
+ $this->messages->append($sid, 'user', "Discussion about Redis caching #{$i}");
+ }
+
+ $results = $this->messages->searchProjectHistoryGrouped('/project', 'Redis', $this->sessionId, 2);
+
+ $this->assertCount(2, $results);
+ }
+
+ public function test_search_grouped_includes_context_messages(): void
+ {
+ $sessions = new SessionRepository($this->db);
+ $sid = $sessions->create('/project', 'model-1');
+
+ $this->messages->append($sid, 'user', 'Tell me about Docker');
+ $this->messages->append($sid, 'assistant', 'Docker uses containers for isolation');
+ $this->messages->append($sid, 'user', 'How does Docker networking work?');
+
+ $results = $this->messages->searchProjectHistoryGrouped('/project', 'Docker networking', $this->sessionId, 5);
+
+ $this->assertCount(1, $results);
+ // Context should contain surrounding messages
+ $this->assertNotEmpty($results[0]['context']);
+ }
+
+ public function test_load_transcript_returns_ordered_messages(): void
+ {
+ $this->messages->append($this->sessionId, 'user', 'Hello');
+ $this->messages->append($this->sessionId, 'assistant', 'Hi there');
+ $this->messages->append($this->sessionId, 'user', 'Help me');
+
+ $transcript = $this->messages->loadTranscript($this->sessionId);
+
+ $this->assertCount(3, $transcript);
+ $this->assertSame('user', $transcript[0]['role']);
+ $this->assertSame('Hello', $transcript[0]['content']);
+ $this->assertSame('assistant', $transcript[1]['role']);
+ $this->assertSame('user', $transcript[2]['role']);
+ }
+
+ public function test_load_transcript_respects_limit(): void
+ {
+ $this->messages->append($this->sessionId, 'user', 'First');
+ $this->messages->append($this->sessionId, 'assistant', 'Second');
+ $this->messages->append($this->sessionId, 'user', 'Third');
+
+ $transcript = $this->messages->loadTranscript($this->sessionId, 2);
+
+ $this->assertCount(2, $transcript);
+ $this->assertSame('First', $transcript[0]['content']);
+ }
+
+ public function test_load_transcript_excludes_compacted(): void
+ {
+ $id1 = $this->messages->append($this->sessionId, 'user', 'Old message');
+ $this->messages->append($this->sessionId, 'assistant', 'Current message');
+ $this->messages->markCompactedIds([$id1]);
+
+ $transcript = $this->messages->loadTranscript($this->sessionId);
+
+ $this->assertCount(1, $transcript);
+ $this->assertSame('Current message', $transcript[0]['content']);
+ }
}
diff --git a/tests/Unit/Session/Tool/MemorySaveToolTest.php b/tests/Unit/Session/Tool/MemorySaveToolTest.php
index 1141afb..eb7cef0 100644
--- a/tests/Unit/Session/Tool/MemorySaveToolTest.php
+++ b/tests/Unit/Session/Tool/MemorySaveToolTest.php
@@ -6,8 +6,10 @@
use Kosmokrator\Session\SessionManager;
use Kosmokrator\Session\Tool\MemorySaveTool;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class MemorySaveToolTest extends TestCase
{
private SessionManager $session;
diff --git a/tests/Unit/Session/Tool/MemorySearchToolTest.php b/tests/Unit/Session/Tool/MemorySearchToolTest.php
index c35f1d4..aa81fe7 100644
--- a/tests/Unit/Session/Tool/MemorySearchToolTest.php
+++ b/tests/Unit/Session/Tool/MemorySearchToolTest.php
@@ -6,8 +6,10 @@
use Kosmokrator\Session\SessionManager;
use Kosmokrator\Session\Tool\MemorySearchTool;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class MemorySearchToolTest extends TestCase
{
private SessionManager $session;
diff --git a/tests/Unit/Session/Tool/SessionReadToolTest.php b/tests/Unit/Session/Tool/SessionReadToolTest.php
new file mode 100644
index 0000000..e328faf
--- /dev/null
+++ b/tests/Unit/Session/Tool/SessionReadToolTest.php
@@ -0,0 +1,144 @@
+session = $this->createMock(SessionManager::class);
+ $this->tool = new SessionReadTool($this->session);
+ }
+
+ public function test_name(): void
+ {
+ $this->assertSame('session_read', $this->tool->name());
+ }
+
+ public function test_session_id_is_required(): void
+ {
+ $this->assertSame(['session_id'], $this->tool->requiredParameters());
+ }
+
+ public function test_returns_formatted_transcript(): void
+ {
+ $this->session->expects($this->once())
+ ->method('findSession')
+ ->with('abcd1234')
+ ->willReturn([
+ 'id' => 'abcd1234-full-uuid',
+ 'title' => 'Auth refactor',
+ 'model' => 'claude-3',
+ 'created_at' => '2026-04-09T10:00:00+00:00',
+ ]);
+
+ $this->session->expects($this->once())
+ ->method('loadSessionTranscript')
+ ->with('abcd1234-full-uuid', 50)
+ ->willReturn([
+ ['role' => 'user', 'content' => 'Fix the auth bug', 'tool_calls' => null, 'created_at' => '2026-04-09T10:00:00'],
+ ['role' => 'assistant', 'content' => 'I found the issue in AuthController', 'tool_calls' => null, 'created_at' => '2026-04-09T10:01:00'],
+ ]);
+
+ $result = $this->tool->execute(['session_id' => 'abcd1234']);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('Auth refactor', $result->output);
+ $this->assertStringContainsString('claude-3', $result->output);
+ $this->assertStringContainsString('[USER]: Fix the auth bug', $result->output);
+ $this->assertStringContainsString('[ASSISTANT]: I found the issue', $result->output);
+ }
+
+ public function test_session_not_found(): void
+ {
+ $this->session->expects($this->once())
+ ->method('findSession')
+ ->willReturn(null);
+
+ $result = $this->tool->execute(['session_id' => 'nonexistent']);
+
+ $this->assertFalse($result->success);
+ $this->assertStringContainsString('No session found', $result->output);
+ }
+
+ public function test_empty_session_id_returns_error(): void
+ {
+ $this->session->expects($this->never())->method('findSession');
+
+ $result = $this->tool->execute(['session_id' => ' ']);
+
+ $this->assertFalse($result->success);
+ $this->assertStringContainsString('session_id is required', $result->output);
+ }
+
+ public function test_respects_limit_parameter(): void
+ {
+ $this->session->method('findSession')->willReturn([
+ 'id' => 'sid', 'title' => 'Test', 'model' => 'm', 'created_at' => '2026-01-01',
+ ]);
+ $this->session->expects($this->once())
+ ->method('loadSessionTranscript')
+ ->with('sid', 10)
+ ->willReturn([]);
+
+ $this->tool->execute(['session_id' => 'sid', 'limit' => 10]);
+ }
+
+ public function test_limit_bounded_to_200(): void
+ {
+ $this->session->method('findSession')->willReturn([
+ 'id' => 'sid', 'title' => 'Test', 'model' => 'm', 'created_at' => '2026-01-01',
+ ]);
+ $this->session->expects($this->once())
+ ->method('loadSessionTranscript')
+ ->with('sid', 200)
+ ->willReturn([]);
+
+ $this->tool->execute(['session_id' => 'sid', 'limit' => 999]);
+ }
+
+ public function test_shows_tool_calls_when_no_content(): void
+ {
+ $this->session->method('findSession')->willReturn([
+ 'id' => 'sid', 'title' => 'Test', 'model' => 'm', 'created_at' => '2026-01-01',
+ ]);
+ $this->session->method('loadSessionTranscript')->willReturn([
+ [
+ 'role' => 'assistant',
+ 'content' => '',
+ 'tool_calls' => json_encode([['name' => 'file_read'], ['name' => 'grep']]),
+ 'created_at' => '2026-01-01',
+ ],
+ ]);
+
+ $result = $this->tool->execute(['session_id' => 'sid']);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('[Called: file_read, grep]', $result->output);
+ }
+
+ public function test_empty_session_shows_message(): void
+ {
+ $this->session->method('findSession')->willReturn([
+ 'id' => 'sid', 'title' => 'Empty Session', 'model' => 'm', 'created_at' => '2026-01-01',
+ ]);
+ $this->session->method('loadSessionTranscript')->willReturn([]);
+
+ $result = $this->tool->execute(['session_id' => 'sid']);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('exists but has no messages', $result->output);
+ }
+}
diff --git a/tests/Unit/Session/Tool/SessionSearchToolTest.php b/tests/Unit/Session/Tool/SessionSearchToolTest.php
index 3015a2f..efa6d39 100644
--- a/tests/Unit/Session/Tool/SessionSearchToolTest.php
+++ b/tests/Unit/Session/Tool/SessionSearchToolTest.php
@@ -6,8 +6,10 @@
use Kosmokrator\Session\SessionManager;
use Kosmokrator\Session\Tool\SessionSearchTool;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class SessionSearchToolTest extends TestCase
{
private SessionManager $session;
@@ -25,47 +27,111 @@ public function test_name(): void
$this->assertSame('session_search', $this->tool->name());
}
- public function test_query_is_required(): void
+ public function test_no_required_parameters(): void
{
- $this->assertSame(['query'], $this->tool->requiredParameters());
+ $this->assertSame([], $this->tool->requiredParameters());
}
- public function test_search_returns_formatted_results(): void
+ public function test_browse_mode_returns_recent_sessions(): void
{
$this->session->expects($this->once())
- ->method('searchSessionHistory')
- ->with('jwt auth', 8)
+ ->method('listSessions')
+ ->with(5)
->willReturn([
- ['session_id' => 'sess1', 'title' => 'Auth session', 'role' => 'assistant', 'content' => 'We switched to JWT auth', 'updated_at' => '2026-04-09T10:00:00+00:00'],
+ [
+ 'id' => 'abcd1234-0000-0000-0000-000000000000',
+ 'title' => 'Auth refactor',
+ 'updated_at' => '1712000000.000000',
+ 'message_count' => 12,
+ 'last_user_message' => 'Fix the JWT token refresh',
+ ],
+ ]);
+
+ $result = $this->tool->execute([]);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('Recent sessions', $result->output);
+ $this->assertStringContainsString('[abcd1234]', $result->output);
+ $this->assertStringContainsString('Auth refactor', $result->output);
+ $this->assertStringContainsString('12 msgs', $result->output);
+ $this->assertStringContainsString('Fix the JWT token refresh', $result->output);
+ }
+
+ public function test_browse_mode_empty_project(): void
+ {
+ $this->session->expects($this->once())
+ ->method('listSessions')
+ ->willReturn([]);
+
+ $result = $this->tool->execute([]);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('No prior sessions', $result->output);
+ }
+
+ public function test_search_mode_returns_grouped_results(): void
+ {
+ $this->session->expects($this->once())
+ ->method('searchSessionHistoryGrouped')
+ ->with('jwt auth', 5)
+ ->willReturn([
+ [
+ 'session_id' => 'sess1234-0000-0000-0000-000000000000',
+ 'title' => 'Auth session',
+ 'updated_at' => '2026-04-09T10:00:00+00:00',
+ 'match_count' => 3,
+ 'best_match' => [
+ 'role' => 'assistant',
+ 'content' => 'We switched to JWT auth with refresh tokens',
+ 'created_at' => '2026-04-09T10:05:00+00:00',
+ ],
+ 'context' => [
+ ['role' => 'user', 'content' => 'How should we handle auth?'],
+ ],
+ ],
]);
$result = $this->tool->execute(['query' => 'jwt auth']);
$this->assertTrue($result->success);
- $this->assertStringContainsString('Found 1 session history matches', $result->output);
- $this->assertStringContainsString('Auth session (2026-04-09) [assistant]: We switched to JWT auth', $result->output);
+ $this->assertStringContainsString('1 session(s)', $result->output);
+ $this->assertStringContainsString('[sess1234]', $result->output);
+ $this->assertStringContainsString('Auth session', $result->output);
+ $this->assertStringContainsString('3 matches', $result->output);
+ $this->assertStringContainsString('JWT auth with refresh tokens', $result->output);
+ $this->assertStringContainsString('[USER]', $result->output);
}
- public function test_search_uses_bounded_limit(): void
+ public function test_search_mode_no_results(): void
{
$this->session->expects($this->once())
- ->method('searchSessionHistory')
- ->with('jwt', 20)
+ ->method('searchSessionHistoryGrouped')
->willReturn([]);
- $result = $this->tool->execute(['query' => 'jwt', 'limit' => 99]);
+ $result = $this->tool->execute(['query' => 'nonexistent']);
$this->assertTrue($result->success);
- $this->assertStringContainsString('No session history matches found.', $result->output);
+ $this->assertStringContainsString('No session history matches', $result->output);
}
- public function test_empty_query_returns_error(): void
+ public function test_search_uses_bounded_limit(): void
{
- $this->session->expects($this->never())->method('searchSessionHistory');
+ $this->session->expects($this->once())
+ ->method('searchSessionHistoryGrouped')
+ ->with('jwt', 10)
+ ->willReturn([]);
+
+ $this->tool->execute(['query' => 'jwt', 'limit' => 99]);
+ }
- $result = $this->tool->execute(['query' => ' ']);
+ public function test_empty_query_triggers_browse_mode(): void
+ {
+ $this->session->expects($this->once())
+ ->method('listSessions')
+ ->willReturn([]);
+ $this->session->expects($this->never())
+ ->method('searchSessionHistoryGrouped');
- $this->assertFalse($result->success);
- $this->assertStringContainsString('Query is required.', $result->output);
+ $this->tool->execute(['query' => ' ']);
}
}
diff --git a/tests/Unit/Settings/SettingsSchemaTest.php b/tests/Unit/Settings/SettingsSchemaTest.php
index 65948ae..a2e01e8 100644
--- a/tests/Unit/Settings/SettingsSchemaTest.php
+++ b/tests/Unit/Settings/SettingsSchemaTest.php
@@ -77,6 +77,7 @@ public function test_categories_returns_expected_list(): void
'context_memory',
'agent',
'permissions',
+ 'gateway',
'integrations',
'subagents',
'advanced',
diff --git a/tests/Unit/Skill/SkillDispatcherTest.php b/tests/Unit/Skill/SkillDispatcherTest.php
index ca2c846..1d06b3e 100644
--- a/tests/Unit/Skill/SkillDispatcherTest.php
+++ b/tests/Unit/Skill/SkillDispatcherTest.php
@@ -8,8 +8,10 @@
use Kosmokrator\Skill\SkillLoader;
use Kosmokrator\Skill\SkillRegistry;
use Kosmokrator\UI\UIManager;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class SkillDispatcherTest extends TestCase
{
private string $tmpDir;
diff --git a/tests/Unit/Tool/AskChoiceToolTest.php b/tests/Unit/Tool/AskChoiceToolTest.php
index 7861163..635f750 100644
--- a/tests/Unit/Tool/AskChoiceToolTest.php
+++ b/tests/Unit/Tool/AskChoiceToolTest.php
@@ -6,8 +6,10 @@
use Kosmokrator\Tool\AskChoiceTool;
use Kosmokrator\UI\RendererInterface;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class AskChoiceToolTest extends TestCase
{
public function test_execute_passes_recommended_choice_metadata_to_renderer(): void
diff --git a/tests/Unit/Tool/AskUserToolTest.php b/tests/Unit/Tool/AskUserToolTest.php
index e983b48..b552a2a 100644
--- a/tests/Unit/Tool/AskUserToolTest.php
+++ b/tests/Unit/Tool/AskUserToolTest.php
@@ -6,8 +6,10 @@
use Kosmokrator\Tool\AskUserTool;
use Kosmokrator\UI\RendererInterface;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class AskUserToolTest extends TestCase
{
private RendererInterface $ui;
diff --git a/tests/Unit/Tool/ToolRegistryScopedTest.php b/tests/Unit/Tool/ToolRegistryScopedTest.php
index 112e759..b74d7d8 100644
--- a/tests/Unit/Tool/ToolRegistryScopedTest.php
+++ b/tests/Unit/Tool/ToolRegistryScopedTest.php
@@ -12,6 +12,7 @@
use Kosmokrator\Session\SettingsRepository;
use Kosmokrator\Session\Tool\MemorySaveTool;
use Kosmokrator\Session\Tool\MemorySearchTool;
+use Kosmokrator\Session\Tool\SessionReadTool;
use Kosmokrator\Session\Tool\SessionSearchTool;
use Kosmokrator\Tool\Coding\ApplyPatchTool;
use Kosmokrator\Tool\Coding\BashTool;
@@ -63,6 +64,7 @@ protected function setUp(): void
$this->registry->register(new MemorySaveTool($sessionManager));
$this->registry->register(new MemorySearchTool($sessionManager));
$this->registry->register(new SessionSearchTool($sessionManager));
+ $this->registry->register(new SessionReadTool($sessionManager));
// Simulate root SubagentTool in registry
$this->orchestrator = new SubagentOrchestrator(new NullLogger, 3);
$rootCtx = new AgentContext(AgentType::General, 0, 3, $this->orchestrator, 'root', '');
diff --git a/tests/Unit/Tool/Web/WebFetchToolTest.php b/tests/Unit/Tool/Web/WebFetchToolTest.php
new file mode 100644
index 0000000..96eed25
--- /dev/null
+++ b/tests/Unit/Tool/Web/WebFetchToolTest.php
@@ -0,0 +1,216 @@
+makeTool($this->provider());
+
+ $result = $tool->execute([
+ 'url' => 'https://example.com/docs',
+ 'mode' => 'metadata',
+ ]);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('Metadata:', $result->output);
+ $this->assertStringContainsString('title: Example Docs', $result->output);
+ $this->assertStringNotContainsString('Token auth details', $result->output);
+ }
+
+ public function test_section_mode_returns_selected_section(): void
+ {
+ $tool = $this->makeTool($this->provider());
+
+ $result = $tool->execute([
+ 'url' => 'https://example.com/docs',
+ 'mode' => 'section',
+ 'section_id' => 'authentication',
+ ]);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('Token auth details', $result->output);
+ $this->assertStringNotContainsString('Error model details', $result->output);
+ }
+
+ public function test_chunk_mode_continues_from_next_chunk_token(): void
+ {
+ $tool = $this->makeTool($this->provider(str_repeat('A', 8000)));
+
+ $first = $tool->execute([
+ 'url' => 'https://example.com/docs',
+ 'mode' => 'main',
+ 'max_chars' => 3000,
+ ]);
+
+ $this->assertTrue($first->success);
+ preg_match('/Next chunk token: ([A-Za-z0-9\-_]+)/', $first->output, $matches);
+ $this->assertArrayHasKey(1, $matches);
+
+ $second = $tool->execute([
+ 'url' => 'https://example.com/docs',
+ 'mode' => 'chunk',
+ 'chunk_token' => $matches[1],
+ 'max_chars' => 3000,
+ ]);
+
+ $this->assertTrue($second->success);
+ $this->assertStringContainsString('Content:', $second->output);
+ }
+
+ public function test_provider_parameter_only_lists_available_fetch_providers(): void
+ {
+ $available = $this->provider();
+ $unavailable = new class implements WebFetchProvider
+ {
+ public function id(): string
+ {
+ return 'firecrawl';
+ }
+
+ public function isAvailable(): bool
+ {
+ return false;
+ }
+
+ public function fetch(WebFetchRequest $request): WebFetchResponse
+ {
+ throw new \RuntimeException('not used');
+ }
+ };
+
+ $tool = $this->makeToolSet([$available, $unavailable]);
+
+ $providerParameter = $tool->parameters()['provider'];
+
+ $this->assertArrayHasKey('options', $providerParameter);
+ /** @var array{type: string, description: string, options: list} $providerParameter */
+ $this->assertSame(['direct'], $providerParameter['options']);
+ }
+
+ public function test_text_format_returns_plain_text_not_markdown(): void
+ {
+ $tool = $this->makeTool($this->provider("## Authentication\n\nUse [docs](https://example.com/docs) and `TOKEN` auth."));
+
+ $result = $tool->execute([
+ 'url' => 'https://example.com/docs',
+ 'mode' => 'main',
+ 'format' => 'text',
+ ]);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('Use docs and TOKEN auth.', $result->output);
+ $this->assertStringNotContainsString('[docs](https://example.com/docs)', $result->output);
+ $this->assertStringNotContainsString('## Authentication', $result->output);
+ }
+
+ public function test_max_chars_respects_small_values(): void
+ {
+ $tool = $this->makeTool($this->provider(str_repeat('A', 500)));
+
+ $result = $tool->execute([
+ 'url' => 'https://example.com/docs',
+ 'mode' => 'main',
+ 'max_chars' => 50,
+ ]);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('Next chunk token:', $result->output);
+ $this->assertSame(50, strlen((string) ($result->metadata['content'] ?? '')));
+ }
+
+ private function makeTool(WebFetchProvider $provider): WebFetchTool
+ {
+ return $this->makeToolSet([$provider]);
+ }
+
+ /**
+ * @param list $providers
+ */
+ private function makeToolSet(array $providers): WebFetchTool
+ {
+ $settings = $this->makeSettingsManager();
+
+ return new WebFetchTool(
+ new WebFetchProviderManager($providers, $settings, new WebTransientCache),
+ $settings,
+ );
+ }
+
+ private function provider(string $content = "## Authentication\n\nToken auth details\n\n## Errors\n\nError model details"): WebFetchProvider
+ {
+ return new class($content) implements WebFetchProvider
+ {
+ public function __construct(private readonly string $content) {}
+
+ public function id(): string
+ {
+ return 'direct';
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ public function fetch(WebFetchRequest $request): WebFetchResponse
+ {
+ return new WebFetchResponse(
+ provider: 'direct',
+ url: $request->url,
+ finalUrl: $request->url,
+ statusCode: 200,
+ contentType: 'text/html',
+ format: 'markdown',
+ title: 'Example Docs',
+ metadata: ['title' => 'Example Docs', 'description' => 'Reference'],
+ outline: [
+ ['id' => 'authentication', 'title' => 'Authentication', 'level' => 1],
+ ['id' => 'errors', 'title' => 'Errors', 'level' => 1],
+ ],
+ sections: [
+ 'authentication' => "## Authentication\n\nToken auth details",
+ 'errors' => "## Errors\n\nError model details",
+ ],
+ content: $this->content,
+ );
+ }
+ };
+ }
+
+ private function makeSettingsManager(): SettingsManager
+ {
+ $dir = sys_get_temp_dir().'/kosmo-web-fetch-tool-'.bin2hex(random_bytes(4));
+ @mkdir($dir, 0777, true);
+
+ return new SettingsManager(
+ new Repository([
+ 'kosmokrator' => [
+ 'web' => [
+ 'search' => ['default_provider' => 'tavily', 'fallback_providers' => [], 'max_results' => 5],
+ 'fetch' => ['default_provider' => 'direct', 'fallback_providers' => [], 'max_chars' => 12000],
+ ],
+ ],
+ ]),
+ new SettingsSchema,
+ new YamlConfigStore(new NullLogger),
+ $dir,
+ );
+ }
+}
diff --git a/tests/Unit/Tool/Web/WebSearchToolTest.php b/tests/Unit/Tool/Web/WebSearchToolTest.php
new file mode 100644
index 0000000..be2a05f
--- /dev/null
+++ b/tests/Unit/Tool/Web/WebSearchToolTest.php
@@ -0,0 +1,132 @@
+query,
+ results: [new WebSearchHit('PHPUnit docs', 'https://phpunit.de', 'Assertions and testing')],
+ answer: 'PHPUnit is the standard testing framework for PHP.',
+ );
+ }
+ };
+
+ $tool = new WebSearchTool(
+ new WebSearchProviderManager([$provider], $this->makeSettingsManager(), new WebTransientCache),
+ $this->makeSettingsManager(),
+ );
+
+ $result = $tool->execute(['query' => 'phpunit']);
+
+ $this->assertTrue($result->success);
+ $this->assertStringContainsString('Provider: tavily', $result->output);
+ $this->assertStringContainsString('Answer:', $result->output);
+ $this->assertStringContainsString('PHPUnit docs', $result->output);
+ $this->assertStringContainsString('https://phpunit.de', $result->output);
+ $this->assertSame('PHPUnit is the standard testing framework for PHP.', $result->metadata['answer'] ?? null);
+ }
+
+ public function test_provider_parameter_only_lists_available_providers(): void
+ {
+ $available = new class implements WebSearchProvider
+ {
+ public function id(): string
+ {
+ return 'zai';
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ public function search(WebSearchRequest $request): WebSearchResponse
+ {
+ throw new \RuntimeException('not used');
+ }
+ };
+
+ $unavailable = new class implements WebSearchProvider
+ {
+ public function id(): string
+ {
+ return 'exa';
+ }
+
+ public function isAvailable(): bool
+ {
+ return false;
+ }
+
+ public function search(WebSearchRequest $request): WebSearchResponse
+ {
+ throw new \RuntimeException('not used');
+ }
+ };
+
+ $tool = new WebSearchTool(
+ new WebSearchProviderManager([$available, $unavailable], $this->makeSettingsManager(), new WebTransientCache),
+ $this->makeSettingsManager(),
+ );
+
+ $providerParameter = $tool->parameters()['provider'];
+
+ $this->assertArrayHasKey('options', $providerParameter);
+ /** @var array{type: string, description: string, options: list} $providerParameter */
+ $this->assertSame(['zai'], $providerParameter['options']);
+ }
+
+ private function makeSettingsManager(): SettingsManager
+ {
+ $dir = sys_get_temp_dir().'/kosmo-web-search-tool-'.bin2hex(random_bytes(4));
+ @mkdir($dir, 0777, true);
+
+ return new SettingsManager(
+ new Repository([
+ 'kosmokrator' => [
+ 'web' => [
+ 'search' => ['default_provider' => 'tavily', 'fallback_providers' => [], 'max_results' => 5],
+ 'fetch' => ['default_provider' => 'direct', 'fallback_providers' => [], 'max_chars' => 12000],
+ ],
+ ],
+ ]),
+ new SettingsSchema,
+ new YamlConfigStore(new NullLogger),
+ $dir,
+ );
+ }
+}
diff --git a/tests/Unit/UI/Ansi/AnsiRendererTest.php b/tests/Unit/UI/Ansi/AnsiRendererTest.php
index b786d56..d6391e9 100644
--- a/tests/Unit/UI/Ansi/AnsiRendererTest.php
+++ b/tests/Unit/UI/Ansi/AnsiRendererTest.php
@@ -11,8 +11,10 @@
use Kosmokrator\UI\Ansi\AnsiRenderer;
use Kosmokrator\UI\Ansi\AnsiSubagentRenderer;
use Kosmokrator\UI\Ansi\AnsiToolRenderer;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class AnsiRendererTest extends TestCase
{
private AnsiRenderer $renderer;
@@ -42,9 +44,10 @@ public function test_consume_queued_message_returns_null(): void
$this->assertNull($this->renderer->consumeQueuedMessage());
}
- public function test_approve_plan_returns_null(): void
+ public function test_approve_plan_returns_defaults_in_non_interactive(): void
{
- $this->assertNull($this->renderer->approvePlan('guardian'));
+ $result = $this->renderer->approvePlan('guardian');
+ $this->assertSame(['permission' => 'guardian', 'context' => 'keep'], $result);
}
public function test_initialize_is_noop(): void
@@ -148,8 +151,10 @@ public function test_show_mode_ask_omits_permission(): void
public function test_set_phase_thinking_outputs_indicator(): void
{
$output = $this->captureOutput(fn () => $this->renderer->setPhase(AgentPhase::Thinking));
+ $plain = $this->stripAnsi($output);
- $this->assertStringContainsString('Thinking', $output);
+ $this->assertStringContainsString('┌', $plain);
+ $this->assertStringContainsString('...', $plain);
}
public function test_set_phase_idle_without_prior_activity_is_noop(): void
@@ -164,8 +169,10 @@ public function test_set_phase_idle_without_prior_activity_is_noop(): void
public function test_show_thinking_outputs_text(): void
{
$output = $this->captureOutput(fn () => $this->renderer->showThinking());
+ $plain = $this->stripAnsi($output);
- $this->assertStringContainsString('Thinking', $output);
+ $this->assertStringContainsString('┌', $plain);
+ $this->assertStringContainsString('...', $plain);
}
// ── showCompacting ───────────────────────────────────────────────────
@@ -174,7 +181,8 @@ public function test_show_compacting_outputs_text(): void
{
$output = $this->captureOutput(fn () => $this->renderer->showCompacting());
- $this->assertStringContainsString('Compacting context', $output);
+ $this->assertStringContainsString('⧫', $output);
+ $this->assertStringContainsString('...', $output);
}
// ── showNotice ───────────────────────────────────────────────────────
@@ -413,10 +421,10 @@ public function test_show_subagent_spawn_multiple_agents(): void
public function test_show_tool_call_outputs_tool_info(): void
{
- $output = $this->captureOutput(fn () => $this->renderer->showToolCall('bash', ['command' => 'ls -la']));
+ $output = $this->captureOutput(fn () => $this->renderer->showToolCall('bash', ['command' => 'npm run build']));
$plain = $this->stripAnsi($output);
- $this->assertStringContainsString('ls -la', $plain);
+ $this->assertStringContainsString('npm run build', $plain);
}
public function test_show_tool_call_skips_content_keys(): void
diff --git a/tests/Unit/UI/Tui/Composition/TaskTreeTest.php b/tests/Unit/UI/Tui/Composition/TaskTreeTest.php
index dd14bcd..45d4605 100644
--- a/tests/Unit/UI/Tui/Composition/TaskTreeTest.php
+++ b/tests/Unit/UI/Tui/Composition/TaskTreeTest.php
@@ -7,9 +7,11 @@
use Kosmokrator\Task\TaskStore;
use Kosmokrator\UI\Tui\Composition\TaskTree;
use Kosmokrator\UI\Tui\State\TuiStateStore;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Tui\Render\RenderContext;
+#[AllowMockObjectsWithoutExpectations]
final class TaskTreeTest extends TestCase
{
private TuiStateStore $state;
diff --git a/tests/Unit/UI/Tui/Toast/ToastItemTest.php b/tests/Unit/UI/Tui/Toast/ToastItemTest.php
index a6eff8f..4df1fb7 100644
--- a/tests/Unit/UI/Tui/Toast/ToastItemTest.php
+++ b/tests/Unit/UI/Tui/Toast/ToastItemTest.php
@@ -15,7 +15,6 @@ protected function setUp(): void
{
// Reset ID counter for predictable tests
$ref = new \ReflectionProperty(ToastItem::class, 'idCounter');
- $ref->setAccessible(true);
$ref->setValue(null, 0);
}
diff --git a/tests/Unit/UI/Tui/Toast/ToastManagerTest.php b/tests/Unit/UI/Tui/Toast/ToastManagerTest.php
index 62cf48a..eaf7a2c 100644
--- a/tests/Unit/UI/Tui/Toast/ToastManagerTest.php
+++ b/tests/Unit/UI/Tui/Toast/ToastManagerTest.php
@@ -21,7 +21,6 @@ protected function setUp(): void
// Reset ID counter for predictable tests
$ref = new \ReflectionProperty(ToastItem::class, 'idCounter');
- $ref->setAccessible(true);
$ref->setValue(null, 0);
$this->desktopNotificationFired = false;
diff --git a/tests/Unit/UI/Tui/TuiModalManagerTest.php b/tests/Unit/UI/Tui/TuiModalManagerTest.php
index a716ad8..ef047ea 100644
--- a/tests/Unit/UI/Tui/TuiModalManagerTest.php
+++ b/tests/Unit/UI/Tui/TuiModalManagerTest.php
@@ -6,6 +6,7 @@
use Kosmokrator\UI\Tui\State\TuiStateStore;
use Kosmokrator\UI\Tui\TuiModalManager;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Revolt\EventLoop\Suspension;
use Symfony\Component\Tui\Tui;
@@ -13,6 +14,7 @@
use Symfony\Component\Tui\Widget\ContainerWidget;
use Symfony\Component\Tui\Widget\EditorWidget;
+#[AllowMockObjectsWithoutExpectations]
final class TuiModalManagerTest extends TestCase
{
private function createManager(): TuiModalManager
diff --git a/tests/Unit/UI/Tui/Widget/SettingsWorkspaceWidgetTest.php b/tests/Unit/UI/Tui/Widget/SettingsWorkspaceWidgetTest.php
index c1820b0..936a302 100644
--- a/tests/Unit/UI/Tui/Widget/SettingsWorkspaceWidgetTest.php
+++ b/tests/Unit/UI/Tui/Widget/SettingsWorkspaceWidgetTest.php
@@ -50,6 +50,22 @@ public function test_constructor_extracts_field_values(): void
$this->assertSame('gpt-4', $values['agent.default_model']);
}
+ public function test_constructor_normalizes_array_field_values(): void
+ {
+ $widget = $this->createWidget([
+ 'categories' => [[
+ 'id' => 'gateway',
+ 'label' => 'Gateway',
+ 'fields' => [
+ ['id' => 'gateway.telegram.allowed_users', 'label' => 'Allowed users', 'value' => ['alice', 'bob']],
+ ],
+ ]],
+ ]);
+
+ $values = $this->getProperty($widget, 'values');
+ $this->assertSame('alice, bob', $values['gateway.telegram.allowed_users']);
+ }
+
public function test_constructor_stores_original_values(): void
{
$widget = $this->createWidget([
@@ -152,6 +168,32 @@ public function test_build_result_includes_delete_custom_provider(): void
$this->assertSame('my_custom', $result['delete_custom_provider']);
}
+ public function test_render_gateway_details_shows_status_and_start_command(): void
+ {
+ $widget = $this->createWidget([
+ 'categories' => [
+ [
+ 'id' => 'gateway',
+ 'label' => 'Gateway',
+ 'fields' => [
+ ['id' => 'gateway.telegram.enabled', 'label' => 'Telegram gateway', 'value' => 'on', 'source' => 'project', 'effect' => 'next_session', 'description' => 'Enable it.'],
+ ['id' => 'gateway.telegram.secret.token', 'label' => 'Telegram bot token', 'value' => '(stored)', 'source' => 'secret_store', 'effect' => 'applies_now', 'description' => 'Stored outside YAML.'],
+ ['id' => 'gateway.telegram.session_mode', 'label' => 'Session routing', 'value' => 'thread', 'source' => 'project', 'effect' => 'next_session', 'description' => 'Route mode.'],
+ ['id' => 'gateway.telegram.allowed_users', 'label' => 'Allowed users', 'value' => 'alice', 'source' => 'project', 'effect' => 'next_session', 'description' => 'Users.'],
+ ],
+ ],
+ ],
+ ]);
+
+ $lines = $this->invoke($widget, 'renderGatewayDetails', 80, 16);
+ $output = implode("\n", $lines);
+
+ $this->assertStringContainsString('Telegram Gateway', $output);
+ $this->assertStringContainsString('Token: configured', $output);
+ $this->assertStringContainsString('Session routing: thread', $output);
+ $this->assertStringContainsString('php bin/kosmokrator gateway:telegram', $output);
+ }
+
// ── buildCustomProvider ──────────────────────────────────────────────
public function test_build_custom_provider_returns_null_when_no_provider_id(): void
@@ -684,7 +726,6 @@ public function test_render_returns_lines_array(): void
]);
$context = new RenderContext(120, 30);
$lines = $widget->render($context);
- $this->assertIsArray($lines);
$this->assertNotEmpty($lines);
}
diff --git a/tests/Unit/UI/UIManagerTest.php b/tests/Unit/UI/UIManagerTest.php
index 8168736..2497a64 100644
--- a/tests/Unit/UI/UIManagerTest.php
+++ b/tests/Unit/UI/UIManagerTest.php
@@ -9,8 +9,10 @@
use Kosmokrator\Task\TaskStore;
use Kosmokrator\UI\RendererInterface;
use Kosmokrator\UI\UIManager;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
+#[AllowMockObjectsWithoutExpectations]
class UIManagerTest extends TestCase
{
/**
@@ -22,7 +24,6 @@ private function createManagerWithMock(string $preference = 'ansi'): array
$mock = $this->createMock(RendererInterface::class);
$ref = new \ReflectionProperty($manager, 'renderer');
- $ref->setAccessible(true);
$ref->setValue($manager, $mock);
return [$manager, $mock];
diff --git a/tests/Unit/Web/DirectFetchProviderTest.php b/tests/Unit/Web/DirectFetchProviderTest.php
new file mode 100644
index 0000000..882083e
--- /dev/null
+++ b/tests/Unit/Web/DirectFetchProviderTest.php
@@ -0,0 +1,76 @@
+
+
+
+ Example Docs
+
+
+
+
+ Authentication
+ Token auth details
+
+
+
+HTML;
+
+ $guard = $this->createMock(WebRequestGuard::class);
+
+ $provider = new DirectFetchProvider(
+ $guard,
+ new HtmlPageExtractor,
+ httpClient: new HttpClient(new class(gzencode($html, 9)) implements DelegateHttpClient
+ {
+ public function __construct(private readonly string $body) {}
+
+ public function request(Request $request, Cancellation $cancellation): Response
+ {
+ return new Response(
+ '2',
+ HttpStatus::OK,
+ 'OK',
+ [
+ 'content-type' => 'text/html; charset=utf-8',
+ 'content-encoding' => 'gzip',
+ ],
+ new ReadableBuffer($this->body),
+ $request,
+ );
+ }
+ }, []),
+ );
+
+ $response = $provider->fetch(new WebFetchRequest(
+ url: 'https://example.com/docs',
+ provider: 'direct',
+ mode: 'main',
+ ));
+
+ self::assertSame('Example Docs', $response->title);
+ self::assertStringContainsString('Token auth details', $response->content);
+ self::assertSame('Reference', $response->metadata['description']);
+ }
+}
diff --git a/tests/Unit/Web/HtmlPageExtractorTest.php b/tests/Unit/Web/HtmlPageExtractorTest.php
new file mode 100644
index 0000000..569fd9f
--- /dev/null
+++ b/tests/Unit/Web/HtmlPageExtractorTest.php
@@ -0,0 +1,73 @@
+
+
+
+ Example Docs
+
+
+
+
+
+ Authentication
+ Use bearer tokens.
+ Errors
+ Errors are returned as JSON.
+
+
+
+HTML;
+
+ $result = (new HtmlPageExtractor)->extract($html, 'https://example.com/docs');
+
+ $this->assertSame('Example Docs', $result->title);
+ $this->assertSame('Reference docs', $result->metadata['description']);
+ $this->assertCount(2, $result->outline);
+ $this->assertSame('Authentication', $result->outline[0]['title']);
+ $this->assertArrayHasKey('authentication', $result->sections);
+ $this->assertStringContainsString('Use bearer tokens.', $result->sections['authentication']);
+ }
+
+ public function test_outline_ids_round_trip_to_section_keys(): void
+ {
+ $html = <<<'HTML'
+
+
+ Docs
+
+
+ PHP 8.4: Here’s what’s new and improved
+ Intro.
+ New features and improvements in PHP 8.4
+ Body.
+ Property hooks
+ Hook body.
+
+
+
+HTML;
+
+ $result = (new HtmlPageExtractor)->extract($html, 'https://example.com/docs');
+
+ $this->assertSame([
+ 'php-8-4-here-s-what-s-new-and-improved',
+ 'new-features-and-improvements-in-php-8-4',
+ 'property-hooks',
+ ], array_column($result->outline, 'id'));
+ $this->assertArrayHasKey('property-hooks', $result->sections);
+ $this->assertStringContainsString('Property hooks', $result->sections['property-hooks']);
+ $this->assertStringContainsString('Hook body.', $result->sections['property-hooks']);
+ }
+}
diff --git a/tests/Unit/Web/MarkdownPageExtractorTest.php b/tests/Unit/Web/MarkdownPageExtractorTest.php
new file mode 100644
index 0000000..ca38cce
--- /dev/null
+++ b/tests/Unit/Web/MarkdownPageExtractorTest.php
@@ -0,0 +1,46 @@
+extract(
+ "# Intro\n\nHello world.\n\n## Auth\n\nUse a token.\n",
+ 'Example Docs',
+ ['description' => 'Reference'],
+ );
+
+ self::assertSame('Example Docs', $page->title);
+ self::assertSame('Reference', $page->metadata['description']);
+ self::assertCount(2, $page->outline);
+ self::assertArrayHasKey('intro', $page->sections);
+ self::assertArrayHasKey('auth', $page->sections);
+ }
+
+ public function test_outline_ids_round_trip_to_section_keys(): void
+ {
+ $extractor = new MarkdownPageExtractor;
+
+ $page = $extractor->extract(
+ "# PHP 8.4: Here’s what’s new and improved\n\nIntro.\n\n## New features and improvements in PHP 8.4\n\nBody.\n\n
### Property hooks\n\nHook body.\n"
+ );
+
+ self::assertSame([
+ 'php-8-4-here-s-what-s-new-and-improved',
+ 'new-features-and-improvements-in-php-8-4',
+ 'property-hooks',
+ ], array_column($page->outline, 'id'));
+ self::assertArrayHasKey('property-hooks', $page->sections);
+ self::assertStringContainsString('Property hooks', $page->sections['property-hooks']);
+ self::assertStringContainsString('Hook body.', $page->sections['property-hooks']);
+ }
+}
diff --git a/tests/Unit/Web/StreamableMcpToolInvokerTest.php b/tests/Unit/Web/StreamableMcpToolInvokerTest.php
new file mode 100644
index 0000000..a82b195
--- /dev/null
+++ b/tests/Unit/Web/StreamableMcpToolInvokerTest.php
@@ -0,0 +1,75 @@
+phase++;
+
+ return match ($this->phase) {
+ 1 => new Response(
+ '2',
+ HttpStatus::OK,
+ 'OK',
+ [
+ 'content-type' => 'text/event-stream',
+ 'mcp-session-id' => 'session-123',
+ ],
+ new ReadableBuffer("data: {\"result\":{\"protocolVersion\":\"2025-11-25\"}}\n\n"),
+ $request,
+ ),
+ 2 => new Response(
+ '2',
+ HttpStatus::OK,
+ 'OK',
+ ['content-type' => 'application/json'],
+ new ReadableBuffer('{}'),
+ $request,
+ ),
+ default => new Response(
+ '2',
+ HttpStatus::OK,
+ 'OK',
+ ['content-type' => 'text/event-stream'],
+ new ReadableBuffer("event: message\ndata: {\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"[{\\\"title\\\":\\\"Result\\\",\\\"link\\\":\\\"https://example.com\\\"}]\"}],\"isError\":false}}\n\n"),
+ $request,
+ ),
+ };
+ }
+ }, []));
+
+ $result = $invoker->call(
+ 'https://api.z.ai/api/mcp/web_search_prime/mcp',
+ 'web_search_prime',
+ ['search_query' => 'example'],
+ ['Authorization' => 'Bearer zai-test-key'],
+ );
+
+ self::assertIsArray($result);
+ self::assertCount(1, $result);
+ self::assertSame('Result', $result[0]['title']);
+ self::assertSame('https://example.com', $result[0]['link']);
+ }
+}
diff --git a/tests/Unit/Web/WebProviderManagerTest.php b/tests/Unit/Web/WebProviderManagerTest.php
new file mode 100644
index 0000000..60f3cef
--- /dev/null
+++ b/tests/Unit/Web/WebProviderManagerTest.php
@@ -0,0 +1,265 @@
+ 0];
+ $provider = new class($state) implements WebSearchProvider
+ {
+ public function __construct(private readonly object $state) {}
+
+ public function id(): string
+ {
+ return 'tavily';
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ public function search(WebSearchRequest $request): WebSearchResponse
+ {
+ $this->state->calls++;
+
+ return new WebSearchResponse(
+ provider: 'tavily',
+ query: $request->query,
+ results: [new WebSearchHit('Result', 'https://example.com', 'Snippet')],
+ );
+ }
+ };
+
+ $manager = new WebSearchProviderManager(
+ [$provider],
+ $this->makeSettingsManager(),
+ new WebTransientCache(keepTurns: 2, maxEntries: 16),
+ );
+
+ $request = new WebSearchRequest('phpunit');
+ $manager->search($request);
+ $manager->search($request);
+
+ $this->assertSame(1, $state->calls);
+ }
+
+ public function test_fetch_manager_does_not_fallback_after_permanent_failure(): void
+ {
+ $state = (object) ['fallback_calls' => 0];
+
+ $first = new class implements WebFetchProvider
+ {
+ public function id(): string
+ {
+ return 'direct';
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ public function fetch(WebFetchRequest $request): WebFetchResponse
+ {
+ throw new WebFetchPermanentException('Direct fetch failed (404) for '.$request->url.'.');
+ }
+ };
+
+ $fallback = new class($state) implements WebFetchProvider
+ {
+ public function __construct(private readonly object $state) {}
+
+ public function id(): string
+ {
+ return 'zai';
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ public function fetch(WebFetchRequest $request): WebFetchResponse
+ {
+ $this->state->fallback_calls++;
+
+ throw new \RuntimeException('Should not be called');
+ }
+ };
+
+ $manager = new WebFetchProviderManager(
+ [$first, $fallback],
+ $this->makeSettingsManager(),
+ new WebTransientCache(keepTurns: 2, maxEntries: 16),
+ );
+
+ $this->expectException(WebFetchPermanentException::class);
+
+ try {
+ $manager->fetch(new WebFetchRequest(url: 'https://example.com/missing'));
+ } finally {
+ $this->assertSame(0, $state->fallback_calls);
+ }
+ }
+
+ public function test_search_manager_hard_filters_allowed_domains(): void
+ {
+ $provider = new class implements WebSearchProvider
+ {
+ public function id(): string
+ {
+ return 'zai';
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ public function search(WebSearchRequest $request): WebSearchResponse
+ {
+ return new WebSearchResponse(
+ provider: 'zai',
+ query: $request->query,
+ results: [
+ new WebSearchHit('Good', 'https://symfony.com/doc', 'good'),
+ new WebSearchHit('Wrong', 'https://github.com/symfony/symfony', 'wrong'),
+ ],
+ );
+ }
+ };
+
+ $manager = new WebSearchProviderManager(
+ [$provider],
+ $this->makeSettingsManager(),
+ new WebTransientCache(keepTurns: 2, maxEntries: 16),
+ );
+
+ $response = $manager->search(new WebSearchRequest(
+ query: 'symfony routing',
+ allowedDomains: ['symfony.com'],
+ ));
+
+ $this->assertCount(1, $response->results);
+ $this->assertSame('https://symfony.com/doc', $response->results[0]->url);
+ }
+
+ public function test_fetch_manager_provider_only_excludes_direct_without_explicit_override(): void
+ {
+ $state = (object) ['direct_calls' => 0, 'zai_calls' => 0];
+
+ $direct = new class($state) implements WebFetchProvider
+ {
+ public function __construct(private readonly object $state) {}
+
+ public function id(): string
+ {
+ return 'direct';
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ public function fetch(WebFetchRequest $request): WebFetchResponse
+ {
+ $this->state->direct_calls++;
+
+ throw new \RuntimeException('Direct should not be used');
+ }
+ };
+
+ $zai = new class($state) implements WebFetchProvider
+ {
+ public function __construct(private readonly object $state) {}
+
+ public function id(): string
+ {
+ return 'zai';
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ public function fetch(WebFetchRequest $request): WebFetchResponse
+ {
+ $this->state->zai_calls++;
+
+ return new WebFetchResponse(
+ provider: 'zai',
+ url: $request->url,
+ finalUrl: $request->url,
+ statusCode: 200,
+ contentType: 'text/plain',
+ format: 'markdown',
+ title: 'Z.AI',
+ metadata: [],
+ outline: [],
+ sections: [],
+ content: 'ok',
+ );
+ }
+ };
+
+ $manager = new WebFetchProviderManager(
+ [$direct, $zai],
+ $this->makeSettingsManager(),
+ new WebTransientCache(keepTurns: 2, maxEntries: 16),
+ );
+
+ $response = $manager->fetch(new WebFetchRequest(
+ url: 'https://example.com/docs',
+ strategy: 'provider_only',
+ ));
+
+ $this->assertSame('zai', $response->provider);
+ $this->assertSame(0, $state->direct_calls);
+ $this->assertSame(1, $state->zai_calls);
+ }
+
+ private function makeSettingsManager(): SettingsManager
+ {
+ $dir = sys_get_temp_dir().'/kosmo-web-tests-'.bin2hex(random_bytes(4));
+ @mkdir($dir, 0777, true);
+
+ return new SettingsManager(
+ new Repository([
+ 'kosmokrator' => [
+ 'web' => [
+ 'search' => ['default_provider' => 'tavily', 'fallback_providers' => []],
+ 'fetch' => ['default_provider' => 'direct', 'fallback_providers' => []],
+ ],
+ ],
+ ]),
+ new SettingsSchema,
+ new YamlConfigStore(new NullLogger),
+ $dir,
+ );
+ }
+}
diff --git a/tests/Unit/Web/WebTransientCacheTest.php b/tests/Unit/Web/WebTransientCacheTest.php
new file mode 100644
index 0000000..a72c3a9
--- /dev/null
+++ b/tests/Unit/Web/WebTransientCacheTest.php
@@ -0,0 +1,47 @@
+put('a', 'value');
+
+ $this->assertSame('value', $cache->get('a'));
+
+ $cache->advanceTurn();
+ $cache->advanceTurn();
+ $this->assertSame('value', $cache->get('a'));
+
+ $cache->advanceTurn();
+ $this->assertNull($cache->get('a'));
+ }
+
+ public function test_remember_uses_cached_value(): void
+ {
+ $cache = new WebTransientCache(keepTurns: 2, maxEntries: 16);
+ $calls = 0;
+
+ $first = $cache->remember('key', function () use (&$calls): string {
+ $calls++;
+
+ return 'cached';
+ });
+ $second = $cache->remember('key', function () use (&$calls): string {
+ $calls++;
+
+ return 'new';
+ });
+
+ $this->assertSame('cached', $first);
+ $this->assertSame('cached', $second);
+ $this->assertSame(1, $calls);
+ }
+}
diff --git a/tests/Unit/Web/ZaiProvidersTest.php b/tests/Unit/Web/ZaiProvidersTest.php
new file mode 100644
index 0000000..5834f28
--- /dev/null
+++ b/tests/Unit/Web/ZaiProvidersTest.php
@@ -0,0 +1,223 @@
+createConfiguredMock(ProviderAuthService::class, [
+ 'apiKey' => 'zai-test-key',
+ ]);
+
+ $provider = new ZaiMcpSearchProvider(
+ new class implements McpToolInvokerInterface
+ {
+ public function call(string $remoteUrl, string $toolName, array $arguments, array $headers = []): array
+ {
+ return [
+ [
+ 'title' => 'Example Result',
+ 'content' => 'Summary',
+ 'link' => 'https://example.com/page',
+ 'media' => 'Example',
+ 'publish_date' => '2026-04-13',
+ ],
+ ];
+ }
+ },
+ $auth,
+ );
+
+ $response = $provider->search(new WebSearchRequest(
+ query: 'example',
+ maxResults: 5,
+ blockedDomains: ['blocked.example'],
+ ));
+
+ self::assertSame('zai', $response->provider);
+ self::assertCount(1, $response->results);
+ self::assertSame('https://example.com/page', $response->results[0]->url);
+ }
+
+ public function test_zai_reader_fetch_provider_builds_sections_from_markdown(): void
+ {
+ $auth = $this->createConfiguredMock(ProviderAuthService::class, [
+ 'apiKey' => 'zai-test-key',
+ ]);
+
+ $guard = $this->createMock(WebRequestGuard::class);
+
+ $provider = new ZaiReaderFetchProvider(
+ auth: $auth,
+ guard: $guard,
+ extractor: new MarkdownPageExtractor,
+ httpClient: new HttpClient(new class implements DelegateHttpClient
+ {
+ public function request(Request $request, Cancellation $cancellation): Response
+ {
+ return new Response(
+ '2',
+ HttpStatus::OK,
+ 'OK',
+ [],
+ new ReadableBuffer(json_encode([
+ 'model' => 'web-reader',
+ 'request_id' => 'req-123',
+ 'reader_result' => [
+ 'title' => 'Example Docs',
+ 'url' => 'https://example.com/docs',
+ 'description' => 'Reference',
+ 'content' => "# Intro\n\nHello\n\n## Auth\n\nUse a token.\n",
+ 'metadata' => ['lang' => 'en'],
+ ],
+ ], JSON_THROW_ON_ERROR)),
+ $request,
+ );
+ }
+ }, []),
+ );
+
+ $response = $provider->fetch(new WebFetchRequest(
+ url: 'https://example.com/docs',
+ format: 'markdown',
+ ));
+
+ self::assertSame('zai', $response->provider);
+ self::assertSame('Example Docs', $response->title);
+ self::assertArrayHasKey('auth', $response->sections);
+ self::assertSame('Reference', $response->metadata['description']);
+ }
+
+ public function test_zai_search_provider_falls_back_to_chat_results_when_mcp_is_empty(): void
+ {
+ $auth = $this->createConfiguredMock(ProviderAuthService::class, [
+ 'apiKey' => 'zai-test-key',
+ ]);
+
+ $provider = new ZaiMcpSearchProvider(
+ new class implements McpToolInvokerInterface
+ {
+ public function call(string $remoteUrl, string $toolName, array $arguments, array $headers = []): array
+ {
+ return [];
+ }
+ },
+ $auth,
+ null,
+ 'https://api.z.ai/api/mcp/web_search_prime/mcp',
+ 'https://api.z.ai/api/coding/paas/v4',
+ [1, 2],
+ new HttpClient(new class implements DelegateHttpClient
+ {
+ public function request(Request $request, Cancellation $cancellation): Response
+ {
+ return new Response(
+ '2',
+ HttpStatus::OK,
+ 'OK',
+ [],
+ new ReadableBuffer(json_encode([
+ 'choices' => [[
+ 'message' => [
+ 'content' => json_encode([
+ 'answer' => 'Example answer',
+ 'results' => [[
+ 'title' => 'Example Result',
+ 'url' => 'https://example.com/result',
+ 'source' => 'Example Source',
+ 'published_at' => '2026-04-13',
+ 'snippet' => 'Example snippet',
+ ]],
+ ], JSON_THROW_ON_ERROR),
+ ],
+ ]],
+ ], JSON_THROW_ON_ERROR)),
+ $request,
+ );
+ }
+ }, []),
+ );
+
+ $response = $provider->search(new WebSearchRequest(
+ query: 'example',
+ maxResults: 5,
+ includeAnswer: true,
+ ));
+
+ self::assertCount(1, $response->results);
+ self::assertSame('https://example.com/result', $response->results[0]->url);
+ self::assertSame('Example Source', $response->results[0]->source);
+ self::assertSame('Example answer', $response->answer);
+ }
+
+ public function test_zai_search_provider_retries_rate_limits_without_falling_back(): void
+ {
+ $auth = $this->createConfiguredMock(ProviderAuthService::class, [
+ 'apiKey' => 'zai-test-key',
+ ]);
+
+ $attempts = 0;
+ $slept = [];
+
+ $provider = new ZaiMcpSearchProvider(
+ new class($attempts) implements McpToolInvokerInterface
+ {
+ public function __construct(private int &$attempts) {}
+
+ public function call(string $remoteUrl, string $toolName, array $arguments, array $headers = []): array
+ {
+ $this->attempts++;
+
+ throw new \RuntimeException('MCP error -429: {"error":{"code":"1302","message":"Rate limit reached for requests"}}');
+ }
+ },
+ $auth,
+ null,
+ 'https://api.z.ai/api/mcp/web_search_prime/mcp',
+ 'https://api.z.ai/api/coding/paas/v4',
+ [0, 0],
+ new HttpClient(new class implements DelegateHttpClient
+ {
+ public function request(Request $request, Cancellation $cancellation): Response
+ {
+ throw new \RuntimeException('Chat fallback should not be invoked for rate limits.');
+ }
+ }, []),
+ static function (int $seconds) use (&$slept): void {
+ $slept[] = $seconds;
+ },
+ );
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('rate limited');
+
+ try {
+ $provider->search(new WebSearchRequest(query: 'example', maxResults: 5));
+ } finally {
+ self::assertSame(3, $attempts);
+ self::assertSame([0, 0], $slept);
+ }
+ }
+}
diff --git a/website/pages/_docs-layout.php b/website/pages/_docs-layout.php
index da8a64e..a8dca9c 100644
--- a/website/pages/_docs-layout.php
+++ b/website/pages/_docs-layout.php
@@ -5,23 +5,25 @@
* Expects: $docTitle, $docSlug, $docContent
* Produces: $pageTitle, $pageClass, $pageBody — then includes _layout.php
*/
-$pageTitle = $docTitle . ' — KosmoKrator Docs';
+$pageTitle = $docTitle.' — KosmoKrator Docs';
$pageClass = 'docs-page';
$topics = [
'getting-started' => ['Getting Started', 'Quick-start guide'],
- 'installation' => ['Installation', 'Get up and running'],
+ 'installation' => ['Installation', 'Get up and running'],
+ 'termux' => ['Termux (Android)', 'Running on Android via Termux'],
'configuration' => ['Configuration', 'Settings and config files'],
- 'headless' => ['Headless Mode', 'CI/CD and non-interactive execution'],
- 'tools' => ['Tools', 'Built-in tool reference'],
- 'providers' => ['Providers', 'LLM providers and models'],
- 'agents' => ['Agents', 'Subagent types and swarms'],
- 'permissions' => ['Permissions', 'Permission modes and rules'],
- 'context' => ['Context & Memory', 'Context management pipeline'],
- 'commands' => ['Commands', 'Slash and power commands'],
- 'patterns' => ['Advanced Patterns', 'Real-world usage recipes'],
- 'ui-guide' => ['UI Guide', 'TUI and ANSI renderers, terminal compatibility'],
- 'architecture' => ['Architecture', 'Request lifecycle, rendering layer, agent loop internals'],
+ 'headless' => ['Headless Mode', 'CI/CD and non-interactive execution'],
+ 'tools' => ['Tools', 'Built-in tool reference'],
+ 'lua' => ['Lua', 'Lua runtime, namespaces, and app.tools reference'],
+ 'providers' => ['Providers', 'LLM providers and models'],
+ 'agents' => ['Agents', 'Subagent types and swarms'],
+ 'permissions' => ['Permissions', 'Permission modes and rules'],
+ 'context' => ['Context & Memory', 'Context management pipeline'],
+ 'commands' => ['Commands', 'Slash and power commands'],
+ 'patterns' => ['Advanced Patterns', 'Real-world usage recipes'],
+ 'ui-guide' => ['UI Guide', 'TUI and ANSI renderers, terminal compatibility'],
+ 'architecture' => ['Architecture', 'Request lifecycle, rendering layer, agent loop internals'],
];
ob_start();
@@ -31,9 +33,9 @@
Menu
Documentation
- $info): ?>
+ $info) { ?>
= $info[0] ?>
-
+
@@ -46,4 +48,4 @@
/ prefix, power commands
prefixed with : that activate specialized agent behaviors, and skill
commands prefixed with $ that invoke reusable skill templates. This page
- covers every command available in the interactive session.
+ covers the interactive command surface plus the main shell-level commands you are expected to
+ run directly.
@@ -106,6 +107,31 @@
+
+Model Switching
+
+
+/models (alias: /model)
+
+ Open the curated model switcher for day-to-day use. Instead of dumping the
+ full provider catalog, it shows the most likely choices in this order:
+ recent models you used, likely models from the current provider, and likely
+ models from your most recently used non-current provider.
+
+
+ You can also jump directly with arguments:
+
+/models
+/models openai
+/models gpt-5.4
+/models anthropic:claude-sonnet-4-20250514
+
+ Switching updates the live runtime immediately when the active LLM backend
+ supports it, and also updates the default provider/model used for future
+ sessions. Full provider and model inventory remains in /settings.
+
+
+
Permission Control
@@ -181,8 +207,8 @@
/settings
Open the interactive settings workspace. Navigate through categories (LLM, permissions, UI,
- tools, etc.) and change configuration values in real time. Changes are persisted to your
- user-level configuration and take effect immediately.
+ tools, gateway, integrations, etc.) and change configuration values in real time. Changes are
+ persisted to your user-level configuration and take effect immediately when supported.
@@ -196,11 +222,87 @@
conversation history or context window. The agent retains full memory of the session.
-/update
+kosmokrator update
+
+ Check for new KosmoKrator versions from the shell. Static binary and PHAR
+ installs update in place. Source installs are detected and you get the
+ exact manual update commands to run.
+
+
+
+Shell Commands
+
+
+
+ These commands are run directly from your terminal rather than inside the
+ interactive REPL.
+
+
+kosmokrator update
+
+ Check GitHub Releases for a newer version. Static binary and PHAR installs
+ can replace themselves in place. Source installs print the exact
+ git pull and composer install commands to run.
+
+
+kosmokrator gateway:telegram
+
+ Start the Telegram gateway worker. This turns KosmoKrator into a Telegram
+ bot surface backed by the normal agent/session runtime. The gateway keeps a
+ linked session per configured chat route, supports typing indicators,
+ streamed replies, queued follow-up messages, and inline approval buttons.
+
+
+ Typical flow:
+
+/settings → Gateway
+enable Telegram, store bot token, set allowlist
+
+kosmokrator gateway:telegram
+
+ The internal worker command kosmokrator gateway:telegram:worker
+ is process-managed by the main gateway and is not intended for normal manual
+ use.
+
+
+
+Telegram Gateway Commands
+
+
+
+ When you talk to KosmoKrator through Telegram, the bot exposes a transport
+ layer of Telegram-native commands in addition to the normal Kosmo slash
+ commands.
+
+
+/help
+Show Telegram gateway help, available controls, and the supported slash-command bridge.
+
+/status
+Show the linked session, active run state, queued input count, and current mode details for that chat route.
+
+/new
+Start a fresh linked session for the current Telegram route.
+
+/resume
+Reuse the existing linked session for the current Telegram route if one exists.
+
+/cancel
+Cancel the active run for the current Telegram route.
+
+/approve / /deny
- Check for new KosmoKrator versions. If an update is available, displays the changelog and
- offers to apply the update automatically. For PHAR installations this downloads the new binary;
- for Composer installations it runs the appropriate update command.
+ Resolve the latest pending approval request. Approval also supports scoped
+ variants:
+
+/approve
+/approve always
+/approve guardian
+/approve prometheus
+/deny
+
+ The Telegram gateway also provides inline approval buttons, so typing these
+ commands is optional in the normal case.
/feedback <text> (aliases: /bug, /issue)
@@ -290,6 +392,11 @@
None
Switch to Plan mode (read-only analysis).
+
+ /models
+ [provider|model|provider:model]
+ Open the curated model switcher or jump directly to a likely provider/model.
+
/ask
None
@@ -340,11 +447,6 @@
None
Clear the terminal display.
-
- /update
- None
- Check for and apply KosmoKrator updates.
-
/feedback
<text>
@@ -368,6 +470,12 @@
+
+ Shell-level commands are documented separately above. The main operational
+ ones are kosmokrator update and
+ kosmokrator gateway:telegram.
+
+
Power Commands
@@ -923,4 +1031,4 @@
-
Tip: You can switch providers and models mid-session with the /model slash command without restarting.
+
Tip: You can switch providers and models mid-session with /models without restarting. The command shows recent and likely choices first; full inventory remains in /settings.
@@ -374,6 +374,92 @@
+
+Gateway
+
+
+ The Gateway category controls external chat surfaces. Today the shipped
+ gateway is Telegram, started with kosmokrator gateway:telegram.
+ Gateway state lives partly in normal config and partly in the local secret
+ store, so you usually manage it through /settings rather than
+ by editing YAML directly.
+
+
+
+
+
+ Setting
+ Type
+ Default
+ Description
+ Effect
+
+
+
+
+ gateway.telegram.enabled
+ toggle
+ off
+ Enable or disable the Telegram gateway runtime.
+ next session
+
+
+ gateway.telegram.session_mode
+ choice: chat, chat_user, thread, thread_user
+ thread
+
+ Controls how Telegram chats map to Kosmo sessions.
+
+ chat — one session per chat
+ chat_user — one session per chat/user pair
+ thread — one session per Telegram thread/topic
+ thread_user — one session per thread/user pair
+
+
+ next session
+
+
+ gateway.telegram.allowed_users
+ list
+ empty
+ Optional Telegram user allowlist. Accepts numeric user IDs and usernames. Empty means any user is allowed.
+ next session
+
+
+ gateway.telegram.allowed_chats
+ list
+ empty
+ Optional Telegram chat allowlist. Empty means all chats are allowed.
+ next session
+
+
+ gateway.telegram.require_mention
+ toggle
+ on
+ Require a mention or direct reply in group chats before the bot responds. Direct messages are unaffected.
+ next session
+
+
+ gateway.telegram.free_response_chats
+ list
+ empty
+ Chats that are allowed to receive normal free-form responses without mention gating.
+ next session
+
+
+ gateway.telegram.poll_timeout_seconds
+ number
+ 20
+ Long-poll timeout for the Telegram bot loop.
+ next session
+
+
+
+
+
+
Telegram token: The bot token is managed through /settings → Gateway and stored outside normal YAML by default. You can also provide it via KOSMOKRATOR_TELEGRAM_BOT_TOKEN.
+
+
Codex
diff --git a/website/pages/docs/getting-started.php b/website/pages/docs/getting-started.php
index e0abdb0..52db56d 100644
--- a/website/pages/docs/getting-started.php
+++ b/website/pages/docs/getting-started.php
@@ -137,6 +137,39 @@
+
+Optional: Add Telegram
+
+
+
+ KosmoKrator can also run as a Telegram bot surface backed by the same
+ session and agent runtime. This is optional, but useful when you want to
+ continue a session from your phone or use KosmoKrator outside the terminal.
+
+
+
+ Open KosmoKrator locally and go to /settings → Gateway .
+ Enable Telegram, store the bot token, and set an allowlist for your Telegram user ID or username.
+ Choose a session mode such as chat_user for isolated personal chats.
+ Start the gateway from your shell:
+
+
+kosmokrator gateway:telegram
+
+
+ Then DM the bot and start with:
+
+
+/status
+
+
+ The gateway supports streaming replies, typing indicators, queued follow-up
+ messages, inline approval buttons, and a bridge for the main Kosmo slash
+ commands like /edit, /plan, /ask,
+ /compact, and /models.
+
+
+
Your First Task
@@ -412,4 +445,4 @@ function switchInstallTab(event, panelId) {
['Getting Started', 'Five-minute quickstart: install, configure, and make your first AI-assisted code change.', '🚀'],
- 'installation' => ['Installation', 'Get up and running with static binaries, PHAR, or from source. First-run setup and CLI options.', '📥'],
+ 'installation' => ['Installation', 'Get up and running with static binaries, PHAR, or from source. First-run setup and CLI options.', '📥'],
'configuration' => ['Configuration', 'Config file layering, all settings categories, environment variables, and YAML examples.', '⚙️'],
- 'headless' => ['Headless Mode', 'Non-interactive execution for CI/CD, scripts, and automated workflows with JSON and streaming output.', '🤖'],
- 'tools' => ['Tools', 'Complete reference for all built-in tools: file ops, search, bash, shell sessions, and more.', '🛠️'],
- 'providers' => ['Providers', '40+ LLM providers, authentication setup, custom endpoints, and per-depth model overrides.', '🔌'],
- 'agents' => ['Agents', 'Agent types, subagent swarms, dependency DAGs, concurrency control, and stuck detection.', '👥'],
- 'permissions' => ['Permissions', 'Guardian, Argus, and Prometheus modes. Evaluation chain, heuristics, and approval flows.', '🛡️'],
- 'context' => ['Context & Memory', 'Context pipeline, token budgets, compaction, pruning, and persistent memory system.', '🧠'],
- 'commands' => ['Commands', 'All slash commands, power commands, and keyboard shortcuts with usage examples.', '⚡'],
- 'patterns' => ['Advanced Patterns', 'Real-world recipes: CI/CD, cost optimization, code review, swarm orchestration, and more.', '🍳'],
- 'ui-guide' => ['UI Guide', 'TUI and ANSI renderers, terminal compatibility, and output display.', '🖥️'],
- 'architecture' => ['Architecture', 'Request lifecycle, key directories, rendering layer, agent loop, and session persistence.', '🏗️'],
+ 'headless' => ['Headless Mode', 'Non-interactive execution for CI/CD, scripts, and automated workflows with JSON and streaming output.', '🤖'],
+ 'tools' => ['Tools', 'Complete reference for all built-in tools: file ops, search, bash, shell sessions, and more.', '🛠️'],
+ 'lua' => ['Lua', 'Lua runtime guide: namespaces, app.tools, docs flow, and integration scripting patterns.', '🐍'],
+ 'providers' => ['Providers', '40+ LLM providers, authentication setup, custom endpoints, and per-depth model overrides.', '🔌'],
+ 'agents' => ['Agents', 'Agent types, subagent swarms, dependency DAGs, concurrency control, and stuck detection.', '👥'],
+ 'permissions' => ['Permissions', 'Guardian, Argus, and Prometheus modes. Evaluation chain, heuristics, and approval flows.', '🛡️'],
+ 'context' => ['Context & Memory', 'Context pipeline, token budgets, compaction, pruning, and persistent memory system.', '🧠'],
+ 'commands' => ['Commands', 'All slash commands, power commands, and keyboard shortcuts with usage examples.', '⚡'],
+ 'patterns' => ['Advanced Patterns', 'Real-world recipes: CI/CD, cost optimization, code review, swarm orchestration, and more.', '🍳'],
+ 'ui-guide' => ['UI Guide', 'TUI and ANSI renderers, terminal compatibility, and output display.', '🖥️'],
+ 'architecture' => ['Architecture', 'Request lifecycle, key directories, rendering layer, agent loop, and session persistence.', '🏗️'],
];
ob_start();
@@ -25,9 +26,9 @@
Menu
Documentation
- $info): ?>
+ $info) { ?>
= $info[0] ?>
-
+
@@ -35,16 +36,16 @@
Everything you need to get the most out of KosmoKrator. Pick a topic to dive in.
Updating
- KosmoKrator includes a built-in update mechanism. From a running session,
- use the slash command:
+ KosmoKrator includes a built-in update command. From your shell, run:
-/update
+kosmokrator update
- This checks the GitHub Releases page for a newer version and, if one is
- available, downloads and replaces the current binary or PHAR in place.
+ This checks GitHub Releases for a newer version and follows the right
+ update path for your installation method. Static binary and PHAR installs
+ update in place. Source installs print the exact manual update commands.
@@ -471,4 +471,4 @@
+
+
+ KosmoKrator exposes a sandboxed Lua runtime for multi-step integration work. Lua sits between
+ plain tool calls and a full agent turn: you can discover namespaces with docs tools, then run
+ deterministic scripts against app.integrations.* and app.tools.*.
+
+
+
+ Use the discovery flow in this order: lua_list_docs to see what exists,
+ lua_read_doc to confirm the exact namespace or function contract, then
+ execute_lua to run code. Do not guess function names or response shapes.
+
+
+
+How Lua Works
+
+
+
+ Lua scripts run inside a restricted sandbox. They do not get direct filesystem, shell, or
+ network access. Instead, KosmoKrator exposes controlled entry points under the app
+ namespace.
+
+
+
+ There are two main surfaces:
+
+
+
+ app.integrations.* — enabled integrations exposed as callable Lua namespaces
+ app.tools.* — KosmoKrator's built-in native tools, callable from Lua
+
+
+
+ Lua also exposes helper namespaces:
+
+
+
+ json.decode(...) and json.encode(...) for JSON parsing/serialization
+ regex.match(...), regex.match_all(...), and regex.gsub(...) for PCRE regex work
+
+
+
+
+
+
+
+ These are the KosmoKrator tools you call from the agent side to discover or run Lua. They are
+ not part of app.tools; they are top-level agent tools.
+
+
+
+
+
+ Tool
+ Role
+ When to use it
+
+
+
+
+ lua_list_docs
+ Discovery catalog
+ See which namespaces exist without dumping full function references.
+
+
+ lua_search_docs
+ Keyword search
+ Find likely namespaces or functions by term when you do not know the exact path.
+
+
+ lua_read_doc
+ Detailed reference
+ Read one namespace, one function, or one guide page before writing code.
+
+
+ execute_lua
+ Runtime execution
+ Run a Lua script after you have confirmed the contract with the docs tools.
+
+
+
+
+
+ Typical workflow:
+
+
+lua_list_docs
+lua_read_doc page="integrations.coingecko"
+lua_read_doc page="integrations.coingecko.price"
+execute_lua code="local prices = app.integrations.coingecko.price({ids={'bitcoin'}, vs_currencies={'usd'}})
+print(prices.bitcoin.usd)"
+
+
+Namespace Model
+
+
+
+ Integration namespaces follow a stable account-aware shape:
+
+
+app.integrations.{name}.* -- default account path
+app.integrations.{name}.default.* -- explicit default alias
+app.integrations.{name}.{account}.* -- named account/credential alias
+app.tools.{tool_name}(args) -- native KosmoKrator tools
+
+
+ Important rules:
+
+
+
+ Only integrations that are enabled and configured are callable in a session.
+ lua_list_docs and lua_read_doc can still describe installed-but-inactive integrations.
+ The root integration namespace usually means "use the default account".
+ .default is an explicit alias for the default account.
+ .{account} is only present when you have multiple named credentials/accounts saved for the same integration.
+
+
+Single vs Multi-Account
+
+
+ Single-account integration:
+
+
+app.integrations.github.search_repositories({...})
+app.integrations.github.default.search_repositories({...}) -- explicit alias
+
+
+ Multi-account integration:
+
+
+app.integrations.github.default.search_repositories({...})
+app.integrations.github.work.search_repositories({...})
+app.integrations.github.personal.search_repositories({...})
+
+
+
+
+
+
+ Inside execute_lua, KosmoKrator's built-in tools are available through
+ app.tools.*. These are the internal Lua tools you can compose inside scripts.
+
+
+
+ Current built-in namespaces:
+
+
+
+
+
+ Tool
+ Purpose
+
+
+
+ app.tools.file_readRead file content from the project.
+ app.tools.file_writeCreate or fully overwrite a file.
+ app.tools.file_editApply a targeted find/replace edit.
+ app.tools.apply_patchApply structured patch hunks to files.
+ app.tools.globList files by pattern.
+ app.tools.grepSearch file contents by pattern.
+ app.tools.bashRun a shell command through the normal permission system.
+ app.tools.shell_startStart a persistent shell session.
+ app.tools.shell_writeWrite to an existing shell session.
+ app.tools.shell_readRead output from an existing shell session.
+ app.tools.shell_killTerminate a shell session.
+ app.tools.task_createCreate a task in the current session task store.
+ app.tools.task_updateUpdate task status or fields.
+ app.tools.task_listList tasks in the current session.
+ app.tools.task_getRead one task by id.
+ app.tools.memory_savePersist a memory item.
+ app.tools.memory_searchSearch stored memories.
+ app.tools.session_searchSearch prior sessions.
+ app.tools.session_readRead one prior session in detail.
+ app.tools.subagentSpawn one or more child agents from Lua.
+
+
+
+
+ Not exposed inside app.tools:
+
+
+
+ execute_lua — Lua does not recursively call itself
+ lua_list_docs, lua_search_docs, lua_read_doc — these stay at the host-tool level
+ ask_user, ask_choice — interactive prompt tools are not bridged into Lua
+
+
+
+
+
+ Native tools return structured tables rather than plain strings. Every result includes at least:
+
+
+
+ output — human-readable combined result string
+ success — boolean success flag
+
+
+
+ Some tools expose more metadata. For example, bash also returns
+ stdout, stderr, and exit_code.
+
+
+local result = app.tools.bash({command = "git status --short"})
+if result.success then
+ print(result.stdout)
+else
+ print(result.stderr)
+end
+
+
+Discovery and Docs Flow
+
+
+
+ lua_list_docs is intentionally short. It is a namespace catalog, not a partial
+ reference. That is by design: short discovery output reduces guessing and pushes the agent to
+ read the real docs before calling anything.
+
+
+
+ Use the tools like this:
+
+
+
+ lua_list_docs — see what namespaces exist
+ lua_search_docs — search by concept when needed
+ lua_read_doc page="integrations.NAME" — inspect the namespace
+ lua_read_doc page="integrations.NAME.function" — inspect one function
+ execute_lua — run the script
+
+
+
+ Guide pages currently include:
+
+
+
+ overview
+ context
+ errors
+ examples
+ tools — generated docs for app.tools.*
+
+
+
+Examples
+
+
+Single Integration Call
+
+execute_lua code="local result = app.integrations.coingecko.price({
+ ids = {'bitcoin', 'ethereum'},
+ vs_currencies = {'usd'}
+})
+dump(result)"
+
+
+
+execute_lua code="local files = app.tools.glob({pattern = 'src/**/*.php'})
+print(files.output)
+
+local status = app.tools.bash({command = 'git status --short'})
+print(status.stdout)"
+
+Spawning Subagents from Lua
+
+execute_lua code="local result = app.tools.subagent({
+ agents = {
+ {task = 'Inspect routing', id = 'routing'},
+ {task = 'Inspect auth', id = 'auth'},
+ {task = 'Inspect db', id = 'db'},
+ }
+})
+dump(result)"
+
+
+Practical Rules
+
+
+
+ Read the docs before writing Lua. The docs-first flow is part of the system design.
+ Do not assume raw upstream API response shapes. Integrations may normalize fields and structure.
+ If the docs do not describe the return shape clearly, inspect with a minimal call first.
+ Use Lua when you need deterministic multi-step orchestration; use normal tools when one direct tool call is enough.
+ All normal permission and integration access rules still apply inside Lua.
+
+
+
+ For per-tool parameter reference, see
Tools . For integration setup and
+ activation, see
Configuration .
+
+
+
+
+ KosmoKrator runs on Android via Termux , a
+ terminal emulator that provides a full Linux environment without root.
+ This guide covers all three installation methods — static binary,
+ PHAR, and source — with Termux-specific tips and workarounds.
+
+
+
+Prerequisites
+
+
+ Install Termux from F-Droid .
+ The Google Play version is outdated and no longer receives updates —
+ always use the F-Droid release.
+
+
+Once Termux is open, update the package index:
+
+pkg update && pkg upgrade -y
+
+
+Static Binary (Recommended)
+
+
+ The fastest path. Most Android devices use ARM, so download the
+ aarch64 binary. No PHP installation required.
+
+
+pkg install -y curl
+
+curl -fSL https://github.com/OpenCompanyApp/kosmokrator/releases/latest/download/kosmokrator-linux-aarch64 \
+ -o $PREFIX/bin/kosmokrator \
+ && chmod +x $PREFIX/bin/kosmokrator
+
+Verify:
+
+kosmokrator --version
+
+
+
+ Tip: Termux uses $PREFIX/bin (typically
+ /data/data/com.termux/files/usr/bin) instead of
+ /usr/local/bin. There is no sudo —
+ Termux packages are installed in user space.
+
+
+
+
+PHAR Package
+
+
+ If you prefer the PHAR, install PHP first:
+
+
+pkg install -y php curl
+
+curl -fSL https://github.com/OpenCompanyApp/kosmokrator/releases/latest/download/kosmokrator.phar \
+ -o $PREFIX/bin/kosmokrator \
+ && chmod +x $PREFIX/bin/kosmokrator
+
+
+ Termux's PHP package ships with curl, mbstring,
+ openssl, and pdo_sqlite built in. Verify with:
+
+
+php -m | grep -E 'curl|mbstring|openssl|pdo_sqlite|pcntl|readline'
+
+
+ If any extensions are missing, install the matching Termux package:
+
+
+pkg install -y php-pdo php-sqlite
+
+
+From Source
+
+
+ A source checkout gives you the full development environment.
+
+
+Install packages
+
+pkg install -y php git composer openssh
+
+Clone and install
+
+git clone https://github.com/OpenCompanyApp/kosmokrator.git
+cd kosmokrator
+composer install
+
+Run directly from the checkout:
+
+php bin/kosmokrator --renderer=ansi
+
+
+ Or symlink for convenience:
+
+
+ln -s "$(pwd)/bin/kosmokrator" $PREFIX/bin/kosmokrator
+
+
+
+ Tip: If Composer runs out of memory, set
+ COMPOSER_MEMORY_LIMIT=-1 composer install to remove the cap.
+
+
+
+
+First Run
+
+
+ Run the setup wizard to configure your LLM provider:
+
+
+kosmokrator setup
+
+
+ Then start a session. Use ANSI mode for the most reliable experience:
+
+
+kosmokrator --renderer=ansi
+
+
+ The ANSI renderer works well in Termux and does not depend on
+ stty or pcntl features that may behave
+ differently on Android.
+
+
+
+Choosing a Renderer
+
+
+
+
+ Renderer
+ Termux Support
+ Notes
+
+
+
+
+ ansi
+ Recommended
+ Pure escape codes, works everywhere, readline input
+
+
+ tui
+ Experimental
+
+ Requires stty and a terminal that correctly
+ reports size. May work in Termux but can be glitchy on
+ some devices.
+
+
+
+
+
+
+ If you want to try the TUI renderer, launch with
+ kosmokrator --renderer=tui and fall back to ANSI if you
+ encounter display issues.
+
+
+
+Keyboard Tips
+
+
+ A hardware keyboard or
+ Hacker's Keyboard
+ makes the experience much better. If you are using the default
+ Termux soft keyboard:
+
+
+
+ Swipe the extra-keys bar left/right to access Ctrl , Tab , Esc , and arrow keys
+ Long-press the volume-down key while typing for Ctrl combos
+ Ctrl+C cancels a running tool, Ctrl+D exits the session
+
+
+
+Storage & Sessions
+
+
+ By default, KosmoKrator stores its SQLite database and configuration in
+ ~/.kosmokrator/. In Termux, ~ resolves to
+ /data/data/com.termux/files/home, which is private to the
+ Termux app.
+
+
+
+ If you need to access project files on shared storage (Downloads,
+ Documents, etc.), grant Termux storage access first:
+
+
+termux-setup-storage
+
+
+ This creates ~/storage/ with symlinks to shared directories.
+ You can then cd ~/storage/downloads to work on files there.
+
+
+
+Troubleshooting
+
+stty: stdin not a terminal
+
+
+ This warning is harmless. The ANSI renderer handles it gracefully. If
+ you see persistent issues, force ANSI mode with --renderer=ansi.
+
+
+SQLite errors or permission denied
+
+
+ Ensure the storage directory is writable:
+
+
+chmod -R 775 ~/.kosmokrator/
+
+
+ If running from source, also check the project's storage/
+ directory:
+
+
+chmod -R 775 storage/
+
+Composer memory errors
+
+
+ Termux's default memory limit may be too low for Composer on some devices.
+ Override it:
+
+
+COMPOSER_MEMORY_LIMIT=-1 composer install
+
+Process killed by Android
+
+
+ Android may kill background Termux processes to reclaim memory. To keep
+ sessions alive:
+
+
+
+ Run termux-wake-lock to acquire a wake lock
+
+ In Android settings, disable battery optimization for the Termux app
+
+
+ Use Termux:API
+ with termux-notification for persistent foreground
+ notifications
+
+
+
+pcntl extension not available
+
+
+ The pcntl extension may not be available in Termux's PHP
+ build. KosmoKrator's ANSI renderer works without it — Revolt's
+ event loop falls back to stream_select. You can safely
+ ignore pcntl-related warnings when using ANSI mode.
+
+
+
+
+ For the full Lua model — host-side Lua tools,
app.integrations.*,
+
app.tools.*, multi-account namespaces, and discovery workflow — see
+
Lua .
+
+
execute_lua
@@ -1362,4 +1368,4 @@ public function __construct(string $name)