From b06d7e475ed120375c62365a7ef48b739d5d67e6 Mon Sep 17 00:00:00 2001 From: ruttydm Date: Sat, 11 Apr 2026 22:19:56 +0200 Subject: [PATCH 1/8] Add Hermes-style Telegram gateway --- bin/kosmokrator | 6 +- config/kosmokrator.yaml | 11 + docs/README.md | 1 + docs/proposals/hermes-style-gateway.md | 412 +++++++++++++++++ src/Agent/AgentSessionBuilder.php | 107 +++++ src/Command/AgentCommand.php | 32 +- src/Command/Slash/SettingsCommand.php | 111 ++++- src/Command/SlashCommandRegistryFactory.php | 51 ++ src/Command/TelegramGatewayCommand.php | 99 ++++ src/Command/TelegramGatewayWorkerCommand.php | 194 ++++++++ src/Gateway/GatewayApproval.php | 24 + src/Gateway/GatewayApprovalStore.php | 142 ++++++ src/Gateway/GatewayCheckpointStore.php | 45 ++ src/Gateway/GatewayMessageEvent.php | 92 ++++ src/Gateway/GatewayMessagePointer.php | 17 + src/Gateway/GatewayMessageStore.php | 80 ++++ .../GatewaySessionContextPromptBuilder.php | 48 ++ src/Gateway/GatewaySessionLink.php | 21 + src/Gateway/GatewaySessionStore.php | 99 ++++ .../SymfonyProcessTelegramWorkerHandle.php | 37 ++ .../SymfonyProcessTelegramWorkerLauncher.php | 35 ++ .../Telegram/TelegramBotCommandCatalog.php | 81 ++++ src/Gateway/Telegram/TelegramClient.php | 223 +++++++++ .../Telegram/TelegramClientInterface.php | 52 +++ .../Telegram/TelegramGatewayConfig.php | 137 ++++++ .../Telegram/TelegramGatewayRenderer.php | 436 ++++++++++++++++++ .../Telegram/TelegramGatewayRuntime.php | 370 +++++++++++++++ .../TelegramGatewayWorkerHandleInterface.php | 14 + ...TelegramGatewayWorkerLauncherInterface.php | 12 + src/Gateway/Telegram/TelegramPollerLock.php | 52 +++ .../Telegram/TelegramSessionRouter.php | 28 ++ .../Telegram/TelegramSlashCommandBridge.php | 51 ++ .../Telegram/TelegramUpdateNormalizer.php | 104 +++++ src/Session/Database.php | 117 ++++- src/Settings/SettingsSchema.php | 75 +++ src/UI/Ansi/AnsiCoreRenderer.php | 44 +- src/UI/Ansi/AnsiDialogRenderer.php | 76 ++- src/UI/Ansi/AnsiRenderer.php | 2 + src/UI/Ansi/AnsiToolRenderer.php | 174 ++++++- src/UI/Tui/Widget/SettingsWorkspaceWidget.php | 94 +++- .../Command/Slash/SettingsCommandTest.php | 13 + .../Unit/Gateway/GatewayApprovalStoreTest.php | 26 ++ .../Unit/Gateway/GatewaySessionStoreTest.php | 40 ++ .../Gateway/Telegram/FakeTelegramClient.php | 109 +++++ .../Telegram/FakeTelegramWorkerHandle.php | 36 ++ .../Telegram/FakeTelegramWorkerLauncher.php | 32 ++ .../Telegram/TelegramGatewayConfigTest.php | 44 ++ .../Telegram/TelegramGatewayRendererTest.php | 135 ++++++ .../Telegram/TelegramGatewayRuntimeTest.php | 264 +++++++++++ .../Telegram/TelegramPollerLockTest.php | 25 + .../Telegram/TelegramSessionRouterTest.php | 36 ++ .../Telegram/TelegramUpdateNormalizerTest.php | 99 ++++ tests/Unit/Session/DatabaseTest.php | 8 +- tests/Unit/Settings/SettingsSchemaTest.php | 1 + tests/Unit/UI/Ansi/AnsiRendererTest.php | 15 +- .../Widget/SettingsWorkspaceWidgetTest.php | 43 +- website/pages/_docs-layout.php | 1 + 57 files changed, 4680 insertions(+), 53 deletions(-) create mode 100644 docs/proposals/hermes-style-gateway.md create mode 100644 src/Command/SlashCommandRegistryFactory.php create mode 100644 src/Command/TelegramGatewayCommand.php create mode 100644 src/Command/TelegramGatewayWorkerCommand.php create mode 100644 src/Gateway/GatewayApproval.php create mode 100644 src/Gateway/GatewayApprovalStore.php create mode 100644 src/Gateway/GatewayCheckpointStore.php create mode 100644 src/Gateway/GatewayMessageEvent.php create mode 100644 src/Gateway/GatewayMessagePointer.php create mode 100644 src/Gateway/GatewayMessageStore.php create mode 100644 src/Gateway/GatewaySessionContextPromptBuilder.php create mode 100644 src/Gateway/GatewaySessionLink.php create mode 100644 src/Gateway/GatewaySessionStore.php create mode 100644 src/Gateway/Telegram/SymfonyProcessTelegramWorkerHandle.php create mode 100644 src/Gateway/Telegram/SymfonyProcessTelegramWorkerLauncher.php create mode 100644 src/Gateway/Telegram/TelegramBotCommandCatalog.php create mode 100644 src/Gateway/Telegram/TelegramClient.php create mode 100644 src/Gateway/Telegram/TelegramClientInterface.php create mode 100644 src/Gateway/Telegram/TelegramGatewayConfig.php create mode 100644 src/Gateway/Telegram/TelegramGatewayRenderer.php create mode 100644 src/Gateway/Telegram/TelegramGatewayRuntime.php create mode 100644 src/Gateway/Telegram/TelegramGatewayWorkerHandleInterface.php create mode 100644 src/Gateway/Telegram/TelegramGatewayWorkerLauncherInterface.php create mode 100644 src/Gateway/Telegram/TelegramPollerLock.php create mode 100644 src/Gateway/Telegram/TelegramSessionRouter.php create mode 100644 src/Gateway/Telegram/TelegramSlashCommandBridge.php create mode 100644 src/Gateway/Telegram/TelegramUpdateNormalizer.php create mode 100644 tests/Unit/Gateway/GatewayApprovalStoreTest.php create mode 100644 tests/Unit/Gateway/GatewaySessionStoreTest.php create mode 100644 tests/Unit/Gateway/Telegram/FakeTelegramClient.php create mode 100644 tests/Unit/Gateway/Telegram/FakeTelegramWorkerHandle.php create mode 100644 tests/Unit/Gateway/Telegram/FakeTelegramWorkerLauncher.php create mode 100644 tests/Unit/Gateway/Telegram/TelegramGatewayConfigTest.php create mode 100644 tests/Unit/Gateway/Telegram/TelegramGatewayRendererTest.php create mode 100644 tests/Unit/Gateway/Telegram/TelegramGatewayRuntimeTest.php create mode 100644 tests/Unit/Gateway/Telegram/TelegramPollerLockTest.php create mode 100644 tests/Unit/Gateway/Telegram/TelegramSessionRouterTest.php create mode 100644 tests/Unit/Gateway/Telegram/TelegramUpdateNormalizerTest.php diff --git a/bin/kosmokrator b/bin/kosmokrator index 1b14767..c2e85d2 100644 --- a/bin/kosmokrator +++ b/bin/kosmokrator @@ -10,6 +10,8 @@ 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\Kernel; use NunoMaduro\Collision\Provider; @@ -38,12 +40,14 @@ $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 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', '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/config/kosmokrator.yaml b/config/kosmokrator.yaml index 3cbb1c7..2aa22a4 100644 --- a/config/kosmokrator.yaml +++ b/config/kosmokrator.yaml @@ -125,6 +125,17 @@ 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 + tools: # Tools that are always denied, overriding all modes (including Prometheus). # Useful for hard-disabling tools in specific projects or CI environments. 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/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/src/Agent/AgentSessionBuilder.php b/src/Agent/AgentSessionBuilder.php index 0bcfcc7..5c790c3 100644 --- a/src/Agent/AgentSessionBuilder.php +++ b/src/Agent/AgentSessionBuilder.php @@ -19,6 +19,7 @@ use Kosmokrator\Tool\ToolRegistry; use Kosmokrator\UI\HeadlessRenderer; use Kosmokrator\UI\OutputFormat; +use Kosmokrator\UI\RendererInterface; use Kosmokrator\UI\UIManager; use OpenCompany\PrismRelay\Registry\RelayRegistry; use OpenCompany\PrismRelay\Relay; @@ -282,6 +283,112 @@ 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); + $llm = $llmFactory->create('ansi', $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(); + + $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, + ); + + 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. */ diff --git a/src/Command/AgentCommand.php b/src/Command/AgentCommand.php index db32c10..b4b47f0 100644 --- a/src/Command/AgentCommand.php +++ b/src/Command/AgentCommand.php @@ -502,39 +502,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/Slash/SettingsCommand.php b/src/Command/Slash/SettingsCommand.php index 619e92e..3e64672 100644 --- a/src/Command/Slash/SettingsCommand.php +++ b/src/Command/Slash/SettingsCommand.php @@ -124,6 +124,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', @@ -490,6 +492,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 +640,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 +808,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 +976,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..654eaa6 --- /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\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\UpdateCommand($version)); + $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..51e06fd --- /dev/null +++ b/src/Command/TelegramGatewayCommand.php @@ -0,0 +1,99 @@ +container->make(SettingsManager::class); + $settings->setProjectRoot(InstructionLoader::gitRoot() ?? getcwd()); + $config = TelegramGatewayConfig::fromSettings($settings, $this->container->make('config')); + $secretToken = $this->container->make(SettingsRepositoryInterface::class)->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), + 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..e66ecc8 --- /dev/null +++ b/src/Command/TelegramGatewayWorkerCommand.php @@ -0,0 +1,194 @@ +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; + } + + $settings = $this->container->make(SettingsManager::class); + $settings->setProjectRoot(InstructionLoader::gitRoot() ?? getcwd()); + $config = TelegramGatewayConfig::fromSettings($settings, $this->container->make('config')); + $secretToken = $this->container->make(SettingsRepositoryInterface::class)->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: fn (): string => 'deny', + 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.'); + } + } finally { + $session?->orchestrator?->cancelAll(); + $this->container->make(ShellSessionManager::class)->killAll(); + } + + return Command::SUCCESS; + } +} 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/GatewaySessionContextPromptBuilder.php b/src/Gateway/GatewaySessionContextPromptBuilder.php new file mode 100644 index 0000000..1f4cb87 --- /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, /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..e0cb59e --- /dev/null +++ b/src/Gateway/Telegram/SymfonyProcessTelegramWorkerLauncher.php @@ -0,0 +1,35 @@ +find(false) ?: PHP_BINARY; + $console = $this->projectRoot.'/bin/kosmokrator'; + $payload = base64_encode(json_encode($event->toArray(), JSON_THROW_ON_ERROR)); + + $process = new Process([ + $phpBinary, + $console, + 'gateway:telegram:worker', + '--event='.$payload, + ], $this->projectRoot); + $process->setTimeout(null); + $process->disableOutput(); + $process->start(); + + return new SymfonyProcessTelegramWorkerHandle($process); + } +} diff --git a/src/Gateway/Telegram/TelegramBotCommandCatalog.php b/src/Gateway/Telegram/TelegramBotCommandCatalog.php new file mode 100644 index 0000000..a4a4b89 --- /dev/null +++ b/src/Gateway/Telegram/TelegramBotCommandCatalog.php @@ -0,0 +1,81 @@ + + */ + 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']); + } + + return implode("\n", $lines); + } +} diff --git a/src/Gateway/Telegram/TelegramClient.php b/src/Gateway/Telegram/TelegramClient.php new file mode 100644 index 0000000..d896804 --- /dev/null +++ b/src/Gateway/Telegram/TelegramClient.php @@ -0,0 +1,223 @@ +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): 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; + } + + return $this->request('sendMessage', $payload); + } + + /** + * @return array + */ + public function editMessageText(string $chatId, int $messageId, string $text, ?array $replyMarkup = null): array + { + $payload = [ + 'chat_id' => $chatId, + 'message_id' => $messageId, + 'text' => $text, + 'disable_web_page_preview' => true, + ]; + + if ($replyMarkup !== null) { + $payload['reply_markup'] = $replyMarkup; + } + + return $this->request('editMessageText', $payload); + } + + public function sendPhoto(string $chatId, string $path, ?string $threadId = null, ?string $caption = null): array + { + $payload = ['chat_id' => $chatId]; + if ($threadId !== null) { + $payload['message_thread_id'] = (int) $threadId; + } + if ($caption !== null && $caption !== '') { + $payload['caption'] = $caption; + } + + 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): array + { + $payload = ['chat_id' => $chatId]; + if ($threadId !== null) { + $payload['message_thread_id'] = (int) $threadId; + } + if ($caption !== null && $caption !== '') { + $payload['caption'] = $caption; + } + + 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): array + { + $payload = ['chat_id' => $chatId]; + if ($threadId !== null) { + $payload['message_thread_id'] = (int) $threadId; + } + if ($caption !== null && $caption !== '') { + $payload['caption'] = $caption; + } + + return $this->upload('sendVoice', 'voice', $path, $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..77cee10 --- /dev/null +++ b/src/Gateway/Telegram/TelegramClientInterface.php @@ -0,0 +1,52 @@ + $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): array; + + /** + * @return array + */ + public function editMessageText(string $chatId, int $messageId, string $text, ?array $replyMarkup = null): array; + + /** + * @return array + */ + public function sendPhoto(string $chatId, string $path, ?string $threadId = null, ?string $caption = null): array; + + /** + * @return array + */ + public function sendDocument(string $chatId, string $path, ?string $threadId = null, ?string $caption = null): array; + + /** + * @return array + */ + public function sendVoice(string $chatId, string $path, ?string $threadId = null, ?string $caption = null): array; + + 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..88b1602 --- /dev/null +++ b/src/Gateway/Telegram/TelegramGatewayConfig.php @@ -0,0 +1,137 @@ + $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): self + { + $enabled = self::toBool($settings->getRaw('kosmokrator.gateway.telegram.enabled') + ?? $config->get('kosmokrator.gateway.telegram.enabled', false)); + + $token = trim((string) ( + $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) ( + $settings->getRaw('kosmokrator.gateway.telegram.session_mode') + ?? $config->get('kosmokrator.gateway.telegram.session_mode', 'thread') + ), + allowedUsers: self::toList( + $settings->getRaw('kosmokrator.gateway.telegram.allowed_users') + ?? $config->get('kosmokrator.gateway.telegram.allowed_users', []) + ), + allowedChats: self::toList( + $settings->getRaw('kosmokrator.gateway.telegram.allowed_chats') + ?? $config->get('kosmokrator.gateway.telegram.allowed_chats', []) + ), + requireMention: self::toBool( + $settings->getRaw('kosmokrator.gateway.telegram.require_mention') + ?? $config->get('kosmokrator.gateway.telegram.require_mention', true) + ), + freeResponseChats: self::toList( + $settings->getRaw('kosmokrator.gateway.telegram.free_response_chats') + ?? $config->get('kosmokrator.gateway.telegram.free_response_chats', []) + ), + pollTimeoutSeconds: max(1, (int) ( + $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..82b3b3b --- /dev/null +++ b/src/Gateway/Telegram/TelegramGatewayRenderer.php @@ -0,0 +1,436 @@ + []]; + + private string $buffer = ''; + + private string $placeholderText = 'Thinking…'; + + private ?string $statusNotice = null; + + private ?string $activeToolName = null; + + private ?int $statusMessageId = null; + + private ?int $answerMessageId = null; + + private float $lastFlushAt = 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 + { + 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->buffer .= $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->updateStatusMessage("Preparing tool: {$name}"); + } + + public function showToolResult(string $name, string $output, bool $success): void {} + + 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->answerMessageId, + ); + + $lines = [ + "Approval required for `{$toolName}`.", + 'Use the buttons below or reply with /approve or /deny.', + ]; + $this->client->sendMessage( + $this->chatId, + implode("\n", $lines), + $this->threadId, + replyMarkup: [ + 'inline_keyboard' => [[ + ['text' => 'Approve', 'callback_data' => 'ga:approve:'.$approval->id], + ['text' => 'Deny', 'callback_data' => 'ga:deny:'.$approval->id], + ]], + ], + ); + + $decision = ($this->approvalCallback)($approval->id, $toolName, $args); + $this->approvals->resolve($approval->id, $decision === 'deny' ? 'denied' : 'approved'); + + return $decision; + } + + public function showAutoApproveIndicator(string $toolName): void {} + + public function showToolExecuting(string $name): void + { + $this->activeToolName = $name; + $this->updateStatusMessage($name === 'concurrent' ? 'Running tools…' : "Using tool: {$name}"); + } + + public function updateToolExecuting(string $output): void + { + $output = trim($output); + if ($output !== '') { + $this->updateStatusMessage($output); + } + } + + public function clearToolExecuting(): void + { + $this->activeToolName = 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 ensureAnswerMessage(string $text): void + { + if ($this->answerMessageId !== null) { + return; + } + + $message = $this->client->sendMessage($this->chatId, $this->limit($text), $this->threadId); + $messageId = (int) ($message['message_id'] ?? 0); + if ($messageId > 0) { + $this->answerMessageId = $messageId; + $this->messages->save('telegram', $this->routeKey, 'response', $this->chatId, $messageId, $this->threadId); + } + } + + private function flushBufferedText(bool $force): void + { + if ($this->buffer === '') { + return; + } + + $display = $this->visibleText(); + if ($display === '') { + return; + } + + $now = microtime(true); + if (! $force && ($now - $this->lastFlushAt) < 0.75) { + return; + } + + $this->ensureAnswerMessage($display); + if ($this->answerMessageId !== null) { + $this->client->editMessageText($this->chatId, $this->answerMessageId, $this->limit($display)); + $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 visibleText(): string + { + return $this->extractMediaPayload($this->buffer)['text']; + } + + 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->ensureStatusMessage($text); + + if ($this->statusMessageId !== null) { + $this->client->editMessageText($this->chatId, $this->statusMessageId, $this->limit($text)); + } + } + + private function deliverMediaAttachments(): void + { + $payload = $this->extractMediaPayload($this->buffer); + 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, + ]; + } +} diff --git a/src/Gateway/Telegram/TelegramGatewayRuntime.php b/src/Gateway/Telegram/TelegramGatewayRuntime.php new file mode 100644 index 0000000..b5b1258 --- /dev/null +++ b/src/Gateway/Telegram/TelegramGatewayRuntime.php @@ -0,0 +1,370 @@ +> */ + private array $backlog = []; + + private string $botUsername = ''; + + private readonly TelegramSessionRouter $router; + + /** + * @var array + */ + private array $activeRoutes = []; + + 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 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(); + $updates = $this->nextBatch($offset); + + 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); + + 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'; + $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, + 'Active routes: '.count($this->activeRoutes), + ])); + $this->client->sendMessage($event->chatId, $text, $event->threadId); + + return; + } + + if ($event->isCommand('/new')) { + $this->sessionLinks->delete('telegram', $event->routeKey); + $this->messages->delete('telegram', $event->routeKey, 'response'); + $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')) { + $pending = $this->approvals->resolveLatestPending( + 'telegram', + $event->routeKey, + $event->isCommand('/approve') ? 'approved' : 'denied', + ); + $this->client->sendMessage( + $event->chatId, + $pending === null + ? 'No pending approval for this chat.' + : ($event->isCommand('/approve') ? 'Approved.' : 'Denied.'), + $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->client->sendMessage( + $event->chatId, + 'A run is already active for this chat. Use /status to inspect it or /cancel to stop it.', + $event->threadId, + ); + + return; + } + + $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 awaitApproval(int $approvalId, GatewayMessageEvent $origin): string + { + $normalizer = new TelegramUpdateNormalizer($this->botUsername); + $offset = $this->loadOffset(); + + while (true) { + $approval = $this->approvals->find($approvalId); + if ($approval !== null && $approval->status === 'approved') { + return 'allow'; + } + + if ($approval !== null && $approval->status === 'denied') { + return 'deny'; + } + + $updates = $this->nextBatch($offset); + 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)); + + if ($event->routeKey === $origin->routeKey) { + if ($event->isCommand('/approve')) { + $this->approvals->resolve($approvalId, 'approved'); + + return 'allow'; + } + + if ($event->isCommand('/deny') || $event->isCommand('/cancel')) { + $this->approvals->resolve($approvalId, 'denied'); + + return 'deny'; + } + } + + $this->backlog[] = $update; + } + } + } + + 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]); + } + } + } + + private function handleCallbackQuery(GatewayMessageEvent $event): void + { + if ($event->callbackQueryId === null) { + return; + } + + if (preg_match('/^ga:(approve|deny):(\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 = $matches[1] === 'approve' ? 'approved' : 'denied'; + $this->approvals->resolve($approvalId, $status); + $label = $status === 'approved' ? 'Approved' : 'Denied'; + $this->client->answerCallbackQuery($event->callbackQueryId, $label.'.'); + + if ($event->messageId !== null) { + $this->client->editMessageText( + $event->chatId, + $event->messageId, + "{$label} `{$approval->toolName}`.", + ['inline_keyboard' => []], + ); + } + } +} 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/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/Session/Database.php b/src/Session/Database.php index c22971f..8ebe1a7 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 = 6; /** * @param string|null $path Absolute path to the SQLite database file, or ':memory:' for an ephemeral db. @@ -167,6 +167,65 @@ 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) + ) + '); } /** Runs incremental schema migrations starting from the given version. */ @@ -198,6 +257,62 @@ 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) + ) + '); + } } /** Adds a column to a table only if it does not already exist. */ diff --git a/src/Settings/SettingsSchema.php b/src/Settings/SettingsSchema.php index 1f54d3d..ac9abf8 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,79 @@ 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: 'context.memories', path: 'kosmokrator.context.memories', diff --git a/src/UI/Ansi/AnsiCoreRenderer.php b/src/UI/Ansi/AnsiCoreRenderer.php index 84f2734..c1b6266 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 = ''; @@ -124,8 +149,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 +164,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 +223,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..f52bb2c 100644 --- a/src/UI/Ansi/AnsiDialogRenderer.php +++ b/src/UI/Ansi/AnsiDialogRenderer.php @@ -251,10 +251,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 = readline("{$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 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/Tui/Widget/SettingsWorkspaceWidget.php b/src/UI/Tui/Widget/SettingsWorkspaceWidget.php index c949d38..ac3c978 100644 --- a/src/UI/Tui/Widget/SettingsWorkspaceWidget.php +++ b/src/UI/Tui/Widget/SettingsWorkspaceWidget.php @@ -98,7 +98,7 @@ public function __construct( continue; } - $value = (string) ($field['value'] ?? ''); + $value = $this->stringifyFieldValue($field['value'] ?? ''); $this->values[$id] = $value; $this->originalValues[$id] = $value; } @@ -1085,6 +1085,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 +1261,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 +1561,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/tests/Unit/Command/Slash/SettingsCommandTest.php b/tests/Unit/Command/Slash/SettingsCommandTest.php index 1697424..c244cdb 100644 --- a/tests/Unit/Command/Slash/SettingsCommandTest.php +++ b/tests/Unit/Command/Slash/SettingsCommandTest.php @@ -52,6 +52,19 @@ 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'); + $method->setAccessible(true); + + $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/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/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..c9650ae --- /dev/null +++ b/tests/Unit/Gateway/Telegram/FakeTelegramClient.php @@ -0,0 +1,109 @@ + */ + 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 = []; + + 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): array + { + $message = [ + 'message_id' => count($this->sent) + 1, + 'chat_id' => $chatId, + 'text' => $text, + 'thread_id' => $threadId, + 'reply_to_message_id' => $replyToMessageId, + 'reply_markup' => $replyMarkup, + ]; + $this->sent[] = $message; + + return ['message_id' => $message['message_id']]; + } + + public function editMessageText(string $chatId, int $messageId, string $text, ?array $replyMarkup = null): array + { + $this->edited[] = [ + 'chat_id' => $chatId, + 'message_id' => $messageId, + 'text' => $text, + 'reply_markup' => $replyMarkup, + ]; + + return ['message_id' => $messageId]; + } + + public function sendPhoto(string $chatId, string $path, ?string $threadId = null, ?string $caption = null): array + { + $this->photos[] = compact('chatId', 'path', 'threadId', 'caption'); + + return ['message_id' => count($this->photos) + 100]; + } + + public function sendDocument(string $chatId, string $path, ?string $threadId = null, ?string $caption = null): array + { + $this->documents[] = compact('chatId', 'path', 'threadId', 'caption'); + + return ['message_id' => count($this->documents) + 200]; + } + + public function sendVoice(string $chatId, string $path, ?string $threadId = null, ?string $caption = null): array + { + $this->voices[] = compact('chatId', 'path', 'threadId', 'caption'); + + return ['message_id' => count($this->voices) + 300]; + } + + 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..68f7163 --- /dev/null +++ b/tests/Unit/Gateway/Telegram/TelegramGatewayRendererTest.php @@ -0,0 +1,135 @@ +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']); + } + + 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('Deny', $keyboard[0][1]['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']); + $answerEdit = $client->edited[1]['text'] ?? ''; + $this->assertSame('See attached', $answerEdit); + $this->assertSame('Done', $client->edited[array_key_last($client->edited)]['text']); + } finally { + @unlink($photo); + } + } + + public function test_tool_execution_updates_status_message_only(): 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(1, $client->sent); + $this->assertSame('Thinking…', $client->sent[0]['text']); + $this->assertSame('Preparing tool: grep', $client->edited[1]['text']); + $this->assertSame('Using tool: grep', $client->edited[2]['text']); + } +} diff --git a/tests/Unit/Gateway/Telegram/TelegramGatewayRuntimeTest.php b/tests/Unit/Gateway/Telegram/TelegramGatewayRuntimeTest.php new file mode 100644 index 0000000..a545070 --- /dev/null +++ b/tests/Unit/Gateway/Telegram/TelegramGatewayRuntimeTest.php @@ -0,0 +1,264 @@ +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), + 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), + 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, + 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), + 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), + log: new NullLogger, + launcher: new FakeTelegramWorkerLauncher, + ); + $runtime->setBotUsername('kosmokrator_bot'); + + $runtime->processUpdates([[ + 'update_id' => 3, + 'callback_query' => [ + 'id' => 'cbq-1', + 'data' => 'ga:approve:'.$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']); + } +} 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/Session/DatabaseTest.php b/tests/Unit/Session/DatabaseTest.php index 4eae703..4432fbd 100644 --- a/tests/Unit/Session/DatabaseTest.php +++ b/tests/Unit/Session/DatabaseTest.php @@ -23,6 +23,10 @@ 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('schema_version', $tableNames); } @@ -33,7 +37,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(6, $version['version']); } public function test_idempotent_schema_creation(): void @@ -43,7 +47,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(6, $version['version']); } public function test_foreign_keys_enabled(): void 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/UI/Ansi/AnsiRendererTest.php b/tests/Unit/UI/Ansi/AnsiRendererTest.php index b786d56..a7902c0 100644 --- a/tests/Unit/UI/Ansi/AnsiRendererTest.php +++ b/tests/Unit/UI/Ansi/AnsiRendererTest.php @@ -148,8 +148,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 +166,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 +178,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 +418,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/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/website/pages/_docs-layout.php b/website/pages/_docs-layout.php index da8a64e..3a0fe20 100644 --- a/website/pages/_docs-layout.php +++ b/website/pages/_docs-layout.php @@ -11,6 +11,7 @@ $topics = [ 'getting-started' => ['Getting Started', 'Quick-start guide'], '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'], From 42566430c5bcd33a16fb8eb9ff62de548b99fb7c Mon Sep 17 00:00:00 2001 From: ruttydm Date: Sun, 12 Apr 2026 03:34:46 +0200 Subject: [PATCH 2/8] Implement gateway UX, model switcher, and update command --- README.md | 2 +- bin/kosmokrator | 4 +- docs/proposals/desktop-app.md | 4 +- docs/proposals/lazy-integration-boot.md | 181 +++++++ src/Agent/AgentSessionBuilder.php | 8 +- src/Command/AgentCommand.php | 2 +- src/Command/Slash/ModelsCommand.php | 379 +++++++++++++++ src/Command/Slash/SettingsCommand.php | 2 + src/Command/SlashCommandRegistryFactory.php | 2 +- src/Command/TelegramGatewayCommand.php | 7 +- src/Command/TelegramGatewayWorkerCommand.php | 65 ++- src/Command/UpdateCommand.php | 120 +++++ src/Gateway/GatewayPendingInput.php | 19 + src/Gateway/GatewayPendingInputStore.php | 85 ++++ .../GatewaySessionContextPromptBuilder.php | 2 +- .../SymfonyProcessTelegramWorkerLauncher.php | 19 +- .../Telegram/TelegramBotCommandCatalog.php | 4 + src/Gateway/Telegram/TelegramClient.php | 41 +- .../Telegram/TelegramClientInterface.php | 12 +- .../Telegram/TelegramGatewayConfig.php | 27 +- .../Telegram/TelegramGatewayRenderer.php | 279 ++++++++--- .../Telegram/TelegramGatewayRuntime.php | 223 ++++++--- .../Telegram/TelegramTextFormatter.php | 169 +++++++ src/LLM/ModelSwitcherHistory.php | 172 +++++++ src/Session/Database.php | 27 +- src/Update/SelfUpdater.php | 37 +- src/Update/SelfUpdaterInterface.php | 14 + src/Update/UpdateChecker.php | 2 +- src/Update/UpdateCheckerInterface.php | 12 + storage/logs/audio.log | 29 ++ tests/Unit/Agent/AgentSessionBuilderTest.php | 1 + .../Unit/Command/Slash/ModelsCommandTest.php | 442 ++++++++++++++++++ tests/Unit/Command/UpdateCommandTest.php | 133 ++++++ .../Gateway/GatewayPendingInputStoreTest.php | 87 ++++ .../Gateway/Telegram/FakeTelegramClient.php | 26 +- .../Telegram/TelegramGatewayRendererTest.php | 69 ++- .../Telegram/TelegramGatewayRuntimeTest.php | 235 +++++++++- tests/Unit/Session/DatabaseTest.php | 5 +- website/pages/docs/commands.php | 14 +- website/pages/docs/installation.php | 12 +- website/pages/docs/termux.php | 271 +++++++++++ 41 files changed, 3062 insertions(+), 182 deletions(-) create mode 100644 docs/proposals/lazy-integration-boot.md create mode 100644 src/Command/Slash/ModelsCommand.php create mode 100644 src/Command/UpdateCommand.php create mode 100644 src/Gateway/GatewayPendingInput.php create mode 100644 src/Gateway/GatewayPendingInputStore.php create mode 100644 src/Gateway/Telegram/TelegramTextFormatter.php create mode 100644 src/LLM/ModelSwitcherHistory.php create mode 100644 src/Update/SelfUpdaterInterface.php create mode 100644 src/Update/UpdateCheckerInterface.php create mode 100644 tests/Unit/Command/Slash/ModelsCommandTest.php create mode 100644 tests/Unit/Command/UpdateCommandTest.php create mode 100644 tests/Unit/Gateway/GatewayPendingInputStoreTest.php create mode 100644 website/pages/docs/termux.php 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 c2e85d2..6b75202 100644 --- a/bin/kosmokrator +++ b/bin/kosmokrator @@ -12,6 +12,7 @@ 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; @@ -40,6 +41,7 @@ $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())); @@ -47,7 +49,7 @@ $console->addCommand(new TelegramGatewayWorkerCommand($kernel->getContainer())); // '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', 'gateway:telegram', 'gateway:telegram:worker', '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/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/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/AgentSessionBuilder.php b/src/Agent/AgentSessionBuilder.php index 5c790c3..4e649e1 100644 --- a/src/Agent/AgentSessionBuilder.php +++ b/src/Agent/AgentSessionBuilder.php @@ -167,7 +167,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'])) { @@ -298,7 +301,8 @@ public function buildGateway(RendererInterface $ui, array $options = []): AgentS $ui->initialize(); $llmFactory = new LlmClientFactory($this->container); - $llm = $llmFactory->create('ansi', $ui); + // Gateway surfaces need the same async-capable client selection as TUI. + $llm = $llmFactory->create('tui', $ui); if (! empty($options['model'])) { $llm->setModel($options['model']); diff --git a/src/Command/AgentCommand.php b/src/Command/AgentCommand.php index b4b47f0..620d3c9 100644 --- a/src/Command/AgentCommand.php +++ b/src/Command/AgentCommand.php @@ -266,7 +266,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."); } } 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 3e64672..f4e8a7d 100644 --- a/src/Command/Slash/SettingsCommand.php +++ b/src/Command/Slash/SettingsCommand.php @@ -15,6 +15,7 @@ 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; @@ -152,6 +153,7 @@ 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)); } diff --git a/src/Command/SlashCommandRegistryFactory.php b/src/Command/SlashCommandRegistryFactory.php index 654eaa6..e8adefa 100644 --- a/src/Command/SlashCommandRegistryFactory.php +++ b/src/Command/SlashCommandRegistryFactory.php @@ -22,6 +22,7 @@ public static function build( $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); @@ -38,7 +39,6 @@ public static function build( $registry->register(new Slash\ResumeCommand); $registry->register(new Slash\SettingsCommand($container)); $registry->register(new Slash\AgentsCommand); - $registry->register(new Slash\UpdateCommand($version)); $registry->register(new Slash\FeedbackCommand($version)); $registry->register(new Slash\RenameCommand); diff --git a/src/Command/TelegramGatewayCommand.php b/src/Command/TelegramGatewayCommand.php index 51e06fd..92fa790 100644 --- a/src/Command/TelegramGatewayCommand.php +++ b/src/Command/TelegramGatewayCommand.php @@ -9,6 +9,7 @@ use Kosmokrator\Gateway\GatewayApprovalStore; use Kosmokrator\Gateway\GatewayCheckpointStore; use Kosmokrator\Gateway\GatewayMessageStore; +use Kosmokrator\Gateway\GatewayPendingInputStore; use Kosmokrator\Gateway\GatewaySessionStore; use Kosmokrator\Gateway\Telegram\SymfonyProcessTelegramWorkerLauncher; use Kosmokrator\Gateway\Telegram\TelegramClient; @@ -36,8 +37,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $settings = $this->container->make(SettingsManager::class); $settings->setProjectRoot(InstructionLoader::gitRoot() ?? getcwd()); - $config = TelegramGatewayConfig::fromSettings($settings, $this->container->make('config')); - $secretToken = $this->container->make(SettingsRepositoryInterface::class)->get('global', 'gateway.telegram.token'); + $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, @@ -88,6 +90,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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()), ); diff --git a/src/Command/TelegramGatewayWorkerCommand.php b/src/Command/TelegramGatewayWorkerCommand.php index e66ecc8..66570f3 100644 --- a/src/Command/TelegramGatewayWorkerCommand.php +++ b/src/Command/TelegramGatewayWorkerCommand.php @@ -71,10 +71,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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()); - $config = TelegramGatewayConfig::fromSettings($settings, $this->container->make('config')); - $secretToken = $this->container->make(SettingsRepositoryInterface::class)->get('global', 'gateway.telegram.token'); + $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, @@ -117,7 +125,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int sessionId: $link?->sessionId ?? '', chatId: $event->chatId, threadId: $event->threadId, - approvalCallback: fn (): string => 'deny', + approvalCallback: function (int $approvalId): string { + return $this->awaitApprovalDecision($approvalId); + }, cancellation: fn () => $cancellation->getCancellation(), ); @@ -184,11 +194,60 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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/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 index 1f4cb87..50389c9 100644 --- a/src/Gateway/GatewaySessionContextPromptBuilder.php +++ b/src/Gateway/GatewaySessionContextPromptBuilder.php @@ -40,7 +40,7 @@ public static function build(GatewayMessageEvent $event, ?string $sessionId = nu $lines[] = ''; $lines[] = 'Gateway notes:'; $lines[] = '- Inline approval buttons may appear for dangerous operations.'; - $lines[] = '- The user can also reply with /approve, /deny, or /cancel.'; + $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/Telegram/SymfonyProcessTelegramWorkerLauncher.php b/src/Gateway/Telegram/SymfonyProcessTelegramWorkerLauncher.php index e0cb59e..cb92b9f 100644 --- a/src/Gateway/Telegram/SymfonyProcessTelegramWorkerLauncher.php +++ b/src/Gateway/Telegram/SymfonyProcessTelegramWorkerLauncher.php @@ -19,6 +19,11 @@ public function launch(GatewayMessageEvent $event): TelegramGatewayWorkerHandleI $phpBinary = (new PhpExecutableFinder)->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, @@ -27,8 +32,18 @@ public function launch(GatewayMessageEvent $event): TelegramGatewayWorkerHandleI '--event='.$payload, ], $this->projectRoot); $process->setTimeout(null); - $process->disableOutput(); - $process->start(); + $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 index a4a4b89..9732a56 100644 --- a/src/Gateway/Telegram/TelegramBotCommandCatalog.php +++ b/src/Gateway/Telegram/TelegramBotCommandCatalog.php @@ -76,6 +76,10 @@ public static function helpText(): string $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 index d896804..8547457 100644 --- a/src/Gateway/Telegram/TelegramClient.php +++ b/src/Gateway/Telegram/TelegramClient.php @@ -50,7 +50,7 @@ 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): 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, @@ -70,13 +70,17 @@ public function sendMessage(string $chatId, string $text, ?string $threadId = nu $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): array + public function editMessageText(string $chatId, int $messageId, string $text, ?array $replyMarkup = null, ?string $parseMode = null): array { $payload = [ 'chat_id' => $chatId, @@ -89,10 +93,14 @@ public function editMessageText(string $chatId, int $messageId, string $text, ?a $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): array + public function sendPhoto(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array { $payload = ['chat_id' => $chatId]; if ($threadId !== null) { @@ -101,6 +109,9 @@ public function sendPhoto(string $chatId, string $path, ?string $threadId = null 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; @@ -111,7 +122,7 @@ public function sendPhoto(string $chatId, string $path, ?string $threadId = null return $this->upload('sendPhoto', 'photo', $path, $payload); } - public function sendDocument(string $chatId, string $path, ?string $threadId = null, ?string $caption = null): array + public function sendDocument(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array { $payload = ['chat_id' => $chatId]; if ($threadId !== null) { @@ -120,6 +131,9 @@ public function sendDocument(string $chatId, string $path, ?string $threadId = n 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; @@ -130,7 +144,7 @@ public function sendDocument(string $chatId, string $path, ?string $threadId = n return $this->upload('sendDocument', 'document', $path, $payload); } - public function sendVoice(string $chatId, string $path, ?string $threadId = null, ?string $caption = null): array + public function sendVoice(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array { $payload = ['chat_id' => $chatId]; if ($threadId !== null) { @@ -139,10 +153,27 @@ public function sendVoice(string $chatId, string $path, ?string $threadId = null 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]; diff --git a/src/Gateway/Telegram/TelegramClientInterface.php b/src/Gateway/Telegram/TelegramClientInterface.php index 77cee10..f16ac26 100644 --- a/src/Gateway/Telegram/TelegramClientInterface.php +++ b/src/Gateway/Telegram/TelegramClientInterface.php @@ -24,27 +24,29 @@ 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): 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): 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): 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): 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): 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; diff --git a/src/Gateway/Telegram/TelegramGatewayConfig.php b/src/Gateway/Telegram/TelegramGatewayConfig.php index 88b1602..45c636c 100644 --- a/src/Gateway/Telegram/TelegramGatewayConfig.php +++ b/src/Gateway/Telegram/TelegramGatewayConfig.php @@ -5,6 +5,7 @@ namespace Kosmokrator\Gateway\Telegram; use Illuminate\Config\Repository; +use Kosmokrator\Session\SettingsRepositoryInterface; use Kosmokrator\Settings\SettingsManager; final readonly class TelegramGatewayConfig @@ -25,13 +26,15 @@ public function __construct( public int $pollTimeoutSeconds, ) {} - public static function fromSettings(SettingsManager $settings, Repository $config): self + public static function fromSettings(SettingsManager $settings, Repository $config, ?SettingsRepositoryInterface $repository = null): self { - $enabled = self::toBool($settings->getRaw('kosmokrator.gateway.telegram.enabled') + $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) ( - $settings->getRaw('kosmokrator.gateway.telegram.token') + $repository?->get('global', 'kosmokrator.gateway.telegram.token') + ?? $settings->getRaw('kosmokrator.gateway.telegram.token') ?? $config->get('kosmokrator.gateway.telegram.token', '') ?? getenv('KOSMOKRATOR_TELEGRAM_BOT_TOKEN') ?: '' @@ -41,27 +44,33 @@ public static function fromSettings(SettingsManager $settings, Repository $confi enabled: $enabled, token: $token, sessionMode: (string) ( - $settings->getRaw('kosmokrator.gateway.telegram.session_mode') + ($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( - $settings->getRaw('kosmokrator.gateway.telegram.allowed_users') + ($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( - $settings->getRaw('kosmokrator.gateway.telegram.allowed_chats') + ($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( - $settings->getRaw('kosmokrator.gateway.telegram.require_mention') + ($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( - $settings->getRaw('kosmokrator.gateway.telegram.free_response_chats') + ($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) ( - $settings->getRaw('kosmokrator.gateway.telegram.poll_timeout_seconds') + ($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) )), ); diff --git a/src/Gateway/Telegram/TelegramGatewayRenderer.php b/src/Gateway/Telegram/TelegramGatewayRenderer.php index 82b3b3b..a31c783 100644 --- a/src/Gateway/Telegram/TelegramGatewayRenderer.php +++ b/src/Gateway/Telegram/TelegramGatewayRenderer.php @@ -16,7 +16,10 @@ final class TelegramGatewayRenderer implements RendererInterface { private const EMPTY_INLINE_KEYBOARD = ['inline_keyboard' => []]; - private string $buffer = ''; + private const TELEGRAM_PARSE_MODE = 'HTML'; + + /** @var list */ + private array $answerSegments = ['']; private string $placeholderText = 'Thinking…'; @@ -26,10 +29,15 @@ final class TelegramGatewayRenderer implements RendererInterface private ?int $statusMessageId = null; - private ?int $answerMessageId = 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 */ @@ -63,6 +71,7 @@ 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) { @@ -100,7 +109,8 @@ public function showReasoningContent(string $content): void {} public function streamChunk(string $text): void { - $this->buffer .= $text; + $this->maybeSendTyping(); + $this->answerSegments[array_key_last($this->answerSegments)] .= $text; $this->flushBufferedText(false); } @@ -223,10 +233,23 @@ public function showAgentsDashboard(array $summary, array $allStats, ?\Closure $ 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 {} + 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 { @@ -238,27 +261,42 @@ public function askToolPermission(string $toolName, array $args): string arguments: $args, chatId: $this->chatId, threadId: $this->threadId, - requestMessageId: $this->answerMessageId, + requestMessageId: $this->answerMessageIds === [] ? null : $this->answerMessageIds[array_key_last($this->answerMessageIds)], ); $lines = [ - "Approval required for `{$toolName}`.", - 'Use the buttons below or reply with /approve or /deny.', + '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.', ]; - $this->client->sendMessage( + $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:approve:'.$approval->id], - ['text' => 'Deny', 'callback_data' => 'ga:deny:'.$approval->id], - ]], + '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); - $this->approvals->resolve($approval->id, $decision === 'deny' ? 'denied' : 'approved'); return $decision; } @@ -268,6 +306,11 @@ 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}"); } @@ -275,6 +318,11 @@ 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); } } @@ -282,6 +330,7 @@ public function updateToolExecuting(string $output): void public function clearToolExecuting(): void { $this->activeToolName = null; + $this->toolMessageId = null; } private function ensureStatusMessage(string $text): void @@ -298,26 +347,8 @@ private function ensureStatusMessage(string $text): void } } - private function ensureAnswerMessage(string $text): void - { - if ($this->answerMessageId !== null) { - return; - } - - $message = $this->client->sendMessage($this->chatId, $this->limit($text), $this->threadId); - $messageId = (int) ($message['message_id'] ?? 0); - if ($messageId > 0) { - $this->answerMessageId = $messageId; - $this->messages->save('telegram', $this->routeKey, 'response', $this->chatId, $messageId, $this->threadId); - } - } - private function flushBufferedText(bool $force): void { - if ($this->buffer === '') { - return; - } - $display = $this->visibleText(); if ($display === '') { return; @@ -328,11 +359,33 @@ private function flushBufferedText(bool $force): void return; } - $this->ensureAnswerMessage($display); - if ($this->answerMessageId !== null) { - $this->client->editMessageText($this->chatId, $this->answerMessageId, $this->limit($display)); - $this->lastFlushAt = $now; + $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 @@ -355,9 +408,22 @@ private function limit(string $text): string 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 $this->extractMediaPayload($this->buffer)['text']; + 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 @@ -371,6 +437,7 @@ private function composeStatusText(): string private function updateStatusMessage(string $text): void { + $this->maybeSendTyping(); $this->ensureStatusMessage($text); if ($this->statusMessageId !== null) { @@ -380,27 +447,29 @@ private function updateStatusMessage(string $text): void private function deliverMediaAttachments(): void { - $payload = $this->extractMediaPayload($this->buffer); - foreach ($payload['media'] as $item) { - $path = $item['path']; - if ($path === '' || ! is_file($path)) { - continue; - } + 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); + $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; - } + continue; + } - if ($item['voice'] && in_array($extension, ['ogg', 'opus', 'mp3', 'm4a', 'wav'], true)) { - $this->client->sendVoice($this->chatId, $path, $this->threadId); + if ($item['voice'] && in_array($extension, ['ogg', 'opus', 'mp3', 'm4a', 'wav'], true)) { + $this->client->sendVoice($this->chatId, $path, $this->threadId); - continue; - } + continue; + } - $this->client->sendDocument($this->chatId, $path, $this->threadId); + $this->client->sendDocument($this->chatId, $path, $this->threadId); + } } } @@ -433,4 +502,110 @@ private function extractMediaPayload(string $text): array '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 index b5b1258..369f7a8 100644 --- a/src/Gateway/Telegram/TelegramGatewayRuntime.php +++ b/src/Gateway/Telegram/TelegramGatewayRuntime.php @@ -10,11 +10,14 @@ use Kosmokrator\Gateway\GatewayCheckpointStore; use Kosmokrator\Gateway\GatewayMessageEvent; use Kosmokrator\Gateway\GatewayMessageStore; +use Kosmokrator\Gateway\GatewayPendingInputStore; use Kosmokrator\Gateway\GatewaySessionStore; use Psr\Log\LoggerInterface; final class TelegramGatewayRuntime { + private const MAX_POLL_BACKOFF_SECONDS = 30; + /** @var list> */ private array $backlog = []; @@ -32,6 +35,8 @@ final class TelegramGatewayRuntime */ private array $activeRoutes = []; + private int $pollFailureCount = 0; + public function __construct( private readonly Container $container, private readonly TelegramClientInterface $client, @@ -40,6 +45,7 @@ public function __construct( 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, ) { @@ -69,7 +75,21 @@ public function run(): never while (true) { $this->syncActiveRoutes(); - $updates = $this->nextBatch($offset); + 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; @@ -137,7 +157,7 @@ private function handleEvent(GatewayMessageEvent $event): void } if ($event->isCommand('/help')) { - $this->client->sendMessage($event->chatId, $this->helpText(), $event->threadId); + $this->client->sendMessage($event->chatId, $this->helpText(), $event->threadId, replyMarkup: $this->controlKeyboard()); return; } @@ -147,6 +167,7 @@ private function handleEvent(GatewayMessageEvent $event): void $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, @@ -157,9 +178,10 @@ private function handleEvent(GatewayMessageEvent $event): void '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); + $this->client->sendMessage($event->chatId, $text, $event->threadId, replyMarkup: $this->controlKeyboard()); return; } @@ -167,6 +189,7 @@ private function handleEvent(GatewayMessageEvent $event): void 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; @@ -186,16 +209,15 @@ private function handleEvent(GatewayMessageEvent $event): void } if ($event->isCommand('/approve') || $event->isCommand('/deny')) { - $pending = $this->approvals->resolveLatestPending( - 'telegram', - $event->routeKey, - $event->isCommand('/approve') ? 'approved' : 'denied', - ); + [$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.' - : ($event->isCommand('/approve') ? 'Approved.' : 'Denied.'), + : $message, $event->threadId, ); @@ -234,15 +256,21 @@ 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, - 'A run is already active for this chat. Use /status to inspect it or /cancel to stop it.', + '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); @@ -254,52 +282,6 @@ private function runAgentForEvent(GatewayMessageEvent $event): void ]; } - private function awaitApproval(int $approvalId, GatewayMessageEvent $origin): string - { - $normalizer = new TelegramUpdateNormalizer($this->botUsername); - $offset = $this->loadOffset(); - - while (true) { - $approval = $this->approvals->find($approvalId); - if ($approval !== null && $approval->status === 'approved') { - return 'allow'; - } - - if ($approval !== null && $approval->status === 'denied') { - return 'deny'; - } - - $updates = $this->nextBatch($offset); - 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)); - - if ($event->routeKey === $origin->routeKey) { - if ($event->isCommand('/approve')) { - $this->approvals->resolve($approvalId, 'approved'); - - return 'allow'; - } - - if ($event->isCommand('/deny') || $event->isCommand('/cancel')) { - $this->approvals->resolve($approvalId, 'denied'); - - return 'deny'; - } - } - - $this->backlog[] = $update; - } - } - } - private function helpText(): string { return TelegramBotCommandCatalog::helpText(); @@ -329,6 +311,11 @@ 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)); + } } } } @@ -339,7 +326,13 @@ private function handleCallbackQuery(GatewayMessageEvent $event): void return; } - if (preg_match('/^ga:(approve|deny):(\d+)$/', $event->text, $matches) !== 1) { + 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; @@ -353,18 +346,128 @@ private function handleCallbackQuery(GatewayMessageEvent $event): void return; } - $status = $matches[1] === 'approve' ? 'approved' : 'denied'; + $status = match ($matches[1]) { + 'allow' => 'approved', + 'always' => 'always', + 'guardian' => 'guardian', + 'prometheus' => 'prometheus', + default => 'denied', + }; $this->approvals->resolve($approvalId, $status); - $label = $status === 'approved' ? 'Approved' : 'Denied'; + $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} `{$approval->toolName}`.", + ''.$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/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/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/Session/Database.php b/src/Session/Database.php index 8ebe1a7..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 = 6; + private const SCHEMA_VERSION = 7; /** * @param string|null $path Absolute path to the SQLite database file, or ':memory:' for an ephemeral db. @@ -226,6 +226,18 @@ private function createInitialSchema(): void 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. */ @@ -313,6 +325,19 @@ private function migrate(int $from): void ) '); } + + 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/Update/SelfUpdater.php b/src/Update/SelfUpdater.php index 5e12b27..fb2c462 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,17 @@ 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) { + if (function_exists('http_get_last_response_headers')) { + $headers = http_get_last_response_headers() ?: []; + } else { + /** @var list $http_response_header */ + $headers = $http_response_header; + } + + 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 @@ +build('ansi', false); + $this->assertInstanceOf(UIManager::class, $session->ui); $this->assertSame('ansi', $session->ui->getActiveRenderer()); } 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/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/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/Telegram/FakeTelegramClient.php b/tests/Unit/Gateway/Telegram/FakeTelegramClient.php index c9650ae..988e817 100644 --- a/tests/Unit/Gateway/Telegram/FakeTelegramClient.php +++ b/tests/Unit/Gateway/Telegram/FakeTelegramClient.php @@ -32,6 +32,9 @@ final class FakeTelegramClient implements TelegramClientInterface /** @var list> */ public array $callbackAnswers = []; + /** @var list> */ + public array $chatActions = []; + public function __construct() {} public function setMyCommands(array $commands): void @@ -52,7 +55,7 @@ public function getUpdates(?int $offset, int $timeout): array return $batch; } - public function sendMessage(string $chatId, string $text, ?string $threadId = null, ?int $replyToMessageId = null, ?array $replyMarkup = null): array + 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, @@ -61,45 +64,52 @@ public function sendMessage(string $chatId, string $text, ?string $threadId = nu '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): array + 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): array + public function sendPhoto(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array { - $this->photos[] = compact('chatId', 'path', 'threadId', 'caption'); + $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): array + public function sendDocument(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array { - $this->documents[] = compact('chatId', 'path', 'threadId', 'caption'); + $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): array + public function sendVoice(string $chatId, string $path, ?string $threadId = null, ?string $caption = null, ?string $parseMode = null): array { - $this->voices[] = compact('chatId', 'path', 'threadId', 'caption'); + $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'); diff --git a/tests/Unit/Gateway/Telegram/TelegramGatewayRendererTest.php b/tests/Unit/Gateway/Telegram/TelegramGatewayRendererTest.php index 68f7163..1c9773e 100644 --- a/tests/Unit/Gateway/Telegram/TelegramGatewayRendererTest.php +++ b/tests/Unit/Gateway/Telegram/TelegramGatewayRendererTest.php @@ -38,6 +38,7 @@ public function test_show_notice_edits_existing_thinking_message(): void $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 @@ -63,7 +64,10 @@ public function test_ask_tool_permission_sends_inline_buttons(): void $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('Deny', $keyboard[0][1]['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 @@ -98,15 +102,17 @@ public function test_stream_complete_uses_separate_status_and_answer_messages(): $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_updates_status_message_only(): void + 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 (?, ?, ?, ?, ?, ?)') @@ -127,9 +133,64 @@ public function test_tool_execution_updates_status_message_only(): void $renderer->showToolCall('grep', ['pattern' => 'telegram']); $renderer->showToolExecuting('grep'); - $this->assertCount(1, $client->sent); + $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->assertSame('Using tool: grep', $client->edited[2]['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
index a545070..c5b9122 100644
--- a/tests/Unit/Gateway/Telegram/TelegramGatewayRuntimeTest.php
+++ b/tests/Unit/Gateway/Telegram/TelegramGatewayRuntimeTest.php
@@ -8,6 +8,7 @@
 use Kosmokrator\Gateway\GatewayApprovalStore;
 use Kosmokrator\Gateway\GatewayCheckpointStore;
 use Kosmokrator\Gateway\GatewayMessageStore;
