diff --git a/.run/ManualTests.run.xml b/.run/ManualTests.run.xml
new file mode 100644
index 00000000..6c737b18
--- /dev/null
+++ b/.run/ManualTests.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.serena/memories/handoff/module-hygiene-dependency-analyze-2026-04-30.md b/.serena/memories/handoff/module-hygiene-dependency-analyze-2026-04-30.md
new file mode 100644
index 00000000..92a7971c
--- /dev/null
+++ b/.serena/memories/handoff/module-hygiene-dependency-analyze-2026-04-30.md
@@ -0,0 +1,165 @@
+# Handoff: module hygiene / dependency analyze / ArchUnit
+
+Date: 2026-04-30
+Project: open-daimon
+
+## User request
+Implement Maven Central readiness plan:
+- minimal dependency declarations per module (`declare what you use`)
+- reactor-wide `dependency:analyze`
+- wire `maven-dependency-plugin:analyze-only` into `verify` with `failOnWarning=true`
+- add ArchUnit boundary/layer rules
+- add Maven Enforcer rules: dependency convergence, upper bounds, ban commons-logging, ban Spring Boot starters in non-app modules.
+
+User then asked to split remaining work by module and persist state for a new session.
+
+## Important project constraints
+- Do not revert unrelated user/AI dirty changes.
+- Public APIs matter. Avoid public type/method removals/renames unless explicitly approved.
+- Modules are published/consumed independently; each module must declare directly-used libraries even if transitively available.
+- No `@Service`, `@Component`, `@Repository` in main sources; explicit `@Bean` config only.
+- Code/docs in repo must be English.
+
+## Dirty state known before this work
+Unrelated/generated files existed and should not be reverted unless user asks:
+- `.serena/project.yml` modified
+- docs/team files added
+- various repository interfaces had `@Repository` removed by prior work
+- some POMs were already partially edited
+
+## Completed changes
+### Root `pom.xml`
+- Spring Boot aligned to `3.5.13`.
+- Removed explicit Spring Framework BOM override.
+- Updated several managed versions:
+ - `postgresql.version=42.7.10`
+ - `flyway.version=11.7.2`
+ - `flyway-database-postgresql.version=11.7.2`
+ - `jakarta-xml-bind.version=4.0.4`
+ - `lombok.version=1.18.44`
+ - `testcontainers.version=1.21.4`
+ - `h2.version=2.3.232`
+ - `maven-dependency-plugin.version=3.8.1`
+ - `maven-enforcer-plugin.version=3.6.2`
+ - `archunit.version=1.4.2`
+- Added commons-logging exclusions to managed `httpclient` and `pdfbox`.
+- Added pluginManagement for `maven-dependency-plugin:analyze-only` bound to `verify` with `failOnWarning=true`, `ignoreNonCompile=true`, `outputXML=true`.
+- Added pluginManagement for `maven-enforcer-plugin` bound to `verify` with `dependencyConvergence`, `requireUpperBoundDeps`, and transitive banned `commons-logging:commons-logging`.
+- Activated dependency/enforcer plugins in root ``.
+
+### Module POMs
+- Copied dependency-cleanup baseline POMs from `../open-daimon-2` into current repo before patching further.
+- Added module-local enforcer config banning transitive `org.springframework.boot:spring-boot-starter*` in non-app modules:
+ - `opendaimon-common`
+ - `opendaimon-spring-ai`
+ - `opendaimon-rest`
+ - `opendaimon-telegram`
+ - `opendaimon-ui`
+ - `opendaimon-gateway-mock`
+- `opendaimon-app/pom.xml`: added `com.tngtech.archunit:archunit-junit5` test dependency and analyzer ignores for ArchUnit.
+- `opendaimon-spring-ai/pom.xml`: replaced Spring AI starter runtime deps with non-starter autoconfigure deps:
+ - `spring-ai-autoconfigure-model-chat-memory`
+ - `spring-ai-autoconfigure-model-chat-memory-repository-jdbc`
+- `opendaimon-common/pom.xml`: removed unused main deps reported by analyzer:
+ - `reactor-netty-http`
+ - `hibernate-validator`
+ - `postgresql`
+ - `micrometer-registry-prometheus`
+ - `resilience4j-spring-boot2`
+
+### Code boundary changes
+Moved direct repository access out of delivery/service clients and behind services:
+- `ConversationThreadService` gained:
+ - `findThreads(ThreadScopeKind scopeKind, Long scopeId)`
+ - `closeCurrentThread(ThreadScopeKind scopeKind, Long scopeId)`
+ - existing `findByThreadKey` marked read-only transactional
+- `OpenDaimonMessageService` gained:
+ - `findByThreadOrderBySequenceNumberAsc(ConversationThread thread)`
+ - `findByThreadAndSequenceNumberGreaterThanOrderBySequenceNumberAsc(ConversationThread thread, Integer minSequenceNumber)`
+- `HistoryTelegramCommandHandler` uses `ConversationThreadService` and `OpenDaimonMessageService`.
+- `ThreadsTelegramCommandHandler` uses `ConversationThreadService.findThreads`.
+- `NewThreadTelegramCommandHandler` uses `ConversationThreadService.closeCurrentThread`.
+- `SummarizingChatMemory` uses `ConversationThreadService` and `OpenDaimonMessageService`.
+- `TelegramCommandHandlerConfig` and `SpringAIAutoConfig` wiring updated accordingly.
+
+### Tests partially updated
+- `SummarizingChatMemoryTest` updated from repository mocks to service mocks.
+- Telegram handler tests were patched but not re-verified after patch due user interrupt:
+ - `ThreadsTelegramCommandHandlerTest`: removed repository mock and uses `threadService.findThreads`.
+ - `HistoryTelegramCommandHandlerTest`: uses `ConversationThreadService` and `OpenDaimonMessageService` mocks.
+ - `NewThreadTelegramCommandHandlerTest`: removed repository mock and verifies `closeCurrentThread`.
+
+### ArchUnit
+- Deleted old frozen `ArchitectureTest` and frozen store files:
+ - `opendaimon-app/src/test/resources/archunit.properties`
+ - files under `opendaimon-app/archunit_store/`
+- Added new `opendaimon-app/src/test/java/io/github/ngirchev/opendaimon/arch/ArchitectureTest.java` with rules:
+ - no `@Service`, `@Component`, `@Repository` in common/springai/telegram/rest/ui main packages
+ - no cyclic library module dependencies
+ - telegram must not depend on rest
+ - rest must not depend on telegram
+ - only app/root package may depend on multiple delivery channels
+ - repository layer may only be accessed by service/config layers
+
+## Verification completed before interrupt
+- `./mvnw -pl opendaimon-app -am clean compile -DskipTests` passed.
+- `./mvnw dependency:analyze -DskipTests` first failed on `SummarizingChatMemoryTest`; fixed.
+- Re-run of `dependency:analyze -DskipTests` progressed and found module warnings before telegram test compile failure:
+ - `opendaimon-common`: no dependency problems at that point.
+ - `opendaimon-spring-ai`: unused declared warnings for:
+ - `org.springframework.ai:spring-ai-autoconfigure-model-chat-memory` runtime
+ - `org.springframework.ai:spring-ai-autoconfigure-model-chat-memory-repository-jdbc` runtime
+ - `com.h2database:h2` test
+ - `opendaimon-rest`: warnings:
+ - unused declared `org.hamcrest:hamcrest:test`
+ - non-test scoped test-only `com.fasterxml.jackson.core:jackson-core:compile`
+ - non-test scoped test-only `org.springframework:spring-beans:compile`
+ - `opendaimon-telegram`: test compile failed because handler tests still used old constructors; patched afterwards, but not re-run.
+- Targeted command `./mvnw -pl opendaimon-telegram -am test -DskipITs -DskipIT -DfailIfNoTests=false` failed in upstream `opendaimon-common` tests because `hibernate-validator` had been removed and Spring configuration properties validation needs a provider at test runtime.
+
+## Current blocker at interrupt
+`opendaimon-common` tests fail with:
+`jakarta.validation.NoProviderFoundException: Unable to create a Configuration, because no Jakarta Bean Validation provider could be found.`
+This came from `BulkHeadPropertiesTest` loading Spring context. Likely fix: add `org.hibernate.validator:hibernate-validator` back as test-scoped dependency in `opendaimon-common`, not compile scoped, unless production module needs to provide validation provider to downstream consumers. Verify analyzer afterwards.
+
+## Suggested module-by-module continuation plan
+1. `opendaimon-common`
+ - Add `hibernate-validator` as test dependency or otherwise provide validation provider only for tests.
+ - Run: `./mvnw -pl opendaimon-common test dependency:analyze -DskipITs -DskipIT`.
+ - Ensure no analyzer warnings.
+
+2. `opendaimon-spring-ai`
+ - Decide on analyzer handling for runtime Spring AI autoconfig glue and H2.
+ - If runtime autoconfig jars are intentionally present for Boot auto-configuration, add module-local `ignoredUnusedDeclaredDependencies` with precise comments.
+ - Remove H2 if genuinely unused, or ignore if Boot test infra loads it implicitly.
+ - Review `jakarta.persistence-api`: currently test scoped and compile has warnings about missing enum constants during app compile; may need compile scope if main bytecode references persistence types indirectly.
+ - Run: `./mvnw -pl opendaimon-spring-ai -am clean compile test dependency:analyze -DskipITs -DskipIT`.
+
+3. `opendaimon-rest`
+ - Remove `org.hamcrest:hamcrest` if no direct imports.
+ - For `spring-beans` and `jackson-core`, either move to test scope if truly test-only, or add `ignoredNonTestScopedDependencies` if they must remain main-runtime deps. Existing comment incorrectly only handles unused-declared category.
+ - Run: `./mvnw -pl opendaimon-rest -am clean compile test dependency:analyze -DskipITs -DskipIT`.
+
+4. `opendaimon-telegram`
+ - Re-run tests after patched constructors.
+ - Confirm Caffeine is declared directly because `TelegramChatPacerImpl` imports it.
+ - Run: `./mvnw -pl opendaimon-telegram -am clean compile test dependency:analyze -DskipITs -DskipIT`.
+
+5. `opendaimon-ui` and `opendaimon-gateway-mock`
+ - Run module analyzer/enforcer separately and fix only local warnings.
+
+6. `opendaimon-app` ArchUnit
+ - Run: `./mvnw -pl opendaimon-app -am test -Dtest=ArchitectureTest -Dsurefire.failIfNoSpecifiedTests=false`.
+ - Fix real violations, do not restore freeze store.
+
+7. Reactor final checks
+ - `./mvnw clean compile`
+ - `./mvnw dependency:analyze -DskipTests`
+ - targeted ArchUnit
+ - `./mvnw clean verify`
+
+## Notes for next session
+- Do not keep editing globally. Finish one module at a time and verify that module before moving on.
+- Watch Maven Enforcer merge behavior: module-local banned starter config may override root rules unless Maven merges as expected. Confirm with `clean verify`.
+- The banned starter pattern `org.springframework.boot:spring-boot-starter*` may need to be split into `spring-boot-starter` and `spring-boot-starter-*` if enforcer does not match as intended.
+- If Maven needs network and sandbox blocks it, rerun exact command with escalation per Codex instructions.
\ No newline at end of file
diff --git a/.serena/memories/workflow/subagent_usage_preference.md b/.serena/memories/workflow/subagent_usage_preference.md
new file mode 100644
index 00000000..bbe35c2e
--- /dev/null
+++ b/.serena/memories/workflow/subagent_usage_preference.md
@@ -0,0 +1,12 @@
+# Subagent Usage Preference
+
+User asked to use subagents autonomously only for larger work, especially when many modules are involved, to save main context.
+
+Apply this rule conservatively:
+- Do not spawn subagents for small, single-file, or straightforward tasks.
+- Consider subagents for large multi-module changes, broad investigations, parallel verification, or independent review tracks.
+- Keep delegated tasks concrete and bounded, with disjoint responsibilities where code edits are involved.
+- Continue to do the immediate blocking work locally; delegate only side work that can run in parallel.
+- Summarize subagent results back into the main thread instead of carrying all raw context forward.
+
+This preference does not override Codex/developer constraints: only use subagents when the user has authorized delegation/subagent use, and avoid unnecessary delegation.
\ No newline at end of file
diff --git a/.serena/project.yml b/.serena/project.yml
index 99b3bd60..7d9c2a11 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -3,15 +3,18 @@ project_name: "open-daimon"
# list of languages for which language servers are started; choose from:
-# al bash clojure cpp csharp
-# csharp_omnisharp dart elixir elm erlang
-# fortran fsharp go groovy haskell
-# java julia kotlin lua markdown
-# matlab nix pascal perl php
-# php_phpactor powershell python python_jedi r
-# rego ruby ruby_solargraph rust scala
-# swift terraform toml typescript typescript_vts
-# vue yaml zig
+# al ansible bash clojure cpp
+# cpp_ccls crystal csharp csharp_omnisharp dart
+# elixir elm erlang fortran fsharp
+# go groovy haskell haxe hlsl
+# java json julia kotlin lean4
+# lua luau markdown matlab msl
+# nix ocaml pascal perl php
+# php_phpactor powershell python python_jedi python_ty
+# r rego ruby ruby_solargraph rust
+# scala solidity swift systemverilog terraform
+# toml typescript typescript_vts vue yaml
+# zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
@@ -65,53 +68,17 @@ read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
-#
-# Below is the complete list of tools for convenience.
-# To make sure you have the latest list of tools, and to view their descriptions,
-# execute `uv run scripts/print_tool_overview.py`.
-#
-# * `activate_project`: Activates a project by name.
-# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
-# * `create_text_file`: Creates/overwrites a file in the project directory.
-# * `delete_lines`: Deletes a range of lines within a file.
-# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
-# * `execute_shell_command`: Executes a shell command.
-# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
-# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
-# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
-# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
-# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
-# * `initial_instructions`: Gets the initial instructions for the current project.
-# Should only be used in settings where the system prompt cannot be set,
-# e.g. in clients you have no control over, like Claude Desktop.
-# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
-# * `insert_at_line`: Inserts content at a given line in a file.
-# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
-# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
-# * `list_memories`: Lists memories in Serena's project-specific memory store.
-# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
-# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
-# * `read_file`: Reads a file within the project directory.
-# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
-# * `remove_project`: Removes a project from the Serena configuration.
-# * `replace_lines`: Replaces a range of lines within a file with new content.
-# * `replace_symbol_body`: Replaces the full definition of a symbol.
-# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
-# * `search_for_pattern`: Performs a search for a pattern in the project.
-# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
-# * `switch_modes`: Activates modes by providing a list of their names
-# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
-# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
-# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
-# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
@@ -122,11 +89,14 @@ fixed_tools: []
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
-# list of mode names that are to be activated by default.
-# The full set of modes to be activated is base_modes + default_modes.
-# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# list of mode names that are to be activated by default, overriding the setting in the global configuration.
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
+# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -150,3 +120,8 @@ read_only_memory_patterns: []
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
+
+# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
+added_modes:
diff --git a/AGENTS.md b/AGENTS.md
index 00d61be6..00a321cb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,18 +21,37 @@ Consequences for any change touching `pom.xml`, public types, or shared APIs:
## Rules for AI Agents
-### Serena activation on session start
+### Codex subagents
-- At the beginning of each new session in this repository, verify Serena state first.
-- If Serena reports `Active Project: None`, immediately call `activate_project("open-daimon")`.
-- Do this before any code exploration or edits to ensure project-aware symbol tooling works correctly.
+- Use Codex subagents only when the user explicitly asks for delegation, parallel agent work, or a subagent.
+- For small, bounded side tasks, prefer a Spark-backed Codex subagent with `model: gpt-5.3-codex-spark` and the lightest reasoning effort that fits the task.
+- Keep Spark subagent work concrete and sidecar: codebase lookup, narrow verification, or a small disjoint patch. Do not hand off the immediate blocking task if the main agent needs that result before moving.
+- When assigning a worker subagent, define its owned files or module clearly, and tell it that other changes may exist in the same worktree and must not be reverted.
+
+### Serena project context
+
+- Before using Serena tools for project-aware navigation, silently verify that the active project is `open-daimon`.
+- If Serena is inactive or points to another project, activate `open-daimon`.
+- Do not mention this check in user-facing updates unless activation fails or the Serena state is directly relevant to the task.
### MCP tools for information lookup
-- Two MCP servers are available and should be used for information lookup when relevant:
+- MCP servers are available and should be used for information lookup when relevant:
- `Serena` — codebase navigation, symbol search, and project-aware exploration.
+ - `JetBrains` — IDE-indexed code search/navigation, symbol documentation, rename refactoring, open-editor context, and inspections.
- `Context7` — library/framework documentation lookup and API usage search.
- Prefer these MCP tools first for discovery and verification before broader ad-hoc searching.
+- Prefer JetBrains MCP for Java refactoring and IDE-backed checks: use it before text-only replacement for renames, before broad shell search when IDE indexing is likely more precise, and for targeted file diagnostics after edits.
+- Prefer Context7 for Spring AI, OpenAI API, MCP SDK/transport, Maven plugin, and dependency API questions before answering or implementing from memory.
+
+### Code exploration with ast-outline
+
+- Use `ast-outline` as a pre-read layer for supported source and documentation files when a structural view is enough.
+- For unfamiliar directories, start with `ast-outline digest ` to get a compact type and public-method map.
+- For file-level shape, use `ast-outline ` to inspect declarations with line ranges and without method bodies.
+- For one method, type, markdown heading, or YAML key, use `ast-outline show ` and then read the full file only if the extracted context is not enough.
+- For implementation lookups, use `ast-outline implements ` when an AST-based search is more precise than text search.
+- Batch paths in one call where useful. `ast-outline` complements `rg`, Serena, and JetBrains; it does not replace IDE-backed symbol navigation or full reads when exact code context is needed.
### Documentation maintenance
@@ -40,6 +59,12 @@ Consequences for any change touching `pom.xml`, public types, or shared APIs:
- If you add or change a use case, command flow, branching condition, input/output format, or error path — update the corresponding doc in the same commit.
- Docs live next to the module root (e.g. `opendaimon-spring-ai/SPRING_AI_MODULE.md`, `opendaimon-telegram/TELEGRAM_MODULE.md`).
+### ArchUnit scope
+
+- Keep ArchUnit focused on modules with meaningful architectural boundaries: `opendaimon-common`, `opendaimon-spring-ai`, `opendaimon-telegram`, `opendaimon-rest`, and cross-module checks from `opendaimon-app`.
+- Do not add module-local ArchUnit suites to `opendaimon-ui` or `opendaimon-gateway-mock` while they remain thin support modules without their own repository/domain/service layering.
+- For `opendaimon-ui` and `opendaimon-gateway-mock`, prefer compile checks, dependency analysis/enforcer checks, and focused behavior tests when behavior changes. Reconsider ArchUnit only if one of these modules grows stable internal architectural boundaries that need executable enforcement.
+
### Language in code and documentation
- **Code, comments, javadoc, commit messages, and in-repo documentation** must be written in **English**.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 309deec7..78dce6a0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -75,6 +75,16 @@ Before submitting a Pull Request, ensure:
4. JavaDoc is added or updated for public APIs where relevant.
5. No secrets or API keys are committed (use environment variables or `.env`).
+## Contribution licensing
+
+By submitting a contribution, you certify that you have the right to submit it
+and agree to license it under the Apache License, Version 2.0.
+
+You also grant Nikolai Girchev a perpetual, worldwide, non-exclusive,
+royalty-free right to use, reproduce, modify, distribute, sublicense, and
+relicense your contribution as part of OpenDaimon and related commercial or
+closed-source products.
+
## Security
- **API keys and secrets**: Only in environment variables or `.env` (and `.env` must not be committed).
diff --git a/Dockerfile b/Dockerfile
index 4cdea25c..baf1a4ae 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,6 +8,7 @@ ARG APP_VERSION=1.0.0-SNAPSHOT
COPY pom.xml .
COPY opendaimon-common/pom.xml ./opendaimon-common/
COPY opendaimon-spring-ai/pom.xml ./opendaimon-spring-ai/
+COPY opendaimon-spring-boot-starter/pom.xml ./opendaimon-spring-boot-starter/
COPY opendaimon-ui/pom.xml ./opendaimon-ui/
COPY opendaimon-rest/pom.xml ./opendaimon-rest/
COPY opendaimon-telegram/pom.xml ./opendaimon-telegram/
@@ -43,4 +44,3 @@ EXPOSE 8080
# Run application
ENTRYPOINT ["java", "-jar", "app.jar"]
-
diff --git a/LICENSE b/LICENSE
index c686a91d..66af111c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,182 @@
-MIT License
-
-Copyright (c) 2026 Nikolai Girchev
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and
+distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright
+owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities
+that control, are controlled by, or are under common control with that entity.
+For the purposes of this definition, "control" means (i) the power, direct or
+indirect, to cause the direction or management of such entity, whether by
+contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
+outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising
+permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including
+but not limited to software source code, documentation source, and configuration
+files.
+
+"Object" form shall mean any form resulting from mechanical transformation or
+translation of a Source form, including but not limited to compiled object code,
+generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form,
+made available under the License, as indicated by a copyright notice that is
+included in or attached to the work (an example is provided in the Appendix
+below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that
+is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative Works
+shall not include works that remain separable from, or merely link (or bind by
+name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version
+of the Work and any modifications or additions to that Work or Derivative Works
+thereof, that is intentionally submitted to Licensor for inclusion in the Work by
+the copyright owner or by an individual or Legal Entity authorized to submit on
+behalf of the copyright owner. For the purposes of this definition, "submitted"
+means any form of electronic, verbal, or written communication sent to the
+Licensor or its representatives, including but not limited to communication on
+electronic mailing lists, source code control systems, and issue tracking systems
+that are managed by, or on behalf of, the Licensor for the purpose of discussing
+and improving the Work, but excluding communication that is conspicuously marked
+or otherwise designated in writing by the copyright owner as "Not a
+Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently
+incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this
+License, each Contributor hereby grants to You a perpetual, worldwide,
+non-exclusive, no-charge, royalty-free, irrevocable copyright license to
+reproduce, prepare Derivative Works of, publicly display, publicly perform,
+sublicense, and distribute the Work and such Derivative Works in Source or
+Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License,
+each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section) patent
+license to make, have made, use, offer to sell, sell, import, and otherwise
+transfer the Work, where such license applies only to those patent claims
+licensable by such Contributor that are necessarily infringed by their
+Contribution(s) alone or by combination of their Contribution(s) with the Work to
+which such Contribution(s) was submitted. If You institute patent litigation
+against any entity (including a cross-claim or counterclaim in a lawsuit)
+alleging that the Work or a Contribution incorporated within the Work constitutes
+direct or contributory patent infringement, then any patent licenses granted to
+You under this License for that Work shall terminate as of the date such
+litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or
+Derivative Works thereof in any medium, with or without modifications, and in
+Source or Object form, provided that You meet the following conditions:
+
+(a) You must give any other recipients of the Work or Derivative Works a copy of
+this License; and
+
+(b) You must cause any modified files to carry prominent notices stating that You
+changed the files; and
+
+(c) You must retain, in the Source form of any Derivative Works that You
+distribute, all copyright, patent, trademark, and attribution notices from the
+Source form of the Work, excluding those notices that do not pertain to any part
+of the Derivative Works; and
+
+(d) If the Work includes a "NOTICE" text file as part of its distribution, then
+any Derivative Works that You distribute must include a readable copy of the
+attribution notices contained within such NOTICE file, excluding those notices
+that do not pertain to any part of the Derivative Works, in at least one of the
+following places: within a NOTICE text file distributed as part of the Derivative
+Works; within the Source form or documentation, if provided along with the
+Derivative Works; or, within a display generated by the Derivative Works, if and
+wherever such third-party notices normally appear. The contents of the NOTICE
+file are for informational purposes only and do not modify the License. You may
+add Your own attribution notices within Derivative Works that You distribute,
+alongside or as an addendum to the NOTICE text from the Work, provided that such
+additional attribution notices cannot be construed as modifying the License.
+
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction, or
+distribution of Your modifications, or for any such Derivative Works as a whole,
+provided Your use, reproduction, and distribution of the Work otherwise complies
+with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any
+Contribution intentionally submitted for inclusion in the Work by You to the
+Licensor shall be under the terms and conditions of this License, without any
+additional terms or conditions. Notwithstanding the above, nothing herein shall
+supersede or modify the terms of any separate license agreement you may have
+executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names,
+trademarks, service marks, or product names of the Licensor, except as required
+for reasonable and customary use in describing the origin of the Work and
+reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
+writing, Licensor provides the Work (and each Contributor provides its
+Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+either express or implied, including, without limitation, any warranties or
+conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+PARTICULAR PURPOSE. You are solely responsible for determining the
+appropriateness of using or redistributing the Work and assume any risks
+associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in
+tort (including negligence), contract, or otherwise, unless required by
+applicable law (such as deliberate and grossly negligent acts) or agreed to in
+writing, shall any Contributor be liable to You for damages, including any
+direct, indirect, special, incidental, or consequential damages of any character
+arising as a result of this License or out of the use or inability to use the
+Work (including but not limited to damages for loss of goodwill, work stoppage,
+computer failure or malfunction, or any and all other commercial damages or
+losses), even if such Contributor has been advised of the possibility of such
+damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or
+Derivative Works thereof, You may choose to offer, and charge a fee for,
+acceptance of support, warranty, indemnity, or other liability obligations and/or
+rights consistent with this License. However, in accepting such obligations, You
+may act only on Your own behalf and on Your sole responsibility, not on behalf of
+any other Contributor, and only if You agree to indemnify, defend, and hold each
+Contributor harmless for any liability incurred by, or claims asserted against,
+such Contributor by reason of your accepting any such warranty or additional
+liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following boilerplate
+notice, with the fields enclosed by brackets "[]" replaced with your own
+identifying information. (Do not include the brackets!) The text should be
+enclosed in the appropriate comment syntax for the file format. We also
+recommend that a file or class name and description of purpose be included on the
+same "printed page" as the copyright notice for easier identification within
+third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..131397f6
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,6 @@
+OpenDaimon
+Copyright 2026 Nikolai Girchev
+
+This product includes software developed by Nikolai Girchev.
+
+OpenDaimon is distributed under the Apache License, Version 2.0.
diff --git a/README.md b/README.md
index 1bb61864..2e1d4f74 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,9 @@
[](https://openjdk.org/)
[](https://spring.io/projects/spring-boot)
-[](https://github.com/NGirchev/open-daimon/blob/master/LICENSE)
+[](https://github.com/NGirchev/open-daimon/blob/master/LICENSE)
+
+
## Quick Setup
@@ -711,6 +713,7 @@ File -> Invalidate Caches / Restart
- **[docs/setup-telegram.md](docs/setup-telegram.md)** — Create a Telegram bot and get your user ID
- **[docs/setup-serper.md](docs/setup-serper.md)** — Enable web search (optional)
+- **[docs/codex/setup.md](docs/codex/setup.md)** — Recreate the Codex, MCP, and Serena workstation setup
### Project docs
@@ -762,4 +765,10 @@ docker-compose -H tcp://localhost:23750 up -d
## License
-See [LICENSE](LICENSE) file for details.
+OpenDaimon is licensed under the Apache License, Version 2.0. See
+[LICENSE](LICENSE) and [NOTICE](NOTICE) for details.
+
+The Apache License does not grant trademark rights. If you distribute a fork,
+modified version, hosted service, or commercial product based on OpenDaimon, use
+a distinct product name and preserve the required attribution notices. See
+[TRADEMARKS.md](TRADEMARKS.md).
diff --git a/Screen Recording 2026-05-03 at 23.28.03.gif b/Screen Recording 2026-05-03 at 23.28.03.gif
new file mode 100644
index 00000000..47fb8a5f
Binary files /dev/null and b/Screen Recording 2026-05-03 at 23.28.03.gif differ
diff --git a/TODO.md b/TODO.md
index 1d00d386..b710face 100644
--- a/TODO.md
+++ b/TODO.md
@@ -46,14 +46,79 @@
- [ ] Provider Registry — replace ProviderType enum with String + Strategy pattern ([plan](docs/provider-registry-plan.md))
- [ ] Different models in the flow
- [ ] Add balance loader
+- [ ] Do not show the embedding models (add hide param for models to application.yml)
- [x] WebTools need to parse result — JSoup-based HTML parsing in `WebTools.java:5,173` strips markup and returns clean text to the model
-- [ ] **opendaimon-spring-boot-starter** — auto-configuration starter for easy integration
- - [ ] New module `opendaimon-spring-boot-starter` with `AutoConfiguration.imports`
- - [ ] Minimal dependency: `opendaimon-common` + `opendaimon-spring-ai`
- - [ ] **Module hygiene & ArchUnit** — enforce clean module boundaries before publishing to Maven Central (see `AGENTS.md` § Project Nature)
- - [ ] **`./mvnw dependency:analyze` reactor-wide** — fix every `Used undeclared dependencies` and `Unused declared dependencies` finding, then wire `maven-dependency-plugin:analyze-only` into the `verify` phase with `failOnWarning=true` so future undeclared / unused deps break CI. First known cases: `opendaimon-telegram` uses Caffeine in `TelegramChatPacerImpl` without declaring it (transitively via `opendaimon-common`); `opendaimon-spring-ai` re-declares Caffeine that already comes through `opendaimon-common` — keep the declaration (per "declare what you use") and verify nothing else falls in the same trap.
- - [ ] **ArchUnit test module** — inter-module boundary rules (`opendaimon-telegram` ↛ `opendaimon-rest`, `opendaimon-rest` ↛ `opendaimon-telegram`, only `opendaimon-app` may depend on multiple delivery-channel modules), per-module layering (`config` → `service` → `repository`, never the reverse), and a "no `@Service`/`@Component`/`@Repository` outside test sources" guard that codifies the explicit-`@Bean` rule from `AGENTS.md` § Spring Bean Configuration.
- - [ ] **`maven-enforcer-plugin` rules** — `dependencyConvergence` (single resolved version per transitive dep), `requireUpperBoundDeps`, `bannedDependencies` (no `commons-logging`, no `*-spring-boot-starter` in non-`opendaimon-app` modules to keep delivery-channel modules embeddable in third-party Spring Boot apps).
+- [x] **opendaimon-spring-boot-starter** — auto-configuration starter for easy integration
+ - [x] New module `opendaimon-spring-boot-starter` with `AutoConfiguration.imports`
+ - [x] Minimal dependency: `opendaimon-common` + `opendaimon-spring-ai`
+ - [x] Standalone consumer example outside the published reactor (`starter-consumer-example`)
+ - [x] Consumer example with REST API, Spring AI, dotenv loading, and opt-in OpenRouter contract test
+ - [x] **Module hygiene & ArchUnit** — enforce clean module boundaries before publishing to Maven Central (see `AGENTS.md` § Project Nature)
+ - [x] **`./mvnw dependency:analyze` reactor-wide** — fix every `Used undeclared dependencies` and `Unused declared dependencies` finding, then wire `maven-dependency-plugin:analyze-only` into the `verify` phase with `failOnWarning=true` so future undeclared / unused deps break CI. First known cases: `opendaimon-telegram` uses Caffeine in `TelegramChatPacerImpl` without declaring it (transitively via `opendaimon-common`); `opendaimon-spring-ai` re-declares Caffeine that already comes through `opendaimon-common` — keep the declaration (per "declare what you use") and verify nothing else falls in the same trap.
+ - [x] **ArchUnit test module** — inter-module boundary rules (`opendaimon-telegram` ↛ `opendaimon-rest`, `opendaimon-rest` ↛ `opendaimon-telegram`, only `opendaimon-app` may depend on multiple delivery-channel modules), per-module layering (`config` → `service` → `repository`, never the reverse), and a guard that forbids `@Service` / `@Component` beans plus concrete `@Repository` classes outside test sources while allowing Spring Data repository interfaces.
+ - [x] **`maven-enforcer-plugin` rules** — `dependencyConvergence` (single resolved version per transitive dep), `requireUpperBoundDeps`, `bannedDependencies` (no `commons-logging`, no `*-spring-boot-starter` in non-`opendaimon-app` modules to keep delivery-channel modules embeddable in third-party Spring Boot apps).
+ - [x] **Continuation checkpoint: module hygiene / dependency analyze / ArchUnit**
+ - [x] Root Maven hygiene baseline
+ - Spring Boot aligned to `3.5.13`.
+ - Removed explicit Spring Framework BOM override.
+ - Added `maven-dependency-plugin:analyze-only` in `verify` with `failOnWarning=true`.
+ - Added root `maven-enforcer-plugin` in `verify` with `dependencyConvergence`, `requireUpperBoundDeps`, and transitive `commons-logging:commons-logging` ban.
+ - Added managed `archunit.version=1.4.2` and `maven-enforcer-plugin.version=3.6.2`.
+ - [x] Non-app starter ban baseline
+ - Added module-local enforcer config banning transitive `org.springframework.boot:spring-boot-starter*` in `opendaimon-common`, `opendaimon-spring-ai`, `opendaimon-rest`, `opendaimon-telegram`, `opendaimon-ui`, and `opendaimon-gateway-mock`.
+ - Follow-up verification still needed: confirm the module-local enforcer config merges with root convergence / upper-bound / commons-logging rules instead of overriding them.
+ - [x] ArchUnit baseline
+ - Added `opendaimon-app/src/test/java/io/github/ngirchev/opendaimon/arch/ArchitectureTest.java`.
+ - Rules cover no `@Service` / `@Component` beans and no concrete `@Repository` classes in main module packages, no library-module cycles, telegram ↛ rest, rest ↛ telegram, only app/root may depend on multiple delivery channels, and repository access only from `service` / `config`.
+ - Removed frozen ArchUnit store/config files; do not restore freeze mode.
+ - [x] `opendaimon-common` ArchUnit hardening
+ - Added `opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/arch/CommonArchitectureTest.java`.
+ - Rules cover no `@Service` / `@Component` beans, no concrete `@Repository` classes, no delivery controllers in common/bulkhead, no downstream module dependencies, no common runtime slice cycles, repository interfaces, repository access boundaries, and config/property package conventions.
+ - Verified with `./mvnw clean compile -pl opendaimon-common`, `./mvnw test -pl opendaimon-common -Dtest=CommonArchitectureTest`, and `./mvnw test -pl opendaimon-common` (283 tests, 0 failures/errors, 2 skipped).
+ - [x] Repository boundary cleanup in production code
+ - `ConversationThreadService` gained `findThreads(...)`, `closeCurrentThread(...)`, and read-only `findByThreadKey(...)`.
+ - `OpenDaimonMessageService` gained read methods used by Telegram and Spring AI memory code.
+ - `HistoryTelegramCommandHandler`, `ThreadsTelegramCommandHandler`, `NewThreadTelegramCommandHandler`, and `SummarizingChatMemory` were moved off direct repository access.
+ - `TelegramCommandHandlerConfig` and `SpringAIAutoConfig` constructor wiring was updated for the service-layer boundary.
+ - [x] Tests updated so far
+ - `SummarizingChatMemoryTest` uses service mocks instead of repository mocks.
+ - `HistoryTelegramCommandHandlerTest`, `ThreadsTelegramCommandHandlerTest`, and `NewThreadTelegramCommandHandlerTest` were patched for the new constructors/service methods, but still need a clean re-run.
+ - [x] `opendaimon-common` module cleanup
+ - `org.hibernate.validator:hibernate-validator` is declared as a test-scoped validation provider for `BulkHeadPropertiesTest`; it is not exported as compile API.
+ - ArchUnit test dependencies are declared as direct test dependencies (`archunit`, `archunit-junit5-api`) plus the JUnit Platform runtime engine (`archunit-junit5-engine`) with a targeted analyzer ignore.
+ - Verified with `./mvnw -pl opendaimon-common test dependency:analyze -DskipITs -DskipIT`: 283 tests, 0 failures/errors, 2 skipped; dependency analyzer reports `No dependency problems found`.
+ - [x] `opendaimon-spring-ai` module cleanup
+ - Resolved previous analyzer warnings for Spring AI chat-memory autoconfig runtime glue and `com.h2database:h2:test`.
+ - Kept module-local ArchUnit dependencies with a targeted analyzer ignore for the JUnit Platform runtime engine.
+ - Verified with `./mvnw -pl opendaimon-spring-ai -am clean compile dependency:analyze -DskipTests -DskipITs -DskipIT`: dependency analyzer reports `No dependency problems found`.
+ - Verified module tests with `./mvnw -pl opendaimon-spring-ai -am test -Dtest='io.github.ngirchev.opendaimon.ai.springai.**.*Test' -Dsurefire.failIfNoSpecifiedTests=false -DskipITs -DskipIT`: 463 tests, 0 failures/errors, 1 skipped.
+ - [x] `opendaimon-rest` module cleanup
+ - Added REST-local `RestArchitectureTest` with layer, explicit-configuration, repository, DTO/model, and service/delivery boundary rules.
+ - Added REST ArchUnit test dependencies and targeted analyzer ignore for the JUnit Platform engine.
+ - Kept `org.hamcrest:hamcrest:test` because `SessionControllerContractTest` imports Hamcrest matchers directly.
+ - Resolved previous `jackson-core` / `spring-beans` analyzer warnings through direct dependency cleanup.
+ - Verified with `./mvnw -pl opendaimon-rest -am clean compile -DskipTests`, `./mvnw -pl opendaimon-rest -am test -Dtest=RestArchitectureTest -Dsurefire.failIfNoSpecifiedTests=false -DskipITs -DskipIT`, `./mvnw -pl opendaimon-rest -am dependency:analyze -DskipTests`, and `./mvnw -pl opendaimon-rest -am test -DskipITs -DskipIT`.
+ - [x] `opendaimon-telegram` module cleanup
+ - Re-ran tests after handler-test constructor patches.
+ - Confirmed `com.github.ben-manes.caffeine:caffeine` is declared directly because `TelegramChatPacerImpl` imports it.
+ - Verified with `./mvnw -pl opendaimon-telegram -am clean compile dependency:analyze -DskipTests -DskipITs -DskipIT`: dependency analyzer reports `No dependency problems found`.
+ - Verified module tests with `./mvnw -pl opendaimon-telegram -am clean test -Dtest='io.github.ngirchev.opendaimon.telegram.**.*Test' -Dsurefire.failIfNoSpecifiedTests=false -DskipITs -DskipIT`: 481 tests, 0 failures/errors, 19 skipped.
+ - [x] `opendaimon-ui` and `opendaimon-gateway-mock` module cleanup
+ - Run analyzer/enforcer per module and fix only local POM warnings.
+ - [x] `opendaimon-app` ArchUnit verification
+ - Run `./mvnw -pl opendaimon-app -am test -Dtest=ArchitectureTest -Dsurefire.failIfNoSpecifiedTests=false`.
+ - Fix real boundary/layer violations in code; do not reintroduce frozen ArchUnit rules.
+ - Verified with `./mvnw -pl opendaimon-app -am test -Dtest=ArchitectureTest -Dsurefire.failIfNoSpecifiedTests=false`: `ArchitectureTest` passed (7 tests, 0 failures/errors/skipped).
+ - [x] Final reactor verification
+ - Run `./mvnw clean compile`.
+ - Run `./mvnw dependency:analyze -DskipTests`.
+ - Run targeted `ArchitectureTest`.
+ - Run `./mvnw clean verify`.
+ - Verified `./mvnw clean compile`: reactor build success across 8 modules.
+ - Verified `./mvnw dependency:analyze -DskipTests`: dependency analyzer reports `No dependency problems found` across all jar modules.
+ - Verified `./mvnw -pl opendaimon-app -am test -Dtest=ArchitectureTest -Dsurefire.failIfNoSpecifiedTests=false`: `ArchitectureTest` passed (7 tests, 0 failures/errors/skipped).
+ - First sandboxed `./mvnw clean verify` failed in `opendaimon-spring-ai` because the sandbox blocked local socket binding / DNS used by tests (`MockWebServer.start`, `example.com`).
+ - Verified outside the sandbox with `./mvnw clean verify`: full reactor build success; `dependency:analyze-only` and enforcer rules passed in `verify`, including app integration tests.
## Agent Framework Pivot
diff --git a/TRADEMARKS.md b/TRADEMARKS.md
new file mode 100644
index 00000000..b4e056c8
--- /dev/null
+++ b/TRADEMARKS.md
@@ -0,0 +1,18 @@
+# Trademarks
+
+The Apache License, Version 2.0 grants rights to use, copy, modify, and
+redistribute the OpenDaimon software. It does not grant trademark rights.
+
+The names "OpenDaimon" and "OpenDaimon AI", the project logos, and associated
+branding are trademarks or project identifiers of Nikolai Girchev.
+
+You may use the OpenDaimon name to truthfully refer to the original project,
+compatible integrations, or unmodified distributions.
+
+You may not use the OpenDaimon name, logos, or branding in a way that suggests
+that a modified version, hosted service, commercial product, or third-party
+distribution is the official OpenDaimon project or is endorsed by Nikolai Girchev
+without written permission.
+
+If you distribute a fork or modified version, use a distinct product name and
+clearly state that it is derived from OpenDaimon.
diff --git a/cli/TESTING.md b/cli/TESTING.md
index 9fce9a29..1c120bd3 100644
--- a/cli/TESTING.md
+++ b/cli/TESTING.md
@@ -36,17 +36,23 @@ This is the closest simulation to what an end-user runs. It verifies:
Pass `--local-image` to the wizard — it generates `docker-compose.yml` with `open-daimon:local` and `pull_policy: never` instead of pulling from the internet.
-**Step 1** — build the local image from the repository root:
+**Step 1** — build the local image from the repository root or from the repository `cli/` directory:
```bash
-cd ..
-docker build -t open-daimon:local .
+OPEN_DAIMON_REPO="$(git -C . rev-parse --show-toplevel)"
+docker build -t open-daimon:local "$OPEN_DAIMON_REPO"
+```
+
+Verify that Docker can see the image before running the wizard:
+
+```bash
+docker image inspect open-daimon:local >/dev/null
```
**Step 2** — pack the wizard:
```bash
-cd cli
+cd "$OPEN_DAIMON_REPO/cli"
npm pack --pack-destination /tmp/
```
@@ -57,6 +63,10 @@ cd /tmp/test-pack
npx file:/tmp/ngirchev-open-daimon-1.0.1.tgz --local-image
```
+If you choose `Start the stack now?`, the wizard checks that `open-daimon:local` exists before `docker compose up -d`.
+If the image is missing, it stops with the build command instead of partially creating containers and failing with
+`No such image: open-daimon:local`.
+
---
## Setup (once)
diff --git a/cli/bin/setup.js b/cli/bin/setup.js
index d300926f..09d067da 100644
--- a/cli/bin/setup.js
+++ b/cli/bin/setup.js
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { input, password, select, checkbox, confirm } from '@inquirer/prompts';
-import { execSync, spawn } from 'child_process';
+import { execFileSync, execSync, spawn } from 'child_process';
import { existsSync, readFileSync, writeFileSync, rmSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
@@ -24,6 +24,15 @@ function checkCommand(cmd) {
}
}
+function dockerImageExists(image) {
+ try {
+ execFileSync('docker', ['image', 'inspect', image], { stdio: 'ignore' });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
async function checkOllama(url) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
@@ -49,9 +58,17 @@ function spawnAsync(cmd, args, cwd) {
return new Promise((resolve) => {
const proc = spawn(cmd, args, { stdio: 'inherit', cwd });
proc.on('close', resolve);
+ proc.on('error', () => resolve(1));
});
}
+async function runCommand(cmd, args, cwd) {
+ const code = await spawnAsync(cmd, args, cwd);
+ if (code !== 0) {
+ throw new Error(`Command failed: ${[cmd, ...args].join(' ')}`);
+ }
+}
+
async function waitForApp(url, timeoutMs = 120000) {
const start = Date.now();
process.stdout.write('\nWaiting for app to be ready');
@@ -393,14 +410,28 @@ async function main() {
default: true,
});
if (doStart) {
- console.log('\nDownloading images (this may take a few minutes on first run)...');
- await spawnAsync(composeCmd[0], [...composeCmd.slice(1), 'pull'], TARGET_DIR);
+ if (USE_LOCAL_IMAGE) {
+ if (!dockerImageExists(APP_IMAGE)) {
+ console.error(`\nLocal Docker image ${APP_IMAGE} was not found.`);
+ console.error('Build it from the open-daimon repository root first.');
+ console.error('If you are in the repository cli/ directory, run:');
+ console.error(' OPEN_DAIMON_REPO="$(cd .. && pwd)"');
+ console.error(` docker build -t ${APP_IMAGE} "$OPEN_DAIMON_REPO"`);
+ console.error('\nThen start the generated stack from this directory:');
+ console.error(' docker compose up -d');
+ process.exit(1);
+ }
+ console.log(`\nUsing local Docker image ${APP_IMAGE}.`);
+ } else {
+ console.log('\nDownloading images (this may take a few minutes on first run)...');
+ await runCommand(composeCmd[0], [...composeCmd.slice(1), 'pull'], TARGET_DIR);
+ }
console.log('\nStarting containers...');
- await spawnAsync(composeCmd[0], [...composeCmd.slice(1), 'up', '-d'], TARGET_DIR);
+ await runCommand(composeCmd[0], [...composeCmd.slice(1), 'up', '-d'], TARGET_DIR);
console.log('\nContainer status:');
- await spawnAsync(composeCmd[0], [...composeCmd.slice(1), 'ps'], TARGET_DIR);
+ await runCommand(composeCmd[0], [...composeCmd.slice(1), 'ps'], TARGET_DIR);
await waitForApp('http://localhost:8080/actuator/health');
}
diff --git a/cli/package-lock.json b/cli/package-lock.json
index e43b8dac..b8b162a2 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -7,7 +7,7 @@
"": {
"name": "@ngirchev/open-daimon",
"version": "1.0.0",
- "license": "MIT",
+ "license": "Apache-2.0",
"dependencies": {
"@inquirer/prompts": "^7"
},
diff --git a/cli/package.json b/cli/package.json
index 22ee2988..291fef41 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -24,8 +24,8 @@
"openrouter",
"ollama"
],
- "author": "ngirchev",
- "license": "MIT",
+ "author": "Nikolai Girchev",
+ "license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/NGirchev/open-daimon"
diff --git a/docs/codex/config.example.toml b/docs/codex/config.example.toml
new file mode 100644
index 00000000..ce5a8698
--- /dev/null
+++ b/docs/codex/config.example.toml
@@ -0,0 +1,68 @@
+# Copy selected sections into ~/.codex/config.toml on a workstation that should
+# use the same Codex project setup for OpenDaimon.
+#
+# Keep real user paths and account-specific preferences in ~/.codex/config.toml.
+# Do not commit the copied file back to the repository.
+
+model = "gpt-5.5"
+model_reasoning_effort = "high"
+plan_mode_reasoning_effort = "xhigh"
+service_tier = "fast"
+
+[projects.""]
+trust_level = "trusted"
+
+[features]
+codex_hooks = true
+memories = true
+terminal_resize_reflow = true
+
+[tui]
+status_line = [
+ "model-with-reasoning",
+ "context-remaining",
+ "current-dir",
+ "model-name",
+ "project-root",
+ "git-branch",
+ "five-hour-limit",
+ "weekly-limit",
+ "used-tokens",
+ "total-input-tokens",
+ "total-output-tokens"
+]
+
+[mcp_servers.context7]
+command = "npx"
+args = ["-y", "@upstash/context7-mcp"]
+
+[mcp_servers.serena]
+command = "uvx"
+args = [
+ "--from",
+ "git+https://github.com/oraios/serena",
+ "serena",
+ "start-mcp-server",
+ "--context",
+ "codex",
+ "--project-from-cwd"
+]
+
+[mcp_servers.serena.tools.edit_memory]
+approval_mode = "approve"
+
+[mcp_servers.serena.tools.write_memory]
+approval_mode = "approve"
+
+[mcp_servers.exa]
+url = "https://mcp.exa.ai/mcp"
+
+[mcp_servers.jetbrains]
+command = "npx"
+args = ["-y", "mcp-remote", "http://127.0.0.1:64342/sse"]
+
+[mcp_servers.jetbrains.tools.get_project_modules]
+approval_mode = "approve"
+
+[mcp_servers.jetbrains.tools.get_all_open_file_paths]
+approval_mode = "approve"
diff --git a/docs/codex/mcp.example.json b/docs/codex/mcp.example.json
new file mode 100644
index 00000000..3176e1d3
--- /dev/null
+++ b/docs/codex/mcp.example.json
@@ -0,0 +1,34 @@
+{
+ "mcpServers": {
+ "context7": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/context7-mcp"
+ ]
+ },
+ "serena": {
+ "command": "uvx",
+ "args": [
+ "--from",
+ "git+https://github.com/oraios/serena",
+ "serena",
+ "start-mcp-server",
+ "--context",
+ "codex",
+ "--project-from-cwd"
+ ]
+ },
+ "exa": {
+ "url": "https://mcp.exa.ai/mcp"
+ },
+ "jetbrains": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "mcp-remote",
+ "http://127.0.0.1:64342/sse"
+ ]
+ }
+ }
+}
diff --git a/docs/codex/serena.example.yml b/docs/codex/serena.example.yml
new file mode 100644
index 00000000..67fc49e5
--- /dev/null
+++ b/docs/codex/serena.example.yml
@@ -0,0 +1,29 @@
+# Copy selected values into ~/.serena/serena_config.yml.
+# This file is a workstation template, not a repository runtime config.
+
+language_backend: LSP
+line_ending: native
+
+gui_log_window: false
+web_dashboard: true
+web_dashboard_listen_address: 127.0.0.1
+web_dashboard_open_on_launch: false
+web_dashboard_interface:
+
+jetbrains_plugin_server_address: 127.0.0.1
+log_level: 20
+trace_lsp_communication: false
+tool_timeout: 240
+default_max_tool_answer_chars: 150000
+token_count_estimator: CHAR_COUNT
+symbol_info_budget: 10
+
+project_serena_folder_location: "$projectDir/.serena"
+
+default_modes:
+ - interactive
+ - editing
+
+# Optional. Serena updates this list automatically after activation.
+projects:
+ -
diff --git a/docs/codex/setup.md b/docs/codex/setup.md
new file mode 100644
index 00000000..a878d0e4
--- /dev/null
+++ b/docs/codex/setup.md
@@ -0,0 +1,129 @@
+# Codex Workstation Setup
+
+This guide documents the project-specific Codex, MCP, and Serena setup needed to work on OpenDaimon from a new machine.
+
+Do not commit personal global files such as `~/.codex/config.toml`, `~/.codex/hooks.json`, or `~/.serena/serena_config.yml`. This repository keeps portable examples and project instructions instead.
+
+## What Is Versioned
+
+- `AGENTS.md` contains the project instructions for Codex and other coding agents.
+- `.serena/project.yml` contains the shared Serena project configuration.
+- `.serena/memories/` contains curated project memories that are safe to share.
+- `docs/codex/mcp.example.json` is a portable MCP example for clients that support repo-local MCP config.
+- `docs/codex/config.example.toml` is a template for the relevant `~/.codex/config.toml` sections.
+- `docs/codex/serena.example.yml` is a template for the relevant `~/.serena/serena_config.yml` values.
+
+The local `.mcp.json`, `.serena/project.local.yml`, `.serena/cache/`, and OS/editor artifacts stay ignored.
+
+## Prerequisites
+
+Install the normal project toolchain first:
+
+- Java 21
+- Maven wrapper support through `./mvnw`
+- Docker, for integration tests and local runtime dependencies
+- Node.js with `npx`, for Context7 and JetBrains MCP bridge
+- `uvx`, for Serena
+- `rg`, for fast repository search
+- `ast-outline`, optional but recommended because `AGENTS.md` asks agents to use it for structural pre-reads
+
+For JetBrains MCP, open the project in a JetBrains IDE with the MCP plugin enabled. The documented bridge expects the plugin SSE endpoint at `http://127.0.0.1:64342/sse`.
+
+## Codex Global Config
+
+Copy the relevant sections from `docs/codex/config.example.toml` into `~/.codex/config.toml`.
+
+Replace:
+
+- `` with the real checkout path on that machine.
+
+Keep API keys and personal preferences out of repository files. If a server requires authentication, configure it through the normal global Codex or provider-specific mechanism on that workstation.
+
+The important MCP servers for this project are:
+
+- `serena`, for project-aware symbol navigation and memories.
+- `jetbrains`, for IDE-indexed Java navigation and inspections.
+- `context7`, for current framework and library docs.
+- `exa`, for web research when needed.
+
+Restart Codex after editing `~/.codex/config.toml`. MCP discovery is session-scoped, so changed servers usually do not appear in an already-running session.
+
+## Repo-Local MCP Option
+
+If the Codex build or another MCP-aware client supports repo-local `.mcp.json`, create a local copy:
+
+```bash
+cp docs/codex/mcp.example.json .mcp.json
+```
+
+The committed example uses Serena `--project-from-cwd`, so it does not contain a machine-specific project path.
+
+Keep `.mcp.json` ignored. It is allowed to diverge per machine when a local MCP endpoint, transport, or path differs.
+
+## Serena Global Config
+
+Copy the relevant values from `docs/codex/serena.example.yml` into `~/.serena/serena_config.yml`.
+
+Recommended behavior for this project:
+
+- Keep `project_serena_folder_location: "$projectDir/.serena"` so shared memories live in the checkout.
+- Keep `web_dashboard: true` for diagnostics.
+- Keep `web_dashboard_open_on_launch: false` so Serena does not open a browser tab on every startup.
+- Use `language_backend: LSP` by default. Use JetBrains only when you intentionally want Serena to depend on the IDE backend.
+
+After the first activation, Serena may update the global `projects` list with the machine's real checkout path.
+
+## Hooks And Skills
+
+Codex hooks and skills are workstation-level assets, not OpenDaimon runtime files.
+
+The current local setup uses:
+
+- `~/.codex/hooks.json` for hook orchestration.
+- `~/.codex/skills/continuous-learning-v2` for session observations.
+- Java/OpenDaimon skills such as `fix-java`, `open-daimon-spring-patterns`, `springboot-tdd`, and `springboot-verification`.
+
+Do not vendor those directories into this repository. If another machine should behave the same way, install or sync the Codex skill stack into that machine's `~/.codex/skills`, then enable hooks in the global Codex config:
+
+```toml
+[features]
+codex_hooks = true
+memories = true
+```
+
+Hooks should remain optional for building and testing OpenDaimon. A fresh agent must still be able to work from `AGENTS.md`, the Maven project, and the MCP templates.
+
+## Codex Subagents
+
+Project-level subagent behavior is documented in `AGENTS.md`, not in a committed personal `~/.codex/config.toml`.
+
+For this repository, small explicitly delegated side tasks should use the Spark-backed Codex model:
+
+```text
+model: gpt-5.3-codex-spark
+```
+
+Use it for narrow lookup, verification, or small disjoint patches. Keep larger design work, risky edits, and immediate blockers on the main model unless the user asks for broader delegation.
+
+## Smoke Check
+
+From the repository root, start a new Codex session and check:
+
+```bash
+codex mcp list
+```
+
+Expected MCP servers:
+
+- `context7`
+- `serena`
+- `exa`
+- `jetbrains`, when the JetBrains IDE plugin is running
+
+Then ask Codex to verify that Serena is active for `open-daimon`. If Serena starts but the dashboard does not open automatically, that is expected with the recommended config.
+
+For code verification after edits, keep using the repository rule:
+
+```bash
+./mvnw clean compile
+```
diff --git a/docs/setup-telegram.md b/docs/setup-telegram.md
index 3789c505..e29cc2a0 100644
--- a/docs/setup-telegram.md
+++ b/docs/setup-telegram.md
@@ -27,7 +27,7 @@ Your admin ID is your numeric Telegram user ID. To find it:
First: John
...
```
- The `Id` value is your **`ADMIN_TELEGRAM_ID`**.
+ Put the `Id` value into **`TELEGRAM_ACCESS_ADMIN_IDS`**.
## Step 3: (Optional) Allow bot in groups
@@ -56,5 +56,5 @@ If your token is compromised:
## Notes
- The bot will only respond to users listed in `TELEGRAM_ACCESS_*_IDS` or channels in `TELEGRAM_ACCESS_*_CHANNELS`
-- As admin, you are added automatically (via `ADMIN_TELEGRAM_ID`)
+- Add your own user ID to `TELEGRAM_ACCESS_ADMIN_IDS` to get admin access
- See [User Priorities](../README.md#user-priorities-and-bulkhead) for how access levels work
diff --git a/docs/team/archunit-rules.md b/docs/team/archunit-rules.md
new file mode 100644
index 00000000..8fe08703
--- /dev/null
+++ b/docs/team/archunit-rules.md
@@ -0,0 +1,148 @@
+---
+slug: archunit-rules
+title: "ArchUnit Architecture Rules"
+owner: ngirchev
+created: 2026-04-28
+updated: 2026-05-03
+status: done
+base_branch: fsm
+---
+
+## Summary
+
+OpenDaimon now has executable ArchUnit checks for the core Spring Boot library architecture:
+
+- `opendaimon-app` keeps the cross-module rules: no Spring component-discovery stereotypes in published library modules, no concrete `@Repository` classes, and no cyclic dependencies between `common`, `spring-ai`, `telegram`, and `rest`.
+- `opendaimon-common` keeps common-module rules for stereotypes, configuration placement, property classes, service naming, implementation suffixes, and repository placement.
+- `opendaimon-rest`, `opendaimon-telegram`, and `opendaimon-spring-ai` now each have module-local layer rules.
+
+The rules encode the project style from `AGENTS.md`: library modules are consumed by downstream applications, so bean creation is explicit through configuration classes, configuration properties are kept in `config`, and service layers must not leak controller, handler, DTO, or transport concerns inward.
+
+## Scope
+
+In scope:
+
+- `opendaimon-common`
+- `opendaimon-spring-ai`
+- `opendaimon-telegram`
+- `opendaimon-rest`
+- cross-module checks from `opendaimon-app`
+
+Out of scope:
+
+- `opendaimon-ui`
+- `opendaimon-gateway-mock`
+- application runtime wiring beyond the existing cross-module `ArchitectureTest`
+
+`opendaimon-ui` and `opendaimon-gateway-mock` are intentionally out of scope for module-local ArchUnit suites. They are thin support modules without independent repository/domain/service layering. For those modules, use compile checks, dependency analysis/enforcer checks, and focused behavior tests when behavior changes. Reconsider ArchUnit only if either module grows stable internal architectural boundaries that need executable enforcement.
+
+## Rule Set
+
+### Cross-Module Rules
+
+File: `opendaimon-app/src/test/java/io/github/ngirchev/opendaimon/arch/ArchitectureTest.java`
+
+- Published library modules must not use `@Service` or `@Component`.
+- Concrete classes must not use `@Repository`; Spring Data repository interfaces remain allowed.
+- Library modules must not form package-level dependency cycles.
+- `IncludeOpendaimonOnly` keeps ArchUnit import behavior stable across both `mvn test` and `mvn verify -Pfixture` by allowing exploded classes and only project-owned `opendaimon-*` JARs.
+
+### Common Rules
+
+File: `opendaimon-common/src/test/java/io/github/ngirchev/opendaimon/common/arch/CommonArchitectureTest.java`
+
+- No `@Service` or `@Component`.
+- Configuration classes and `@Bean` methods stay under `common.config`.
+- `@ConfigurationProperties` classes stay under `common.config`, end with `Properties`, and use validation.
+- Services live under `common.service`, service implementations end with `Impl`, and interfaces stay interface-only.
+- Repository access is limited to repository and service layers.
+
+### REST Rules
+
+File: `opendaimon-rest/src/test/java/io/github/ngirchev/opendaimon/rest/arch/RestArchitectureTest.java`
+
+- No `@Service` or `@Component`.
+- No concrete `@Repository` classes.
+- `@Bean`, `@Configuration`, and `@AutoConfiguration` classes stay under `rest.config`.
+- `@ConfigurationProperties` classes stay under `rest.config`, end with `Properties`, and use validation.
+- `@RestController` classes stay under `rest.controller`.
+- `@ControllerAdvice` and `@RestControllerAdvice` classes stay under `rest.exception`.
+- Repository access is limited to config, repository, and service layers.
+- `rest.service..` does not depend on `rest.dto..` or `rest.handler..`.
+
+Implementation cleanup required for these rules:
+
+- `RestChatCommand` and `RestChatCommandType` moved from `rest.handler` to `rest.command`.
+- REST service return types were split into internal service models under `rest.service.model`.
+- Controllers now map internal service models to public DTOs at the boundary.
+
+### Telegram Rules
+
+File: `opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/arch/TelegramArchitectureTest.java`
+
+- No `@Service` or `@Component`.
+- No concrete `@Repository` classes.
+- `@Bean`, `@Configuration`, and `@AutoConfiguration` classes stay under `telegram.config`.
+- `@ConfigurationProperties` classes stay under `telegram.config`, end with `Properties`, and use validation.
+- Repository access is limited to config, repository, and service layers.
+- `telegram.service..` does not depend on `telegram.command.handler..`.
+
+Implementation cleanup required for these rules:
+
+- Telegram message FSM types moved from `telegram.command.handler.impl.fsm` to `telegram.service.fsm`.
+- `TelegramMessageSender` and `TelegramDeliveryFailedException` moved to `telegram.service`.
+- `TelegramSupportedCommandProvider` moved to `telegram.command`.
+
+### Spring AI Rules
+
+File: `opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/arch/SpringAIArchitectureTest.java`
+
+- No `@Service` or `@Component`.
+- No concrete `@Repository` classes.
+- `@Bean`, `@Configuration`, and `@AutoConfiguration` classes stay under `ai.springai.config`.
+- `@ConfigurationProperties` classes stay under `ai.springai.config`, end with `Properties`, and use validation.
+- Runtime slices are checked for cycles across `advisor`, `agent`, `embedding`, `memory`, `rag`, `rest`, `retry`, `service`, and `tool`.
+
+Implementation cleanup required for these rules:
+
+- `AgentAutoConfig` moved from `ai.springai.agent` to `ai.springai.config`.
+- `AgentProperties` moved from `ai.springai.agent` to `ai.springai.config`.
+- `OpenRouterModelsProperties` moved from `ai.springai.retry` to `ai.springai.config`.
+- `AutoConfiguration.imports` now references `ai.springai.config.AgentAutoConfig`.
+
+## Maven Wiring
+
+Root `pom.xml` owns the ArchUnit version through `archunit.version`.
+
+The modules with local ArchUnit tests declare ArchUnit test dependencies directly:
+
+- `opendaimon-app`
+- `opendaimon-common`
+- `opendaimon-rest`
+- `opendaimon-telegram`
+- `opendaimon-spring-ai`
+
+Modules that use `archunit-junit5-engine` only through test discovery list it in the Maven dependency plugin's `ignoredUsedUndeclaredDependencies`, matching the existing `opendaimon-common` pattern.
+
+## Verification
+
+Commands used during this cleanup:
+
+```bash
+./mvnw -pl opendaimon-rest -am test -DskipITs -DskipIT
+./mvnw -pl opendaimon-telegram -am test -DskipITs -DskipIT
+./mvnw -pl opendaimon-spring-ai -am test -DskipITs -DskipIT
+```
+
+The final cleanup pass should also run:
+
+```bash
+./mvnw -pl opendaimon-app -am test -Dtest=ArchitectureTest -Dsurefire.failIfNoSpecifiedTests=false -DskipITs -DskipIT
+./mvnw -pl opendaimon-rest -am dependency:analyze -DskipITs -DskipIT
+./mvnw -pl opendaimon-telegram -am dependency:analyze -DskipITs -DskipIT
+./mvnw -pl opendaimon-spring-ai -am dependency:analyze -DskipITs -DskipIT
+```
+
+## Status
+
+Done when all module-local ArchUnit suites and dependency analysis checks pass, and no references remain to the old package locations for moved REST, Telegram, and Spring AI types.
diff --git a/docs/usecases/doc-xls-tika-rag.md b/docs/usecases/doc-xls-tika-rag.md
index 1707077a..7bb55566 100644
--- a/docs/usecases/doc-xls-tika-rag.md
+++ b/docs/usecases/doc-xls-tika-rag.md
@@ -4,7 +4,9 @@
> - `DocRagOllamaManualIT`, `DocRagOpenRouterManualIT` — DOC files
> - `XlsRagOllamaManualIT`, `XlsRagOpenRouterManualIT` — XLS files
>
-> Run with: `./mvnw -pl opendaimon-app -am clean test-compile failsafe:integration-test failsafe:verify -Dit.test= -Dfailsafe.failIfNoSpecifiedTests=false -Dmanual.ollama.e2e=true`
+> Run with `-Dmanual.ollama.e2e=true` for `*OllamaManualIT` classes or
+> `-Dmanual.openrouter.e2e=true` for `*OpenRouterManualIT` classes:
+> `./mvnw -pl opendaimon-app -am clean test-compile failsafe:integration-test failsafe:verify -Dit.test= -Dfailsafe.failIfNoSpecifiedTests=false ...`
When a user uploads a DOC, XLS, DOCX, XLSX or other office document, the system extracts
text via Apache Tika (through Spring AI's `TikaDocumentReader`), indexes chunks in
diff --git a/docs/usecases/image-pdf-vision-cache.md b/docs/usecases/image-pdf-vision-cache.md
index 4fa14027..63f34fac 100644
--- a/docs/usecases/image-pdf-vision-cache.md
+++ b/docs/usecases/image-pdf-vision-cache.md
@@ -3,9 +3,11 @@
> **Fixture test:** `ImagePdfVisionCacheFixtureIT` — run with `./mvnw clean verify -pl opendaimon-app -am -Pfixture`
>
> **Manual tests:**
-> - `ImagePdfVisionRagOllamaManualIT` — `image-based-pdf-sample.pdf` with OCR via gemma3:4b
+> - `ImagePdfVisionRagOllamaManualIT`, `ImagePdfVisionRagOpenRouterManualIT` — `image-based-pdf-sample.pdf` with OCR via a vision model
>
-> Run with: `./mvnw -pl opendaimon-app -am clean test-compile failsafe:integration-test failsafe:verify -Dit.test=ImagePdfVisionRagOllamaManualIT -Dfailsafe.failIfNoSpecifiedTests=false -Dmanual.ollama.e2e=true`
+> Run with `-Dmanual.ollama.e2e=true` for `ImagePdfVisionRagOllamaManualIT` or
+> `-Dmanual.openrouter.e2e=true` for `ImagePdfVisionRagOpenRouterManualIT`:
+> `./mvnw -pl opendaimon-app -am clean test-compile failsafe:integration-test failsafe:verify -Dit.test= -Dfailsafe.failIfNoSpecifiedTests=false ...`
When a user uploads an image-only PDF (scan, certificate, etc.), the system detects it
before the gateway call, renders pages as images, extracts text via a vision-capable model,
diff --git a/docs/usecases/image-vision-direct.md b/docs/usecases/image-vision-direct.md
index bca42237..aee3190f 100644
--- a/docs/usecases/image-vision-direct.md
+++ b/docs/usecases/image-vision-direct.md
@@ -4,7 +4,9 @@
> - `ObjectsImageVisionOllamaManualIT`, `ObjectsImageVisionOpenRouterManualIT` — photo of objects
> - `GreekImageVisionOllamaManualIT`, `GreekImageVisionOpenRouterManualIT` — image with Greek text
>
-> Run with: `./mvnw -pl opendaimon-app -am clean test-compile failsafe:integration-test failsafe:verify -Dit.test= -Dfailsafe.failIfNoSpecifiedTests=false -Dmanual.ollama.e2e=true`
+> Run with `-Dmanual.ollama.e2e=true` for `*OllamaManualIT` classes or
+> `-Dmanual.openrouter.e2e=true` for `*OpenRouterManualIT` classes:
+> `./mvnw -pl opendaimon-app -am clean test-compile failsafe:integration-test failsafe:verify -Dit.test= -Dfailsafe.failIfNoSpecifiedTests=false ...`
When a user uploads a JPEG/PNG image (not a PDF), the system sends it directly to a
vision-capable model as a `Media` object. **No RAG indexing is performed** — images bypass
diff --git a/docs/usecases/text-pdf-rag.md b/docs/usecases/text-pdf-rag.md
index f9e7c0fa..c72d1497 100644
--- a/docs/usecases/text-pdf-rag.md
+++ b/docs/usecases/text-pdf-rag.md
@@ -6,7 +6,9 @@
> - `TextPdfRagOllamaManualIT`, `TextPdfRagOpenRouterManualIT` — single-page `sample.pdf` with follow-up RAG
> - `ImagesWithTextPdfVisionRagOllamaManualIT`, `ImagesWithTextPdfVisionRagOpenRouterManualIT` — 3-page `images_with_text.pdf` with cross-chunk RAG retrieval
>
-> Run with: `./mvnw -pl opendaimon-app -am clean test-compile failsafe:integration-test failsafe:verify -Dit.test= -Dfailsafe.failIfNoSpecifiedTests=false -Dmanual.ollama.e2e=true`
+> Run with `-Dmanual.ollama.e2e=true` for `*OllamaManualIT` classes or
+> `-Dmanual.openrouter.e2e=true` for `*OpenRouterManualIT` classes:
+> `./mvnw -pl opendaimon-app -am clean test-compile failsafe:integration-test failsafe:verify -Dit.test= -Dfailsafe.failIfNoSpecifiedTests=false ...`
When a user uploads a PDF with a text layer (selectable text), the system extracts text
via PDFBox, indexes chunks in VectorStore, and builds an augmented prompt for the LLM.
diff --git a/opendaimon-app/pom.xml b/opendaimon-app/pom.xml
index e6d4335d..a8aa63eb 100644
--- a/opendaimon-app/pom.xml
+++ b/opendaimon-app/pom.xml
@@ -27,36 +27,41 @@
UTF-8UTF-8
-
- 3.0.5
-
+
io.github.ngirchevopendaimon-telegram${project.version}
+ runtimeio.github.ngirchevopendaimon-rest${project.version}
+ runtimeio.github.ngirchevopendaimon-ui${project.version}
+ runtimeio.github.ngirchevopendaimon-spring-ai${project.version}
+ runtimeio.github.ngirchevopendaimon-gateway-mock${project.version}
+ runtime
@@ -64,39 +69,62 @@
dotenv
-
+
+
+ org.springframework.boot
+ spring-boot
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+
+
+
+
+ org.springframework
+ spring-context
+
+
+
org.springframework.bootspring-boot-starter-validation
+ runtimeorg.springframework.bootspring-boot-starter-data-jpa
+ runtimeorg.springframework.bootspring-boot-starter-web
+ runtimeorg.springframework.bootspring-boot-starter-actuator
+ runtime
-
-
+
- org.springdoc
- springdoc-openapi-starter-webmvc-ui
+ org.springframework.boot
+ spring-boot-starter-data-redis
+ runtime
+
- jakarta.xml.bind
- jakarta.xml.bind-api
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ runtime
-
+
org.postgresqlpostgresql
+ runtimeorg.flywaydb
@@ -105,42 +133,83 @@
org.flywaydbflyway-database-postgresql
-
-
- com.h2database
- h2
- test
+ runtime
-
- org.projectlombok
- lombok
-
-
io.miniominio
- ${minio.version}
+ runtime
-
-
+
- org.springframework.boot
- spring-boot-starter-data-redis
+ com.squareup.okhttp3
+ okhttp
+ runtime
-
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ ch.qos.logback
+ logback-classic
+
+
+ ch.qos.logback
+ logback-core
+
+
net.logstash.logbacklogstash-logback-encoder
- 7.4
+ runtime
-
+
+
+ org.projectlombok
+ lombok
+ provided
+ true
+
+
+
+
+ org.apache.pdfbox
+ pdfbox
+ runtime
+
+
+ org.apache.pdfbox
+ pdfbox-io
+ runtime
+
+
+ commons-logging
+ commons-logging
+
+
+
+
+
+
+ org.springframework
+ spring-test
+ test
+ org.springframework.boot
- spring-boot-starter-test
+ spring-boot-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-test-autoconfiguretest
@@ -148,6 +217,26 @@
spring-boot-testcontainerstest
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+ org.testcontainerstestcontainers
@@ -164,26 +253,70 @@
test
- com.squareup.okhttp3
- mockwebserver
+ com.h2database
+ h2test
-
- org.apache.pdfbox
- pdfbox
- ${pdfbox.version}
+ com.squareup.okhttp3
+ mockwebserver
+ test
- org.apache.pdfbox
- pdfbox-io
- ${pdfbox.version}
+ com.tngtech.archunit
+ archunit-junit5
+ ${archunit.version}
+ test
-
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+ org.springframework:*
+ org.springframework.boot:*
+ org.springframework.data:*
+ org.springframework.ai:*
+ org.telegram:*
+ io.projectreactor:*
+ io.micrometer:*
+ io.github.ngirchev:*
+ jakarta.persistence:*
+ com.tngtech.archunit:*
+
+
+ io.github.ngirchev:opendaimon-ui
+ org.springframework.boot:spring-boot-starter-*
+ org.springdoc:springdoc-openapi-starter-webmvc-ui
+ org.postgresql:postgresql
+ org.flywaydb:flyway-database-postgresql
+ io.minio:minio
+ com.squareup.okhttp3:okhttp
+ net.logstash.logback:logstash-logback-encoder
+ org.apache.pdfbox:pdfbox-io
+ org.springframework.boot:spring-boot-testcontainers
+
+ org.junit.jupiter:junit-jupiter-engine
+ org.testcontainers:junit-jupiter
+ com.h2database:h2
+ com.tngtech.archunit:archunit-junit5
+
+
+ org.springframework:spring-core
+ org.springframework:spring-beans
+
+
+
+
org.springframework.bootspring-boot-maven-plugin
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java
index 588269ba..ba6b1885 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/TelegramMessageHandlerActionsTestWiring.java
@@ -7,12 +7,12 @@
import io.github.ngirchev.opendaimon.common.service.OpenDaimonMessageService;
import io.github.ngirchev.opendaimon.telegram.TelegramBot;
import io.github.ngirchev.opendaimon.telegram.command.handler.impl.MessageTelegramCommandHandler;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerEvent;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerFsmFactory;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerState;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageHandlerActions;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.MessageHandlerContext;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.MessageHandlerEvent;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.MessageHandlerFsmFactory;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.MessageHandlerState;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.TelegramMessageHandlerActions;
+import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageSender;
import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties;
import io.github.ngirchev.opendaimon.telegram.service.ChatSettingsService;
import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService;
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/AgentAutoConfigSmokeIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/AgentAutoConfigSmokeIT.java
index c8e196e8..57f3b15c 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/AgentAutoConfigSmokeIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/AgentAutoConfigSmokeIT.java
@@ -1,6 +1,6 @@
package io.github.ngirchev.opendaimon.it.config;
-import io.github.ngirchev.opendaimon.ai.springai.agent.AgentAutoConfig;
+import io.github.ngirchev.opendaimon.ai.springai.config.AgentAutoConfig;
import io.github.ngirchev.opendaimon.ai.springai.agent.PlanAndExecuteAgentExecutor;
import io.github.ngirchev.opendaimon.ai.springai.agent.ReActAgentExecutor;
import io.github.ngirchev.opendaimon.ai.springai.agent.SimpleChainExecutor;
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/AppRuntimeDependencyContractIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/AppRuntimeDependencyContractIT.java
new file mode 100644
index 00000000..ac5cdd0e
--- /dev/null
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/AppRuntimeDependencyContractIT.java
@@ -0,0 +1,89 @@
+package io.github.ngirchev.opendaimon.it.config;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+import java.util.jar.JarFile;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that runtime-only application composition declares the companion
+ * dependencies needed by optional library modules.
+ */
+class AppRuntimeDependencyContractIT {
+
+ @Test
+ @DisplayName("opendaimon-app keeps MinIO runtime dependencies on the packaged classpath")
+ void minioRuntimeDependency_hasOkHttpRuntimeCompanion() throws Exception {
+ Document pom = DocumentBuilderFactory.newInstance()
+ .newDocumentBuilder()
+ .parse(Path.of("pom.xml").toFile());
+
+ Optional minioScope = dependencyScope(pom, "io.minio", "minio");
+ Optional okhttpScope = dependencyScope(pom, "com.squareup.okhttp3", "okhttp");
+
+ assertThat(minioScope)
+ .as("opendaimon-app must include MinIO for runtime storage auto-configuration")
+ .hasValueSatisfying(scope -> assertThat(isRuntimeVisible(scope)).isTrue());
+
+ assertThat(okhttpScope)
+ .as("MinIO constructs MinioClient with okhttp3.RequestBody on the runtime classpath")
+ .hasValueSatisfying(scope -> assertThat(isRuntimeVisible(scope)).isTrue());
+ }
+
+ @Test
+ @DisplayName("opendaimon-app packages Spring AI provider auto-configurations")
+ void springAiRuntimeDependency_hasProviderAutoconfigCompanions() throws Exception {
+ List packagedLibraries = packagedLibraries();
+
+ assertThat(packagedLibraries)
+ .as("OpenAI/OpenRouter models require OpenAiChatAutoConfiguration to create OpenAiChatModel")
+ .anyMatch(name -> name.startsWith("spring-ai-autoconfigure-model-openai-"));
+ assertThat(packagedLibraries)
+ .as("Ollama models require OllamaChatAutoConfiguration to create OllamaChatModel")
+ .anyMatch(name -> name.startsWith("spring-ai-autoconfigure-model-ollama-"));
+ }
+
+ private static Optional dependencyScope(Document pom, String groupId, String artifactId) {
+ NodeList dependencies = pom.getElementsByTagName("dependency");
+ for (int index = 0; index < dependencies.getLength(); index++) {
+ Element dependency = (Element) dependencies.item(index);
+ if (groupId.equals(text(dependency, "groupId"))
+ && artifactId.equals(text(dependency, "artifactId"))) {
+ return Optional.ofNullable(text(dependency, "scope"));
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static boolean isRuntimeVisible(String scope) {
+ return scope == null || scope.isBlank() || "compile".equals(scope) || "runtime".equals(scope);
+ }
+
+ private static String text(Element element, String tagName) {
+ NodeList nodes = element.getElementsByTagName(tagName);
+ if (nodes.getLength() == 0) {
+ return null;
+ }
+ return nodes.item(0).getTextContent().trim();
+ }
+
+ private static List packagedLibraries() throws Exception {
+ Path jarPath = Path.of("target", "opendaimon-app-1.0.0-SNAPSHOT.jar");
+ try (JarFile jar = new JarFile(jarPath.toFile())) {
+ return jar.stream()
+ .map(entry -> entry.getName())
+ .filter(name -> name.startsWith("BOOT-INF/lib/"))
+ .map(name -> name.substring("BOOT-INF/lib/".length()))
+ .toList();
+ }
+ }
+}
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/CoreAutoConfigSmokeIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/CoreAutoConfigSmokeIT.java
index 2fdc51a9..7358dd26 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/CoreAutoConfigSmokeIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/config/CoreAutoConfigSmokeIT.java
@@ -38,7 +38,7 @@
"org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration," +
"org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration," +
"io.github.ngirchev.opendaimon.ai.springai.config.SpringAIAutoConfig," +
- "io.github.ngirchev.opendaimon.ai.springai.agent.AgentAutoConfig," +
+ "io.github.ngirchev.opendaimon.ai.springai.config.AgentAutoConfig," +
"io.github.ngirchev.opendaimon.bulkhead.config.BulkHeadAutoConfig," +
"io.github.ngirchev.opendaimon.telegram.config.TelegramAutoConfig",
"open-daimon.common.bulkhead.enabled=false",
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java
index 2f8d4fe7..ad244852 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/fixture/config/TelegramFixtureConfig.java
@@ -30,19 +30,11 @@
import io.github.ngirchev.opendaimon.telegram.TelegramBot;
import io.github.ngirchev.opendaimon.it.TelegramMessageHandlerActionsTestWiring;
import io.github.ngirchev.opendaimon.telegram.command.handler.impl.MessageTelegramCommandHandler;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerEvent;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerFsmFactory;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerState;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageHandlerActions;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender;
-import io.github.ngirchev.fsm.impl.extended.ExDomainFsm;
import io.github.ngirchev.opendaimon.telegram.config.TelegramProperties;
import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserRepository;
import io.github.ngirchev.opendaimon.telegram.repository.TelegramUserSessionRepository;
import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService;
import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService;
-import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView;
import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacerImpl;
import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService;
import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageService;
@@ -237,10 +229,12 @@ public TelegramUserService telegramUserService(
@Bean
public ObjectProvider storagePropertiesProvider() {
- @SuppressWarnings("unchecked")
- ObjectProvider provider = mock(ObjectProvider.class);
- when(provider.getIfAvailable()).thenReturn(null);
- return provider;
+ return new ObjectProvider<>() {
+ @Override
+ public StorageProperties getIfAvailable() {
+ return null;
+ }
+ };
}
@Bean
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOllamaManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOllamaManualIT.java
index 00e14335..bbfef8ec 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOllamaManualIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOllamaManualIT.java
@@ -12,6 +12,8 @@
import io.github.ngirchev.opendaimon.common.model.OpenDaimonMessage;
import io.github.ngirchev.opendaimon.common.repository.ConversationThreadRepository;
import io.github.ngirchev.opendaimon.common.repository.OpenDaimonMessageRepository;
+import io.github.ngirchev.opendaimon.it.manual.support.ManualScenarioCache;
+import io.github.ngirchev.opendaimon.it.manual.support.ManualTestPrerequisites;
import io.github.ngirchev.opendaimon.telegram.TelegramBot;
import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand;
import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType;
@@ -28,12 +30,12 @@
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
@@ -52,10 +54,6 @@
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import java.io.IOException;
-import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
import lombok.extern.slf4j.Slf4j;
import java.time.Duration;
@@ -71,7 +69,6 @@
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
@@ -104,6 +101,7 @@
@EnabledIfSystemProperty(named = "manual.ollama.e2e", matches = "true")
@SpringBootTest(classes = AgentModeOllamaManualIT.TestConfig.class)
@ActiveProfiles({"integration-test", "manual-ollama"})
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Slf4j
class AgentModeOllamaManualIT extends AbstractContainerIT {
@@ -136,6 +134,11 @@ class AgentModeOllamaManualIT extends AbstractContainerIT {
private static final MockWebServer mockWebServer = createMockWebServer();
+ private final ManualScenarioCache adminReactWebSearchScenario =
+ ManualScenarioCache.of(this::runAdminReactWebSearchScenario);
+ private final ManualScenarioCache regularSimpleScenario =
+ ManualScenarioCache.of(this::runRegularSimpleScenario);
+
@Autowired
private MessageTelegramCommandHandler messageHandler;
@@ -165,7 +168,7 @@ class AgentModeOllamaManualIT extends AbstractContainerIT {
@BeforeAll
static void checkOllama() {
- requireLocalOllamaWithModels();
+ ManualTestPrerequisites.requireLocalOllamaWithModels(REQUIRED_OLLAMA_MODELS, OLLAMA_TIMEOUT);
}
@AfterAll
@@ -200,21 +203,7 @@ void setUpEach() throws TelegramApiException {
@Test
@Timeout(3 * 60)
@DisplayName("ADMIN: agent uses REACT strategy and invokes web_search tool")
- void admin_agentReact_invokesWebSearch() {
- TelegramCommand command = createMessageCommand(
- ADMIN_CHAT_ID,
- 1,
- "Какая последняя версия Spring Boot вышла в 2026 году? Поищи в интернете."
- );
-
- messageHandler.handle(command);
-
- TelegramUser user = telegramUserRepository.findByTelegramId(ADMIN_CHAT_ID)
- .orElseThrow(() -> new IllegalStateException("Telegram user should be created"));
-
- ConversationThread thread = threadRepository.findMostRecentActiveThread(user)
- .orElseThrow(() -> new IllegalStateException("Active thread should exist"));
-
+ void admin_agentReact_invokesWebSearch() throws Exception {
// The primary goal of this test is to verify that ADMIN users activate
// REACT strategy (not SIMPLE). With a 3B model, the LLM may occasionally:
// - invoke tools and produce a response (ideal path)
@@ -222,10 +211,9 @@ void admin_agentReact_invokesWebSearch() {
// - return an empty response causing agent FAILED state (known 3B quirk)
// All three outcomes confirm that REACT was activated and the pipeline
// ran end-to-end. We verify at least one assistant message was persisted.
- List assistantMessages = messageRepository
- .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.ASSISTANT);
+ HandledCommandResult result = adminReactWebSearchScenario.get();
- assertThat(assistantMessages)
+ assertThat(result.assistantMessages())
.as("Handler must save an assistant message (even on agent FAILED state)")
.isNotEmpty();
}
@@ -235,32 +223,18 @@ void admin_agentReact_invokesWebSearch() {
@Test
@Timeout(3 * 60)
@DisplayName("REGULAR: agent uses SIMPLE strategy without tools")
- void regular_agentSimple_noTools() {
- TelegramCommand command = createMessageCommand(
- REGULAR_CHAT_ID,
- 2,
- "Привет, расскажи анекдот"
- );
-
- messageHandler.handle(command);
-
- TelegramUser user = telegramUserRepository.findByTelegramId(REGULAR_CHAT_ID)
- .orElseThrow(() -> new IllegalStateException("Telegram user should be created"));
-
- ConversationThread thread = threadRepository.findMostRecentActiveThread(user)
- .orElseThrow(() -> new IllegalStateException("Active thread should exist"));
-
- String assistantReply = latestAssistantReply(thread);
+ void regular_agentSimple_noTools() throws Exception {
+ HandledCommandResult result = regularSimpleScenario.get();
- assertThat(assistantReply)
+ assertThat(result.assistantReply())
.as("SIMPLE agent should produce a non-blank response")
.isNotBlank();
- assertThat(WEB_SEARCH_CALLED.get())
+ assertThat(result.webSearchCalled())
.as("REGULAR (CHAT-only) should NOT invoke web_search")
.isFalse();
- assertThat(FETCH_URL_CALLED.get())
+ assertThat(result.fetchUrlCalled())
.as("REGULAR (CHAT-only) should NOT invoke fetch_url")
.isFalse();
}
@@ -270,35 +244,18 @@ void regular_agentSimple_noTools() {
@Test
@Timeout(3 * 60)
@DisplayName("Agent response saved to DB with correct structure")
- void agentResponse_persistedToDb() {
- TelegramCommand command = createMessageCommand(
- ADMIN_CHAT_ID,
- 3,
- "Скажи одним словом: работает ли агент?"
- );
-
- messageHandler.handle(command);
-
- TelegramUser user = telegramUserRepository.findByTelegramId(ADMIN_CHAT_ID)
- .orElseThrow();
+ void agentResponse_persistedToDb() throws Exception {
+ HandledCommandResult result = regularSimpleScenario.get();
- ConversationThread thread = threadRepository.findMostRecentActiveThread(user)
- .orElseThrow();
-
- List userMessages = messageRepository
- .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.USER);
- List assistantMessages = messageRepository
- .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.ASSISTANT);
-
- assertThat(userMessages)
+ assertThat(result.userMessages())
.as("User message should be saved")
.hasSize(1);
- assertThat(assistantMessages)
+ assertThat(result.assistantMessages())
.as("Assistant message should be saved")
.hasSize(1);
- assertThat(assistantMessages.getFirst().getContent())
+ assertThat(result.assistantMessages().getFirst().getContent())
.as("Assistant content should not be blank")
.isNotBlank();
}
@@ -706,6 +663,47 @@ void admin_reactStrategy_thinkingAndToolCallSentToTelegram() throws TelegramApiE
// --- Helpers ---
+ private HandledCommandResult runAdminReactWebSearchScenario() {
+ TelegramCommand command = createMessageCommand(
+ ADMIN_CHAT_ID,
+ 1,
+ "Какая последняя версия Spring Boot вышла в 2026 году? Поищи в интернете."
+ );
+ return handleCommand(command, ADMIN_CHAT_ID);
+ }
+
+ private HandledCommandResult runRegularSimpleScenario() {
+ TelegramCommand command = createMessageCommand(
+ REGULAR_CHAT_ID,
+ 2,
+ "Привет, расскажи анекдот"
+ );
+ return handleCommand(command, REGULAR_CHAT_ID);
+ }
+
+ private HandledCommandResult handleCommand(TelegramCommand command, Long chatId) {
+ messageHandler.handle(command);
+
+ TelegramUser user = telegramUserRepository.findByTelegramId(chatId)
+ .orElseThrow(() -> new IllegalStateException("Telegram user should be created"));
+ ConversationThread thread = threadRepository.findMostRecentActiveThread(user)
+ .orElseThrow(() -> new IllegalStateException("Active thread should exist"));
+ List userMessages = messageRepository
+ .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.USER);
+ List assistantMessages = messageRepository
+ .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.ASSISTANT);
+ String assistantReply = assistantMessages.isEmpty() ? "" : assistantMessages.getLast().getContent();
+ return new HandledCommandResult(
+ userMessages,
+ assistantMessages,
+ assistantReply,
+ WEB_SEARCH_CALLED.get(),
+ FETCH_URL_CALLED.get(),
+ HTTP_GET_CALLED.get(),
+ TOOL_CALL_COUNT.get()
+ );
+ }
+
private TelegramCommand createMessageCommand(Long chatId, int messageId, String text) {
Update update = new Update();
@@ -783,37 +781,15 @@ private static MockWebServer createMockWebServer() {
return server;
}
- static void requireLocalOllamaWithModels() {
- String baseUrl = resolveOllamaBaseUrl();
- HttpClient client = HttpClient.newBuilder()
- .connectTimeout(OLLAMA_TIMEOUT)
- .build();
- HttpRequest request = HttpRequest.newBuilder()
- .GET()
- .timeout(OLLAMA_TIMEOUT)
- .uri(URI.create(baseUrl + "/api/tags"))
- .build();
- try {
- HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
- boolean statusOk = response.statusCode() == 200;
- boolean modelsPresent = REQUIRED_OLLAMA_MODELS.stream().allMatch(response.body()::contains);
- Assumptions.assumeTrue(statusOk && modelsPresent,
- "Skipping: Ollama/models unavailable at " + baseUrl + ". Required: " + REQUIRED_OLLAMA_MODELS);
- } catch (Exception ex) {
- Assumptions.assumeTrue(false,
- "Skipping: cannot connect to Ollama at " + baseUrl + ". " + ex.getMessage());
- }
- }
-
- private static String resolveOllamaBaseUrl() {
- String baseUrl = System.getenv("OLLAMA_BASE_URL");
- if (baseUrl == null || baseUrl.isBlank()) {
- baseUrl = "http://localhost:11434";
- }
- if (baseUrl.endsWith("/")) {
- return baseUrl.substring(0, baseUrl.length() - 1);
- }
- return baseUrl;
+ private record HandledCommandResult(
+ List userMessages,
+ List assistantMessages,
+ String assistantReply,
+ boolean webSearchCalled,
+ boolean fetchUrlCalled,
+ boolean httpGetCalled,
+ int toolCallCount
+ ) {
}
@SpringBootConfiguration
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java
index 295871bd..648b42fb 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/AgentModeOpenRouterManualIT.java
@@ -1,6 +1,5 @@
package io.github.ngirchev.opendaimon.it.manual;
-import io.github.ngirchev.dotenv.DotEnvLoader;
import io.github.ngirchev.opendaimon.ai.springai.tool.HttpApiTool;
import io.github.ngirchev.opendaimon.ai.springai.tool.WebTools;
import io.github.ngirchev.opendaimon.common.agent.AgentExecutor;
@@ -9,6 +8,8 @@
import io.github.ngirchev.opendaimon.common.model.OpenDaimonMessage;
import io.github.ngirchev.opendaimon.common.repository.ConversationThreadRepository;
import io.github.ngirchev.opendaimon.common.repository.OpenDaimonMessageRepository;
+import io.github.ngirchev.opendaimon.it.manual.support.ManualScenarioCache;
+import io.github.ngirchev.opendaimon.it.manual.support.ManualTestPrerequisites;
import io.github.ngirchev.opendaimon.telegram.TelegramBot;
import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand;
import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType;
@@ -25,12 +26,12 @@
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
@@ -49,7 +50,6 @@
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import java.io.IOException;
-import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Set;
@@ -107,12 +107,9 @@
}
)
@ActiveProfiles({"integration-test", "manual-openrouter"})
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AgentModeOpenRouterManualIT extends AbstractContainerIT {
- static {
- DotEnvLoader.loadDotEnv(Path.of("../.env"));
- }
-
private static final Long ADMIN_CHAT_ID = 350009010L;
private static final Long REGULAR_CHAT_ID = 350009012L;
@@ -135,6 +132,11 @@ class AgentModeOpenRouterManualIT extends AbstractContainerIT {
private static final MockWebServer mockWebServer = createMockWebServer();
+ private final ManualScenarioCache adminReactWebSearchScenario =
+ ManualScenarioCache.of(this::runAdminReactWebSearchScenario);
+ private final ManualScenarioCache regularSimpleScenario =
+ ManualScenarioCache.of(this::runRegularSimpleScenario);
+
@Autowired
private MessageTelegramCommandHandler messageHandler;
@@ -164,12 +166,7 @@ class AgentModeOpenRouterManualIT extends AbstractContainerIT {
@BeforeAll
static void requireOpenRouterKey() {
- DotEnvLoader.loadDotEnv(Path.of("../.env"));
- String openRouterKey = System.getProperty("OPENROUTER_KEY", System.getenv("OPENROUTER_KEY"));
- Assumptions.assumeTrue(
- openRouterKey != null && !openRouterKey.isBlank() && !openRouterKey.equals("sk-placeholder"),
- "Skipping manual test: OPENROUTER_KEY not set in .env or environment"
- );
+ ManualTestPrerequisites.requireOpenRouterKey();
}
@AfterAll
@@ -200,30 +197,13 @@ void setUpEach() throws TelegramApiException {
@Test
@Timeout(3 * 60)
@DisplayName("B1: ADMIN agent uses REACT strategy and invokes web_search via OpenRouter")
- void admin_agentReact_invokesWebSearch() {
- TelegramCommand command = createMessageCommand(
- ADMIN_CHAT_ID,
- 1,
- "What is the latest version of Spring Boot released in 2026? Search the internet."
- );
-
- messageHandler.handle(command);
-
- TelegramUser user = telegramUserRepository.findByTelegramId(ADMIN_CHAT_ID)
- .orElseThrow(() -> new IllegalStateException("Telegram user should be created"));
-
- ConversationThread thread = threadRepository.findMostRecentActiveThread(user)
- .orElseThrow(() -> new IllegalStateException("Active thread should exist"));
-
- String assistantReply = latestAssistantReply(thread);
-
+ void admin_agentReact_invokesWebSearch() throws Exception {
// The primary goal is to verify that ADMIN activates REACT strategy.
// LLM may occasionally return an empty response (known quirk in batch runs).
// All outcomes confirm the pipeline ran end-to-end.
- List assistantMessages = messageRepository
- .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.ASSISTANT);
+ HandledCommandResult result = adminReactWebSearchScenario.get();
- assertThat(assistantMessages)
+ assertThat(result.assistantMessages())
.as("Handler must save an assistant message (even on agent FAILED state)")
.isNotEmpty();
}
@@ -273,35 +253,18 @@ void admin_agentReact_chainsWebSearchAndFetchUrl() {
@Test
@Timeout(3 * 60)
@DisplayName("B3: Agent response saved to DB with correct structure (OpenRouter)")
- void agentResponse_persistedToDb() {
- TelegramCommand command = createMessageCommand(
- ADMIN_CHAT_ID,
- 3,
- "Answer in one word: is the agent working?"
- );
+ void agentResponse_persistedToDb() throws Exception {
+ HandledCommandResult result = regularSimpleScenario.get();
- messageHandler.handle(command);
-
- TelegramUser user = telegramUserRepository.findByTelegramId(ADMIN_CHAT_ID)
- .orElseThrow();
-
- ConversationThread thread = threadRepository.findMostRecentActiveThread(user)
- .orElseThrow();
-
- List userMessages = messageRepository
- .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.USER);
- List assistantMessages = messageRepository
- .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.ASSISTANT);
-
- assertThat(userMessages)
+ assertThat(result.userMessages())
.as("User message should be saved")
.hasSize(1);
- assertThat(assistantMessages)
+ assertThat(result.assistantMessages())
.as("Assistant message should be saved")
.hasSize(1);
- assertThat(assistantMessages.getFirst().getContent())
+ assertThat(result.assistantMessages().getFirst().getContent())
.as("Assistant content should not be blank")
.isNotBlank();
}
@@ -430,32 +393,18 @@ void admin_agentReact_invokesHttpGet() {
@Test
@Timeout(3 * 60)
@DisplayName("B4: REGULAR agent uses SIMPLE strategy without tools (OpenRouter)")
- void regular_agentSimple_noTools() {
- TelegramCommand command = createMessageCommand(
- REGULAR_CHAT_ID,
- 4,
- "Tell me a short joke"
- );
+ void regular_agentSimple_noTools() throws Exception {
+ HandledCommandResult result = regularSimpleScenario.get();
- messageHandler.handle(command);
-
- TelegramUser user = telegramUserRepository.findByTelegramId(REGULAR_CHAT_ID)
- .orElseThrow(() -> new IllegalStateException("Telegram user should be created"));
-
- ConversationThread thread = threadRepository.findMostRecentActiveThread(user)
- .orElseThrow(() -> new IllegalStateException("Active thread should exist"));
-
- String assistantReply = latestAssistantReply(thread);
-
- assertThat(assistantReply)
+ assertThat(result.assistantReply())
.as("SIMPLE agent should produce a non-blank response")
.isNotBlank();
- assertThat(WEB_SEARCH_CALLED.get())
+ assertThat(result.webSearchCalled())
.as("REGULAR (CHAT-only) should NOT invoke web_search")
.isFalse();
- assertThat(FETCH_URL_CALLED.get())
+ assertThat(result.fetchUrlCalled())
.as("REGULAR (CHAT-only) should NOT invoke fetch_url")
.isFalse();
}
@@ -550,6 +499,47 @@ private io.github.ngirchev.opendaimon.common.model.Attachment loadImageAttachmen
// --- Helpers ---
+ private HandledCommandResult runAdminReactWebSearchScenario() {
+ TelegramCommand command = createMessageCommand(
+ ADMIN_CHAT_ID,
+ 1,
+ "What is the latest version of Spring Boot released in 2026? Search the internet."
+ );
+ return handleCommand(command, ADMIN_CHAT_ID);
+ }
+
+ private HandledCommandResult runRegularSimpleScenario() {
+ TelegramCommand command = createMessageCommand(
+ REGULAR_CHAT_ID,
+ 4,
+ "Tell me a short joke"
+ );
+ return handleCommand(command, REGULAR_CHAT_ID);
+ }
+
+ private HandledCommandResult handleCommand(TelegramCommand command, Long chatId) {
+ messageHandler.handle(command);
+
+ TelegramUser user = telegramUserRepository.findByTelegramId(chatId)
+ .orElseThrow(() -> new IllegalStateException("Telegram user should be created"));
+ ConversationThread thread = threadRepository.findMostRecentActiveThread(user)
+ .orElseThrow(() -> new IllegalStateException("Active thread should exist"));
+ List userMessages = messageRepository
+ .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.USER);
+ List assistantMessages = messageRepository
+ .findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.ASSISTANT);
+ String assistantReply = assistantMessages.isEmpty() ? "" : assistantMessages.getLast().getContent();
+ return new HandledCommandResult(
+ userMessages,
+ assistantMessages,
+ assistantReply,
+ WEB_SEARCH_CALLED.get(),
+ FETCH_URL_CALLED.get(),
+ HTTP_GET_CALLED.get(),
+ TOOL_CALL_COUNT.get()
+ );
+ }
+
private TelegramCommand createMessageCommand(Long chatId, int messageId, String text) {
return createMessageCommand(chatId, messageId, text, "en");
}
@@ -632,6 +622,17 @@ public MockResponse dispatch(RecordedRequest request) {
return server;
}
+ private record HandledCommandResult(
+ List userMessages,
+ List assistantMessages,
+ String assistantReply,
+ boolean webSearchCalled,
+ boolean fetchUrlCalled,
+ boolean httpGetCalled,
+ int toolCallCount
+ ) {
+ }
+
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestConfig {
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/DocRagOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/DocRagOpenRouterManualIT.java
index 4fa4c1b0..b82d1c8a 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/DocRagOpenRouterManualIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/DocRagOpenRouterManualIT.java
@@ -83,7 +83,7 @@
*
*/
@Tag("manual")
-@EnabledIfSystemProperty(named = "manual.ollama.e2e", matches = "true")
+@EnabledIfSystemProperty(named = "manual.openrouter.e2e", matches = "true")
@SpringBootTest(
classes = OpenRouterSimpleManualTestConfig.class,
properties = "open-daimon.agent.enabled=false"
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GreekImageVisionOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GreekImageVisionOpenRouterManualIT.java
index 3ef58213..efc9a1c0 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GreekImageVisionOpenRouterManualIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/GreekImageVisionOpenRouterManualIT.java
@@ -79,7 +79,7 @@
*
*/
@Tag("manual")
-@EnabledIfSystemProperty(named = "manual.ollama.e2e", matches = "true")
+@EnabledIfSystemProperty(named = "manual.openrouter.e2e", matches = "true")
@SpringBootTest(
classes = OpenRouterSimpleManualTestConfig.class,
properties = "open-daimon.agent.enabled=false"
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ImagePdfVisionRagOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ImagePdfVisionRagOpenRouterManualIT.java
index 90bd9ae2..665ebe49 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ImagePdfVisionRagOpenRouterManualIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ImagePdfVisionRagOpenRouterManualIT.java
@@ -87,7 +87,7 @@
*
*/
@Tag("manual")
-@EnabledIfSystemProperty(named = "manual.ollama.e2e", matches = "true")
+@EnabledIfSystemProperty(named = "manual.openrouter.e2e", matches = "true")
@SpringBootTest(
classes = OpenRouterSimpleManualTestConfig.class,
properties = "open-daimon.agent.enabled=false"
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ImagesWithTextPdfVisionRagOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ImagesWithTextPdfVisionRagOpenRouterManualIT.java
index 988cc6c3..b634cbc8 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ImagesWithTextPdfVisionRagOpenRouterManualIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ImagesWithTextPdfVisionRagOpenRouterManualIT.java
@@ -84,7 +84,7 @@
*
*/
@Tag("manual")
-@EnabledIfSystemProperty(named = "manual.ollama.e2e", matches = "true")
+@EnabledIfSystemProperty(named = "manual.openrouter.e2e", matches = "true")
@SpringBootTest(
classes = OpenRouterSimpleManualTestConfig.class,
properties = "open-daimon.agent.enabled=false"
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ObjectsImageVisionOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ObjectsImageVisionOpenRouterManualIT.java
index 8c91d0e9..dd50f2b5 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ObjectsImageVisionOpenRouterManualIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/ObjectsImageVisionOpenRouterManualIT.java
@@ -83,7 +83,7 @@
*
*/
@Tag("manual")
-@EnabledIfSystemProperty(named = "manual.ollama.e2e", matches = "true")
+@EnabledIfSystemProperty(named = "manual.openrouter.e2e", matches = "true")
@SpringBootTest(
classes = OpenRouterSimpleManualTestConfig.class,
properties = "open-daimon.agent.enabled=false"
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/TextPdfRagOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/TextPdfRagOpenRouterManualIT.java
index d29a7903..d300cc78 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/TextPdfRagOpenRouterManualIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/TextPdfRagOpenRouterManualIT.java
@@ -79,7 +79,7 @@
*
*/
@Tag("manual")
-@EnabledIfSystemProperty(named = "manual.ollama.e2e", matches = "true")
+@EnabledIfSystemProperty(named = "manual.openrouter.e2e", matches = "true")
@SpringBootTest(
classes = OpenRouterSimpleManualTestConfig.class,
properties = "open-daimon.agent.enabled=false"
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/XlsRagOpenRouterManualIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/XlsRagOpenRouterManualIT.java
index c03312b1..c787db91 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/XlsRagOpenRouterManualIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/XlsRagOpenRouterManualIT.java
@@ -87,7 +87,7 @@
*
*/
@Tag("manual")
-@EnabledIfSystemProperty(named = "manual.ollama.e2e", matches = "true")
+@EnabledIfSystemProperty(named = "manual.openrouter.e2e", matches = "true")
@SpringBootTest(
classes = OpenRouterSimpleManualTestConfig.class,
properties = "open-daimon.agent.enabled=false"
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/support/ManualScenarioCache.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/support/ManualScenarioCache.java
new file mode 100644
index 00000000..59825669
--- /dev/null
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/support/ManualScenarioCache.java
@@ -0,0 +1,36 @@
+package io.github.ngirchev.opendaimon.it.manual.support;
+
+/**
+ * Lazily runs an expensive manual scenario once per test instance and reuses
+ * the captured result for assertions in multiple test methods.
+ */
+public final class ManualScenarioCache {
+
+ private final ThrowingSupplier supplier;
+ private T value;
+
+ private ManualScenarioCache(ThrowingSupplier supplier) {
+ this.supplier = supplier;
+ }
+
+ public static ManualScenarioCache of(ThrowingSupplier supplier) {
+ return new ManualScenarioCache<>(supplier);
+ }
+
+ public T get() throws Exception {
+ if (value == null) {
+ value = supplier.get();
+ }
+ return value;
+ }
+
+ public void clear() {
+ value = null;
+ }
+
+ @FunctionalInterface
+ public interface ThrowingSupplier {
+
+ T get() throws Exception;
+ }
+}
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/support/ManualTelegramTestSupport.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/support/ManualTelegramTestSupport.java
new file mode 100644
index 00000000..dafb21a9
--- /dev/null
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/support/ManualTelegramTestSupport.java
@@ -0,0 +1,115 @@
+package io.github.ngirchev.opendaimon.it.manual.support;
+
+import io.github.ngirchev.opendaimon.common.model.Attachment;
+import io.github.ngirchev.opendaimon.common.model.AttachmentType;
+import io.github.ngirchev.opendaimon.common.model.ConversationThread;
+import io.github.ngirchev.opendaimon.common.model.MessageRole;
+import io.github.ngirchev.opendaimon.common.model.OpenDaimonMessage;
+import io.github.ngirchev.opendaimon.common.repository.OpenDaimonMessageRepository;
+import io.github.ngirchev.opendaimon.telegram.TelegramBot;
+import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand;
+import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType;
+import java.io.IOException;
+import java.util.List;
+import org.springframework.core.io.ClassPathResource;
+import org.telegram.telegrambots.meta.api.objects.Chat;
+import org.telegram.telegrambots.meta.api.objects.Message;
+import org.telegram.telegrambots.meta.api.objects.Update;
+import org.telegram.telegrambots.meta.api.objects.User;
+import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboard;
+import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.reset;
+
+public final class ManualTelegramTestSupport {
+
+ private ManualTelegramTestSupport() {
+ }
+
+ public static void stubTelegramBot(TelegramBot telegramBot) throws TelegramApiException {
+ reset(telegramBot);
+ doNothing().when(telegramBot).showTyping(anyLong());
+ doNothing().when(telegramBot).sendMessage(anyLong(), anyString(), any(), any(ReplyKeyboard.class));
+ doNothing().when(telegramBot).sendMessage(anyLong(), anyString(), any());
+ doNothing().when(telegramBot).sendErrorMessage(anyLong(), anyString(), any());
+ }
+
+ public static TelegramCommand createMessageCommand(
+ Long chatId,
+ int messageId,
+ String text,
+ String languageCode,
+ List attachments
+ ) {
+ Update update = new Update();
+
+ User from = new User();
+ from.setId(chatId);
+ from.setUserName("manual-user-" + chatId);
+ from.setFirstName("Manual");
+ from.setLastName("User");
+ from.setLanguageCode(languageCode);
+
+ Message message = new Message();
+ message.setMessageId(messageId);
+ Chat chat = new Chat();
+ chat.setId(chatId);
+ message.setChat(chat);
+ message.setFrom(from);
+ message.setText(text);
+ update.setMessage(message);
+
+ TelegramCommand command = new TelegramCommand(
+ null,
+ chatId,
+ new TelegramCommandType(TelegramCommand.MESSAGE),
+ update,
+ text,
+ false,
+ attachments
+ );
+ command.languageCode(languageCode);
+ return command;
+ }
+
+ public static Attachment loadAttachment(
+ String resourcePath,
+ String contentType,
+ String originalFilename,
+ AttachmentType attachmentType
+ ) throws IOException {
+ ClassPathResource resource = new ClassPathResource(resourcePath);
+ byte[] bytes = resource.getInputStream().readAllBytes();
+ return new Attachment(
+ "manual/" + originalFilename,
+ contentType,
+ originalFilename,
+ bytes.length,
+ attachmentType,
+ bytes
+ );
+ }
+
+ public static List assistantMessages(
+ ConversationThread thread,
+ OpenDaimonMessageRepository messageRepository
+ ) {
+ return messageRepository.findByThreadAndRoleOrderBySequenceNumberAsc(thread, MessageRole.ASSISTANT);
+ }
+
+ public static String latestAssistantReply(
+ ConversationThread thread,
+ OpenDaimonMessageRepository messageRepository
+ ) {
+ List assistantMessages = assistantMessages(thread, messageRepository);
+ assertThat(assistantMessages)
+ .as("Assistant message should be saved")
+ .isNotEmpty();
+ return assistantMessages.getLast().getContent();
+ }
+}
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/support/ManualTestPrerequisites.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/support/ManualTestPrerequisites.java
new file mode 100644
index 00000000..d56d263b
--- /dev/null
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/manual/support/ManualTestPrerequisites.java
@@ -0,0 +1,68 @@
+package io.github.ngirchev.opendaimon.it.manual.support;
+
+import io.github.ngirchev.dotenv.DotEnvLoader;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import org.junit.jupiter.api.Assumptions;
+
+public final class ManualTestPrerequisites {
+
+ private ManualTestPrerequisites() {
+ }
+
+ public static void requireOpenRouterKey() {
+ DotEnvLoader.loadDotEnv(Path.of("../.env"));
+ String openRouterKey = System.getProperty("OPENROUTER_KEY", System.getenv("OPENROUTER_KEY"));
+ Assumptions.assumeTrue(
+ openRouterKey != null && !openRouterKey.isBlank() && !openRouterKey.equals("sk-placeholder"),
+ "Skipping manual test: OPENROUTER_KEY not set in .env or environment"
+ );
+ }
+
+ public static void requireSerperKey() {
+ DotEnvLoader.loadDotEnv(Path.of("../.env"));
+ String serperKey = System.getProperty("SERPER_KEY", System.getenv("SERPER_KEY"));
+ Assumptions.assumeTrue(
+ serperKey != null && !serperKey.isBlank(),
+ "Skipping manual test: SERPER_KEY not set in .env or environment"
+ );
+ }
+
+ public static void requireLocalOllamaWithModels(List requiredModels, Duration timeout) {
+ String baseUrl = resolveOllamaBaseUrl();
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(timeout)
+ .build();
+ HttpRequest request = HttpRequest.newBuilder()
+ .GET()
+ .timeout(timeout)
+ .uri(URI.create(baseUrl + "/api/tags"))
+ .build();
+ try {
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ boolean statusOk = response.statusCode() == 200;
+ boolean modelsPresent = requiredModels.stream().allMatch(response.body()::contains);
+ Assumptions.assumeTrue(statusOk && modelsPresent,
+ "Skipping manual test: Ollama/models unavailable at " + baseUrl + ". Required: " + requiredModels);
+ } catch (Exception ex) {
+ Assumptions.assumeTrue(false,
+ "Skipping manual test: cannot connect to Ollama at " + baseUrl + ". " + ex.getMessage());
+ }
+ }
+
+ public static String resolveOllamaBaseUrl() {
+ String baseUrl = System.getenv("OLLAMA_BASE_URL");
+ if (baseUrl == null || baseUrl.isBlank()) {
+ baseUrl = "http://localhost:11434";
+ }
+ if (baseUrl.endsWith("/")) {
+ return baseUrl.substring(0, baseUrl.length() - 1);
+ }
+ return baseUrl;
+ }
+}
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java
index c99f4b93..36f6a82c 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramMockGatewayIT.java
@@ -58,12 +58,12 @@
import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType;
import io.github.ngirchev.opendaimon.it.TelegramMessageHandlerActionsTestWiring;
import io.github.ngirchev.opendaimon.telegram.command.handler.impl.MessageTelegramCommandHandler;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerEvent;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerFsmFactory;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerState;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageHandlerActions;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.MessageHandlerContext;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.MessageHandlerEvent;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.MessageHandlerFsmFactory;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.MessageHandlerState;
+import io.github.ngirchev.opendaimon.telegram.service.fsm.TelegramMessageHandlerActions;
+import io.github.ngirchev.opendaimon.telegram.service.TelegramMessageSender;
import io.github.ngirchev.fsm.impl.extended.ExDomainFsm;
import io.github.ngirchev.opendaimon.telegram.config.TelegramFlywayConfig;
import io.github.ngirchev.opendaimon.telegram.config.TelegramJpaConfig;
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramRealGatewayIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramRealGatewayIT.java
index 9857e8ef..2b6c0cca 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramRealGatewayIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramRealGatewayIT.java
@@ -50,7 +50,7 @@
*
*
To run the test:
*
- *
Ensure .env contains TELEGRAM_TOKEN, TELEGRAM_USERNAME and ADMIN_TELEGRAM_ID
+ *
Ensure .env contains TELEGRAM_TOKEN, TELEGRAM_USERNAME and TEST_TELEGRAM_CHAT_ID
*
Remove @Disabled from the test or the whole class
*
Run the test
*
@@ -84,7 +84,7 @@ class TelegramRealGatewayIT extends AbstractContainerIT {
DotEnvLoader.loadDotEnv(Path.of("../.env"));
}
- @Value("${ADMIN_TELEGRAM_ID}")
+ @Value("${TEST_TELEGRAM_CHAT_ID}")
private Long adminTelegramId;
@Autowired
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java
index f5b3a047..2aa4c92a 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/command/handler/MessageTelegramCommandHandlerIT.java
@@ -3,7 +3,6 @@
import io.github.ngirchev.opendaimon.it.ITTestConfiguration;
import io.github.ngirchev.opendaimon.telegram.service.PersistentKeyboardService;
import io.github.ngirchev.opendaimon.telegram.service.ReplyImageAttachmentService;
-import io.github.ngirchev.opendaimon.telegram.service.TelegramAgentStreamView;
import io.github.ngirchev.opendaimon.telegram.service.TelegramChatPacerImpl;
import io.github.ngirchev.opendaimon.telegram.service.TelegramFileService;
import io.github.ngirchev.opendaimon.common.service.ChatOwnerLookup;
@@ -49,13 +48,6 @@
import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType;
import io.github.ngirchev.opendaimon.it.TelegramMessageHandlerActionsTestWiring;
import io.github.ngirchev.opendaimon.telegram.command.handler.impl.MessageTelegramCommandHandler;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerContext;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerEvent;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerFsmFactory;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.MessageHandlerState;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageHandlerActions;
-import io.github.ngirchev.opendaimon.telegram.command.handler.impl.fsm.TelegramMessageSender;
-import io.github.ngirchev.fsm.impl.extended.ExDomainFsm;
import io.github.ngirchev.opendaimon.telegram.config.TelegramFlywayConfig;
import io.github.ngirchev.opendaimon.telegram.config.TelegramJpaConfig;
import io.github.ngirchev.opendaimon.common.storage.config.StorageProperties;
@@ -243,9 +235,12 @@ public TelegramUserService telegramUserService(
@Bean
@Primary
public ObjectProvider storagePropertiesProvider() {
- ObjectProvider provider = mock(ObjectProvider.class);
- when(provider.getIfAvailable()).thenReturn(null);
- return provider;
+ return new ObjectProvider<>() {
+ @Override
+ public StorageProperties getIfAvailable() {
+ return null;
+ }
+ };
}
@Bean
diff --git a/opendaimon-app/src/main/resources/application-mock.yml b/opendaimon-app/src/main/resources/application-mock.yml
index b0f34b11..0ecc22f1 100644
--- a/opendaimon-app/src/main/resources/application-mock.yml
+++ b/opendaimon-app/src/main/resources/application-mock.yml
@@ -15,10 +15,6 @@ open-daimon:
REGULAR:
maxConcurrentCalls: 1
maxWaitDuration: 500ms
- admin:
- enabled: ${ADMIN_ENABLED:true}
- telegram-id: ${ADMIN_TELEGRAM_ID:}
- rest-email: ${ADMIN_REST_EMAIL:}
assistant-role: role.content.default
max-output-tokens: 1000
max-user-message-tokens: 4000
diff --git a/opendaimon-app/src/main/resources/application.yml b/opendaimon-app/src/main/resources/application.yml
index 10602676..b8fc93c2 100644
--- a/opendaimon-app/src/main/resources/application.yml
+++ b/opendaimon-app/src/main/resources/application.yml
@@ -1,3 +1,8 @@
+# Bundled OpenDaimon runtime keeps its effective configuration explicit in this file.
+# Reference only: external apps that use opendaimon-spring-boot-starter receive low-priority defaults from:
+# opendaimon-spring-boot-starter/src/main/resources/META-INF/opendaimon/opendaimon-defaults.yml
+# This app does not depend on the starter and does not import those defaults, so change runtime values here.
+
server:
port: 8080
@@ -23,10 +28,6 @@ open-daimon:
REGULAR:
maxConcurrentCalls: 1
maxWaitDuration: 500ms
- admin:
- enabled: ${ADMIN_ENABLED:true}
- telegram-id: ${ADMIN_TELEGRAM_ID:}
- rest-email: ${ADMIN_REST_EMAIL:}
assistant-role: role.content.default
# Hard limit for entire prompt to API (system + history + current). When exceeded — trim/reject and return error.
max-total-prompt-tokens: 32000
@@ -190,6 +191,7 @@ open-daimon:
- stepfun/step-3.5-flash:free
blacklist:
exclude-model-ids: [] # add model IDs here to block them permanently
+ exclude-contains: [] # add substrings here to block model families
ranking:
enabled: true
retry-max-attempts: 3
diff --git a/opendaimon-app/src/test/java/io/github/ngirchev/opendaimon/arch/ArchitectureTest.java b/opendaimon-app/src/test/java/io/github/ngirchev/opendaimon/arch/ArchitectureTest.java
new file mode 100644
index 00000000..ade33f2a
--- /dev/null
+++ b/opendaimon-app/src/test/java/io/github/ngirchev/opendaimon/arch/ArchitectureTest.java
@@ -0,0 +1,167 @@
+package io.github.ngirchev.opendaimon.arch;
+
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
+import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
+import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
+
+import com.tngtech.archunit.core.domain.Dependency;
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.core.importer.ImportOption;
+import com.tngtech.archunit.core.importer.Location;
+import com.tngtech.archunit.junit.AnalyzeClasses;
+import com.tngtech.archunit.junit.ArchTest;
+import com.tngtech.archunit.lang.ArchCondition;
+import com.tngtech.archunit.lang.ArchRule;
+import com.tngtech.archunit.lang.ConditionEvents;
+import com.tngtech.archunit.lang.SimpleConditionEvent;
+import com.tngtech.archunit.library.dependencies.SliceAssignment;
+import com.tngtech.archunit.library.dependencies.SliceIdentifier;
+
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Executable architectural invariants for the {@code opendaimon-*} library modules.
+ *
+ *