+use Kosmokrator\Gateway\GatewayPendingInputStore;
 use Kosmokrator\Gateway\GatewaySessionStore;
 use Kosmokrator\Gateway\Telegram\TelegramGatewayConfig;
 use Kosmokrator\Gateway\Telegram\TelegramGatewayRuntime;
@@ -27,6 +28,7 @@ public function test_register_bot_commands_syncs_native_telegram_commands(): voi
             messages: new GatewayMessageStore($db),
             approvals: new GatewayApprovalStore($db),
             checkpoints: new GatewayCheckpointStore($db),
+            pendingInputs: new GatewayPendingInputStore($db),
             log: new NullLogger,
             launcher: new FakeTelegramWorkerLauncher,
         );
@@ -70,6 +72,7 @@ public function test_process_updates_handles_help_command_without_running_agent(
             messages: new GatewayMessageStore($db),
             approvals: new GatewayApprovalStore($db),
             checkpoints: new GatewayCheckpointStore($db),
+            pendingInputs: new GatewayPendingInputStore($db),
             log: new NullLogger,
             launcher: $launcher,
         );
@@ -104,6 +107,7 @@ public function test_process_updates_launches_worker_for_normal_message(): void
             messages: new GatewayMessageStore($db),
             approvals: new GatewayApprovalStore($db),
             checkpoints: new GatewayCheckpointStore($db),
+            pendingInputs: new GatewayPendingInputStore($db),
             log: new NullLogger,
             launcher: $launcher,
         );
@@ -143,6 +147,7 @@ public function test_status_reports_active_run_details(): void
             messages: new GatewayMessageStore($db),
             approvals: new GatewayApprovalStore($db),
             checkpoints: $checkpoints,
+            pendingInputs: new GatewayPendingInputStore($db),
             log: new NullLogger,
             launcher: $launcher,
         );
@@ -189,6 +194,7 @@ public function test_cancel_terminates_active_worker(): void
             messages: new GatewayMessageStore($db),
             approvals: new GatewayApprovalStore($db),
             checkpoints: new GatewayCheckpointStore($db),
+            pendingInputs: new GatewayPendingInputStore($db),
             log: new NullLogger,
             launcher: $launcher,
         );
@@ -236,6 +242,7 @@ public function test_callback_query_approves_pending_request(): void
             messages: new GatewayMessageStore($db),
             approvals: $approvals,
             checkpoints: new GatewayCheckpointStore($db),
+            pendingInputs: new GatewayPendingInputStore($db),
             log: new NullLogger,
             launcher: new FakeTelegramWorkerLauncher,
         );
@@ -245,7 +252,7 @@ public function test_callback_query_approves_pending_request(): void
             'update_id' => 3,
             'callback_query' => [
                 'id' => 'cbq-1',
-                'data' => 'ga:approve:'.$approval->id,
+                'data' => 'ga:allow:'.$approval->id,
                 'from' => ['id' => 5, 'username' => 'rutger'],
                 'message' => [
                     'message_id' => 99,
@@ -259,6 +266,230 @@ public function test_callback_query_approves_pending_request(): void
         $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('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/Session/DatabaseTest.php b/tests/Unit/Session/DatabaseTest.php
index 4432fbd..7424e25 100644
--- a/tests/Unit/Session/DatabaseTest.php
+++ b/tests/Unit/Session/DatabaseTest.php
@@ -27,6 +27,7 @@ public function test_creates_schema_on_fresh_database(): void
         $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);
     }
 
@@ -37,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(6, $version['version']);
+        $this->assertEquals(7, $version['version']);
     }
 
     public function test_idempotent_schema_creation(): void
@@ -47,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(6, $version['version']);
+        $this->assertEquals(7, $version['version']);
     }
 
     public function test_foreign_keys_enabled(): void
diff --git a/website/pages/docs/commands.php b/website/pages/docs/commands.php
index 3ee91eb..93bca59 100644
--- a/website/pages/docs/commands.php
+++ b/website/pages/docs/commands.php
@@ -196,11 +196,11 @@
     conversation history or context window. The agent retains full memory of the session.
 

-

/update

+

kosmokrator update

- 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. + 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.

/feedback <text> (aliases: /bug, /issue)

@@ -341,9 +341,9 @@ Clear the terminal display. - /update + kosmokrator update None - Check for and apply KosmoKrator updates. + Check for and apply KosmoKrator updates based on install method. /feedback @@ -923,4 +923,4 @@ 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 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

+ + + + + + + + + + + + + + + + + + + + + +
RendererTermux SupportNotes
ansiRecommendedPure escape codes, works everywhere, readline input
tuiExperimental + 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. +

+ + Date: Sun, 12 Apr 2026 03:42:43 +0200 Subject: [PATCH 3/8] feat: enhance session search with dual-mode and session_read tool session_search now supports two modes: browse (empty query returns recent sessions with titles, dates, message counts) and search (FTS5 grouped by session with match counts and surrounding context). New session_read tool loads full session transcripts by ID prefix for drill-down after search. Co-Authored-By: Claude Opus 4.6 (1M context) --- config/kosmokrator.yaml | 2 + src/Agent/AgentType.php | 6 +- src/Provider/ToolServiceProvider.php | 2 + src/Session/MessageRepository.php | 145 ++++++++++++++++++ src/Session/MessageRepositoryInterface.php | 20 +++ src/Session/SessionManager.php | 28 ++++ src/Session/Tool/SessionReadTool.php | 120 +++++++++++++++ src/Session/Tool/SessionSearchTool.php | 123 ++++++++++++--- src/UI/Tui/ExplorationClassifier.php | 2 + tests/Unit/Session/MessageRepositoryTest.php | 101 ++++++++++++ .../Unit/Session/Tool/SessionReadToolTest.php | 142 +++++++++++++++++ .../Session/Tool/SessionSearchToolTest.php | 100 +++++++++--- tests/Unit/Tool/ToolRegistryScopedTest.php | 2 + 13 files changed, 752 insertions(+), 41 deletions(-) create mode 100644 src/Session/Tool/SessionReadTool.php create mode 100644 tests/Unit/Session/Tool/SessionReadToolTest.php diff --git a/config/kosmokrator.yaml b/config/kosmokrator.yaml index 2aa22a4..ea8ab02 100644 --- a/config/kosmokrator.yaml +++ b/config/kosmokrator.yaml @@ -157,6 +157,8 @@ tools: - ask_user - ask_choice - subagent + - session_search + - session_read - lua_list_docs - lua_search_docs - lua_read_doc diff --git a/src/Agent/AgentType.php b/src/Agent/AgentType.php index 49b2aa3..62e676c 100644 --- a/src/Agent/AgentType.php +++ b/src/Agent/AgentType.php @@ -45,9 +45,9 @@ public function allowedTools(): array $luaTools = ['lua_list_docs', 'lua_search_docs', 'lua_read_doc', 'execute_lua']; 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', ...$luaTools], + self::Explore => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', 'session_read', ...$luaTools], + self::Plan => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', 'session_read', ...$luaTools], }; } diff --git a/src/Provider/ToolServiceProvider.php b/src/Provider/ToolServiceProvider.php index 7612b88..2476330 100644 --- a/src/Provider/ToolServiceProvider.php +++ b/src/Provider/ToolServiceProvider.php @@ -12,6 +12,7 @@ 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\Task\TaskStore; use Kosmokrator\Task\Tool\TaskCreateTool; @@ -149,6 +150,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/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/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/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/SessionReadToolTest.php b/tests/Unit/Session/Tool/SessionReadToolTest.php new file mode 100644 index 0000000..fa35ba1 --- /dev/null +++ b/tests/Unit/Session/Tool/SessionReadToolTest.php @@ -0,0 +1,142 @@ +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..b3d1d96 100644 --- a/tests/Unit/Session/Tool/SessionSearchToolTest.php +++ b/tests/Unit/Session/Tool/SessionSearchToolTest.php @@ -25,47 +25,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/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', ''); From d27138208519672501d46337c41367dcf209faed Mon Sep 17 00:00:00 2001 From: ruttydm Date: Sun, 12 Apr 2026 22:50:16 +0200 Subject: [PATCH 4/8] Fix setup command PHAR prompt fallback --- src/Command/SetupCommand.php | 32 +++++- tests/Unit/Command/SetupCommandTest.php | 143 +++++++++++++++++++++++- website/pages/docs/commands.php | 124 ++++++++++++++++++-- website/pages/docs/configuration.php | 88 ++++++++++++++- website/pages/docs/getting-started.php | 35 +++++- 5 files changed, 405 insertions(+), 17 deletions(-) diff --git a/src/Command/SetupCommand.php b/src/Command/SetupCommand.php index d761054..2eacde9 100644 --- a/src/Command/SetupCommand.php +++ b/src/Command/SetupCommand.php @@ -20,9 +20,14 @@ #[AsCommand(name: 'setup', description: 'Configure KosmoKrator (API keys, provider, model)')] class SetupCommand extends Command { + /** @var (callable(string): string)|null */ + private $promptReader; + public function __construct( private readonly Container $container, + ?callable $promptReader = null, ) { + $this->promptReader = $promptReader; parent::__construct(); } @@ -53,7 +58,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } echo "\n"; - $provider = readline("{$dim} Provider [{$currentProvider}]: {$r}") ?: $currentProvider; + $provider = $this->prompt("{$dim} Provider [{$currentProvider}]: {$r}") ?: $currentProvider; $provider = trim($provider); $definition = $providers->provider($provider); @@ -81,7 +86,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($currentModel === null || ! $providers->supportsModel($provider, $currentModel)) { $currentModel = $definition->defaultModel !== '' ? $definition->defaultModel : ($providerModels[0] ?? ''); } - $model = readline("{$dim} Model [{$currentModel}]: {$r}") ?: $currentModel; + $model = $this->prompt("{$dim} Model [{$currentModel}]: {$r}") ?: $currentModel; $model = trim($model); $configSettings->set('agent.default_provider', $provider, 'global'); @@ -90,7 +95,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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}"))); + $action = strtolower(trim($this->prompt("{$dim} Authenticate now? [browser/device/skip] {$r}"))); try { if ($action === 'browser' || $action === '') { @@ -118,7 +123,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $maskedKey = $currentKey !== '' ? substr($currentKey, 0, 8).'...'.substr($currentKey, -4) : ''; $keyPrompt = $maskedKey !== '' ? " [{$maskedKey}]" : ''; - $apiKey = readline("{$dim} API key{$keyPrompt}: {$r}") ?: $currentKey; + $apiKey = $this->prompt("{$dim} API key{$keyPrompt}: {$r}") ?: $currentKey; $apiKey = trim($apiKey); if ($apiKey === '') { @@ -135,4 +140,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + + private function prompt(string $message): string + { + if ($this->promptReader !== null) { + return (string) ($this->promptReader)($message); + } + + if (\function_exists('readline')) { + $input = \readline($message); + + return $input === false ? '' : $input; + } + + echo $message; + + $input = fgets(STDIN); + + return $input === false ? '' : rtrim($input, "\r\n"); + } } diff --git a/tests/Unit/Command/SetupCommandTest.php b/tests/Unit/Command/SetupCommandTest.php index c9f7771..3942c4b 100644 --- a/tests/Unit/Command/SetupCommandTest.php +++ b/tests/Unit/Command/SetupCommandTest.php @@ -4,29 +4,125 @@ namespace Kosmokrator\Tests\Unit\Command; +use Illuminate\Config\Repository; use Illuminate\Container\Container; use Kosmokrator\Command\SetupCommand; +use Kosmokrator\LLM\Codex\CodexAuthFlow; +use Kosmokrator\LLM\ProviderCatalog; +use Kosmokrator\Session\Database; +use Kosmokrator\Session\SettingsRepository; +use Kosmokrator\Session\SettingsRepositoryInterface; +use Kosmokrator\Settings\SettingsManager; +use Kosmokrator\Settings\SettingsSchema; +use Kosmokrator\Settings\YamlConfigStore; +use OpenCompany\PrismCodex\CodexOAuthService; +use OpenCompany\PrismCodex\Contracts\CodexTokenStore; +use OpenCompany\PrismCodex\ValueObjects\CodexToken; +use OpenCompany\PrismRelay\Meta\ProviderMeta; +use OpenCompany\PrismRelay\Registry\RelayRegistry; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Yaml\Yaml; -class SetupCommandTest extends TestCase +final class SetupCommandTest extends TestCase { + private string $originalHome; + + private string $tempHome; + + private Container $container; + + private SettingsRepository $settings; + + private SettingsManager $settingsManager; + private SetupCommand $command; private CommandTester $tester; protected function setUp(): void { - $container = new Container; + $this->originalHome = (string) getenv('HOME'); + $this->tempHome = sys_get_temp_dir().'/kk-setup-test-'.uniqid(); + mkdir($this->tempHome.'/.kosmokrator', 0777, true); + putenv("HOME={$this->tempHome}"); + + $configDir = dirname(__DIR__, 3).'/config'; + $defaults = []; + foreach (glob($configDir.'/*.yaml') as $file) { + $key = pathinfo($file, PATHINFO_FILENAME); + $defaults[$key] = Yaml::parse(file_get_contents($file)) ?? []; + } + + $config = new Repository($defaults); + $this->settings = new SettingsRepository(new Database(':memory:')); + $this->settingsManager = new SettingsManager( + $config, + new SettingsSchema, + new YamlConfigStore, + $configDir, + ); - $this->command = new SetupCommand($container); + $meta = new ProviderMeta([ + 'openai' => [ + 'default_model' => 'gpt-5.4-mini', + 'url' => 'https://api.openai.com/v1', + 'models' => [ + 'gpt-5.4-mini' => [ + 'display_name' => 'GPT-5.4 Mini', + 'context' => 128000, + 'max_output' => 16384, + ], + ], + ], + ]); + + $catalog = new ProviderCatalog( + $meta, + new RelayRegistry([ + 'openai' => [ + 'url' => 'https://api.openai.com/v1', + 'auth' => 'api_key', + 'driver' => 'openai', + ], + ]), + $config, + $this->settings, + $this->createTokenStore(), + ); + + $oauth = (new \ReflectionClass(CodexOAuthService::class))->newInstanceWithoutConstructor(); + $codex = new CodexAuthFlow($oauth, $this->createTokenStore(), $config); + + $this->container = new Container; + $this->container->instance('config', $config); + $this->container->instance(SettingsRepositoryInterface::class, $this->settings); + $this->container->instance(SettingsManager::class, $this->settingsManager); + $this->container->instance(ProviderCatalog::class, $catalog); + $this->container->instance(CodexAuthFlow::class, $codex); + + $promptValues = ['openai', 'gpt-5.4-mini', 'sk-test-1234']; + $this->command = new SetupCommand( + $this->container, + static function () use (&$promptValues): string { + return array_shift($promptValues) ?? ''; + }, + ); $app = new Application; $app->addCommand($this->command); $this->tester = new CommandTester($this->command); } + protected function tearDown(): void + { + putenv("HOME={$this->originalHome}"); + @unlink($this->tempHome.'/.kosmokrator/config.yaml'); + @rmdir($this->tempHome.'/.kosmokrator'); + @rmdir($this->tempHome); + } + public function test_command_name_is_setup(): void { $this->assertSame('setup', $this->command->getName()); @@ -36,4 +132,45 @@ public function test_command_has_correct_description(): void { $this->assertSame('Configure KosmoKrator (API keys, provider, model)', $this->command->getDescription()); } + + public function test_setup_command_can_run_without_readline_and_persists_settings(): void + { + ob_start(); + $exitCode = $this->tester->execute([]); + $display = (string) ob_get_clean(); + + $this->assertSame(0, $exitCode); + $this->assertStringContainsString('KosmoKrator Setup', $display); + $this->assertStringContainsString('Settings saved', $display); + $this->assertSame('sk-test-1234', $this->settings->get('global', 'provider.openai.api_key')); + + $globalConfig = Yaml::parseFile($this->tempHome.'/.kosmokrator/config.yaml'); + $this->assertSame('openai', $globalConfig['kosmokrator']['agent']['default_provider'] ?? null); + $this->assertSame('gpt-5.4-mini', $globalConfig['kosmokrator']['agent']['default_model'] ?? null); + } + + private function createTokenStore(): CodexTokenStore + { + return new class implements CodexTokenStore + { + private ?CodexToken $token = null; + + public function current(): ?CodexToken + { + return $this->token; + } + + public function save(CodexToken $token): CodexToken + { + $this->token = $token; + + return $token; + } + + public function clear(): void + { + $this->token = null; + } + }; + } } diff --git a/website/pages/docs/commands.php b/website/pages/docs/commands.php index 93bca59..9bdb3cd 100644 --- a/website/pages/docs/commands.php +++ b/website/pages/docs/commands.php @@ -9,7 +9,8 @@ typed at the input prompt with a / 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.

@@ -203,6 +229,82 @@ 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

+

+ 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)

Submit feedback or a bug report. Injects a prompt into the LLM conversation instructing the @@ -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. - - kosmokrator update - None - Check for and apply KosmoKrator updates based on install method. - /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

diff --git a/website/pages/docs/configuration.php b/website/pages/docs/configuration.php index 492a5c0..9fa8e27 100644 --- a/website/pages/docs/configuration.php +++ b/website/pages/docs/configuration.php @@ -164,7 +164,7 @@
-

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. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SettingTypeDefaultDescriptionEffect
gateway.telegram.enabledtoggleoffEnable or disable the Telegram gateway runtime.next session
gateway.telegram.session_modechoice: chat, chat_user, thread, thread_userthread + 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_userslistemptyOptional Telegram user allowlist. Accepts numeric user IDs and usernames. Empty means any user is allowed.next session
gateway.telegram.allowed_chatslistemptyOptional Telegram chat allowlist. Empty means all chats are allowed.next session
gateway.telegram.require_mentiontoggleonRequire a mention or direct reply in group chats before the bot responds. Direct messages are unaffected.next session
gateway.telegram.free_response_chatslistemptyChats that are allowed to receive normal free-form responses without mention gating.next session
gateway.telegram.poll_timeout_secondsnumber20Long-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. +

+ +
    +
  1. Open KosmoKrator locally and go to /settingsGateway.
  2. +
  3. Enable Telegram, store the bot token, and set an allowlist for your Telegram user ID or username.
  4. +
  5. Choose a session mode such as chat_user for isolated personal chats.
  6. +
  7. Start the gateway from your shell:
  8. +
+ +
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) { Date: Mon, 13 Apr 2026 12:05:08 +0200 Subject: [PATCH 5/8] Clean test suite and add Lua docs --- src/Update/SelfUpdater.php | 9 +- storage/logs/audio.log | 31 ++ tests/Unit/Agent/AgentLoopTest.php | 1 + tests/Unit/Agent/ContextBudgetTest.php | 2 + tests/Unit/Agent/ContextCompactorTest.php | 2 + tests/Unit/Agent/ContextManagerTest.php | 2 + tests/Unit/Audio/CompletionSoundTest.php | 2 + tests/Unit/Command/CodexLogoutCommandTest.php | 2 + tests/Unit/Command/CodexStatusCommandTest.php | 2 + .../Unit/Command/Slash/AgentsCommandTest.php | 4 +- tests/Unit/Command/Slash/ClearCommandTest.php | 2 + .../Command/Slash/SettingsCommandTest.php | 1 - .../Unit/Command/SlashCommandContextTest.php | 2 + tests/Unit/LLM/Codex/CodexAuthFlowTest.php | 2 + .../Unit/Session/Tool/MemorySaveToolTest.php | 2 + .../Session/Tool/MemorySearchToolTest.php | 2 + .../Unit/Session/Tool/SessionReadToolTest.php | 2 + .../Session/Tool/SessionSearchToolTest.php | 2 + tests/Unit/Skill/SkillDispatcherTest.php | 2 + tests/Unit/Tool/AskChoiceToolTest.php | 2 + tests/Unit/Tool/AskUserToolTest.php | 2 + tests/Unit/UI/Ansi/AnsiRendererTest.php | 2 + .../Unit/UI/Tui/Composition/TaskTreeTest.php | 2 + tests/Unit/UI/Tui/Toast/ToastItemTest.php | 1 - tests/Unit/UI/Tui/Toast/ToastManagerTest.php | 1 - tests/Unit/UI/Tui/TuiModalManagerTest.php | 2 + tests/Unit/UI/UIManagerTest.php | 3 +- website/pages/_docs-layout.php | 33 +- website/pages/docs/index.php | 33 +- website/pages/docs/lua.php | 301 ++++++++++++++++++ website/pages/docs/tools.php | 8 +- 31 files changed, 420 insertions(+), 44 deletions(-) create mode 100644 website/pages/docs/lua.php diff --git a/src/Update/SelfUpdater.php b/src/Update/SelfUpdater.php index fb2c462..939d92a 100644 --- a/src/Update/SelfUpdater.php +++ b/src/Update/SelfUpdater.php @@ -160,12 +160,9 @@ private function download(string $url, string $destination): void // Check HTTP status from response headers. $status = 0; - if (function_exists('http_get_last_response_headers')) { - $headers = http_get_last_response_headers() ?: []; - } else { - /** @var list $http_response_header */ - $headers = $http_response_header; - } + $headers = function_exists('http_get_last_response_headers') + ? (http_get_last_response_headers() ?: []) + : []; if ($headers !== []) { foreach ($headers as $header) { diff --git a/storage/logs/audio.log b/storage/logs/audio.log index 96a5718..6ca4df0 100644 --- a/storage/logs/audio.log +++ b/storage/logs/audio.log @@ -1278,3 +1278,34 @@ [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/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/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/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/SettingsCommandTest.php b/tests/Unit/Command/Slash/SettingsCommandTest.php index c244cdb..4d5e0b1 100644 --- a/tests/Unit/Command/Slash/SettingsCommandTest.php +++ b/tests/Unit/Command/Slash/SettingsCommandTest.php @@ -58,7 +58,6 @@ public function test_runtime_value_normalizes_array_fallbacks(): void $ctx = $this->createStub(SlashCommandContext::class); $method = new \ReflectionMethod($command, 'runtimeValue'); - $method->setAccessible(true); $value = $method->invoke($command, $ctx, 'gateway.telegram.allowed_users', ['alice', 'bob']); 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/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/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 index fa35ba1..e328faf 100644 --- a/tests/Unit/Session/Tool/SessionReadToolTest.php +++ b/tests/Unit/Session/Tool/SessionReadToolTest.php @@ -6,8 +6,10 @@ use Kosmokrator\Session\SessionManager; use Kosmokrator\Session\Tool\SessionReadTool; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; +#[AllowMockObjectsWithoutExpectations] class SessionReadToolTest extends TestCase { private SessionManager $session; diff --git a/tests/Unit/Session/Tool/SessionSearchToolTest.php b/tests/Unit/Session/Tool/SessionSearchToolTest.php index b3d1d96..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; 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/UI/Ansi/AnsiRendererTest.php b/tests/Unit/UI/Ansi/AnsiRendererTest.php index a7902c0..c60b541 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; 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/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/website/pages/_docs-layout.php b/website/pages/_docs-layout.php index 3a0fe20..a8dca9c 100644 --- a/website/pages/_docs-layout.php +++ b/website/pages/_docs-layout.php @@ -5,24 +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'], - 'termux' => ['Termux (Android)', 'Running on Android via Termux'], + '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(); @@ -32,9 +33,9 @@
@@ -47,4 +48,4 @@ ['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 @@
@@ -35,16 +36,16 @@

Everything you need to get the most out of KosmoKrator. Pick a topic to dive in.

- $info): ?> + $info) { ?>

- +
+ +

+ 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
  • +
+ + +

Lua Host Tools

+ + +

+ 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. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolRoleWhen to use it
lua_list_docsDiscovery catalogSee which namespaces exist without dumping full function references.
lua_search_docsKeyword searchFind likely namespaces or functions by term when you do not know the exact path.
lua_read_docDetailed referenceRead one namespace, one function, or one guide page before writing code.
execute_luaRuntime executionRun 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({...})
+ + +

Native Lua Tools: app.tools.*

+ + +

+ 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: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolPurpose
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
  • +
+ +

Return Shape

+ +

+ 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: +

+ +
    +
  1. lua_list_docs — see what namespaces exist
  2. +
  3. lua_search_docs — search by concept when needed
  4. +
  5. lua_read_doc page="integrations.NAME" — inspect the namespace
  6. +
  7. lua_read_doc page="integrations.NAME.function" — inspect one function
  8. +
  9. execute_lua — run the script
  10. +
+ +

+ 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)"
+ +

Using Native Tools from Lua

+ +
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. +
+ + +
+ 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) Date: Wed, 15 Apr 2026 13:36:33 +0200 Subject: [PATCH 6/8] Unify setup flow with settings workspace --- src/Command/AgentCommand.php | 19 +- src/Command/SetupCommand.php | 154 ++----------- src/Command/Slash/SettingsCommand.php | 52 ++++- src/Provider/CoreServiceProvider.php | 5 + src/Setup/SetupFlowInterface.php | 12 + src/Setup/SetupSettingsFlow.php | 193 ++++++++++++++++ src/UI/Ansi/AnsiCoreRenderer.php | 10 +- src/UI/Ansi/AnsiDialogRenderer.php | 117 +++++++++- src/UI/Tui/Widget/SettingsWorkspaceWidget.php | 28 ++- tests/Feature/AgentCommandTest.php | 48 +++- tests/Unit/Command/SetupCommandTest.php | 206 ++++++------------ 11 files changed, 541 insertions(+), 303 deletions(-) create mode 100644 src/Setup/SetupFlowInterface.php create mode 100644 src/Setup/SetupSettingsFlow.php diff --git a/src/Command/AgentCommand.php b/src/Command/AgentCommand.php index 620d3c9..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); diff --git a/src/Command/SetupCommand.php b/src/Command/SetupCommand.php index 2eacde9..100042a 100644 --- a/src/Command/SetupCommand.php +++ b/src/Command/SetupCommand.php @@ -1,162 +1,50 @@ promptReader = $promptReader; parent::__construct(); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function configure(): void { - $settings = $this->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"; - - $provider = $this->prompt("{$dim} Provider [{$currentProvider}]: {$r}") ?: $currentProvider; - $provider = trim($provider); - $definition = $providers->provider($provider); - - if ($definition === null) { - echo "\n{$primary} ✗ Unknown provider: {$provider}{$r}\n\n"; - - 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 = $this->prompt("{$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($this->prompt("{$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 = $this->prompt("{$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"; - - return Command::SUCCESS; + $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'); } - private function prompt(string $message): string + protected function execute(InputInterface $input, OutputInterface $output): int { - if ($this->promptReader !== null) { - return (string) ($this->promptReader)($message); - } + $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 (\function_exists('readline')) { - $input = \readline($message); + if (! $completed) { + $output->writeln('Setup incomplete. Configure a provider before continuing.'); - return $input === false ? '' : $input; + return Command::FAILURE; } - echo $message; + $output->writeln('Setup complete. Run `kosmokrator` to start.'); - $input = fgets(STDIN); - - return $input === false ? '' : rtrim($input, "\r\n"); + return Command::SUCCESS; } } diff --git a/src/Command/Slash/SettingsCommand.php b/src/Command/Slash/SettingsCommand.php index f4e8a7d..1c85435 100644 --- a/src/Command/Slash/SettingsCommand.php +++ b/src/Command/Slash/SettingsCommand.php @@ -22,6 +22,8 @@ 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; @@ -59,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'] : []; @@ -82,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'] ?? '')); @@ -104,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; @@ -115,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), @@ -151,14 +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) { @@ -187,8 +206,6 @@ public function execute(string $args, SlashCommandContext $ctx): SlashCommandRes if ($updatedKeys !== []) { $ctx->ui->showNotice('Settings updated: '.implode(', ', $updatedKeys)); } - - return SlashCommandResult::continue(); } /** @@ -227,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); 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/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/UI/Ansi/AnsiCoreRenderer.php b/src/UI/Ansi/AnsiCoreRenderer.php index c1b6266..312288a 100644 --- a/src/UI/Ansi/AnsiCoreRenderer.php +++ b/src/UI/Ansi/AnsiCoreRenderer.php @@ -112,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'; diff --git a/src/UI/Ansi/AnsiDialogRenderer.php b/src/UI/Ansi/AnsiDialogRenderer.php index f52bb2c..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; } @@ -295,7 +297,7 @@ public function approvePlan(string $currentPermissionMode): ?array echo "{$border} │{$r}\n"; while (true) { - $answer = readline("{$border} └ {$gold}Enter{$r}{$dim} implement / {$r}{$gold}d{$r}{$dim} dismiss ▸{$r} "); + $answer = $this->prompt("{$border} └ {$gold}Enter{$r}{$dim} implement / {$r}{$gold}d{$r}{$dim} dismiss ▸{$r} "); if ($answer === false) { return null; @@ -334,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); @@ -357,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)); @@ -369,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/Tui/Widget/SettingsWorkspaceWidget.php b/src/UI/Tui/Widget/SettingsWorkspaceWidget.php index ac3c978..ef396c1 100644 --- a/src/UI/Tui/Widget/SettingsWorkspaceWidget.php +++ b/src/UI/Tui/Widget/SettingsWorkspaceWidget.php @@ -105,6 +105,7 @@ public function __construct( } $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 */ 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/Command/SetupCommandTest.php b/tests/Unit/Command/SetupCommandTest.php index 3942c4b..a89b2b7 100644 --- a/tests/Unit/Command/SetupCommandTest.php +++ b/tests/Unit/Command/SetupCommandTest.php @@ -4,173 +4,103 @@ namespace Kosmokrator\Tests\Unit\Command; -use Illuminate\Config\Repository; use Illuminate\Container\Container; use Kosmokrator\Command\SetupCommand; -use Kosmokrator\LLM\Codex\CodexAuthFlow; -use Kosmokrator\LLM\ProviderCatalog; -use Kosmokrator\Session\Database; -use Kosmokrator\Session\SettingsRepository; -use Kosmokrator\Session\SettingsRepositoryInterface; -use Kosmokrator\Settings\SettingsManager; -use Kosmokrator\Settings\SettingsSchema; -use Kosmokrator\Settings\YamlConfigStore; -use OpenCompany\PrismCodex\CodexOAuthService; -use OpenCompany\PrismCodex\Contracts\CodexTokenStore; -use OpenCompany\PrismCodex\ValueObjects\CodexToken; -use OpenCompany\PrismRelay\Meta\ProviderMeta; -use OpenCompany\PrismRelay\Registry\RelayRegistry; +use Kosmokrator\Setup\SetupFlowInterface; use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\Yaml\Yaml; final class SetupCommandTest extends TestCase { - private string $originalHome; + public function test_command_name_is_setup(): void + { + $command = new SetupCommand($this->makeContainer(new FakeSetupFlow(true))); - private string $tempHome; + $this->assertSame('setup', $command->getName()); + } - private Container $container; + public function test_command_has_correct_description(): void + { + $command = new SetupCommand($this->makeContainer(new FakeSetupFlow(true))); - private SettingsRepository $settings; + $this->assertSame( + 'Open setup-focused settings for provider and model configuration', + $command->getDescription(), + ); + } - private SettingsManager $settingsManager; + 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))); - private SetupCommand $command; + $exitCode = $tester->execute(['--renderer' => 'ansi', '--no-animation' => true]); - private CommandTester $tester; + $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()); + } - protected function setUp(): void + public function test_setup_command_fails_when_setup_is_incomplete(): void { - $this->originalHome = (string) getenv('HOME'); - $this->tempHome = sys_get_temp_dir().'/kk-setup-test-'.uniqid(); - mkdir($this->tempHome.'/.kosmokrator', 0777, true); - putenv("HOME={$this->tempHome}"); - - $configDir = dirname(__DIR__, 3).'/config'; - $defaults = []; - foreach (glob($configDir.'/*.yaml') as $file) { - $key = pathinfo($file, PATHINFO_FILENAME); - $defaults[$key] = Yaml::parse(file_get_contents($file)) ?? []; - } - - $config = new Repository($defaults); - $this->settings = new SettingsRepository(new Database(':memory:')); - $this->settingsManager = new SettingsManager( - $config, - new SettingsSchema, - new YamlConfigStore, - $configDir, - ); + $flow = new FakeSetupFlow(false); + $tester = new CommandTester(new SetupCommand($this->makeContainer($flow))); - $meta = new ProviderMeta([ - 'openai' => [ - 'default_model' => 'gpt-5.4-mini', - 'url' => 'https://api.openai.com/v1', - 'models' => [ - 'gpt-5.4-mini' => [ - 'display_name' => 'GPT-5.4 Mini', - 'context' => 128000, - 'max_output' => 16384, - ], - ], - ], - ]); - - $catalog = new ProviderCatalog( - $meta, - new RelayRegistry([ - 'openai' => [ - 'url' => 'https://api.openai.com/v1', - 'auth' => 'api_key', - 'driver' => 'openai', - ], - ]), - $config, - $this->settings, - $this->createTokenStore(), - ); + $exitCode = $tester->execute([]); - $oauth = (new \ReflectionClass(CodexOAuthService::class))->newInstanceWithoutConstructor(); - $codex = new CodexAuthFlow($oauth, $this->createTokenStore(), $config); - - $this->container = new Container; - $this->container->instance('config', $config); - $this->container->instance(SettingsRepositoryInterface::class, $this->settings); - $this->container->instance(SettingsManager::class, $this->settingsManager); - $this->container->instance(ProviderCatalog::class, $catalog); - $this->container->instance(CodexAuthFlow::class, $codex); - - $promptValues = ['openai', 'gpt-5.4-mini', 'sk-test-1234']; - $this->command = new SetupCommand( - $this->container, - static function () use (&$promptValues): string { - return array_shift($promptValues) ?? ''; - }, + $this->assertSame(1, $exitCode); + $this->assertTrue($flow->opened); + $this->assertStringContainsString( + 'Setup incomplete. Configure a provider before continuing.', + $tester->getDisplay(), ); - - $app = new Application; - $app->addCommand($this->command); - $this->tester = new CommandTester($this->command); } - protected function tearDown(): void + private function makeContainer(SetupFlowInterface $flow): Container { - putenv("HOME={$this->originalHome}"); - @unlink($this->tempHome.'/.kosmokrator/config.yaml'); - @rmdir($this->tempHome.'/.kosmokrator'); - @rmdir($this->tempHome); - } + $container = new Container; + $container->instance(SetupFlowInterface::class, $flow); - public function test_command_name_is_setup(): void - { - $this->assertSame('setup', $this->command->getName()); + return $container; } +} - public function test_command_has_correct_description(): void - { - $this->assertSame('Configure KosmoKrator (API keys, provider, model)', $this->command->getDescription()); - } +final class FakeSetupFlow implements SetupFlowInterface +{ + public bool $opened = false; - public function test_setup_command_can_run_without_readline_and_persists_settings(): void - { - ob_start(); - $exitCode = $this->tester->execute([]); - $display = (string) ob_get_clean(); + public string $rendererPref = 'auto'; - $this->assertSame(0, $exitCode); - $this->assertStringContainsString('KosmoKrator Setup', $display); - $this->assertStringContainsString('Settings saved', $display); - $this->assertSame('sk-test-1234', $this->settings->get('global', 'provider.openai.api_key')); + public bool $animated = false; + + public bool $showIntro = false; + + public ?string $notice = null; - $globalConfig = Yaml::parseFile($this->tempHome.'/.kosmokrator/config.yaml'); - $this->assertSame('openai', $globalConfig['kosmokrator']['agent']['default_provider'] ?? null); - $this->assertSame('gpt-5.4-mini', $globalConfig['kosmokrator']['agent']['default_model'] ?? null); + public function __construct( + private readonly bool $completed, + ) {} + + public function needsProviderSetup(): bool + { + return true; } - private function createTokenStore(): CodexTokenStore + public function open(string $rendererPref = 'auto', bool $animated = false, bool $showIntro = false, ?string $notice = null): bool { - return new class implements CodexTokenStore - { - private ?CodexToken $token = null; - - public function current(): ?CodexToken - { - return $this->token; - } - - public function save(CodexToken $token): CodexToken - { - $this->token = $token; - - return $token; - } - - public function clear(): void - { - $this->token = null; - } - }; + $this->opened = true; + $this->rendererPref = $rendererPref; + $this->animated = $animated; + $this->showIntro = $showIntro; + $this->notice = $notice; + + return $this->completed; } } From 30b7d044e72e829e5ac9960532c1f45b9cecdf1e Mon Sep 17 00:00:00 2001 From: ruttydm Date: Tue, 21 Apr 2026 10:30:24 +0200 Subject: [PATCH 7/8] feat: add web_search and web_fetch tools with provider system Multi-provider web research layer: Tavily, Z.AI, Exa, Google Custom Search for search; Direct, Z.AI Reader, Firecrawl for fetch. Includes transient cache, HTML/Markdown extraction, safety guard, settings integration, and system prompt guidance for efficient web research via subagents. Co-Authored-By: Claude Opus 4.6 (1M context) --- composer.json | 1 + composer.lock | 91 +++- config/kosmokrator.yaml | 38 ++ src/Agent/AgentLoop.php | 3 + src/Agent/AgentMode.php | 8 +- src/Agent/AgentSessionBuilder.php | 25 +- src/Agent/AgentType.php | 7 +- src/Kernel.php | 2 + src/Provider/ToolServiceProvider.php | 13 + src/Provider/WebServiceProvider.php | 99 ++++ src/Settings/SettingsSchema.php | 32 ++ .../Permission/PermissionConfigParser.php | 1 + src/Tool/Web/WebFetchTool.php | 434 ++++++++++++++++++ src/Tool/Web/WebSearchTool.php | 152 ++++++ src/UI/Theme.php | 4 + src/Web/Cache/WebTransientCache.php | 88 ++++ src/Web/Contracts/WebFetchProvider.php | 17 + src/Web/Contracts/WebSearchProvider.php | 17 + .../Exception/WebFetchPermanentException.php | 7 + src/Web/Extract/HtmlPageExtractor.php | 283 ++++++++++++ src/Web/Extract/MarkdownPageExtractor.php | 146 ++++++ src/Web/Mcp/McpToolInvokerInterface.php | 15 + src/Web/Mcp/StreamableMcpToolInvoker.php | 228 +++++++++ .../Provider/Fetch/DirectFetchProvider.php | 159 +++++++ .../Provider/Fetch/ZaiReaderFetchProvider.php | 136 ++++++ .../Provider/Search/TavilySearchProvider.php | 100 ++++ .../Provider/Search/ZaiMcpSearchProvider.php | 386 ++++++++++++++++ src/Web/Provider/WebFetchProviderManager.php | 143 ++++++ src/Web/Provider/WebSearchProviderManager.php | 164 +++++++ src/Web/Safety/WebRequestGuard.php | 79 ++++ src/Web/Value/ExtractedPage.php | 21 + src/Web/Value/WebFetchRequest.php | 53 +++ src/Web/Value/WebFetchResponse.php | 33 ++ src/Web/Value/WebSearchHit.php | 17 + src/Web/Value/WebSearchRequest.php | 40 ++ src/Web/Value/WebSearchResponse.php | 20 + tests/Unit/Tool/Web/WebFetchToolTest.php | 216 +++++++++ tests/Unit/Tool/Web/WebSearchToolTest.php | 132 ++++++ tests/Unit/Web/DirectFetchProviderTest.php | 74 +++ tests/Unit/Web/HtmlPageExtractorTest.php | 73 +++ tests/Unit/Web/MarkdownPageExtractorTest.php | 46 ++ .../Unit/Web/StreamableMcpToolInvokerTest.php | 75 +++ tests/Unit/Web/WebProviderManagerTest.php | 265 +++++++++++ tests/Unit/Web/WebTransientCacheTest.php | 47 ++ tests/Unit/Web/ZaiProvidersTest.php | 221 +++++++++ 45 files changed, 4201 insertions(+), 10 deletions(-) create mode 100644 src/Provider/WebServiceProvider.php create mode 100644 src/Tool/Web/WebFetchTool.php create mode 100644 src/Tool/Web/WebSearchTool.php create mode 100644 src/Web/Cache/WebTransientCache.php create mode 100644 src/Web/Contracts/WebFetchProvider.php create mode 100644 src/Web/Contracts/WebSearchProvider.php create mode 100644 src/Web/Exception/WebFetchPermanentException.php create mode 100644 src/Web/Extract/HtmlPageExtractor.php create mode 100644 src/Web/Extract/MarkdownPageExtractor.php create mode 100644 src/Web/Mcp/McpToolInvokerInterface.php create mode 100644 src/Web/Mcp/StreamableMcpToolInvoker.php create mode 100644 src/Web/Provider/Fetch/DirectFetchProvider.php create mode 100644 src/Web/Provider/Fetch/ZaiReaderFetchProvider.php create mode 100644 src/Web/Provider/Search/TavilySearchProvider.php create mode 100644 src/Web/Provider/Search/ZaiMcpSearchProvider.php create mode 100644 src/Web/Provider/WebFetchProviderManager.php create mode 100644 src/Web/Provider/WebSearchProviderManager.php create mode 100644 src/Web/Safety/WebRequestGuard.php create mode 100644 src/Web/Value/ExtractedPage.php create mode 100644 src/Web/Value/WebFetchRequest.php create mode 100644 src/Web/Value/WebFetchResponse.php create mode 100644 src/Web/Value/WebSearchHit.php create mode 100644 src/Web/Value/WebSearchRequest.php create mode 100644 src/Web/Value/WebSearchResponse.php create mode 100644 tests/Unit/Tool/Web/WebFetchToolTest.php create mode 100644 tests/Unit/Tool/Web/WebSearchToolTest.php create mode 100644 tests/Unit/Web/DirectFetchProviderTest.php create mode 100644 tests/Unit/Web/HtmlPageExtractorTest.php create mode 100644 tests/Unit/Web/MarkdownPageExtractorTest.php create mode 100644 tests/Unit/Web/StreamableMcpToolInvokerTest.php create mode 100644 tests/Unit/Web/WebProviderManagerTest.php create mode 100644 tests/Unit/Web/WebTransientCacheTest.php create mode 100644 tests/Unit/Web/ZaiProvidersTest.php 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 ea8ab02..d8e0a2d 100644 --- a/config/kosmokrator.yaml +++ b/config/kosmokrator.yaml @@ -136,6 +136,42 @@ gateway: 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. @@ -159,6 +195,8 @@ tools: - subagent - session_search - session_read + - web_search + - web_fetch - lua_list_docs - lua_search_docs - lua_read_doc 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 4e649e1..726b335 100644 --- a/src/Agent/AgentSessionBuilder.php +++ b/src/Agent/AgentSessionBuilder.php @@ -21,6 +21,7 @@ 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; @@ -92,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); @@ -113,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) @@ -222,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); @@ -243,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 @@ -340,6 +343,7 @@ public function buildGateway(RendererInterface $ui, array $options = []): AgentS } $baseSystemPrompt .= $this->buildLuaDocsSuffix(); + $baseSystemPrompt .= $this->buildWebToolsSuffix($toolRegistry); $taskStore = $this->container->make(TaskStore::class); $contextFactory = new ContextPipelineFactory($sessionManager, $models, $taskStore, $log, $kosmokratorConfig); @@ -353,7 +357,7 @@ public function buildGateway(RendererInterface $ui, array $options = []): AgentS $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), ); if (! empty($options['max_turns'])) { @@ -424,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 62e676c..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', 'session_read', 'memory_save', ...$luaTools], - self::Explore => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', 'session_read', ...$luaTools], - self::Plan => ['file_read', 'glob', 'grep', 'bash', 'shell_start', 'shell_write', 'shell_read', 'shell_kill', 'subagent', 'memory_search', 'session_search', 'session_read', ...$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/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/Provider/ToolServiceProvider.php b/src/Provider/ToolServiceProvider.php index 2476330..04404c7 100644 --- a/src/Provider/ToolServiceProvider.php +++ b/src/Provider/ToolServiceProvider.php @@ -14,6 +14,7 @@ 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; @@ -44,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; @@ -125,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), 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/Settings/SettingsSchema.php b/src/Settings/SettingsSchema.php index ac9abf8..3f24917 100644 --- a/src/Settings/SettingsSchema.php +++ b/src/Settings/SettingsSchema.php @@ -360,6 +360,38 @@ private function buildDefinitions(): array 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/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/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/Web/Cache/WebTransientCache.php b/src/Web/Cache/WebTransientCache.php new file mode 100644 index 0000000..4d2ec1d --- /dev/null +++ b/src/Web/Cache/WebTransientCache.php @@ -0,0 +1,88 @@ + */ + 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..5fabd73 --- /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/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/Web/DirectFetchProviderTest.php b/tests/Unit/Web/DirectFetchProviderTest.php new file mode 100644 index 0000000..9bac3c4 --- /dev/null +++ b/tests/Unit/Web/DirectFetchProviderTest.php @@ -0,0 +1,74 @@ + + + + Example Docs + + + +

+

Authentication

+

Token auth details

+
+ + +HTML; + + $provider = new DirectFetchProvider( + new WebRequestGuard, + 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..f6ed871 --- /dev/null +++ b/tests/Unit/Web/ZaiProvidersTest.php @@ -0,0 +1,221 @@ +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', + ]); + + $provider = new ZaiReaderFetchProvider( + auth: $auth, + guard: new WebRequestGuard, + 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); + } + } +} From 0a6d0453089873c7a3fcc754ac3c1f99d56c2998 Mon Sep 17 00:00:00 2001 From: ruttydm Date: Tue, 21 Apr 2026 10:35:03 +0200 Subject: [PATCH 8/8] fix: update tests for web tools integration - AgentModeTest: update tool count to 27 (added web_search, web_fetch) - AnsiRendererTest: approvePlan now returns defaults in non-interactive mode - DirectFetchProviderTest/ZaiProvidersTest: mock WebRequestGuard to avoid DNS resolution in unit tests - Remove final from WebRequestGuard to enable mocking Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Web/Safety/WebRequestGuard.php | 2 +- tests/Unit/Agent/AgentModeTest.php | 4 +++- tests/Unit/UI/Ansi/AnsiRendererTest.php | 5 +++-- tests/Unit/Web/DirectFetchProviderTest.php | 4 +++- tests/Unit/Web/ZaiProvidersTest.php | 4 +++- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Web/Safety/WebRequestGuard.php b/src/Web/Safety/WebRequestGuard.php index 5fabd73..d49ff83 100644 --- a/src/Web/Safety/WebRequestGuard.php +++ b/src/Web/Safety/WebRequestGuard.php @@ -4,7 +4,7 @@ namespace Kosmokrator\Web\Safety; -final class WebRequestGuard +class WebRequestGuard { public function assertSafePublicUrl(string $url): void { 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/UI/Ansi/AnsiRendererTest.php b/tests/Unit/UI/Ansi/AnsiRendererTest.php index c60b541..d6391e9 100644 --- a/tests/Unit/UI/Ansi/AnsiRendererTest.php +++ b/tests/Unit/UI/Ansi/AnsiRendererTest.php @@ -44,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 diff --git a/tests/Unit/Web/DirectFetchProviderTest.php b/tests/Unit/Web/DirectFetchProviderTest.php index 9bac3c4..882083e 100644 --- a/tests/Unit/Web/DirectFetchProviderTest.php +++ b/tests/Unit/Web/DirectFetchProviderTest.php @@ -37,8 +37,10 @@ public function test_it_decodes_gzip_encoded_html_before_extraction(): void HTML; + $guard = $this->createMock(WebRequestGuard::class); + $provider = new DirectFetchProvider( - new WebRequestGuard, + $guard, new HtmlPageExtractor, httpClient: new HttpClient(new class(gzencode($html, 9)) implements DelegateHttpClient { diff --git a/tests/Unit/Web/ZaiProvidersTest.php b/tests/Unit/Web/ZaiProvidersTest.php index f6ed871..5834f28 100644 --- a/tests/Unit/Web/ZaiProvidersTest.php +++ b/tests/Unit/Web/ZaiProvidersTest.php @@ -67,9 +67,11 @@ public function test_zai_reader_fetch_provider_builds_sections_from_markdown(): 'apiKey' => 'zai-test-key', ]); + $guard = $this->createMock(WebRequestGuard::class); + $provider = new ZaiReaderFetchProvider( auth: $auth, - guard: new WebRequestGuard, + guard: $guard, extractor: new MarkdownPageExtractor, httpClient: new HttpClient(new class implements DelegateHttpClient {