mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-09 06:58:41 -04:00
Compare commits
1 Commits
dev
..
cb6c28113a
| Author | SHA1 | Date | |
|---|---|---|---|
| cb6c28113a |
@@ -189,7 +189,6 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
|
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
|
||||||
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
||||||
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
|
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
|
||||||
# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB)
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Host Docker access (explicit opt-in)
|
# Host Docker access (explicit opt-in)
|
||||||
|
|||||||
@@ -153,16 +153,6 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABEL_BAD = 'needs more info';
|
|
||||||
const LABEL_GOOD = 'ready for review';
|
|
||||||
|
|
||||||
// Closed issues are no longer awaiting review.
|
|
||||||
// This also prevents later edits to closed issues from restoring the label.
|
|
||||||
if (issue.state === 'closed') {
|
|
||||||
await dropLabel(LABEL_GOOD);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Find existing bot comment to update in-place ──────────────────────────
|
// ── Find existing bot comment to update in-place ──────────────────────────
|
||||||
const MARKER = '<!-- issue-description-check -->';
|
const MARKER = '<!-- issue-description-check -->';
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
@@ -170,6 +160,9 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
});
|
});
|
||||||
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
|
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
|
||||||
|
|
||||||
|
const LABEL_BAD = 'needs more info';
|
||||||
|
const LABEL_GOOD = 'ready for review';
|
||||||
|
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: ci / issue description check
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened, edited, reopened, closed]
|
types: [opened, edited, reopened]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
|
|||||||
@@ -692,7 +692,7 @@ from routes.history.history_routes import setup_history_routes
|
|||||||
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
|
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
from routes.search.search_routes import setup_search_routes
|
from routes.search_routes import setup_search_routes
|
||||||
app.include_router(setup_search_routes(config))
|
app.include_router(setup_search_routes(config))
|
||||||
|
|
||||||
# Presets
|
# Presets
|
||||||
@@ -739,7 +739,7 @@ app.include_router(setup_stt_routes(stt_service))
|
|||||||
logger.info("STT service initialized (provider managed via settings)")
|
logger.info("STT service initialized (provider managed via settings)")
|
||||||
|
|
||||||
# Documents (artifacts/canvas)
|
# Documents (artifacts/canvas)
|
||||||
from routes.document.document_routes import setup_document_routes
|
from routes.document_routes import setup_document_routes
|
||||||
document_router = setup_document_routes(session_manager, upload_handler)
|
document_router = setup_document_routes(session_manager, upload_handler)
|
||||||
app.include_router(document_router)
|
app.include_router(document_router)
|
||||||
|
|
||||||
@@ -820,7 +820,7 @@ set_ai_rag_manager(rag_manager, personal_docs_mgr)
|
|||||||
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
|
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
|
||||||
|
|
||||||
# Webhooks
|
# Webhooks
|
||||||
from routes.webhook.webhook_routes import setup_webhook_routes
|
from routes.webhook_routes import setup_webhook_routes
|
||||||
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
||||||
|
|
||||||
# API Tokens
|
# API Tokens
|
||||||
@@ -852,7 +852,7 @@ app.include_router(setup_codex_routes(
|
|||||||
))
|
))
|
||||||
app.include_router(setup_claude_routes())
|
app.include_router(setup_claude_routes())
|
||||||
|
|
||||||
from routes.vault.vault_routes import setup_vault_routes
|
from routes.vault_routes import setup_vault_routes
|
||||||
app.include_router(setup_vault_routes())
|
app.include_router(setup_vault_routes())
|
||||||
|
|
||||||
# Contacts (CardDAV)
|
# Contacts (CardDAV)
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ services:
|
|||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ services:
|
|||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ services:
|
|||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Discovery Baseline Status
|
||||||
|
|
||||||
|
## Purpose and scope
|
||||||
|
|
||||||
|
This package is a commit-pinned discovery baseline and feature index for the `discovery` branch at frozen commit `d8a2059df8e53bc7275c45339849d14c8651e73c`. It inventories **79 feature records** across **16 domains** to help maintainers locate likely implementation areas and identify validation gaps. It is not an authoritative architecture reference or a runtime-certification record.
|
||||||
|
|
||||||
|
The canonical inventory is [`feature-catalog.json`](feature-catalog.json); [`feature-catalog.md`](feature-catalog.md) and the files in [`domains/`](domains/) are derived reading views. See [`audit-method.md`](audit-method.md) for the status and maturity definitions.
|
||||||
|
|
||||||
|
## What maintainers may use now
|
||||||
|
|
||||||
|
- Use the catalog and domain views as a frozen discovery index, including their feature IDs, stated scope, likely source locations, and declared runtime prerequisites.
|
||||||
|
- Treat a catalog status such as `verified` as meaning implementation was identified during discovery. It does **not** mean every evidence locator, line range, test claim, or runtime behaviour has passed semantic validation.
|
||||||
|
- Use the structural checks to confirm package shape and cross-view consistency; use the evidence validator to assess whether individual evidence assertions are semantically supported.
|
||||||
|
|
||||||
|
## Validation snapshot
|
||||||
|
|
||||||
|
Structural validation passes: the catalog has 79 records, the 16 domain views match it, and the package structural validators pass. All 11 focused evidence-validator tests pass.
|
||||||
|
|
||||||
|
The evidence validator found **170 evidence entries**: **82 valid**, **66 invalid**, **6 ambiguous**, and **16 unsupported**. Its non-zero exit is expected while those semantic evidence defects remain.
|
||||||
|
|
||||||
|
Structural validity checks the documentation schema, record counts, derived-view consistency, file existence, line-range bounds, links, and public-safety rules. Semantic evidence validity additionally checks whether the cited locator exists, falls within its cited range, uses a supported parser, and actually supports the feature claim. Passing the former does not establish the latter.
|
||||||
|
|
||||||
|
## E2 review decisions
|
||||||
|
|
||||||
|
E2 means directly relevant automated test evidence supports the feature claim; a test file’s existence alone is insufficient. The generated evidence report was used to reassess all ten E2 records.
|
||||||
|
|
||||||
|
| Feature | Decision | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `CHAT-001` | Retain E2 | Two cited tests directly exercise documented streaming-related behaviour. |
|
||||||
|
| `MODEL-006` | Retain E2 | The cited device-flow test exercises the Copilot start/poll contract and verification URI. |
|
||||||
|
| `MODEL-007` | Retain E2 | The cited device-flow test exercises the ChatGPT subscription contract and verification URI. |
|
||||||
|
| `RESEARCH-003` | Demote to E1 | The cited test covers ranking, not provider connectivity or dispatch; the route-to-provider implementation path was identified. |
|
||||||
|
| `DOCUMENT-002` | Demote to E1 | The cited marker test does not support form processing or rendering; the document route does call the PDF form handlers. |
|
||||||
|
| `EMAIL-001` | Demote to E1 | The cited health test is narrower than setup, SMTP, and polling; application setup invokes the email router and its poller. |
|
||||||
|
| `SECURITY-002` | Demote to E1 | Relevant vault-password tests exist, but the cited test locator is fabricated; the application mounts the vault route implementation. |
|
||||||
|
| `SECURITY-004` | Demote to E1 | Relevant injection tests exist, but the cited locator names are fabricated; callers use the documented context guard. |
|
||||||
|
| `SECURITY-005` | Demote to E1 | Relevant guard tests exist, but the cited locator names are fabricated; route code calls the documented URL guard. |
|
||||||
|
| `PLATFORM-009` | Demote to E0 | The manifests and diagnostic script establish discovered operational artifacts, not a traced application path or directly relevant automated test. |
|
||||||
|
|
||||||
|
The current maturity distribution is **E0: 68**, **E1: 8**, **E2: 3**, **E3: 0**, **E4: 0**. Runtime validation is still pending where the catalog says it requires external services, interactive authentication, specialised hardware, or host Docker GPU support.
|
||||||
|
|
||||||
|
## Known discovery caveats
|
||||||
|
|
||||||
|
- `AGENT-004` includes a legacy no-op activity-log shim rather than active assistant-log behaviour.
|
||||||
|
- `FRONTEND-007` points to a missing `static/backgrounds.html` target; its existing variant pages do not make that route functional.
|
||||||
|
- `RESEARCH-003` retains a compatibility module that aliases the canonical search implementation, and `DOCUMENT-002` separates optional PDF viewing from form handling.
|
||||||
|
- Secret-storage and vault-command handling are distinct implementation areas; this index does not make an end-to-end security guarantee.
|
||||||
|
|
||||||
|
## Recommended next documentation work
|
||||||
|
|
||||||
|
Repair semantic evidence selectively while architecture and operations documentation is written, beginning with the seven E2 demotions and maintainer-owned feature descriptions. Do not wait for the complete evidence queue before documenting the system. Record controlled runtime observations when external services, credentials, hardware, or Docker GPU access are available, and label unsupported claims explicitly.
|
||||||
|
|
||||||
|
## Validation commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 python3 docs/discovery/tools/validate_discovery_docs.py --repo-root .
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 python3 docs/discovery/tools/validate_discovery_consistency.py
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest docs/discovery/tools/test_validate_discovery_evidence.py -v
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 python3 docs/discovery/tools/validate_discovery_evidence.py --repo-root . --catalog docs/discovery/feature-catalog.json --output-dir <local-report-directory>
|
||||||
|
```
|
||||||
|
|
||||||
|
Supply a local report directory outside `docs/discovery/` for the final command so generated reports are not added to the package.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Odysseus Discovery Package
|
||||||
|
|
||||||
|
## Provisional discovery baseline
|
||||||
|
|
||||||
|
This is a commit-pinned discovery baseline and feature index for the `discovery` branch at `d8a2059df8e53bc7275c45339849d14c8651e73c`. It contains 79 feature records across 16 domains. It is **not** an authoritative architecture reference, a runtime certification, or a claim that every evidence citation is semantically valid.
|
||||||
|
|
||||||
|
Read [`BASELINE-STATUS.md`](BASELINE-STATUS.md) first for the publication status, evidence-validation totals, E2 decisions, known caveats, and the recommended next documentation work.
|
||||||
|
|
||||||
|
## Package contents
|
||||||
|
|
||||||
|
- [`feature-catalog.json`](feature-catalog.json) is the canonical machine-readable catalog.
|
||||||
|
- [`feature-catalog.md`](feature-catalog.md) and [`domains/`](domains/) are derived reading views.
|
||||||
|
- [`audit-method.md`](audit-method.md) defines feature status and evidence maturity.
|
||||||
|
- [`references/source-provenance.md`](references/source-provenance.md) records the frozen snapshot.
|
||||||
|
- [`tools/`](tools/) contains the structural, consistency, and evidence validators.
|
||||||
|
|
||||||
|
A feature status such as `verified` means implementation was identified during discovery. It does not mean every evidence locator, line range, test claim, or runtime behaviour has passed semantic validation.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run the commands in [`BASELINE-STATUS.md`](BASELINE-STATUS.md#validation-commands). Structural checks and semantic evidence checks have different purposes; see that status document for the current results and interpretation.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Odysseus Functional Audit Methodology
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document specifies the methodology and evidence standards for the read-only discovery audit of **Odysseus**.
|
||||||
|
|
||||||
|
## Snapshot Baseline
|
||||||
|
|
||||||
|
- **Repository**: `odysseus-dev/odysseus`
|
||||||
|
- **Audit Target Branch**: `discovery`
|
||||||
|
- **Frozen Commit SHA**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Snapshot Date**: `2026-07-23T14:49:02Z`
|
||||||
|
- **Audit Mode**: Read-Only inventory & documentation review
|
||||||
|
|
||||||
|
## Rules of Engagement
|
||||||
|
|
||||||
|
1. **No Code Mutations**: Application code and tests outside `docs/discovery/` remain untouched.
|
||||||
|
2. **No External Operations**: No GitHub issues, PRs, comments, labels, or branch mutations.
|
||||||
|
3. **Zero Inferred Success**: Documentation claims require empirical evidence of implementation and reachability. Filenames, README descriptions, and docstrings alone do not constitute proof.
|
||||||
|
4. **Strict Status Categorization**: All capabilities are assigned exactly one authorized status:
|
||||||
|
- `verified`: Implemented, reachable, and supported by code evidence.
|
||||||
|
- `partial`: Partially implemented or missing full frontend/backend connection.
|
||||||
|
- `disabled`: Gated off by default feature flags or configuration.
|
||||||
|
- `experimental`: Active but requiring non-standard hardware or runtimes.
|
||||||
|
- `legacy`: Obsolete feature retained for backwards compatibility.
|
||||||
|
- `dead-code-candidate`: Code exists but is unreachable from UI or API routes.
|
||||||
|
- `unverified`: Implementation present but untestable without external secrets or hardware.
|
||||||
|
|
||||||
|
## Evidence Maturity Scale
|
||||||
|
|
||||||
|
Evidence maturity is evaluated independently from catalog feature status. A feature status such as `verified` records that implementation was identified during discovery; it is not a statement that every evidence locator, test claim, line range, or runtime behaviour has passed semantic validation.
|
||||||
|
|
||||||
|
- **E0 - Discovered**: Candidate identified in documentation, route declaration, or source file.
|
||||||
|
- **E1 - Code-path traced**: Frontend/API entry point connected through services and data handlers.
|
||||||
|
- **E2 - Test-backed**: At least one directly relevant automated test assertion supports the feature claim. A test file's existence, an unrelated assertion, or an invalid test locator does not establish E2.
|
||||||
|
- **E3 - Runtime-validated**: Maintainer reproduced behavior in a recorded local environment.
|
||||||
|
- **E4 - Maintainer-accepted**: Maintainers accepted the feature description and support status.
|
||||||
|
|
||||||
|
## Audit Workflow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
P0["Phase 0: Snapshot Isolation<br/>(Commit d8a2059)"] --> P1["Phase 1: Codebase Discovery<br/>(Routes, Services, Static JS, Specs)"]
|
||||||
|
P1 --> P2["Phase 2: Feature Reachability & Verification<br/>(Route matching, FE entrypoints, tests)"]
|
||||||
|
P2 --> P3["Phase 3: Catalog & Evidence Compilation<br/>(File paths, symbols, exact line ranges)"]
|
||||||
|
P3 --> P4["Phase 4: Quality & Integrity Audit<br/>(100% path existence check, schema validation)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 0: Snapshot Isolation
|
||||||
|
The audit is pinned to git commit `d8a2059df8e53bc7275c45339849d14c8651e73c`. All file paths, symbol declarations, and line ranges map strictly to this commit.
|
||||||
|
|
||||||
|
### Phase 1: Codebase Discovery
|
||||||
|
All top-level and nested directories were traversed, including:
|
||||||
|
- Backend Entry Points (`app.py`, `routes/`, `routes/*/*.py`, `companion/`)
|
||||||
|
- Core Framework (`core/database.py`, `core/session_manager.py`, `core/auth.py`)
|
||||||
|
- Business Logic Services (`src/`, `services/`, `mcp_servers/`)
|
||||||
|
- Frontend Assets (`static/app.js`, `static/js/`, `static/index.html`)
|
||||||
|
- Test Suites (`tests/`, `tests/cli/`, `tests/streaming/`)
|
||||||
|
- Operations & Docker (`Dockerfile`, `docker-compose*.yml`, `scripts/`)
|
||||||
|
|
||||||
|
### Phase 2: Verification Protocol
|
||||||
|
For each feature candidate, the following table was evaluated:
|
||||||
|
- **User Reachability**: Frontend UI element, modal, route, or CLI script.
|
||||||
|
- **API Entrypoint**: FastAPI `@router` declaration or WebSocket/SSE handler.
|
||||||
|
- **Backend Execution**: Concrete Python module method, service, or tool call.
|
||||||
|
- **Data Persistence**: Disk file, SQLite table, or vector collection.
|
||||||
|
- **Test Coverage**: Automated test file executing assertions against the component.
|
||||||
|
|
||||||
|
### Phase 3: Evidence Linking Standard
|
||||||
|
Every feature entry in `feature-catalog.json` contains a structured `evidence` list with:
|
||||||
|
- `path`: Relative path from repository root.
|
||||||
|
- `symbol`: Route, class, function, or element symbol name.
|
||||||
|
- `line_range`: Inclusive line range (e.g. `L120-L250`).
|
||||||
|
- `explanation`: Short factual statement proving reachability or implementation.
|
||||||
|
|
||||||
|
### Phase 4: Quality Check & Schema Constraints
|
||||||
|
Before finalization:
|
||||||
|
1. Every evidence file path is validated against the checkout.
|
||||||
|
2. Every Markdown entry matches `feature-catalog.json`.
|
||||||
|
3. Recommendation language is separated from empirical factual observations.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Agent
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## AGENT-001 — Autonomous Agent Loop & Tool Execution Engine
|
||||||
|
|
||||||
|
- **Domain**: `agent`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Executes multi-step agent reasoning loops, tool invocation parsing, and automated response generation.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/agent_loop.py` — `run_agent_loop` — Core loop evaluating model tool requests and executing handlers.
|
||||||
|
- `src/tool_execution.py` — `execute_tool_call` — Dispatches tool invocation requests to underlying tool handlers.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Infinite tool loop if termination condition fails.
|
||||||
|
|
||||||
|
## AGENT-002 — Scheduled Tasks & Event Bus Dispatcher
|
||||||
|
|
||||||
|
- **Domain**: `agent`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Schedules background recurring or delayed tasks, emits event bus triggers, and executes automated flows.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/task_routes.py` — `@router.get('')` — Fetches active scheduled tasks.
|
||||||
|
- `src/task_scheduler.py` — `TaskScheduler` — Async task scheduler dispatching cron and delay triggers.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Task execution failure handling on system restart.
|
||||||
|
|
||||||
|
## AGENT-003 — Webhook Event Subscriptions & Trigger Processing
|
||||||
|
|
||||||
|
- **Domain**: `agent`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Manages incoming/outgoing webhook subscriptions, endpoint authentication tokens, and event triggers.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/webhook_routes.py` — `@router.get('/webhooks')` — Returns list of registered webhooks.
|
||||||
|
- `src/webhook_manager.py` — `WebhookManager` — Handles payload delivery and signature verification.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- SSRF risks when contacting external webhook URLs if unvalidated.
|
||||||
|
|
||||||
|
## AGENT-004 — Assistant Settings, Task Check-Ins & Background Job Monitor
|
||||||
|
|
||||||
|
- **Domain**: `agent`
|
||||||
|
- **Status**: `partial`
|
||||||
|
- **Evidence Maturity**: `E1`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Manages per-user assistant sessions and scheduled check-in settings, drains background job completions, and retains a legacy no-op activity logging shim.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/assistant_routes.py` — `setup_assistant_routes` — Active assistant session, settings, manual check-in, run-status and timezone-list endpoints, including the owner-scoping guards.
|
||||||
|
- `src/bg_monitor.py` — `_drain_agent` — Runs the agent loop headless against a session to produce the background-job follow-up turn.
|
||||||
|
- `src/bg_monitor.py` — `_run_followup` — Drains completed background jobs and auto-continues the owning session, deferring while a live turn is in progress.
|
||||||
|
- `src/assistant_log.py` — `log_to_assistant` — Legacy no-op activity logging shim retained for existing callers; documented as inactive rather than as current behaviour.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Route `/api/assistant/logs` cited in legacy docs is absent from assistant router.
|
||||||
|
- Existing unit test `tests/cli/test_logs_cli_resolve_nonstring.py` tests CLI target-name resolution logic, not active assistant routes or bg_monitor execution loop.
|
||||||
|
|
||||||
|
## AGENT-005 — Model Context Protocol (MCP) Server Integration
|
||||||
|
|
||||||
|
- **Domain**: `agent`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Integrates external MCP servers over stdio/SSE to expand agent capabilities dynamically.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/mcp_routes.py` — `setup_mcp_routes` — Exposes management endpoints for external MCP servers.
|
||||||
|
- `src/mcp_manager.py` — `McpManager` — Manages MCP server subprocess lifecycles.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Subprocess leaks if external MCP server process fails to terminate clean.
|
||||||
|
|
||||||
|
## AGENT-006 — AI Interaction Tools & Pipeline Orchestration
|
||||||
|
|
||||||
|
- **Domain**: `agent`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides specialized AI interaction tools for agent self-debugging, debate, and multi-model collaboration.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/ai_interaction.py` — `init_ai_interaction_tools` — Registers specialized multi-agent interaction primitives.
|
||||||
|
- `src/builtin_actions.py` — `execute_builtin_action` — Executes pre-built action intent sequences.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- High API token consumption during extended agent debates.
|
||||||
|
|
||||||
|
## AGENT-007 — Subprocess & Background Job Execution Tools
|
||||||
|
|
||||||
|
- **Domain**: `agent`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides sandboxed bash/shell tool execution capabilities with output streaming and background tracking.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/agent_tools/subprocess_tools.py` — `run_command` — Executes shell commands in background/foreground.
|
||||||
|
- `src/bg_jobs.py` — `JobManager` — Tracks async background subprocess tasks.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Arbitrary shell command execution permissions if sandbox confinement fails.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Calendar
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## CALENDAR-001 — CalDAV Calendar Synchronization & Account Setup
|
||||||
|
|
||||||
|
- **Domain**: `calendar`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires a controlled external CalDAV server.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Connects to remote CalDAV servers (Apple iCloud, Nextcloud, Google) to sync calendar event feeds.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/calendar_routes.py` — `setup_calendar_routes` — Exposes CalDAV setup and manual sync trigger routes.
|
||||||
|
- `src/caldav_sync.py` — `CalDavSync` — Fetches and parses remote iCalendar VEVENT objects.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Invalid SSL certificates on self-hosted CalDAV servers.
|
||||||
|
|
||||||
|
## CALENDAR-002 — Calendar Event Operations & iCalendar Parsing
|
||||||
|
|
||||||
|
- **Domain**: `calendar`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Creates, updates, deletes, and displays calendar events with timezone conversion and reminder notifications.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/calendar_routes.py` — `@router.get('/events')` — Fetches calendar events for requested date window.
|
||||||
|
- `src/tools/calendar.py` — `CalendarTool` — Agent tool for creating and modifying calendar entries.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Recurring RRULE event expansion calculation bugs across leap years.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Chat
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## CHAT-001 — Core Chat Streaming & SSE Message Generation
|
||||||
|
|
||||||
|
- **Domain**: `chat`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E2`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires access to a live LLM provider endpoint (OpenAI API key or local Ollama server).
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Handles real-time Server-Sent Events (SSE) chat streaming, token rendering, and model response generation.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/chat_routes.py` — `chat_stream` — POST /api/chat_stream SSE endpoint; builds the shared chat context, then dispatches to the chat-mode or agent-mode streaming path.
|
||||||
|
- `routes/chat_helpers.py` — `build_chat_context` — Shared context builder invoked by chat_stream; runs message preprocessing and assembles the memory/RAG/web context preface.
|
||||||
|
- `src/chat_handler.py` — `ChatHandler.preprocess_message` — Message preprocessing (attachments, URLs, tool preprocessing) reached from build_chat_context via routes/chat_helpers.py:preprocess.
|
||||||
|
- `src/chat_processor.py` — `ChatProcessor.build_context_preface` — Builds the retrieval and web-source context preface injected into the streamed request.
|
||||||
|
- `src/llm_core.py` — `stream_llm_with_fallback` — Chat-mode streaming dispatcher called from chat_stream; wraps stream_llm with an ordered provider fallback chain.
|
||||||
|
- `src/llm_core.py` — `stream_llm` — Per-request streaming entry wrapped by stream_llm_with_fallback; acquires the local model slot and delegates to _stream_llm_inner.
|
||||||
|
- `src/agent_loop.py` — `stream_agent_loop` — Agent-mode streaming path called from chat_stream when the request selects agent mode.
|
||||||
|
- `tests/test_chat_metrics.py` — `test_stream_llm_passes_through_llamacpp_timings` — Inspected unit test asserting stream_llm forwards backend generation timings into the emitted metrics chunk.
|
||||||
|
- `tests/test_resend_message_nondestructive.py` — `test_resend_message_does_not_truncate_by_default` — Inspected unit test asserting the frontend resend path does not truncate prior conversation turns.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Stream interruption on connection drops requires retry logic.
|
||||||
|
|
||||||
|
## CHAT-002 — Session Management & Conversation State
|
||||||
|
|
||||||
|
- **Domain**: `chat`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Manages session creation, listing, switching, renaming, and persistence of conversation metadata.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/session_routes.py` — `@router.get('/api/sessions')` — Lists active sessions filtered by user owner scope.
|
||||||
|
- `core/session_manager.py` — `SessionManager` — Provides thread-safe session storage operations.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Concurrent file writes to sessions.json under high load.
|
||||||
|
|
||||||
|
## CHAT-003 — Chat History & Message Editing/Truncation
|
||||||
|
|
||||||
|
- **Domain**: `chat`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides history retrieval, message content updating, message deletion, and history branch truncation.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/history/history_routes.py` — `@router.get('/api/history/{session_id}')` — Fetches message history timeline for a session.
|
||||||
|
- `routes/history_routes.py` — `_sys.modules[__name__] = _canonical` — Backward-compatibility shim module.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Truncating messages re-indexes context window and clears cached tool calls.
|
||||||
|
|
||||||
|
## CHAT-004 — File & Multimodal Attachment Handling
|
||||||
|
|
||||||
|
- **Domain**: `chat`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Handles uploading, mime validation, image preview, vision encoding, and file attachments in chat messages.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/upload_routes.py` — `@router.post('')` — Accepts multi-part file uploads and generates vision metadata.
|
||||||
|
- `src/upload_handler.py` — `UploadHandler.save_file` — Validates upload size and atomicity on disk.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Large file uploads may consume server disk space if cleanup task fails.
|
||||||
|
|
||||||
|
## CHAT-005 — Chat Message Search
|
||||||
|
|
||||||
|
- **Domain**: `chat`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Enables full-text keyword search across stored chat messages and sessions.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/search_routes.py` — `setup_search_routes` — Registers chat message search endpoint.
|
||||||
|
- `src/session_search.py` — `search_sessions` — Executes query matching against session transcripts.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Full table scans on un-indexed text columns for very large databases.
|
||||||
|
|
||||||
|
## CHAT-006 — System Prompts & Preset Management
|
||||||
|
|
||||||
|
- **Domain**: `chat`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides creation, selection, and customization of system prompt presets for chat sessions.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/preset_routes.py` — `setup_preset_routes` — API routes for listing and modifying system prompt presets.
|
||||||
|
- `src/preset_manager.py` — `PresetManager` — Disk-backed manager for prompt presets.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Invalid JSON syntax in user presets file can corrupt preset loading.
|
||||||
|
|
||||||
|
## CHAT-007 — Emoji Rendering & Twemoji SVG Proxy
|
||||||
|
|
||||||
|
- **Domain**: `chat`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Proxies Twemoji SVG icons locally to render flat SVG emojis in message text without external CDN dependencies.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/emoji_routes.py` — `setup_emoji_routes` — Serves locally cached Twemoji SVGs.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- First request fetches SVG from remote CDN before caching locally.
|
||||||
|
|
||||||
|
## CHAT-008 — Input History Recall (Arrow Up)
|
||||||
|
|
||||||
|
- **Domain**: `chat`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Allows users to cycle through previously sent prompt messages in the chat composer input using Arrow-Up/Down keys.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `static/js/composerArrowUpRecall.js` — `initComposerRecall` — Listens for ArrowUp keypress on composer textarea.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Client-side browser storage limits.
|
||||||
|
|
||||||
|
## CHAT-009 — Context Window Compaction & Truncation
|
||||||
|
|
||||||
|
- **Domain**: `chat`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Compacts session transcript history when prompt size exceeds context limits using summarization.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/history/history_routes.py` — `@router.post('/api/session/{session_id}/compact')` — Triggers context summarization and compaction.
|
||||||
|
- `src/context_compactor.py` — `compact_context` — Executes context token pruning and summary generation.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Aggressive compaction may discard subtle user instructions.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Contact
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## CONTACT-001 — CardDAV Contact Management & Address Book Integration
|
||||||
|
|
||||||
|
- **Domain**: `contact`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Connects to CardDAV servers, imports VCard contacts, and provides contact lookup for email/calendar autocomplete.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/contacts/contacts_routes.py` — `@router.get('/list')` — Returns contact list filtered by search query.
|
||||||
|
- `src/tools/contacts.py` — `ContactsTool` — Agent tool for querying user address book contacts.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- VCard 3.0 vs 4.0 property parsing mismatches.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Cookbook
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## COOKBOOK-001 — Local Model Download & Recipe Lifecycle Management
|
||||||
|
|
||||||
|
- **Domain**: `cookbook`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Downloads HuggingFace models, configures execution parameters, and manages local GGUF/MLX model servers.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/cookbook_routes.py` — `setup_cookbook_routes` — Exposes model downloading and process serving endpoints.
|
||||||
|
- `static/js/cookbook.js` — `initCookbook` — UI manager for local model library.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Disk space exhaustion during multi-gigabyte GGUF weights downloads.
|
||||||
|
|
||||||
|
## COOKBOOK-002 — Hardware Model Fitting ('What Fits?') Analysis Engine
|
||||||
|
|
||||||
|
- **Domain**: `cookbook`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Calculates RAM/VRAM requirements, quantized size, and context overhead to determine model compatibility.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/hwfit_routes.py` — `setup_hwfit_routes` — Calculates hardware model compatibility.
|
||||||
|
- `services/hwfit/fit.py` — `calculate_fit` — Performs parameter and memory fit calculations.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Inaccurate VRAM estimation for non-standard KV-cache quantization.
|
||||||
|
|
||||||
|
## COOKBOOK-003 — HuggingFace & MLX Model Discovery Services
|
||||||
|
|
||||||
|
- **Domain**: `cookbook`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Searches HuggingFace Hub and local MLX model repositories for compatible GGUF and MLX weights.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `services/hwfit/hf_discovery.py` — `search_hf_models` — Queries HuggingFace API for model tags and files.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- HuggingFace API rate limits when searching without an API token.
|
||||||
|
|
||||||
|
## COOKBOOK-004 — Host Docker Access for Inference Container Runtimes
|
||||||
|
|
||||||
|
- **Domain**: `cookbook`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires Docker access and supported physical GPU hardware.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Detects and connects to host Docker engine to launch containerized Ollama, vLLM, or SGLang runtimes.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/host_docker_access.py` — `HostDockerAccess` — Interacts with host docker daemon.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Permission denied accessing docker socket on non-root setups.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Document
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## DOCUMENT-001 — Document & Canvas Artifact Management
|
||||||
|
|
||||||
|
- **Domain**: `document`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Renders dynamic canvas documents, handles live editing, markdown preview, and side-by-side artifact display.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/document_routes.py` — `setup_document_routes` — Registers document artifact CRUD routes.
|
||||||
|
- `static/js/document.js` — `initDocumentView` — Renders interactive canvas document panel.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Concurrent edits on the same document artifact.
|
||||||
|
|
||||||
|
## DOCUMENT-002 — PDF Form Processing & High-Fidelity Rendering
|
||||||
|
|
||||||
|
- **Domain**: `document`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E1`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires optional PyMuPDF (`fitz`) or pypdf runtime dependency.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Extracts form fields from PDF files, fills dynamic values, and generates PDF previews.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/pdf_runtime.py` — `load_pymupdf_for_pdf_viewer` — Loads optional PyMuPDF runtime for PDF viewing.
|
||||||
|
- `src/pdf_forms.py` — `extract_form_fields` — Handles PDF form field extraction and filling.
|
||||||
|
- `tests/test_document_pdf_marker.py` — `test_marker_removed_without_eating_following_text` — Tests PDF text extraction wrapper stripping without content corruption.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Complex XFA PDF forms may not extract cleanly with standard pdf parsers.
|
||||||
|
|
||||||
|
## DOCUMENT-003 — Personal Document Indexing & RAG Retrieval
|
||||||
|
|
||||||
|
- **Domain**: `document`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Indexes local user documents (PDF, DOCX, TXT) into ChromaDB for semantic vector retrieval.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/personal_routes.py` — `setup_personal_routes` — Personal document RAG indexing and search API endpoints.
|
||||||
|
- `src/personal_docs.py` — `PersonalDocsManager` — Handles file text chunking and vector storage.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Slow vector embedding indexing step for massive multi-thousand page documents.
|
||||||
|
|
||||||
|
## DOCUMENT-004 — Document Conversion & Text Extraction Engine
|
||||||
|
|
||||||
|
- **Domain**: `document`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Converts office formats (.docx, .xlsx, .pptx) and HTML into clean Markdown text representations.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/markitdown_runtime.py` — `convert_to_markdown` — Converts binary office documents into structured Markdown text.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Formatting loss when parsing legacy binary doc/xls files.
|
||||||
|
|
||||||
|
## DOCUMENT-005 — Document Library UI Navigation
|
||||||
|
|
||||||
|
- **Domain**: `document`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides dedicated UI view for browsing, filtering, and organizing saved user documents.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `static/js/documentLibrary.js` — `initDocumentLibrary` — Renders document library navigation grid.
|
||||||
|
- `app.py` — `serve_library` — Serves SPA shell for /library route.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Large folder trees may cause initial DOM render slowdown.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Email
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## EMAIL-001 — Email Account Setup, IMAP/SMTP Connection & Polling
|
||||||
|
|
||||||
|
- **Domain**: `email`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E1`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires a controlled live IMAP account and network access.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Configures IMAP/SMTP email accounts, validates TLS certificates, and polls background inbox updates.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/email_routes.py` — `setup_email_routes` — Sets up email account management and synchronization routes.
|
||||||
|
- `routes/email_pollers.py` — `_start_poller` — Background poller for email inbox synchronization.
|
||||||
|
- `tests/test_service_health_email.py` — `test_email_ok_all_connect` — Tests IMAP connection health probing and status reporting.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Account lockouts if bad credentials are repeatedly polled.
|
||||||
|
|
||||||
|
## EMAIL-002 — Email Searching, Threading & Message Operations
|
||||||
|
|
||||||
|
- **Domain**: `email`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Parses email headers, folds signatures, groups messages into threads, and executes full-text email search.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/email_routes.py` — `@router.get('/search')` — Executes search across cached email headers and text.
|
||||||
|
- `src/email_thread_parser.py` — `parse_email_thread` — Builds conversation tree from Message-ID and In-Reply-To headers.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Malformed MIME email structures failing HTML sanitization.
|
||||||
|
|
||||||
|
## EMAIL-003 — Email Composition, Draft Management & Sending
|
||||||
|
|
||||||
|
- **Domain**: `email`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires a controlled live SMTP account and network access.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Creates, saves, and dispatches HTML/plaintext email messages via SMTP.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/email_routes.py` — `@router.post('/send')` — Sends email message via user SMTP credentials.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- SMTP connection drop mid-send causing unsent mail state.
|
||||||
|
|
||||||
|
## EMAIL-004 — Email MCP Server & Codex Integration Bridge
|
||||||
|
|
||||||
|
- **Domain**: `email`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Exposes constrained email reading and draft capabilities to external Codex / MCP agents with scope checks.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `mcp_servers/email_server.py` — `EmailMcpServer` — MCP server exposing email tools over stdio/SSE.
|
||||||
|
- `routes/codex_routes.py` — `setup_codex_routes` — Bridge endpoints for external Codex plugin integration.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Unauthorized mail sending if token scopes are improperly scoped.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Frontend
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## FRONTEND-001 — Single Page Application Shell & Client Router
|
||||||
|
|
||||||
|
- **Domain**: `frontend`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Main HTML5 SPA shell, DOM lifecycle initializers, tab navigation, and deep-link route handlers.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `static/index.html` — `index.html` — Main SPA entry point containing modal roots and CSS bundles.
|
||||||
|
- `app.py` — `serve_index` — Serves index.html with dynamically generated CSP nonces.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Stale browser static cache if asset hashing is omitted during deployment.
|
||||||
|
|
||||||
|
## FRONTEND-002 — Dynamic Theme, Color System & Custom Fonts
|
||||||
|
|
||||||
|
- **Domain**: `frontend`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Supports dark/light themes, custom CSS variables, color picker controls, and user font uploads.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `static/js/theme.js` — `applyTheme` — Applies custom HSL theme variables to DOM document root.
|
||||||
|
- `routes/font_routes.py` — `setup_font_routes` — Allows uploading and serving custom WOFF2 font files.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Flash of unstyled content (FOUC) on slow connections.
|
||||||
|
|
||||||
|
## FRONTEND-003 — Window Manager, Tile Layout & Modal Control System
|
||||||
|
|
||||||
|
- **Domain**: `frontend`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Manages draggable tool windows, snapped multi-tile viewports, modal dialog Z-ordering, and ESC key stacks.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `static/js/modalManager.js` — `ModalManager` — Controls modal open/close transitions and focus trapping.
|
||||||
|
- `static/js/tileManager.js` — `TileManager` — Handles viewport split-pane grid arrangements.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Overlap artifacts when opening many simultaneous tool floating windows.
|
||||||
|
|
||||||
|
## FRONTEND-004 — Global Keyboard Shortcuts & Accessibility Controls
|
||||||
|
|
||||||
|
- **Domain**: `frontend`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides configurable hotkeys (Ctrl+K search, Esc close, Alt+1-9 tabs) and high-contrast accessibility options.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `static/js/keyboard-shortcuts.js` — `initShortcuts` — Binds global keydown handlers for system shortcuts.
|
||||||
|
- `static/js/a11y.js` — `initA11y` — Applies ARIA roles and dyslexic font toggles.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Browser keybinding collisions with browser default hotkeys.
|
||||||
|
|
||||||
|
## FRONTEND-005 — Markdown, LaTeX & Code Block Streaming Renderer
|
||||||
|
|
||||||
|
- **Domain**: `frontend`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Parses incoming SSE markdown streams, renders KaTeX math formulas, syntax-highlighted code, and interactive runners.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `static/js/markdown.js` — `renderMarkdown` — Converts markdown prose to HTML nodes with syntax highlighting.
|
||||||
|
- `static/js/streamingSegmenter.js` — `Segmenter` — Parses un-closed markdown fences during live stream.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- DOM thrashing if streaming segmenter updates UI too frequently.
|
||||||
|
|
||||||
|
## FRONTEND-006 — Interactive Tour & Guided Onboarding System
|
||||||
|
|
||||||
|
- **Domain**: `frontend`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Presents interactive step-by-step feature tours and UI tooltip hints for new users.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `static/js/tourHints.js` — `startTour` — Renders guided feature tour overlays over target UI elements.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Tour step misalignment if window is resized mid-tour.
|
||||||
|
|
||||||
|
## FRONTEND-007 — Background Effects Prototyping Sandbox
|
||||||
|
|
||||||
|
- **Domain**: `frontend`
|
||||||
|
- **Status**: `dead-code-candidate`
|
||||||
|
- **Evidence Maturity**: `E1`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Standalone sandbox page for prototyping visual background animations, waves, and whirlpool effects.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `app.py` — `serve_backgrounds` — Serves visual background sandbox HTML page route.
|
||||||
|
- `static/wave-variants.html` — `wave-variants.html` — Interactive background effect prototyping sandbox variant.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Route `/backgrounds` in app.py L918 attempts to serve `static/backgrounds.html` which is missing from disk; variant templates `wave-variants.html` and `whirlpool-variants.html` exist.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Media
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## MEDIA-001 — Gallery Image Library & Album Operations
|
||||||
|
|
||||||
|
- **Domain**: `media`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Organizes images into custom albums, provides grid browsing, tagging, and album metadata management.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/gallery/gallery_routes.py` — `@router.get('/api/gallery/library')` — Fetches image library list with tag filters.
|
||||||
|
- `static/js/gallery.js` — `initGallery` — Main gallery grid renderer and uploader.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Thumbnail generation overhead for high-resolution RAW camera images.
|
||||||
|
|
||||||
|
## MEDIA-002 — Image Processing, AI Upscaling & Style Transfer
|
||||||
|
|
||||||
|
- **Domain**: `media`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Executes local image enhancement, background removal, face sharpening, and AI upscaling operations.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/gallery/gallery_routes.py` — `@router.post('/api/gallery/ai-upscale')` — Runs RealESRGAN image upscaling.
|
||||||
|
- `routes/gallery/gallery_routes.py` — `@router.post('/api/image/remove-bg')` — Executes background removal pass.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- High GPU memory allocation when upscaling 4K images.
|
||||||
|
|
||||||
|
## MEDIA-003 — Interactive Image Canvas Editor & Persisted Drafts
|
||||||
|
|
||||||
|
- **Domain**: `media`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides full multi-layer raster canvas editor, brush tools, transforms, masks, and draft project persistence.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/editor_draft_routes.py` — `setup_editor_draft_routes` — API routes for saving and loading canvas project drafts.
|
||||||
|
- `static/js/editor/history-panel.js` — `HistoryManager` — Canvas undo/redo stack manager.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Browser memory leak if multi-gigabyte layer undo buffers are kept indefinitely.
|
||||||
|
|
||||||
|
## MEDIA-004 — Text-to-Speech (TTS) Synthesis Service
|
||||||
|
|
||||||
|
- **Domain**: `media`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Synthesizes spoken audio from text using local Kokoro, EdgeTTS, or OpenAI TTS engines.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/tts_routes.py` — `@router.post('/synthesize')` — Synthesizes TTS audio clip.
|
||||||
|
- `services/tts/tts_service.py` — `TTSService` — Provider abstraction layer for audio speech generation.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Audio synthesis latency on CPU-only hardware setups.
|
||||||
|
|
||||||
|
## MEDIA-005 — Speech-to-Text (STT) Audio Transcription Service
|
||||||
|
|
||||||
|
- **Domain**: `media`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Transcribes user audio recordings into text using faster-whisper or local speech models.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/stt_routes.py` — `@router.post('/transcribe')` — Accepts multipart audio file and returns transcription text.
|
||||||
|
- `services/stt/stt_service.py` — `STTService` — Whisper audio transcription engine wrapper.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Missing ffmpeg system dependency prevents audio format decoding.
|
||||||
|
|
||||||
|
## MEDIA-006 — Digital Signature Stamp Storage & Placement
|
||||||
|
|
||||||
|
- **Domain**: `media`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Stores transparent PNG user signatures and stamps for placement onto PDF forms and documents.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/signature_routes.py` — `setup_signature_routes` — CRUD endpoints for managing user signature PNG stamps.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Cross-site scripting if signature image titles contain unescaped user input.
|
||||||
|
|
||||||
|
## MEDIA-007 — Generated Image Artifact Route & MCP Integration
|
||||||
|
|
||||||
|
- **Domain**: `media`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Serves generated AI artwork artifacts and integrates with image generation MCP server.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `app.py` — `serve_generated_image` — Serves generated image artifacts with cache headers.
|
||||||
|
- `src/generated_images.py` — `resolve_generated_image_path` — Confines requested image path within artifacts directory.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Path traversal vulnerability if filename parameter is un-sanitized.
|
||||||
|
|
||||||
|
## MEDIA-008 — Native MLX Image Bridge (macOS Apple Silicon)
|
||||||
|
|
||||||
|
- **Domain**: `media`
|
||||||
|
- **Status**: `experimental`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires Apple Silicon, macOS tooling, and the compiled MLX bridge.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Native Apple Swift bridge for hardware-accelerated diffusion and MLX image colorization on macOS.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `swift/odysseus-mlx-image-bridge/Package.swift` — `Package` — Swift package manifest for native MLX image bridge.
|
||||||
|
- `scripts/mlx_image_server.py` — `main` — Python daemon wrapping native Swift MLX binary.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Binary build requires Xcode command line tools build step (`build-macos-app.sh`).
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Memory
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## MEMORY-001 — Persistent Long-Term Memory & Vector Indexing
|
||||||
|
|
||||||
|
- **Domain**: `memory`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Extracts facts, user preferences, and temporal memories from chat sessions into vector/relational storage.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/memory/memory_routes.py` — `@router.get('')` — Fetches long-term user memory timeline.
|
||||||
|
- `services/memory/memory_extractor.py` — `MemoryExtractor` — LLM-driven fact extraction from conversation transcripts.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Conflicting memory facts extracted from contradictory user prompts.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Model
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## MODEL-001 — Multi-Provider LLM Model Discovery & Metadata Management
|
||||||
|
|
||||||
|
- **Domain**: `model`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Discovers models from OpenAI, Anthropic, Ollama, vLLM, LMStudio, OpenRouter, and Google AI Studio endpoints.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/model_routes.py` — `@router.get('/api/models')` — Returns unified list of available models across providers.
|
||||||
|
- `src/model_discovery.py` — `ModelDiscovery.discover_all` — Queries connected provider endpoints for available model IDs.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Remote endpoint timeouts may slow down full discovery refresh.
|
||||||
|
|
||||||
|
## MODEL-002 — Model Capability & Context Limits Detection
|
||||||
|
|
||||||
|
- **Domain**: `model`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Detects vision, tool calling, reasoning, and context window limits for connected model endpoints.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/model_capabilities.py` — `get_model_capabilities` — Maps model names to vision and tool support flags.
|
||||||
|
- `src/endpoint_resolver.py` — `resolve_endpoint_headers` — Resolves auth headers and target URLs for model endpoints.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Incorrect context limit metadata for unlisted custom fine-tunes.
|
||||||
|
|
||||||
|
## MODEL-003 — LLM Core Provider Communication & Fallback Routing
|
||||||
|
|
||||||
|
- **Domain**: `model`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Provider dispatch, header injection and fallback advancement are only observable against a reachable LLM provider endpoint; not exercised in this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Manages HTTP request dispatching, authorization header injection, and fallback provider routing for LLM calls.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/llm_core.py` — `llm_call_async` — Non-streaming provider request dispatcher: resolves the endpoint, injects authorization headers and executes the HTTP call.
|
||||||
|
- `src/llm_core.py` — `llm_call_async_with_fallback` — Ordered fallback wrapper that retries llm_call_async across the configured candidate endpoints.
|
||||||
|
- `src/llm_core.py` — `stream_llm_with_fallback` — Ordered fallback wrapper for the streaming path; advances to the next candidate when a provider yields an empty completion.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Unexpected API changes in upstream third-party model providers.
|
||||||
|
|
||||||
|
## MODEL-004 — Model Selection & Display Ordering Preferences
|
||||||
|
|
||||||
|
- **Domain**: `model`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Allows pinning, sorting, and hiding specific models in the UI selection dropdown.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/model_routes.py` — `@router.post('/order')` — Saves custom model display order preference.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Stale model IDs in custom order lists after model endpoints are removed.
|
||||||
|
|
||||||
|
## MODEL-005 — Side-by-Side Model Comparison (A/B Testing)
|
||||||
|
|
||||||
|
- **Domain**: `model`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Enables dual-model side-by-side response evaluation, arena scoring, and latency comparison.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/compare/compare_routes.py` — `@router.post('/start')` — Starts a parallel dual-model comparison stream.
|
||||||
|
- `static/js/compare/index.js` — `initCompareView` — Renders side-by-side model chat panes.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- High memory and network usage when streaming two model responses simultaneously.
|
||||||
|
|
||||||
|
## MODEL-006 — GitHub Copilot Device Flow Authentication
|
||||||
|
|
||||||
|
- **Domain**: `model`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E2`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires an interactive GitHub Copilot OAuth device-flow account.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Authenticates with GitHub Copilot via OAuth device flow to use Copilot models directly.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/copilot_routes.py` — `setup_copilot_routes` — Builds the Copilot device-flow router at prefix /api/copilot, wiring _start_device_flow and _poll_device_flow.
|
||||||
|
- `routes/device_flow.py` — `create_device_flow_router` — Shared factory registering POST /device/start and POST /device/poll under the caller-supplied prefix.
|
||||||
|
- `src/copilot.py` — `request_device_code` — Issues the GitHub device-code request that begins the Copilot OAuth device flow.
|
||||||
|
- `src/copilot.py` — `poll_access_token` — Polls GitHub for the access token once the user has authorized the device code.
|
||||||
|
- `tests/test_provider_device_flow_js.py` — `test_copilot_success_uses_complete_verification_uri` — Inspected unit test asserting the Copilot device-flow runner surfaces the complete verification URI returned by the backend.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Token expiration requires manual device re-authentication.
|
||||||
|
|
||||||
|
## MODEL-007 — ChatGPT Subscription Device Flow Authentication
|
||||||
|
|
||||||
|
- **Domain**: `model`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E2`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires an interactive ChatGPT subscription OAuth flow.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Authenticates with ChatGPT Pro/Plus subscription tokens via device login flow.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/chatgpt_subscription_routes.py` — `setup_chatgpt_subscription_routes` — Builds the ChatGPT subscription device-flow router at prefix /api/chatgpt-subscription.
|
||||||
|
- `routes/device_flow.py` — `create_device_flow_router` — Shared factory registering POST /device/start and POST /device/poll under the caller-supplied prefix.
|
||||||
|
- `src/chatgpt_subscription.py` — `request_device_code` — Issues the ChatGPT device-authorization request that begins the subscription OAuth device flow.
|
||||||
|
- `src/chatgpt_subscription.py` — `poll_device_auth` — Polls the ChatGPT device-authorization endpoint for completion using the stored device_auth_id and user_code.
|
||||||
|
- `tests/test_provider_device_flow_js.py` — `test_chatgpt_success_uses_plain_verification_uri` — Inspected unit test asserting the ChatGPT device-flow runner uses the plain verification URI rather than the Copilot complete-URI form.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Changes in OpenAI auth endpoint security challenges.
|
||||||
|
|
||||||
|
## MODEL-008 — Embedding Model Lane & Vector Provider Setup
|
||||||
|
|
||||||
|
- **Domain**: `model`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Configures local sentence-transformers, FastEmbed, or remote OpenAI embedding model lanes.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/embedding_routes.py` — `setup_embedding_routes` — Provides embedding provider configuration endpoints.
|
||||||
|
- `src/embeddings.py` — `EmbeddingManager` — Generates dense vector embeddings for RAG and memory.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- First-time download of heavy PyTorch model weights on CPU-only machines.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Note
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## NOTE-001 — Interactive Notes & Checklist Management
|
||||||
|
|
||||||
|
- **Domain**: `note`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides Google Keep-style notes, rich markdown text, checklist items, pinning, color tags, and reminders.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/note/note_routes.py` — `@router.get('')` — Lists all user notes with pin and archive states.
|
||||||
|
- `static/js/notes.js` — `initNotesView` — Main interactive notes grid and modal manager.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Concurrent edits on note item checkboxes.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Platform
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## PLATFORM-001 — Application Initialization & Lifespan Management
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Orchestrates server startup, database table migration, background daemon initialization, and clean shutdown.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `app.py` — `_lifespan` — FastAPI lifespan context manager executing startup tasks.
|
||||||
|
- `src/app_initializer.py` — `initialize_app` — Initializes app directories, DB schemas, and logging.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Un-handled exceptions during startup halt application launch.
|
||||||
|
|
||||||
|
## PLATFORM-002 — System Health, Readiness & Version Monitoring APIs
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Exposes Liveness (/api/health), Readiness (/api/ready), App Version (/api/version), and Client Perf APIs.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `app.py` — `readiness_check` — Performs system component integrity check.
|
||||||
|
- `src/readiness.py` — `check_readiness` — Checks database, storage, and key paths for read/write access.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Readiness check delays if verifying connectivity to offline remote endpoints.
|
||||||
|
|
||||||
|
## PLATFORM-003 — Database Schema, Migrations & SQLite Persistence
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Defines core relational tables (users, tokens, tasks, sessions) and executes automated SQLite schema upgrades.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `core/database.py` — `init_db` — Creates ORM tables and establishes connection pool.
|
||||||
|
- `scripts/update_database.py` — `run_migrations` — Applies missing schema columns and indices.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- SQLite file lock contention under high concurrent write loads.
|
||||||
|
|
||||||
|
## PLATFORM-004 — User Data Export & Import Backup Infrastructure
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Exports complete user workspace state (sessions, memory, skills, notes, presets) into a zip archive.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/backup_routes.py` — `setup_backup_routes` — Handles workspace data export and import upload unpack.
|
||||||
|
- `docs/backup-restore.md` — `Documentation` — Backup and restore operational documentation.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Corrupt archive files causing partial data restore.
|
||||||
|
|
||||||
|
## PLATFORM-005 — File Cleanup & Storage Maintenance Engine
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Scans data directories for orphaned files, old uploads, temporary vision images, and frees disk space.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/cleanup/cleanup_routes.py` — `@router.get('/preview')` — Previews reclaimable disk space across storage directories.
|
||||||
|
- `src/cleanup_service.py` — `CleanupService` — Executes filesystem purge of orphaned asset files.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Deletes files uploaded in active sessions if retention window is set too short.
|
||||||
|
|
||||||
|
## PLATFORM-006 — System Health & RAG Diagnostic Suite
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Executes real-time integrity diagnostics across ChromaDB, SearXNG, local models, and network interfaces.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/diagnostics_routes.py` — `setup_diagnostics_routes` — Runs subsystem health check suite.
|
||||||
|
- `src/service_health.py` — `collect_health_status` — Inspects vector database, email, search, and local provider status.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Diagnostic timeout if external search provider is unreachable.
|
||||||
|
|
||||||
|
## PLATFORM-007 — Desktop CLI Utilities & Shell Integration Tools
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides command-line interface tools (`odysseus`, `odysseus-mcp`, `odysseus-mail`) for terminal usage.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `scripts/_lib/cli.py` — `main` — Shared CLI framework for terminal helper commands.
|
||||||
|
- `scripts/odysseus` — `odysseus` — Main terminal launcher script.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Outdated CLI scripts if backend API schemas change.
|
||||||
|
|
||||||
|
## PLATFORM-008 — Desktop Companion App Integration
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides API routes and pairing mechanisms for the native macOS/desktop menu bar companion app.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `companion/routes.py` — `setup_companion_routes` — Endpoints for pairing and status sync with desktop companion.
|
||||||
|
- `companion/pairing.py` — `PairingManager` — Generates and validates companion pairing codes.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Pairing code expiration timing window.
|
||||||
|
|
||||||
|
## PLATFORM-009 — Docker Containerization & GPU Hardware Manifests
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires Docker GPU pass-through and compatible host drivers.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides multi-stage Dockerfile and Docker Compose manifests for CPU, NVIDIA CUDA, and AMD ROCm GPUs.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `Dockerfile` — `multi-stage-build` — Multi-stage container build environment.
|
||||||
|
- `docker-compose.gpu-nvidia.yml` — `nvidia-gpu-manifest` — NVIDIA GPU pass-through container specification.
|
||||||
|
- `scripts/check-docker-gpu.sh` — `check-docker-gpu` — Automated diagnostic test script for host NVIDIA GPU passthrough.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Driver version incompatibility with host NVIDIA/AMD kernel drivers.
|
||||||
|
|
||||||
|
## PLATFORM-010 — Legacy FAISS Vector Index Migration Script
|
||||||
|
|
||||||
|
- **Domain**: `platform`
|
||||||
|
- **Status**: `legacy`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Legacy utility script to migrate older FAISS vector indices into ChromaDB.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `scripts/migrate_faiss_to_chroma.py` — `migrate_faiss` — Reads FAISS vector index files and writes to ChromaDB collection.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Superseded by native ChromaDB vector index pipeline.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Research
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## RESEARCH-001 — Deep Research Execution Engine & SSE Progress Streaming
|
||||||
|
|
||||||
|
- **Domain**: `research`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Executes multi-step recursive deep research tasks, web page scraping, synthesis, and streams live progress.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/research/research_routes.py` — `@router.post('/api/research/start')` — Initiates deep research job.
|
||||||
|
- `src/deep_research.py` — `DeepResearchEngine` — Recursive search and summary crawler.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- High memory consumption when parsing multi-megabyte HTML target pages.
|
||||||
|
|
||||||
|
## RESEARCH-002 — Research Library, Detail View & Image Controls
|
||||||
|
|
||||||
|
- **Domain**: `research`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Stores completed research reports, generated diagrams, reference links, and manages image visibility.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/research/research_routes.py` — `@router.get('/api/research/library')` — Returns all saved research reports.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Orphaned report files if storage directory is modified out-of-band.
|
||||||
|
|
||||||
|
## RESEARCH-003 — Web Search Engine Integration (SearXNG & Multi-Provider)
|
||||||
|
|
||||||
|
- **Domain**: `research`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E1`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires an active SearXNG instance or external search API provider.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Queries SearXNG, DuckDuckGo, or Google Search instances to retrieve web search snippets.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/search_routes.py` — `setup_search_routes` — Defines /api/search, /api/search/config, and /api/search/query endpoints.
|
||||||
|
- `src/search/core.py` — `SearchEngine` — Compatibility module aliasing services.search.core.
|
||||||
|
- `tests/test_search_ranking.py` — `test_news_queries_prefer_news_sources_over_sports_and_social_results` — Tests search result domain ranking and scoring.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Search provider IP throttling or rate-limiting.
|
||||||
|
|
||||||
|
## RESEARCH-004 — Research Result Peeking & Topic Spinoff Generation
|
||||||
|
|
||||||
|
- **Domain**: `research`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Extracts preliminary research snippets and spawns child research sessions focused on specific sub-topics.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/research/research_routes.py` — `@router.post('/api/research/spinoff/{session_id}')` — Spawns child research session for specific query.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Deep recursion tree depth when spawning multiple nested spinoffs.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## SECURITY-001 — Authentication, Session Cookies & User Management
|
||||||
|
|
||||||
|
- **Domain**: `security`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Handles bcrypt password hashing, session cookie issuance, authentication enforcement, and user administration.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/auth_routes.py` — `@router.post('/login')` — Authenticates credentials and sets session cookie.
|
||||||
|
- `core/auth.py` — `AuthManager` — Handles user creation, password verification, and session tokens.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Cookie session hijack if deployed over unencrypted HTTP without HTTPS cookie flags.
|
||||||
|
|
||||||
|
## SECURITY-002 — System Vault Encrypted Secret Storage
|
||||||
|
|
||||||
|
- **Domain**: `security`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E1`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: pending — Requires installed Bitwarden CLI (`bw`) executable.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Encrypts API keys, passphrases, and third-party secrets on disk using AES-GCM / PBKDF2 key derivation.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/vault_routes.py` — `setup_vault_routes` — Admin routes for vault configuration, login, unlock, lock, and logout.
|
||||||
|
- `src/secret_storage.py` — `SecretStorage` — Fernet symmetric key DB secret encryption.
|
||||||
|
- `tests/test_vault_password_not_in_argv.py` — `test_bw_password_not_in_argv` — Verifies master password is fed via stdin and never appears in process argv.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Loss of vault master passphrase renders all encrypted secrets permanently unrecoverable.
|
||||||
|
|
||||||
|
## SECURITY-003 — API Token Management & Scope Access Control
|
||||||
|
|
||||||
|
- **Domain**: `security`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Generates scoped API bearer tokens (read/write/admin) for external tool and script authentication.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/api_token_routes.py` — `setup_api_token_routes` — Exposes API token creation, scope assignment, and revocation.
|
||||||
|
- `core/database.py` — `ApiToken` — SQLAlchemy ORM schema for API tokens and permissions.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Leaked API bearer tokens with excessive permission scopes.
|
||||||
|
|
||||||
|
## SECURITY-004 — Prompt Security & Injection Defense Engine
|
||||||
|
|
||||||
|
- **Domain**: `security`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E1`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Scans system prompts and external inputs for prompt injection attempts, jailbreaks, and sensitive data leaks.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/prompt_security.py` — `untrusted_context_message` — Wraps untrusted context with guard delimiters and sets metadata.trusted = False.
|
||||||
|
- `src/tool_security.py` — `NON_ADMIN_BLOCKED_TOOLS` — Enforces tool execution safety for non-admin user roles.
|
||||||
|
- `tests/test_skill_index_prompt_injection.py` — `test_skill_index` — Verifies skill index descriptions cannot leak into trusted system prompts.
|
||||||
|
- `tests/test_tool_output_prompt_injection.py` — `test_tool_output` — Tool output injection guards.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- False positives blocking legitimate complex coding or security prompts.
|
||||||
|
|
||||||
|
## SECURITY-005 — URL & Path Confinement Security Guards
|
||||||
|
|
||||||
|
- **Domain**: `security`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E1`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Prevents SSRF attacks and path traversal by validating target IP addresses and resolving symlinks.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `src/url_safety.py` — `check_outbound_url` — Rejects non-HTTP(S) schemes, link-local, cloud metadata SSRF addresses.
|
||||||
|
- `src/url_security.py` — `validate_public_http_url` — Validates public-facing endpoints.
|
||||||
|
- `tests/test_url_safety.py` — `test_url_safety` — Scheme validation, cloud metadata SSRF rejection, IP classification.
|
||||||
|
- `tests/test_tool_path_confinement.py` — `test_path_confinement` — Path traversal checks.
|
||||||
|
- `tests/test_workspace_confine.py` — `test_workspace_confine` — Workspace confinement checks.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- DNS rebinding attacks if IP address is re-resolved post-validation.
|
||||||
|
|
||||||
|
## SECURITY-006 — HTTP Security Headers Middleware
|
||||||
|
|
||||||
|
- **Domain**: `security`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Injects standard OWASP HTTP security headers (CSP, HSTS, X-Content-Type-Options, X-Frame-Options).
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `core/middleware.py` — `SecurityHeadersMiddleware` — Sets strict security headers and CSP nonces on HTTP responses.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Strict Content Security Policy (CSP) blocking third-party embedded web resources.
|
||||||
|
|
||||||
|
## SECURITY-007 — Admin System Data Wipe ('Danger Zone')
|
||||||
|
|
||||||
|
- **Domain**: `security`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provides administrative reset operations to wipe sessions, cache, uploaded files, or factory reset state.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/admin_wipe/admin_wipe_routes.py` — `@router.delete('/wipe/{kind}')` — Executes systemic data wipe based on requested scope.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Accidental catastrophic data loss if triggered without user confirmation.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Skill
|
||||||
|
|
||||||
|
Features in this document are generated from [`../feature-catalog.json`](../feature-catalog.json), the canonical inventory.
|
||||||
|
|
||||||
|
## SKILL-001 — Dynamic Skill Management & Code Execution Engine
|
||||||
|
|
||||||
|
- **Domain**: `skill`
|
||||||
|
- **Status**: `verified`
|
||||||
|
- **Evidence Maturity**: `E0`
|
||||||
|
- **Commit Verified**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Runtime Validation**: not-required — No separate environment-dependent runtime validation was identified during this documentation pass.
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Allows users to create, import, edit, test, and execute custom Python/Markdown skills dynamically.
|
||||||
|
|
||||||
|
### Evidence summary
|
||||||
|
|
||||||
|
- `routes/skills_routes.py` — `setup_skills_routes` — Exposes CRUD and remote import routes for user skills.
|
||||||
|
- `services/memory/skills.py` — `SkillsManager` — Handles skill storage, parsing, and execution.
|
||||||
|
|
||||||
|
### Unknowns
|
||||||
|
|
||||||
|
- Arbitrary code execution risks if skill import URL is untrusted.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
|||||||
|
# Feature Catalog
|
||||||
|
|
||||||
|
This is a human-readable index derived from [`feature-catalog.json`](feature-catalog.json). The JSON file is canonical.
|
||||||
|
|
||||||
|
| ID | Feature | Domain | Status | Evidence | Runtime |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `AGENT-001` | Autonomous Agent Loop & Tool Execution Engine | `agent` | `verified` | `E0` | not required |
|
||||||
|
| `AGENT-002` | Scheduled Tasks & Event Bus Dispatcher | `agent` | `verified` | `E0` | not required |
|
||||||
|
| `AGENT-003` | Webhook Event Subscriptions & Trigger Processing | `agent` | `verified` | `E0` | not required |
|
||||||
|
| `AGENT-004` | Assistant Settings, Task Check-Ins & Background Job Monitor | `agent` | `partial` | `E1` | not required |
|
||||||
|
| `AGENT-005` | Model Context Protocol (MCP) Server Integration | `agent` | `verified` | `E0` | not required |
|
||||||
|
| `AGENT-006` | AI Interaction Tools & Pipeline Orchestration | `agent` | `verified` | `E0` | not required |
|
||||||
|
| `AGENT-007` | Subprocess & Background Job Execution Tools | `agent` | `verified` | `E0` | not required |
|
||||||
|
| `CALENDAR-001` | CalDAV Calendar Synchronization & Account Setup | `calendar` | `verified` | `E0` | pending |
|
||||||
|
| `CALENDAR-002` | Calendar Event Operations & iCalendar Parsing | `calendar` | `verified` | `E0` | not required |
|
||||||
|
| `CHAT-001` | Core Chat Streaming & SSE Message Generation | `chat` | `verified` | `E2` | pending |
|
||||||
|
| `CHAT-002` | Session Management & Conversation State | `chat` | `verified` | `E0` | not required |
|
||||||
|
| `CHAT-003` | Chat History & Message Editing/Truncation | `chat` | `verified` | `E0` | not required |
|
||||||
|
| `CHAT-004` | File & Multimodal Attachment Handling | `chat` | `verified` | `E0` | not required |
|
||||||
|
| `CHAT-005` | Chat Message Search | `chat` | `verified` | `E0` | not required |
|
||||||
|
| `CHAT-006` | System Prompts & Preset Management | `chat` | `verified` | `E0` | not required |
|
||||||
|
| `CHAT-007` | Emoji Rendering & Twemoji SVG Proxy | `chat` | `verified` | `E0` | not required |
|
||||||
|
| `CHAT-008` | Input History Recall (Arrow Up) | `chat` | `verified` | `E0` | not required |
|
||||||
|
| `CHAT-009` | Context Window Compaction & Truncation | `chat` | `verified` | `E0` | not required |
|
||||||
|
| `CONTACT-001` | CardDAV Contact Management & Address Book Integration | `contact` | `verified` | `E0` | not required |
|
||||||
|
| `COOKBOOK-001` | Local Model Download & Recipe Lifecycle Management | `cookbook` | `verified` | `E0` | not required |
|
||||||
|
| `COOKBOOK-002` | Hardware Model Fitting ('What Fits?') Analysis Engine | `cookbook` | `verified` | `E0` | not required |
|
||||||
|
| `COOKBOOK-003` | HuggingFace & MLX Model Discovery Services | `cookbook` | `verified` | `E0` | not required |
|
||||||
|
| `COOKBOOK-004` | Host Docker Access for Inference Container Runtimes | `cookbook` | `verified` | `E0` | pending |
|
||||||
|
| `DOCUMENT-001` | Document & Canvas Artifact Management | `document` | `verified` | `E0` | not required |
|
||||||
|
| `DOCUMENT-002` | PDF Form Processing & High-Fidelity Rendering | `document` | `verified` | `E1` | pending |
|
||||||
|
| `DOCUMENT-003` | Personal Document Indexing & RAG Retrieval | `document` | `verified` | `E0` | not required |
|
||||||
|
| `DOCUMENT-004` | Document Conversion & Text Extraction Engine | `document` | `verified` | `E0` | not required |
|
||||||
|
| `DOCUMENT-005` | Document Library UI Navigation | `document` | `verified` | `E0` | not required |
|
||||||
|
| `EMAIL-001` | Email Account Setup, IMAP/SMTP Connection & Polling | `email` | `verified` | `E1` | pending |
|
||||||
|
| `EMAIL-002` | Email Searching, Threading & Message Operations | `email` | `verified` | `E0` | not required |
|
||||||
|
| `EMAIL-003` | Email Composition, Draft Management & Sending | `email` | `verified` | `E0` | pending |
|
||||||
|
| `EMAIL-004` | Email MCP Server & Codex Integration Bridge | `email` | `verified` | `E0` | not required |
|
||||||
|
| `FRONTEND-001` | Single Page Application Shell & Client Router | `frontend` | `verified` | `E0` | not required |
|
||||||
|
| `FRONTEND-002` | Dynamic Theme, Color System & Custom Fonts | `frontend` | `verified` | `E0` | not required |
|
||||||
|
| `FRONTEND-003` | Window Manager, Tile Layout & Modal Control System | `frontend` | `verified` | `E0` | not required |
|
||||||
|
| `FRONTEND-004` | Global Keyboard Shortcuts & Accessibility Controls | `frontend` | `verified` | `E0` | not required |
|
||||||
|
| `FRONTEND-005` | Markdown, LaTeX & Code Block Streaming Renderer | `frontend` | `verified` | `E0` | not required |
|
||||||
|
| `FRONTEND-006` | Interactive Tour & Guided Onboarding System | `frontend` | `verified` | `E0` | not required |
|
||||||
|
| `FRONTEND-007` | Background Effects Prototyping Sandbox | `frontend` | `dead-code-candidate` | `E1` | not required |
|
||||||
|
| `MEDIA-001` | Gallery Image Library & Album Operations | `media` | `verified` | `E0` | not required |
|
||||||
|
| `MEDIA-002` | Image Processing, AI Upscaling & Style Transfer | `media` | `verified` | `E0` | not required |
|
||||||
|
| `MEDIA-003` | Interactive Image Canvas Editor & Persisted Drafts | `media` | `verified` | `E0` | not required |
|
||||||
|
| `MEDIA-004` | Text-to-Speech (TTS) Synthesis Service | `media` | `verified` | `E0` | not required |
|
||||||
|
| `MEDIA-005` | Speech-to-Text (STT) Audio Transcription Service | `media` | `verified` | `E0` | not required |
|
||||||
|
| `MEDIA-006` | Digital Signature Stamp Storage & Placement | `media` | `verified` | `E0` | not required |
|
||||||
|
| `MEDIA-007` | Generated Image Artifact Route & MCP Integration | `media` | `verified` | `E0` | not required |
|
||||||
|
| `MEDIA-008` | Native MLX Image Bridge (macOS Apple Silicon) | `media` | `experimental` | `E0` | pending |
|
||||||
|
| `MEMORY-001` | Persistent Long-Term Memory & Vector Indexing | `memory` | `verified` | `E0` | not required |
|
||||||
|
| `MODEL-001` | Multi-Provider LLM Model Discovery & Metadata Management | `model` | `verified` | `E0` | not required |
|
||||||
|
| `MODEL-002` | Model Capability & Context Limits Detection | `model` | `verified` | `E0` | not required |
|
||||||
|
| `MODEL-003` | LLM Core Provider Communication & Fallback Routing | `model` | `verified` | `E0` | pending |
|
||||||
|
| `MODEL-004` | Model Selection & Display Ordering Preferences | `model` | `verified` | `E0` | not required |
|
||||||
|
| `MODEL-005` | Side-by-Side Model Comparison (A/B Testing) | `model` | `verified` | `E0` | not required |
|
||||||
|
| `MODEL-006` | GitHub Copilot Device Flow Authentication | `model` | `verified` | `E2` | pending |
|
||||||
|
| `MODEL-007` | ChatGPT Subscription Device Flow Authentication | `model` | `verified` | `E2` | pending |
|
||||||
|
| `MODEL-008` | Embedding Model Lane & Vector Provider Setup | `model` | `verified` | `E0` | not required |
|
||||||
|
| `NOTE-001` | Interactive Notes & Checklist Management | `note` | `verified` | `E0` | not required |
|
||||||
|
| `PLATFORM-001` | Application Initialization & Lifespan Management | `platform` | `verified` | `E0` | not required |
|
||||||
|
| `PLATFORM-002` | System Health, Readiness & Version Monitoring APIs | `platform` | `verified` | `E0` | not required |
|
||||||
|
| `PLATFORM-003` | Database Schema, Migrations & SQLite Persistence | `platform` | `verified` | `E0` | not required |
|
||||||
|
| `PLATFORM-004` | User Data Export & Import Backup Infrastructure | `platform` | `verified` | `E0` | not required |
|
||||||
|
| `PLATFORM-005` | File Cleanup & Storage Maintenance Engine | `platform` | `verified` | `E0` | not required |
|
||||||
|
| `PLATFORM-006` | System Health & RAG Diagnostic Suite | `platform` | `verified` | `E0` | not required |
|
||||||
|
| `PLATFORM-007` | Desktop CLI Utilities & Shell Integration Tools | `platform` | `verified` | `E0` | not required |
|
||||||
|
| `PLATFORM-008` | Desktop Companion App Integration | `platform` | `verified` | `E0` | not required |
|
||||||
|
| `PLATFORM-009` | Docker Containerization & GPU Hardware Manifests | `platform` | `verified` | `E0` | pending |
|
||||||
|
| `PLATFORM-010` | Legacy FAISS Vector Index Migration Script | `platform` | `legacy` | `E0` | not required |
|
||||||
|
| `RESEARCH-001` | Deep Research Execution Engine & SSE Progress Streaming | `research` | `verified` | `E0` | not required |
|
||||||
|
| `RESEARCH-002` | Research Library, Detail View & Image Controls | `research` | `verified` | `E0` | not required |
|
||||||
|
| `RESEARCH-003` | Web Search Engine Integration (SearXNG & Multi-Provider) | `research` | `verified` | `E1` | pending |
|
||||||
|
| `RESEARCH-004` | Research Result Peeking & Topic Spinoff Generation | `research` | `verified` | `E0` | not required |
|
||||||
|
| `SECURITY-001` | Authentication, Session Cookies & User Management | `security` | `verified` | `E0` | not required |
|
||||||
|
| `SECURITY-002` | System Vault Encrypted Secret Storage | `security` | `verified` | `E1` | pending |
|
||||||
|
| `SECURITY-003` | API Token Management & Scope Access Control | `security` | `verified` | `E0` | not required |
|
||||||
|
| `SECURITY-004` | Prompt Security & Injection Defense Engine | `security` | `verified` | `E1` | not required |
|
||||||
|
| `SECURITY-005` | URL & Path Confinement Security Guards | `security` | `verified` | `E1` | not required |
|
||||||
|
| `SECURITY-006` | HTTP Security Headers Middleware | `security` | `verified` | `E0` | not required |
|
||||||
|
| `SECURITY-007` | Admin System Data Wipe ('Danger Zone') | `security` | `verified` | `E0` | not required |
|
||||||
|
| `SKILL-001` | Dynamic Skill Management & Code Execution Engine | `skill` | `verified` | `E0` | not required |
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Source Provenance & Audit Baseline
|
||||||
|
|
||||||
|
## Target Repository & Snapshot
|
||||||
|
|
||||||
|
- **Repository**: `odysseus-dev/odysseus`
|
||||||
|
- **Branch**: `discovery`
|
||||||
|
- **Pinned Commit SHA**: `d8a2059df8e53bc7275c45339849d14c8651e73c`
|
||||||
|
- **Snapshot Date**: `2026-07-23T14:49:02Z`
|
||||||
|
|
||||||
|
## Discovery Package Organization
|
||||||
|
|
||||||
|
The public discovery documentation package under `docs/discovery/` is structured as follows:
|
||||||
|
|
||||||
|
- `feature-catalog.json`: Canonical machine-readable JSON catalog containing 79 feature records.
|
||||||
|
- `feature-catalog.md`: Human-readable summary derived from `feature-catalog.json`.
|
||||||
|
- `BASELINE-STATUS.md`: Publication status, evidence-validation snapshot, and durable maintainer guidance.
|
||||||
|
- `audit-method.md`: Audit rules, scope, and evidence maturity definitions (E0 to E4).
|
||||||
|
- `domains/`: 16 functional domain markdown files detailing feature implementations.
|
||||||
|
- `references/`: Audit provenance and repository snapshot metadata.
|
||||||
|
- `tools/`: Structural, consistency, and evidence validators with focused evidence-validator tests.
|
||||||
|
|
||||||
|
## Exclusion Principles
|
||||||
|
|
||||||
|
This public documentation package explicitly excludes:
|
||||||
|
- Internal planning artifacts or private meeting notes.
|
||||||
|
- Machine-specific filesystem paths or user environments.
|
||||||
|
- API keys, credentials, or private service endpoints.
|
||||||
|
- Application code or automated test mutations.
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Focused negative tests for validate_discovery_evidence.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).with_name("validate_discovery_evidence.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("validate_discovery_evidence", MODULE_PATH)
|
||||||
|
assert SPEC and SPEC.loader
|
||||||
|
validator = importlib.util.module_from_spec(SPEC)
|
||||||
|
sys.modules[SPEC.name] = validator
|
||||||
|
SPEC.loader.exec_module(validator)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeJavascriptParser:
|
||||||
|
supported = True
|
||||||
|
reason = "test parser"
|
||||||
|
|
||||||
|
def __init__(self, symbols: list[validator.Located] | None = None) -> None:
|
||||||
|
self.symbols = symbols or []
|
||||||
|
|
||||||
|
def parse(self, path: Path) -> list[validator.Located]:
|
||||||
|
return self.symbols
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedJavascriptParser:
|
||||||
|
supported = False
|
||||||
|
reason = "no repository-local parser"
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceNegativeTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
(self.root / "sample.py").write_text(
|
||||||
|
"from fastapi import APIRouter\n"
|
||||||
|
"router = APIRouter(prefix='/api')\n"
|
||||||
|
"\n"
|
||||||
|
"class ChatHandler:\n"
|
||||||
|
" def preprocess_message(self):\n"
|
||||||
|
" return True\n"
|
||||||
|
"\n"
|
||||||
|
"@router.post('/chat')\n"
|
||||||
|
"def chat_stream():\n"
|
||||||
|
" return True\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(self.root / "sample.js").write_text(
|
||||||
|
"export const present = () => true;\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(self.root / "sample.sh").write_text(
|
||||||
|
"#!/usr/bin/env bash\nreal_function() {\n return 0\n}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self.backup = self.root / "copied-backups"
|
||||||
|
self.backup.mkdir()
|
||||||
|
for path in self.root.glob("sample.*"):
|
||||||
|
shutil.copy2(path, self.backup / path.name)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
for backup in self.backup.iterdir():
|
||||||
|
target = self.root / backup.name
|
||||||
|
shutil.copy2(backup, target)
|
||||||
|
self.assertEqual(target.read_bytes(), backup.read_bytes())
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
evidence: dict[str, str],
|
||||||
|
javascript_parser: object | None = None,
|
||||||
|
) -> validator.Validation:
|
||||||
|
return validator.validate_evidence(
|
||||||
|
self.root,
|
||||||
|
"TEST-001",
|
||||||
|
0,
|
||||||
|
evidence,
|
||||||
|
javascript_parser or FakeJavascriptParser(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_python_symbol(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.py",
|
||||||
|
"kind": "python-function",
|
||||||
|
"locator": "fabricated",
|
||||||
|
"line_range": "L1-L1",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "invalid-locator")
|
||||||
|
|
||||||
|
def test_incorrect_qualified_method(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.py",
|
||||||
|
"kind": "python-method",
|
||||||
|
"locator": "WrongHandler.preprocess_message",
|
||||||
|
"line_range": "L5-L6",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "invalid-locator")
|
||||||
|
|
||||||
|
def test_symbol_outside_cited_range(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.py",
|
||||||
|
"kind": "python-method",
|
||||||
|
"locator": "ChatHandler.preprocess_message",
|
||||||
|
"line_range": "L1-L2",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "locator-outside-range")
|
||||||
|
|
||||||
|
def test_fabricated_test_function(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.py",
|
||||||
|
"kind": "test-function",
|
||||||
|
"locator": "test_fabricated",
|
||||||
|
"line_range": "L1-L2",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "invalid-locator")
|
||||||
|
|
||||||
|
def test_nonexistent_javascript_symbol_with_parser(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.js",
|
||||||
|
"kind": "javascript-function",
|
||||||
|
"locator": "missing",
|
||||||
|
"line_range": "L1-L1",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
},
|
||||||
|
FakeJavascriptParser(
|
||||||
|
[validator.Located("present", "javascript-export", 1, 1)]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "invalid-locator")
|
||||||
|
|
||||||
|
def test_unsupported_javascript_parser(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.js",
|
||||||
|
"kind": "javascript-function",
|
||||||
|
"locator": "present",
|
||||||
|
"line_range": "L1-L1",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
},
|
||||||
|
UnsupportedJavascriptParser(),
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "unsupported")
|
||||||
|
|
||||||
|
def test_route_path_mismatch(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.py",
|
||||||
|
"kind": "python-route",
|
||||||
|
"locator": "POST /api/wrong -> chat_stream",
|
||||||
|
"line_range": "L9-L10",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "invalid-locator")
|
||||||
|
self.assertIn("path", result.problem or "")
|
||||||
|
|
||||||
|
def test_http_method_mismatch(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.py",
|
||||||
|
"kind": "python-route",
|
||||||
|
"locator": "GET /api/chat -> chat_stream",
|
||||||
|
"line_range": "L9-L10",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "invalid-locator")
|
||||||
|
self.assertIn("method", result.problem or "")
|
||||||
|
|
||||||
|
def test_shell_function_mismatch(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.sh",
|
||||||
|
"kind": "shell-function",
|
||||||
|
"locator": "fabricated",
|
||||||
|
"line_range": "L1-L4",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "invalid-locator")
|
||||||
|
|
||||||
|
def test_invalid_file_level_evidence(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "missing.file",
|
||||||
|
"kind": "file",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "invalid-path")
|
||||||
|
|
||||||
|
def test_file_level_evidence_rejects_fake_symbol(self) -> None:
|
||||||
|
result = self.validate(
|
||||||
|
{
|
||||||
|
"path": "sample.sh",
|
||||||
|
"kind": "file",
|
||||||
|
"locator": "whole-script",
|
||||||
|
"explanation": "negative fixture",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.result, "invalid-locator")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+592
@@ -0,0 +1,592 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CATALOG_PATH = ROOT / "feature-catalog.json"
|
||||||
|
CATALOG_MD_PATH = ROOT / "feature-catalog.md"
|
||||||
|
DOMAINS_DIR = ROOT / "domains"
|
||||||
|
REVIEWS_DIR = ROOT / "reviews"
|
||||||
|
|
||||||
|
EXPECTED_COMMIT = "d8a2059df8e53bc7275c45339849d14c8651e73c"
|
||||||
|
EXPECTED_FEATURES = 79
|
||||||
|
EXPECTED_DOMAINS = 16
|
||||||
|
|
||||||
|
VALID_STATUSES = {
|
||||||
|
"verified",
|
||||||
|
"partial",
|
||||||
|
"disabled",
|
||||||
|
"experimental",
|
||||||
|
"legacy",
|
||||||
|
"dead-code-candidate",
|
||||||
|
"unverified",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_MATURITY = {"E0", "E1", "E2", "E3", "E4"}
|
||||||
|
|
||||||
|
VALID_RUNTIME = {
|
||||||
|
"not-required",
|
||||||
|
"pending",
|
||||||
|
"blocked",
|
||||||
|
"completed",
|
||||||
|
}
|
||||||
|
|
||||||
|
REQUIRED_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"domain",
|
||||||
|
"name",
|
||||||
|
"purpose",
|
||||||
|
"status",
|
||||||
|
"evidence_maturity",
|
||||||
|
"verified_at_commit",
|
||||||
|
"evidence",
|
||||||
|
"runtime_validation",
|
||||||
|
}
|
||||||
|
|
||||||
|
FEATURE_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-\d{3}$")
|
||||||
|
|
||||||
|
DOMAIN_HEADING_RE = re.compile(
|
||||||
|
r"^##\s+`?([A-Z][A-Z0-9]*-\d{3})`?"
|
||||||
|
r"\s+(?:—|-)\s+(.+?)\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
DOMAIN_FIELD_RE = re.compile(
|
||||||
|
r"^-\s+\*\*"
|
||||||
|
r"(Domain|Status|Evidence Maturity|Commit Verified)"
|
||||||
|
r"\*\*:\s*(.*?)\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
DOMAIN_RUNTIME_RE = re.compile(
|
||||||
|
r"^-\s+\*\*Runtime Validation\*\*:\s*(.*?)\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
REVIEW_HEADING_RE = re.compile(
|
||||||
|
r"^###\s+(?:\d+\.\s+)?"
|
||||||
|
r"([A-Z][A-Z0-9]*-\d{3})"
|
||||||
|
r"\s+(?:—|-)\s+.+$"
|
||||||
|
)
|
||||||
|
|
||||||
|
REVIEW_RESULT_RE = re.compile(
|
||||||
|
r"^-\s+\*\*Resulting Status(?:\s+and|/)\s+Maturity\*\*:"
|
||||||
|
r"\s*`([^`]+)`\s*/\s*`([^`]+)`\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean(value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(value) >= 2
|
||||||
|
and value.startswith("`")
|
||||||
|
and value.endswith("`")
|
||||||
|
):
|
||||||
|
return value[1:-1].strip()
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def load_catalog(errors: list[str]) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
data = json.loads(
|
||||||
|
CATALOG_PATH.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"Unable to read catalog: {exc}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if isinstance(data, list):
|
||||||
|
features = data
|
||||||
|
elif isinstance(data, dict) and isinstance(data.get("features"), list):
|
||||||
|
features = data["features"]
|
||||||
|
else:
|
||||||
|
errors.append(
|
||||||
|
"Catalog must be an array or contain a features array"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not all(isinstance(feature, dict) for feature in features):
|
||||||
|
errors.append("Every catalog feature must be an object")
|
||||||
|
return []
|
||||||
|
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
def validate_catalog(
|
||||||
|
features: list[dict[str, Any]],
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
if len(features) != EXPECTED_FEATURES:
|
||||||
|
errors.append(
|
||||||
|
f"Expected {EXPECTED_FEATURES} features, "
|
||||||
|
f"found {len(features)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = [feature.get("id") for feature in features]
|
||||||
|
|
||||||
|
duplicates = sorted(
|
||||||
|
feature_id
|
||||||
|
for feature_id, count in Counter(ids).items()
|
||||||
|
if feature_id and count > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if duplicates:
|
||||||
|
errors.append(
|
||||||
|
"Duplicate feature IDs: " + ", ".join(duplicates)
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, feature in enumerate(features):
|
||||||
|
feature_id = feature.get("id")
|
||||||
|
label = (
|
||||||
|
feature_id
|
||||||
|
if isinstance(feature_id, str)
|
||||||
|
else f"<index:{index}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = sorted(
|
||||||
|
field
|
||||||
|
for field in REQUIRED_FIELDS
|
||||||
|
if feature.get(field) in (None, "", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
errors.append(
|
||||||
|
f"{label}: missing fields: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not isinstance(feature_id, str)
|
||||||
|
or not FEATURE_ID_RE.fullmatch(feature_id)
|
||||||
|
):
|
||||||
|
errors.append(f"{label}: invalid feature ID")
|
||||||
|
|
||||||
|
if feature.get("status") not in VALID_STATUSES:
|
||||||
|
errors.append(
|
||||||
|
f"{label}: invalid status "
|
||||||
|
f"{feature.get('status')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if feature.get("evidence_maturity") not in VALID_MATURITY:
|
||||||
|
errors.append(
|
||||||
|
f"{label}: invalid maturity "
|
||||||
|
f"{feature.get('evidence_maturity')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if feature.get("verified_at_commit") != EXPECTED_COMMIT:
|
||||||
|
errors.append(
|
||||||
|
f"{label}: incorrect verified_at_commit"
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime = feature.get("runtime_validation")
|
||||||
|
|
||||||
|
if not isinstance(runtime, dict):
|
||||||
|
errors.append(
|
||||||
|
f"{label}: runtime_validation must be an object"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
required = runtime.get("required")
|
||||||
|
runtime_status = runtime.get("status")
|
||||||
|
reason = runtime.get("reason")
|
||||||
|
|
||||||
|
if not isinstance(required, bool):
|
||||||
|
errors.append(
|
||||||
|
f"{label}: runtime required must be boolean"
|
||||||
|
)
|
||||||
|
|
||||||
|
if runtime_status not in VALID_RUNTIME:
|
||||||
|
errors.append(
|
||||||
|
f"{label}: invalid runtime status "
|
||||||
|
f"{runtime_status!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(reason, str) or not reason.strip():
|
||||||
|
errors.append(
|
||||||
|
f"{label}: runtime reason is blank"
|
||||||
|
)
|
||||||
|
|
||||||
|
if required is False and runtime_status != "not-required":
|
||||||
|
errors.append(
|
||||||
|
f"{label}: required=false requires not-required"
|
||||||
|
)
|
||||||
|
|
||||||
|
if required is True and runtime_status == "not-required":
|
||||||
|
errors.append(
|
||||||
|
f"{label}: required=true cannot be not-required"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_domain(
|
||||||
|
path: Path,
|
||||||
|
errors: list[str],
|
||||||
|
) -> dict[str, dict[str, str]]:
|
||||||
|
records: dict[str, dict[str, str]] = {}
|
||||||
|
current_id: str | None = None
|
||||||
|
|
||||||
|
for line_number, line in enumerate(
|
||||||
|
path.read_text(encoding="utf-8").splitlines(),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
heading = DOMAIN_HEADING_RE.match(line)
|
||||||
|
|
||||||
|
if heading:
|
||||||
|
current_id = heading.group(1)
|
||||||
|
|
||||||
|
if current_id in records:
|
||||||
|
errors.append(
|
||||||
|
f"{path.relative_to(ROOT)}:{line_number}: "
|
||||||
|
f"duplicate heading {current_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
records[current_id] = {
|
||||||
|
"Name": heading.group(2).strip(),
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
field = DOMAIN_FIELD_RE.match(line)
|
||||||
|
|
||||||
|
if field and current_id:
|
||||||
|
value = clean(field.group(2))
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
errors.append(
|
||||||
|
f"{path.relative_to(ROOT)}:{line_number}: "
|
||||||
|
f"blank {field.group(1)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
records[current_id][field.group(1)] = value
|
||||||
|
continue
|
||||||
|
|
||||||
|
runtime = DOMAIN_RUNTIME_RE.match(line)
|
||||||
|
|
||||||
|
if runtime and current_id:
|
||||||
|
value = clean(runtime.group(1))
|
||||||
|
runtime_status = re.split(
|
||||||
|
r"\s+(?:—|-)\s+",
|
||||||
|
value,
|
||||||
|
maxsplit=1,
|
||||||
|
)[0]
|
||||||
|
records[current_id]["Runtime Validation"] = (
|
||||||
|
runtime_status.strip("` ")
|
||||||
|
)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def validate_domains(
|
||||||
|
features: list[dict[str, Any]],
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
catalog = {
|
||||||
|
feature["id"]: feature
|
||||||
|
for feature in features
|
||||||
|
if feature.get("id")
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_by_domain: defaultdict[str, set[str]] = defaultdict(set)
|
||||||
|
|
||||||
|
for feature in features:
|
||||||
|
expected_by_domain[feature["domain"]].add(feature["id"])
|
||||||
|
|
||||||
|
paths = sorted(DOMAINS_DIR.glob("*.md"))
|
||||||
|
|
||||||
|
if len(paths) != EXPECTED_DOMAINS:
|
||||||
|
errors.append(
|
||||||
|
f"Expected {EXPECTED_DOMAINS} domain files, "
|
||||||
|
f"found {len(paths)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
all_found: set[str] = set()
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
domain = path.stem
|
||||||
|
records = parse_domain(path, errors)
|
||||||
|
found = set(records)
|
||||||
|
expected = expected_by_domain.get(domain, set())
|
||||||
|
all_found.update(found)
|
||||||
|
|
||||||
|
if found != expected:
|
||||||
|
missing = sorted(expected - found)
|
||||||
|
extra = sorted(found - expected)
|
||||||
|
|
||||||
|
errors.append(
|
||||||
|
f"{domain}: missing={missing}, unexpected={extra}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for feature_id, record in records.items():
|
||||||
|
feature = catalog.get(feature_id)
|
||||||
|
|
||||||
|
if feature is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
expected_values = {
|
||||||
|
"Name": feature["name"],
|
||||||
|
"Domain": feature["domain"],
|
||||||
|
"Status": feature["status"],
|
||||||
|
"Evidence Maturity": feature["evidence_maturity"],
|
||||||
|
"Commit Verified": feature["verified_at_commit"],
|
||||||
|
"Runtime Validation": (
|
||||||
|
feature["runtime_validation"]["status"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
for field, expected_value in expected_values.items():
|
||||||
|
actual = record.get(field)
|
||||||
|
|
||||||
|
if actual != expected_value:
|
||||||
|
errors.append(
|
||||||
|
f"{path.relative_to(ROOT)}: "
|
||||||
|
f"{feature_id} {field}: "
|
||||||
|
f"{actual!r} != {expected_value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if all_found != set(catalog):
|
||||||
|
errors.append(
|
||||||
|
"Domain Markdown IDs do not match catalog JSON"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_catalog_markdown(
|
||||||
|
features: list[dict[str, Any]],
|
||||||
|
errors: list[str],
|
||||||
|
) -> None:
|
||||||
|
expected = {
|
||||||
|
feature["id"]: feature
|
||||||
|
for feature in features
|
||||||
|
}
|
||||||
|
found: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
for line in CATALOG_MD_PATH.read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
).splitlines():
|
||||||
|
if not line.startswith("|"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cells = [
|
||||||
|
cell.strip()
|
||||||
|
for cell in line.strip().strip("|").split("|")
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(cells) < 6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
feature_id = clean(cells[0])
|
||||||
|
|
||||||
|
if FEATURE_ID_RE.fullmatch(feature_id):
|
||||||
|
found[feature_id] = cells
|
||||||
|
|
||||||
|
if set(found) != set(expected):
|
||||||
|
errors.append(
|
||||||
|
"feature-catalog.md IDs do not match JSON"
|
||||||
|
)
|
||||||
|
|
||||||
|
for feature_id, cells in found.items():
|
||||||
|
feature = expected[feature_id]
|
||||||
|
runtime = feature["runtime_validation"]
|
||||||
|
runtime_display = (
|
||||||
|
runtime["status"]
|
||||||
|
if runtime["required"]
|
||||||
|
else "not required"
|
||||||
|
)
|
||||||
|
|
||||||
|
actual = {
|
||||||
|
"name": cells[1].replace("\\|", "|"),
|
||||||
|
"domain": clean(cells[2]),
|
||||||
|
"status": clean(cells[3]),
|
||||||
|
"maturity": clean(cells[4]),
|
||||||
|
"runtime": clean(cells[5]),
|
||||||
|
}
|
||||||
|
|
||||||
|
wanted = {
|
||||||
|
"name": feature["name"],
|
||||||
|
"domain": feature["domain"],
|
||||||
|
"status": feature["status"],
|
||||||
|
"maturity": feature["evidence_maturity"],
|
||||||
|
"runtime": runtime_display,
|
||||||
|
}
|
||||||
|
|
||||||
|
for field, expected_value in wanted.items():
|
||||||
|
if actual[field] != expected_value:
|
||||||
|
errors.append(
|
||||||
|
f"feature-catalog.md: {feature_id} "
|
||||||
|
f"{field}: {actual[field]!r} "
|
||||||
|
f"!= {expected_value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_reviews(
|
||||||
|
features: list[dict[str, Any]],
|
||||||
|
errors: list[str],
|
||||||
|
) -> int:
|
||||||
|
catalog = {
|
||||||
|
feature["id"]: feature
|
||||||
|
for feature in features
|
||||||
|
}
|
||||||
|
checked = 0
|
||||||
|
|
||||||
|
for path in sorted(
|
||||||
|
REVIEWS_DIR.glob("evidence-sample-*.md")
|
||||||
|
):
|
||||||
|
current_id: str | None = None
|
||||||
|
results: set[str] = set()
|
||||||
|
|
||||||
|
for line_number, line in enumerate(
|
||||||
|
path.read_text(encoding="utf-8").splitlines(),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
heading = REVIEW_HEADING_RE.match(line)
|
||||||
|
|
||||||
|
if heading:
|
||||||
|
current_id = heading.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = REVIEW_RESULT_RE.match(line)
|
||||||
|
|
||||||
|
if not result or current_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status, maturity = result.groups()
|
||||||
|
results.add(current_id)
|
||||||
|
checked += 1
|
||||||
|
|
||||||
|
feature = catalog.get(current_id)
|
||||||
|
|
||||||
|
if feature is None:
|
||||||
|
errors.append(
|
||||||
|
f"{path.relative_to(ROOT)}:{line_number}: "
|
||||||
|
f"unknown feature {current_id}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if feature["status"] != status:
|
||||||
|
errors.append(
|
||||||
|
f"{current_id}: review status {status!r} "
|
||||||
|
f"!= catalog {feature['status']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if feature["evidence_maturity"] != maturity:
|
||||||
|
errors.append(
|
||||||
|
f"{current_id}: review maturity {maturity!r} "
|
||||||
|
f"!= catalog "
|
||||||
|
f"{feature['evidence_maturity']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if path.name == "evidence-sample-01.md" and len(results) != 12:
|
||||||
|
errors.append(
|
||||||
|
f"{path.relative_to(ROOT)}: expected 12 "
|
||||||
|
f"review results, found {len(results)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return checked
|
||||||
|
|
||||||
|
|
||||||
|
def validate_whitespace(errors: list[str]) -> None:
|
||||||
|
for path in sorted(ROOT.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if path.suffix not in {".md", ".json", ".py", ".txt"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for line_number, line in enumerate(
|
||||||
|
path.read_text(
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
).splitlines(),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
if line != line.rstrip(" \t"):
|
||||||
|
errors.append(
|
||||||
|
f"{path.relative_to(ROOT)}:{line_number}: "
|
||||||
|
"trailing whitespace"
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(ROOT.rglob("*.pyc")):
|
||||||
|
errors.append("Generated .pyc files exist")
|
||||||
|
|
||||||
|
if any(
|
||||||
|
path.is_dir()
|
||||||
|
for path in ROOT.rglob("__pycache__")
|
||||||
|
):
|
||||||
|
errors.append("__pycache__ exists")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
errors: list[str] = []
|
||||||
|
features = load_catalog(errors)
|
||||||
|
reviewed = 0
|
||||||
|
|
||||||
|
if features:
|
||||||
|
validate_catalog(features, errors)
|
||||||
|
validate_domains(features, errors)
|
||||||
|
validate_catalog_markdown(features, errors)
|
||||||
|
reviewed = validate_reviews(features, errors)
|
||||||
|
|
||||||
|
validate_whitespace(errors)
|
||||||
|
|
||||||
|
print(f"Catalog Features: {len(features)}")
|
||||||
|
print(
|
||||||
|
"Unique Feature IDs:",
|
||||||
|
len({feature.get("id") for feature in features}),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Domain Files:",
|
||||||
|
len(list(DOMAINS_DIR.glob("*.md"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
if features:
|
||||||
|
print(
|
||||||
|
"Statuses:",
|
||||||
|
dict(
|
||||||
|
Counter(
|
||||||
|
feature.get("status")
|
||||||
|
for feature in features
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Evidence Maturity:",
|
||||||
|
dict(
|
||||||
|
Counter(
|
||||||
|
feature.get("evidence_maturity")
|
||||||
|
for feature in features
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Runtime Validation:",
|
||||||
|
dict(
|
||||||
|
Counter(
|
||||||
|
(
|
||||||
|
feature.get(
|
||||||
|
"runtime_validation",
|
||||||
|
{},
|
||||||
|
).get("required"),
|
||||||
|
feature.get(
|
||||||
|
"runtime_validation",
|
||||||
|
{},
|
||||||
|
).get("status"),
|
||||||
|
)
|
||||||
|
for feature in features
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Review Results Checked: {reviewed}")
|
||||||
|
print(f"Consistency Errors: {len(errors)}")
|
||||||
|
|
||||||
|
for error in errors:
|
||||||
|
print(f"ERROR: {error}")
|
||||||
|
|
||||||
|
return 1 if errors else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate the Odysseus public discovery documentation package."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REPO_ROOT = ROOT.parents[1]
|
||||||
|
CATALOG_PATH = ROOT / "feature-catalog.json"
|
||||||
|
DOMAINS_DIR = ROOT / "domains"
|
||||||
|
|
||||||
|
EXPECTED_COMMIT = "d8a2059df8e53bc7275c45339849d14c8651e73c"
|
||||||
|
ALLOWED_STATUSES = {
|
||||||
|
"verified", "partial", "disabled", "experimental", "legacy",
|
||||||
|
"dead-code-candidate", "unverified"
|
||||||
|
}
|
||||||
|
|
||||||
|
LINE_RANGE_RE = re.compile(r"^L([1-9]\d*)-L([1-9]\d*)$")
|
||||||
|
FEATURE_HEADING_RE = re.compile(
|
||||||
|
r"^##\s+`?([A-Z][A-Z0-9]*-\d{3})`?\s+(?:—|-)\s+.+$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
LINK_RE = re.compile(r"(?<!!)\[[^\]]*\]\(([^)]+)\)")
|
||||||
|
FORBIDDEN_TERMS_RE = re.compile(
|
||||||
|
r"(roadforge|kanban|matrix|owner link|editor link|github support|"
|
||||||
|
r"private maintainer|OD-AUD-|[A-Z]{2,10}-AUD-\d+|TASK-\d+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Validate Odysseus discovery docs.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-root",
|
||||||
|
type=Path,
|
||||||
|
default=REPO_ROOT,
|
||||||
|
help="Path to Odysseus repository root.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
def validate() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
repo_root = args.repo_root.resolve()
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# 1. Validate Catalog JSON existence and content
|
||||||
|
if not CATALOG_PATH.is_file():
|
||||||
|
errors.append(f"Missing catalog file: {CATALOG_PATH}")
|
||||||
|
print(f"Errors: {len(errors)}")
|
||||||
|
for e in errors:
|
||||||
|
print(f"ERROR: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Failed to parse catalog JSON: {e}")
|
||||||
|
print(f"Errors: {len(errors)}")
|
||||||
|
for err in errors:
|
||||||
|
print(f"ERROR: {err}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not isinstance(catalog, list):
|
||||||
|
errors.append("feature-catalog.json must be a JSON array")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
feature_ids = [item.get("id") for item in catalog if isinstance(item, dict)]
|
||||||
|
if len(feature_ids) != 79:
|
||||||
|
errors.append(f"Expected 79 unique feature IDs, found {len(feature_ids)}")
|
||||||
|
if len(feature_ids) != len(set(feature_ids)):
|
||||||
|
errors.append("Duplicate feature IDs found in catalog JSON")
|
||||||
|
|
||||||
|
# Domain catalog counts
|
||||||
|
catalog_domain_counts: dict[str, int] = {}
|
||||||
|
for item in catalog:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
errors.append("Catalog item is not an object")
|
||||||
|
continue
|
||||||
|
fid = item.get("id", "<missing>")
|
||||||
|
status = item.get("status")
|
||||||
|
domain = item.get("domain", "").lower()
|
||||||
|
catalog_domain_counts[domain] = catalog_domain_counts.get(domain, 0) + 1
|
||||||
|
|
||||||
|
if status not in ALLOWED_STATUSES:
|
||||||
|
errors.append(f"{fid}: Invalid status '{status}'")
|
||||||
|
|
||||||
|
evidence_list = item.get("evidence")
|
||||||
|
if not isinstance(evidence_list, list) or not evidence_list:
|
||||||
|
errors.append(f"{fid}: Missing or empty evidence list")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for ev in evidence_list:
|
||||||
|
if not isinstance(ev, dict):
|
||||||
|
errors.append(f"{fid}: Evidence item is not an object")
|
||||||
|
continue
|
||||||
|
path_str = ev.get("path")
|
||||||
|
lr_str = str(ev.get("line_range", ""))
|
||||||
|
if not path_str or Path(path_str).is_absolute() or ".." in Path(path_str).parts:
|
||||||
|
errors.append(f"{fid}: Unsafe or invalid path '{path_str}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check path exists in repo
|
||||||
|
target_path = repo_root / path_str
|
||||||
|
if not target_path.is_file():
|
||||||
|
errors.append(f"{fid}: Referenced path '{path_str}' does not exist on disk")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check line range format & bounds
|
||||||
|
m = LINE_RANGE_RE.fullmatch(lr_str)
|
||||||
|
if not m:
|
||||||
|
errors.append(f"{fid}: Invalid line range format '{lr_str}' for path '{path_str}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
start, end = int(m.group(1)), int(m.group(2))
|
||||||
|
lines_cnt = len(target_path.read_text(encoding="utf-8", errors="ignore").splitlines())
|
||||||
|
if start > end or end > lines_cnt or start < 1:
|
||||||
|
errors.append(
|
||||||
|
f"{fid}: Line range '{lr_str}' exceeds file length ({lines_cnt} lines) in '{path_str}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Check Domain Markdown files
|
||||||
|
md_feature_ids: list[str] = []
|
||||||
|
domain_files = sorted(DOMAINS_DIR.glob("*.md"))
|
||||||
|
for df in domain_files:
|
||||||
|
domain_name = df.stem.lower()
|
||||||
|
content = df.read_text(encoding="utf-8")
|
||||||
|
found_ids = FEATURE_HEADING_RE.findall(content)
|
||||||
|
md_feature_ids.extend(found_ids)
|
||||||
|
if len(found_ids) != catalog_domain_counts.get(domain_name, 0):
|
||||||
|
errors.append(
|
||||||
|
f"Domain '{domain_name}' count mismatch: catalog has {catalog_domain_counts.get(domain_name, 0)}, Markdown has {len(found_ids)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if sorted(md_feature_ids) != sorted(feature_ids):
|
||||||
|
errors.append("Markdown domain feature IDs do not match catalog JSON feature IDs")
|
||||||
|
|
||||||
|
# 3. Check for forbidden/private terms, sensitive credentials, and broken links across all docs
|
||||||
|
for md_file in ROOT.rglob("*.md"):
|
||||||
|
rel_md = md_file.relative_to(ROOT)
|
||||||
|
content = md_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Forbidden terms scan
|
||||||
|
forbidden_matches = FORBIDDEN_TERMS_RE.findall(content)
|
||||||
|
if forbidden_matches:
|
||||||
|
errors.append(
|
||||||
|
f"{rel_md}: Found forbidden/internal terms: {set(forbidden_matches)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broken local link check
|
||||||
|
for target in LINK_RE.findall(content):
|
||||||
|
target = target.strip().strip("<>")
|
||||||
|
if not target or target.startswith(("#", "http://", "https://", "mailto:")):
|
||||||
|
continue
|
||||||
|
target_path = target.split("#", 1)[0]
|
||||||
|
resolved = (md_file.parent / target_path).resolve()
|
||||||
|
if not resolved.exists():
|
||||||
|
errors.append(f"{rel_md}: Broken local link '{target}'")
|
||||||
|
|
||||||
|
print(f"Catalog Features: {len(catalog)}")
|
||||||
|
print(f"Domain Files: {len(domain_files)}")
|
||||||
|
print(f"Validation Errors: {len(errors)}")
|
||||||
|
for err in errors:
|
||||||
|
print(f"ERROR: {err}")
|
||||||
|
|
||||||
|
return 1 if errors else 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(validate())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,6 @@ from mcp.types import Tool, TextContent
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from src.memory import MemoryStoreUnreadable
|
|
||||||
|
|
||||||
server = Server("memory")
|
server = Server("memory")
|
||||||
|
|
||||||
# Late-initialized managers (set during first tool call)
|
# Late-initialized managers (set during first tool call)
|
||||||
@@ -31,10 +29,6 @@ _OWNER_SCOPE_ERROR = (
|
|||||||
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
|
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
|
||||||
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
|
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
|
||||||
)
|
)
|
||||||
_UNREADABLE_STORE_ERROR = (
|
|
||||||
"Error: Memory store is temporarily unreadable — nothing was saved. "
|
|
||||||
"Repair or restore memory.json, then retry."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _configured_owner() -> str | None:
|
def _configured_owner() -> str | None:
|
||||||
@@ -57,21 +51,9 @@ def _owner_scoped_store(entries: list[dict]) -> bool:
|
|||||||
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
|
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
|
||||||
|
|
||||||
|
|
||||||
def _scope_entries(for_update: bool = False) -> tuple[str | None, list[dict], list[dict], str | None]:
|
def _scope_entries() -> tuple[str | None, list[dict], list[dict], str | None]:
|
||||||
"""Return configured owner, all entries, visible entries, and optional error.
|
"""Return configured owner, all entries, visible entries, and optional error."""
|
||||||
|
entries = _memory_manager.load_all()
|
||||||
``for_update=True`` is for read-modify-write callers. They save the ``all
|
|
||||||
entries`` list back, so an unreadable store must be reported as an error
|
|
||||||
instead of degrading to ``[]`` — otherwise the save writes their one new
|
|
||||||
entry over the whole store (issue #5673).
|
|
||||||
"""
|
|
||||||
if for_update:
|
|
||||||
try:
|
|
||||||
entries = _memory_manager.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
return None, [], [], f"{_UNREADABLE_STORE_ERROR} ({e})"
|
|
||||||
else:
|
|
||||||
entries = _memory_manager.load_all()
|
|
||||||
owner = _configured_owner()
|
owner = _configured_owner()
|
||||||
if owner is None and _owner_scoped_store(entries):
|
if owner is None and _owner_scoped_store(entries):
|
||||||
return None, entries, [], _OWNER_SCOPE_ERROR
|
return None, entries, [], _OWNER_SCOPE_ERROR
|
||||||
@@ -179,7 +161,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
category = arguments.get("category", "fact")
|
category = arguments.get("category", "fact")
|
||||||
if not text:
|
if not text:
|
||||||
return _text_result("Error: Memory text cannot be empty")
|
return _text_result("Error: Memory text cannot be empty")
|
||||||
owner, memories, _visible, scope_error = _scope_entries(for_update=True)
|
owner, memories, _visible, scope_error = _scope_entries()
|
||||||
if scope_error:
|
if scope_error:
|
||||||
return _text_result(scope_error)
|
return _text_result(scope_error)
|
||||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
||||||
|
|||||||
+1
-4
@@ -38,10 +38,7 @@ python-dateutil
|
|||||||
caldav
|
caldav
|
||||||
cryptography
|
cryptography
|
||||||
bcrypt
|
bcrypt
|
||||||
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
|
mcp
|
||||||
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
|
|
||||||
# servers are migrated together.
|
|
||||||
mcp<2
|
|
||||||
pyotp
|
pyotp
|
||||||
qrcode[pil]
|
qrcode[pil]
|
||||||
croniter
|
croniter
|
||||||
|
|||||||
+1
-10
@@ -6,7 +6,6 @@ from datetime import datetime
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from services.memory import MemoryStoreUnreadable
|
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.settings import load_settings, save_settings, load_features, save_features
|
from src.settings import load_settings, save_settings, load_features, save_features
|
||||||
|
|
||||||
@@ -77,15 +76,7 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
|
|||||||
|
|
||||||
# ── Memories ──
|
# ── Memories ──
|
||||||
if "memories" in body and isinstance(body["memories"], list):
|
if "memories" in body and isinstance(body["memories"], list):
|
||||||
# Strict load: importing on top of an unreadable store would write
|
existing = memory_manager.load_all()
|
||||||
# only the incoming rows back and drop everything already saved.
|
|
||||||
try:
|
|
||||||
existing = memory_manager.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
logger.error("Refusing to import memories: %s", e)
|
|
||||||
raise HTTPException(
|
|
||||||
503, "Memory store is temporarily unreadable — nothing was imported."
|
|
||||||
)
|
|
||||||
# Dedup against THIS user's own memories only. Using every tenant's
|
# Dedup against THIS user's own memories only. Using every tenant's
|
||||||
# rows (load_all) meant a memory whose text matched any other
|
# rows (load_all) meant a memory whose text matched any other
|
||||||
# user's was silently skipped, so the importing user lost their own
|
# user's was silently skipped, so the importing user lost their own
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Document route domain package (slice 2m, #4082/#4071).
|
|
||||||
|
|
||||||
Contains document_routes.py and document_helpers.py, migrated from the flat
|
|
||||||
routes/ directory. Backward-compat shims at routes/document_routes.py and
|
|
||||||
routes/document_helpers.py re-export from here.
|
|
||||||
"""
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
|
|
||||||
|
|
||||||
"""Document routes — CRUD for living documents with version history."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from core.database import Document, DocumentVersion
|
|
||||||
from core.database import Session as DbSession
|
|
||||||
from src.auth_helpers import _auth_disabled
|
|
||||||
from src.upload_handler import UploadHandler
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Request schemas ----
|
|
||||||
|
|
||||||
class DocumentCreate(BaseModel):
|
|
||||||
session_id: Optional[str] = None
|
|
||||||
title: str = "Untitled"
|
|
||||||
language: Optional[str] = None
|
|
||||||
content: str = ""
|
|
||||||
|
|
||||||
class DocumentUpdate(BaseModel):
|
|
||||||
content: str
|
|
||||||
summary: Optional[str] = None
|
|
||||||
force_version: bool = False
|
|
||||||
|
|
||||||
class DocumentPatch(BaseModel):
|
|
||||||
title: Optional[str] = None
|
|
||||||
language: Optional[str] = None
|
|
||||||
session_id: Optional[str] = None # link/unlink document to a session
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Helpers ----
|
|
||||||
|
|
||||||
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": doc.id,
|
|
||||||
"session_id": doc.session_id,
|
|
||||||
"title": doc.title,
|
|
||||||
"language": doc.language,
|
|
||||||
"current_content": doc.current_content,
|
|
||||||
"version_count": doc.version_count,
|
|
||||||
"is_active": doc.is_active,
|
|
||||||
"archived": bool(getattr(doc, "archived", False)),
|
|
||||||
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
|
|
||||||
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
|
|
||||||
# Source-email provenance (set when doc was created from an email
|
|
||||||
# attachment) — drives the "Send signed reply" menu item.
|
|
||||||
"source_email_uid": getattr(doc, "source_email_uid", None),
|
|
||||||
"source_email_folder": getattr(doc, "source_email_folder", None),
|
|
||||||
"source_email_account_id": getattr(doc, "source_email_account_id", None),
|
|
||||||
"source_email_message_id": getattr(doc, "source_email_message_id", None),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": v.id,
|
|
||||||
"document_id": v.document_id,
|
|
||||||
"version_number": v.version_number,
|
|
||||||
"content": v.content,
|
|
||||||
"summary": v.summary,
|
|
||||||
"source": v.source,
|
|
||||||
"created_at": v.created_at.isoformat() if v.created_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_doc_owner(db, doc: Document, user: str):
|
|
||||||
"""Verify `user` owns this document. Raise 404 if not.
|
|
||||||
|
|
||||||
Documents now carry their own `owner` column, so a doc whose session
|
|
||||||
was deleted (session_id → NULL) can still prove ownership and stay
|
|
||||||
openable / cloneable. We trust that column first and only fall back to
|
|
||||||
the session join for any not-yet-backfilled legacy row.
|
|
||||||
"""
|
|
||||||
if user is None:
|
|
||||||
if _auth_disabled():
|
|
||||||
return # Single-user / no-auth mode: allow access
|
|
||||||
raise HTTPException(403, "Authentication required")
|
|
||||||
if doc.owner is not None:
|
|
||||||
if doc.owner != user:
|
|
||||||
raise HTTPException(404, "Document not found")
|
|
||||||
return
|
|
||||||
# Legacy fallback: derive ownership from the linked session.
|
|
||||||
if not doc.session_id:
|
|
||||||
raise HTTPException(404, "Document not found")
|
|
||||||
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
|
|
||||||
if not session or session.owner != user:
|
|
||||||
raise HTTPException(404, "Document not found")
|
|
||||||
|
|
||||||
|
|
||||||
def _owner_session_filter(q, user):
|
|
||||||
"""Restrict a documents query to those owned by `user`.
|
|
||||||
|
|
||||||
Documents now carry their own `owner` column (backfilled at boot from
|
|
||||||
the linked session, or assigned to the admin user for legacy/orphaned
|
|
||||||
docs). We filter on that directly rather than on a session join, so a
|
|
||||||
document whose session was deleted (session_id → NULL) still shows up
|
|
||||||
for its owner instead of silently vanishing from the Library + search.
|
|
||||||
|
|
||||||
The owner backfill runs in init_db before the app serves requests, so
|
|
||||||
by the time this filter is live there are no NULL-owner rows to leak;
|
|
||||||
we therefore match the owner strictly for authenticated callers."""
|
|
||||||
if not user:
|
|
||||||
if user == "" or _auth_disabled():
|
|
||||||
return q
|
|
||||||
return q.filter(False)
|
|
||||||
return q.filter(Document.owner == user)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _slug(name: str) -> str:
|
|
||||||
"""Filesystem-friendly version of a document title.
|
|
||||||
|
|
||||||
Whitespace becomes underscores; other unsafe punctuation is dropped.
|
|
||||||
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
|
|
||||||
"""
|
|
||||||
import re as _re
|
|
||||||
s = (name or "").strip()
|
|
||||||
# Drop the trailing extension if the title happens to include one
|
|
||||||
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
|
|
||||||
s = _re.sub(r'\s+', '_', s)
|
|
||||||
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
|
|
||||||
s = _re.sub(r'_+', '_', s).strip('_')
|
|
||||||
return s or "form"
|
|
||||||
|
|
||||||
|
|
||||||
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
|
|
||||||
_PDF_RENDER_SCALE = 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def _upload_path_inside(upload_dir: str, path: str) -> bool:
|
|
||||||
base = os.path.realpath(upload_dir)
|
|
||||||
p = os.path.realpath(path)
|
|
||||||
try:
|
|
||||||
return os.path.commonpath([base, p]) == base
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_user_upload_path(
|
|
||||||
upload_handler: Any,
|
|
||||||
upload_id: str,
|
|
||||||
owner: Optional[str],
|
|
||||||
auth_manager=None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Resolve an upload id to a filesystem path the caller may read."""
|
|
||||||
if upload_handler is None:
|
|
||||||
return None
|
|
||||||
resolved = upload_handler.resolve_upload(
|
|
||||||
upload_id,
|
|
||||||
owner=owner,
|
|
||||||
auth_manager=auth_manager,
|
|
||||||
)
|
|
||||||
if not isinstance(resolved, dict) or not resolved:
|
|
||||||
return None
|
|
||||||
path = resolved.get("path")
|
|
||||||
upload_dir = getattr(upload_handler, "upload_dir", None)
|
|
||||||
if path and upload_dir and not _upload_path_inside(upload_dir, path):
|
|
||||||
logger.warning("Upload path outside upload directory: %s", path)
|
|
||||||
return None
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def _locate_upload(
|
|
||||||
upload_dir: str,
|
|
||||||
file_id: str,
|
|
||||||
owner: Optional[str] = None,
|
|
||||||
auth_manager=None,
|
|
||||||
upload_handler: Any = None,
|
|
||||||
):
|
|
||||||
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
|
|
||||||
if upload_handler is None:
|
|
||||||
from src.upload_handler import UploadHandler
|
|
||||||
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(upload_dir))
|
|
||||||
upload_handler = UploadHandler(base_dir, upload_dir)
|
|
||||||
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_pdf_marker_upload_owned(
|
|
||||||
request: Request,
|
|
||||||
content: str,
|
|
||||||
user: Optional[str],
|
|
||||||
upload_handler: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Reject document content whose pdf_source marker points at another user's upload."""
|
|
||||||
if upload_handler is None:
|
|
||||||
return
|
|
||||||
from src.pdf_form_doc import find_source_upload_id
|
|
||||||
|
|
||||||
upload_id = find_source_upload_id(content or "")
|
|
||||||
if not upload_id:
|
|
||||||
return
|
|
||||||
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
|
||||||
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
|
|
||||||
raise HTTPException(
|
|
||||||
400,
|
|
||||||
"Document PDF marker references an upload you do not own",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _derive_title(content: str) -> str:
|
|
||||||
"""Derive a title from document content."""
|
|
||||||
import re
|
|
||||||
if not isinstance(content, str):
|
|
||||||
return "Untitled"
|
|
||||||
text = content.strip()
|
|
||||||
if not text:
|
|
||||||
return "Untitled"
|
|
||||||
|
|
||||||
# Markdown header
|
|
||||||
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
|
|
||||||
if md:
|
|
||||||
title = md.group(1).strip()
|
|
||||||
if len(title) > 50:
|
|
||||||
title = title[:48] + "…"
|
|
||||||
return title
|
|
||||||
|
|
||||||
# HTML heading
|
|
||||||
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
|
|
||||||
if html:
|
|
||||||
title = html.group(1).strip()
|
|
||||||
if len(title) > 50:
|
|
||||||
title = title[:48] + "…"
|
|
||||||
return title
|
|
||||||
|
|
||||||
# First non-empty line (if short enough)
|
|
||||||
for line in text.split('\n'):
|
|
||||||
line = line.strip()
|
|
||||||
if line and 2 <= len(line) <= 60:
|
|
||||||
title = re.sub(r'[:#*`]+$', '', line).strip()
|
|
||||||
if title and len(title) > 50:
|
|
||||||
title = title[:48] + "…"
|
|
||||||
return title or "Untitled"
|
|
||||||
|
|
||||||
return "Untitled"
|
|
||||||
File diff suppressed because it is too large
Load Diff
+239
-10
@@ -1,14 +1,243 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/document/document_helpers.py.
|
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
"""Document routes — CRUD for living documents with version history."""
|
||||||
that ``import routes.document_helpers``, ``from routes.document_helpers import
|
|
||||||
X``, and the ``sys.modules.pop("routes.document_helpers")`` + re-import
|
|
||||||
pattern used by test_security_regressions.py all operate on the *same* object.
|
|
||||||
Keeps existing import paths working after slice 2m (#4082/#4071).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys as _sys
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from routes.document import document_helpers as _canonical # noqa: F401
|
from fastapi import HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
from core.database import Document, DocumentVersion
|
||||||
|
from core.database import Session as DbSession
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
|
from src.upload_handler import UploadHandler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Request schemas ----
|
||||||
|
|
||||||
|
class DocumentCreate(BaseModel):
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
title: str = "Untitled"
|
||||||
|
language: Optional[str] = None
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
class DocumentUpdate(BaseModel):
|
||||||
|
content: str
|
||||||
|
summary: Optional[str] = None
|
||||||
|
force_version: bool = False
|
||||||
|
|
||||||
|
class DocumentPatch(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
session_id: Optional[str] = None # link/unlink document to a session
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Helpers ----
|
||||||
|
|
||||||
|
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": doc.id,
|
||||||
|
"session_id": doc.session_id,
|
||||||
|
"title": doc.title,
|
||||||
|
"language": doc.language,
|
||||||
|
"current_content": doc.current_content,
|
||||||
|
"version_count": doc.version_count,
|
||||||
|
"is_active": doc.is_active,
|
||||||
|
"archived": bool(getattr(doc, "archived", False)),
|
||||||
|
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
|
||||||
|
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
|
||||||
|
# Source-email provenance (set when doc was created from an email
|
||||||
|
# attachment) — drives the "Send signed reply" menu item.
|
||||||
|
"source_email_uid": getattr(doc, "source_email_uid", None),
|
||||||
|
"source_email_folder": getattr(doc, "source_email_folder", None),
|
||||||
|
"source_email_account_id": getattr(doc, "source_email_account_id", None),
|
||||||
|
"source_email_message_id": getattr(doc, "source_email_message_id", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": v.id,
|
||||||
|
"document_id": v.document_id,
|
||||||
|
"version_number": v.version_number,
|
||||||
|
"content": v.content,
|
||||||
|
"summary": v.summary,
|
||||||
|
"source": v.source,
|
||||||
|
"created_at": v.created_at.isoformat() if v.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_doc_owner(db, doc: Document, user: str):
|
||||||
|
"""Verify `user` owns this document. Raise 404 if not.
|
||||||
|
|
||||||
|
Documents now carry their own `owner` column, so a doc whose session
|
||||||
|
was deleted (session_id → NULL) can still prove ownership and stay
|
||||||
|
openable / cloneable. We trust that column first and only fall back to
|
||||||
|
the session join for any not-yet-backfilled legacy row.
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
if _auth_disabled():
|
||||||
|
return # Single-user / no-auth mode: allow access
|
||||||
|
raise HTTPException(403, "Authentication required")
|
||||||
|
if doc.owner is not None:
|
||||||
|
if doc.owner != user:
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
return
|
||||||
|
# Legacy fallback: derive ownership from the linked session.
|
||||||
|
if not doc.session_id:
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
|
||||||
|
if not session or session.owner != user:
|
||||||
|
raise HTTPException(404, "Document not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_session_filter(q, user):
|
||||||
|
"""Restrict a documents query to those owned by `user`.
|
||||||
|
|
||||||
|
Documents now carry their own `owner` column (backfilled at boot from
|
||||||
|
the linked session, or assigned to the admin user for legacy/orphaned
|
||||||
|
docs). We filter on that directly rather than on a session join, so a
|
||||||
|
document whose session was deleted (session_id → NULL) still shows up
|
||||||
|
for its owner instead of silently vanishing from the Library + search.
|
||||||
|
|
||||||
|
The owner backfill runs in init_db before the app serves requests, so
|
||||||
|
by the time this filter is live there are no NULL-owner rows to leak;
|
||||||
|
we therefore match the owner strictly for authenticated callers."""
|
||||||
|
if not user:
|
||||||
|
if user == "" or _auth_disabled():
|
||||||
|
return q
|
||||||
|
return q.filter(False)
|
||||||
|
return q.filter(Document.owner == user)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(name: str) -> str:
|
||||||
|
"""Filesystem-friendly version of a document title.
|
||||||
|
|
||||||
|
Whitespace becomes underscores; other unsafe punctuation is dropped.
|
||||||
|
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
|
||||||
|
"""
|
||||||
|
import re as _re
|
||||||
|
s = (name or "").strip()
|
||||||
|
# Drop the trailing extension if the title happens to include one
|
||||||
|
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
|
||||||
|
s = _re.sub(r'\s+', '_', s)
|
||||||
|
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
|
||||||
|
s = _re.sub(r'_+', '_', s).strip('_')
|
||||||
|
return s or "form"
|
||||||
|
|
||||||
|
|
||||||
|
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
|
||||||
|
_PDF_RENDER_SCALE = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_path_inside(upload_dir: str, path: str) -> bool:
|
||||||
|
base = os.path.realpath(upload_dir)
|
||||||
|
p = os.path.realpath(path)
|
||||||
|
try:
|
||||||
|
return os.path.commonpath([base, p]) == base
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_upload_path(
|
||||||
|
upload_handler: Any,
|
||||||
|
upload_id: str,
|
||||||
|
owner: Optional[str],
|
||||||
|
auth_manager=None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Resolve an upload id to a filesystem path the caller may read."""
|
||||||
|
if upload_handler is None:
|
||||||
|
return None
|
||||||
|
resolved = upload_handler.resolve_upload(
|
||||||
|
upload_id,
|
||||||
|
owner=owner,
|
||||||
|
auth_manager=auth_manager,
|
||||||
|
)
|
||||||
|
if not isinstance(resolved, dict) or not resolved:
|
||||||
|
return None
|
||||||
|
path = resolved.get("path")
|
||||||
|
upload_dir = getattr(upload_handler, "upload_dir", None)
|
||||||
|
if path and upload_dir and not _upload_path_inside(upload_dir, path):
|
||||||
|
logger.warning("Upload path outside upload directory: %s", path)
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_upload(
|
||||||
|
upload_dir: str,
|
||||||
|
file_id: str,
|
||||||
|
owner: Optional[str] = None,
|
||||||
|
auth_manager=None,
|
||||||
|
upload_handler: Any = None,
|
||||||
|
):
|
||||||
|
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
|
||||||
|
if upload_handler is None:
|
||||||
|
from src.upload_handler import UploadHandler
|
||||||
|
|
||||||
|
base_dir = os.path.dirname(os.path.abspath(upload_dir))
|
||||||
|
upload_handler = UploadHandler(base_dir, upload_dir)
|
||||||
|
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_pdf_marker_upload_owned(
|
||||||
|
request: Request,
|
||||||
|
content: str,
|
||||||
|
user: Optional[str],
|
||||||
|
upload_handler: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Reject document content whose pdf_source marker points at another user's upload."""
|
||||||
|
if upload_handler is None:
|
||||||
|
return
|
||||||
|
from src.pdf_form_doc import find_source_upload_id
|
||||||
|
|
||||||
|
upload_id = find_source_upload_id(content or "")
|
||||||
|
if not upload_id:
|
||||||
|
return
|
||||||
|
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
||||||
|
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
"Document PDF marker references an upload you do not own",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_title(content: str) -> str:
|
||||||
|
"""Derive a title from document content."""
|
||||||
|
import re
|
||||||
|
if not isinstance(content, str):
|
||||||
|
return "Untitled"
|
||||||
|
text = content.strip()
|
||||||
|
if not text:
|
||||||
|
return "Untitled"
|
||||||
|
|
||||||
|
# Markdown header
|
||||||
|
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
|
||||||
|
if md:
|
||||||
|
title = md.group(1).strip()
|
||||||
|
if len(title) > 50:
|
||||||
|
title = title[:48] + "…"
|
||||||
|
return title
|
||||||
|
|
||||||
|
# HTML heading
|
||||||
|
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
|
||||||
|
if html:
|
||||||
|
title = html.group(1).strip()
|
||||||
|
if len(title) > 50:
|
||||||
|
title = title[:48] + "…"
|
||||||
|
return title
|
||||||
|
|
||||||
|
# First non-empty line (if short enough)
|
||||||
|
for line in text.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line and 2 <= len(line) <= 60:
|
||||||
|
title = re.sub(r'[:#*`]+$', '', line).strip()
|
||||||
|
if title and len(title) > 50:
|
||||||
|
title = title[:48] + "…"
|
||||||
|
return title or "Untitled"
|
||||||
|
|
||||||
|
return "Untitled"
|
||||||
|
|||||||
+1806
-13
File diff suppressed because it is too large
Load Diff
@@ -247,7 +247,6 @@ import re as _re_reply
|
|||||||
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
|
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
|
||||||
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
|
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
|
||||||
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
|
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
|
||||||
_SUMMARY_BULLET_RE = _re_reply.compile(r"^(?:[-*\u2022]\s+|\d+[.)]\s+)")
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_reply(text: str) -> str:
|
def _extract_reply(text: str) -> str:
|
||||||
@@ -278,125 +277,6 @@ def _extract_reply(text: str) -> str:
|
|||||||
return _strip_think(t).strip()
|
return _strip_think(t).strip()
|
||||||
|
|
||||||
|
|
||||||
def _build_email_summary_messages(sender: str, subject: str, body_for_llm: str) -> list[dict[str, str]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"You are an email summarizer. Format: 1-3 short bullet points "
|
|
||||||
"(use '- '). Cover: main point, action items, deadlines. If the "
|
|
||||||
"email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR "
|
|
||||||
"CONTENTS - pull invoice totals, deadlines, key clauses, concrete "
|
|
||||||
"numbers/dates from PDFs/docs into the bullets. Be terse.\n\n"
|
|
||||||
"OUTPUT FORMAT: Put ONLY the bullet points between these exact "
|
|
||||||
"markers, each on its own line:\n"
|
|
||||||
"<<<SUMMARY>>>\n"
|
|
||||||
"- ...\n"
|
|
||||||
"<<<END>>>\n"
|
|
||||||
"Any reasoning must come BEFORE <<<SUMMARY>>> (ideally inside "
|
|
||||||
"<think>...</think>). Only the text between the markers is kept."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": (
|
|
||||||
f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}"
|
|
||||||
"\n\n---\n\nSummarize the email. Output the bullets between "
|
|
||||||
"<<<SUMMARY>>> and <<<END>>>."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def _generate_email_summary(
|
|
||||||
url: str,
|
|
||||||
model: str,
|
|
||||||
sender: str,
|
|
||||||
subject: str,
|
|
||||||
body_for_llm: str,
|
|
||||||
*,
|
|
||||||
headers: dict | None = None,
|
|
||||||
max_tokens: int = 8192,
|
|
||||||
timeout: int = 180,
|
|
||||||
) -> str:
|
|
||||||
"""Generate an interactive email summary through the shared LLM adapter."""
|
|
||||||
from src.llm_core import llm_call_async
|
|
||||||
|
|
||||||
raw = await llm_call_async(
|
|
||||||
url=url,
|
|
||||||
model=model,
|
|
||||||
messages=_build_email_summary_messages(sender, subject, body_for_llm),
|
|
||||||
temperature=0.3,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
headers=headers,
|
|
||||||
timeout=timeout,
|
|
||||||
workload="foreground",
|
|
||||||
)
|
|
||||||
return _normalize_email_summary(raw)
|
|
||||||
|
|
||||||
|
|
||||||
async def _generate_scheduled_email_summary(
|
|
||||||
url: str,
|
|
||||||
model: str,
|
|
||||||
sender: str,
|
|
||||||
subject: str,
|
|
||||||
body_for_llm: str,
|
|
||||||
*,
|
|
||||||
headers: dict | None = None,
|
|
||||||
owner: str | None = None,
|
|
||||||
max_tokens: int = 8192,
|
|
||||||
timeout: int = 180,
|
|
||||||
) -> str:
|
|
||||||
"""Generate a scheduled summary through the background task candidate chain."""
|
|
||||||
from src.task_endpoint import task_llm_call_async
|
|
||||||
|
|
||||||
raw = await task_llm_call_async(
|
|
||||||
messages=_build_email_summary_messages(sender, subject, body_for_llm),
|
|
||||||
fallback_url=url,
|
|
||||||
fallback_model=model,
|
|
||||||
fallback_headers=headers,
|
|
||||||
owner=owner,
|
|
||||||
temperature=0.3,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
return _normalize_email_summary(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_email_summary(raw) -> str:
|
|
||||||
"""Extract a stable cache/UI summary from provider output."""
|
|
||||||
raw_text = raw or ""
|
|
||||||
if _REPLY_OPEN_RE.search(raw_text):
|
|
||||||
summary = _extract_reply(raw_text)
|
|
||||||
if summary:
|
|
||||||
return summary
|
|
||||||
|
|
||||||
cleaned = _strip_think(raw_text).strip()
|
|
||||||
bullets = [
|
|
||||||
line.strip()
|
|
||||||
for line in cleaned.splitlines()
|
|
||||||
if _SUMMARY_BULLET_RE.match(line.strip())
|
|
||||||
]
|
|
||||||
if bullets:
|
|
||||||
return "\n".join(bullets)
|
|
||||||
return cleaned.strip()
|
|
||||||
|
|
||||||
|
|
||||||
EMAIL_SUMMARY_ERROR_CODE = "email_summary_unavailable"
|
|
||||||
EMAIL_SUMMARY_ERROR_MESSAGE = "Failed to summarize"
|
|
||||||
|
|
||||||
|
|
||||||
def _email_summary_failure_log_detail(exc: BaseException) -> str:
|
|
||||||
"""Return useful provider-failure metadata without echoing exception text."""
|
|
||||||
detail = f"type={type(exc).__name__}"
|
|
||||||
status = getattr(exc, "status_code", None)
|
|
||||||
if status is None:
|
|
||||||
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
||||||
if isinstance(status, int):
|
|
||||||
detail += f" status={status}"
|
|
||||||
return detail
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_email_style_mechanics(text: str) -> str:
|
def _apply_email_style_mechanics(text: str) -> str:
|
||||||
"""Enforce deterministic writing-style mechanics that models often miss."""
|
"""Enforce deterministic writing-style mechanics that models often miss."""
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
+9
-23
@@ -40,7 +40,6 @@ from routes.email_helpers import (
|
|||||||
_pre_retrieve_context,
|
_pre_retrieve_context,
|
||||||
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
||||||
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
||||||
_generate_scheduled_email_summary, _email_summary_failure_log_detail,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -654,7 +653,6 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
no_msgid = 0
|
no_msgid = 0
|
||||||
examined = 0
|
examined = 0
|
||||||
_summaries_created = 0
|
_summaries_created = 0
|
||||||
_summary_failed = 0
|
|
||||||
_events_created = 0
|
_events_created = 0
|
||||||
_replies_drafted = 0
|
_replies_drafted = 0
|
||||||
_reply_failed = 0
|
_reply_failed = 0
|
||||||
@@ -787,17 +785,16 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
|
|
||||||
if need_sum:
|
if need_sum:
|
||||||
try:
|
try:
|
||||||
summary = await _generate_scheduled_email_summary(
|
summary = await task_llm_call_async(
|
||||||
url=url,
|
messages=[
|
||||||
model=model,
|
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning or planning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
|
||||||
sender=sender,
|
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
|
||||||
subject=subject,
|
],
|
||||||
body_for_llm=body_for_llm,
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
headers=req_headers,
|
|
||||||
owner=account_owner or None,
|
owner=account_owner or None,
|
||||||
max_tokens=16384,
|
temperature=0.3, max_tokens=16384, timeout=240,
|
||||||
timeout=240,
|
|
||||||
)
|
)
|
||||||
|
summary = _extract_reply((summary or "").strip())
|
||||||
if summary:
|
if summary:
|
||||||
_c = _sql3.connect(SCHEDULED_DB)
|
_c = _sql3.connect(SCHEDULED_DB)
|
||||||
_c.execute("""
|
_c.execute("""
|
||||||
@@ -811,19 +808,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
_summaries_created += 1
|
_summaries_created += 1
|
||||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||||
else:
|
|
||||||
_summary_failed += 1
|
|
||||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
|
||||||
_detail_lines.append(f"summary empty · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_summary_failed += 1
|
|
||||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||||
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||||
logger.warning(
|
logger.warning(f"Auto-summary {uid} failed: {e}")
|
||||||
"Auto-summary uid=%s failed %s",
|
|
||||||
_uid_text,
|
|
||||||
_email_summary_failure_log_detail(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
if need_reply:
|
if need_reply:
|
||||||
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
|
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
|
||||||
@@ -1332,8 +1320,6 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
parts.append(f"processed {processed} new")
|
parts.append(f"processed {processed} new")
|
||||||
if auto_sum:
|
if auto_sum:
|
||||||
parts.append(f"summarized {_summaries_created}")
|
parts.append(f"summarized {_summaries_created}")
|
||||||
if _summary_failed:
|
|
||||||
parts.append(f"{_summary_failed} summary failed")
|
|
||||||
if auto_reply_draft:
|
if auto_reply_draft:
|
||||||
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
||||||
if _reply_failed:
|
if _reply_failed:
|
||||||
|
|||||||
+47
-47
@@ -57,8 +57,7 @@ from routes.email_helpers import (
|
|||||||
_extract_attachment_to_disk, _extract_html, _extract_text,
|
_extract_attachment_to_disk, _extract_html, _extract_text,
|
||||||
_fetch_sender_thread_context, _pre_retrieve_context,
|
_fetch_sender_thread_context, _pre_retrieve_context,
|
||||||
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
||||||
_friendly_email_auth_error, _email_summary_failure_log_detail,
|
_friendly_email_auth_error,
|
||||||
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
|
|
||||||
SendEmailRequest, ExtractStyleRequest,
|
SendEmailRequest, ExtractStyleRequest,
|
||||||
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
||||||
attachment_extract_dir, _email_cache_owner_clause, email_translation_body_hash,
|
attachment_extract_dir, _email_cache_owner_clause, email_translation_body_hash,
|
||||||
@@ -4767,6 +4766,8 @@ def setup_email_routes():
|
|||||||
"""Generate a quick AI summary of an email body."""
|
"""Generate a quick AI summary of an email body."""
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
from src.llm_core import _uses_max_completion_tokens, _restricts_temperature
|
||||||
|
import requests as _req
|
||||||
|
|
||||||
body = data.get("body", "")
|
body = data.get("body", "")
|
||||||
subject = data.get("subject", "")
|
subject = data.get("subject", "")
|
||||||
@@ -4777,11 +4778,7 @@ def setup_email_routes():
|
|||||||
if account_id:
|
if account_id:
|
||||||
_assert_owns_account(account_id, owner)
|
_assert_owns_account(account_id, owner)
|
||||||
if not body:
|
if not body:
|
||||||
return {
|
return {"success": False, "error": "No body provided"}
|
||||||
"success": False,
|
|
||||||
"error": "No body provided",
|
|
||||||
"error_code": "email_summary_missing_body",
|
|
||||||
}
|
|
||||||
|
|
||||||
# If we know which UID this is, fetch the raw message and pull
|
# If we know which UID this is, fetch the raw message and pull
|
||||||
# attachment text so the summary can reference invoice totals,
|
# attachment text so the summary can reference invoice totals,
|
||||||
@@ -4810,43 +4807,53 @@ def setup_email_routes():
|
|||||||
if not url:
|
if not url:
|
||||||
url, model, headers = resolve_endpoint("default", owner=owner)
|
url, model, headers = resolve_endpoint("default", owner=owner)
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
return {
|
return {"success": False, "error": "No LLM endpoint configured"}
|
||||||
"success": False,
|
|
||||||
"error": "No model configured for email summaries",
|
|
||||||
"error_code": "email_summary_not_configured",
|
|
||||||
}
|
|
||||||
|
|
||||||
req_headers = {"Content-Type": "application/json"}
|
req_headers = {"Content-Type": "application/json"}
|
||||||
if headers:
|
if headers:
|
||||||
req_headers.update(headers)
|
req_headers.update(headers)
|
||||||
try:
|
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||||
content = await _generate_email_summary(
|
payload = {
|
||||||
url=url,
|
"model": model,
|
||||||
model=model,
|
"messages": [
|
||||||
sender=sender,
|
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull invoice totals, deadlines, key clauses, concrete numbers/dates from PDFs/docs into the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
|
||||||
subject=subject,
|
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
|
||||||
body_for_llm=body_for_llm,
|
],
|
||||||
headers=req_headers,
|
tok_key: 8192,
|
||||||
max_tokens=8192,
|
"temperature": 0.3,
|
||||||
timeout=180,
|
"stream": False,
|
||||||
)
|
}
|
||||||
except Exception as e:
|
# Reasoning models (o1/o3/o4/gpt-5) reject an explicit temperature.
|
||||||
logger.warning(
|
if _restricts_temperature(model):
|
||||||
"Email summary LLM call failed %s",
|
payload.pop("temperature", None)
|
||||||
_email_summary_failure_log_detail(e),
|
resp = await asyncio.to_thread(
|
||||||
)
|
_req.post, url, json=payload, headers=req_headers, timeout=180
|
||||||
return {
|
)
|
||||||
"success": False,
|
if not resp.ok:
|
||||||
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
|
return {"success": False, "error": f"LLM HTTP {resp.status_code}"}
|
||||||
"error_code": EMAIL_SUMMARY_ERROR_CODE,
|
rdata = resp.json()
|
||||||
}
|
msg = (rdata.get("choices") or [{}])[0].get("message", {})
|
||||||
|
content = (msg.get("content") or "").strip()
|
||||||
|
content = _extract_reply(content)
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
return {
|
# Model put everything in reasoning_content — extract bullet points
|
||||||
"success": False,
|
rc = (msg.get("reasoning_content") or "").strip()
|
||||||
"error": "The model returned an empty summary",
|
# Find bullet-point style output (lines starting with -, •, *, or numbered)
|
||||||
"error_code": "email_summary_empty",
|
bullet_lines = []
|
||||||
}
|
for line in rc.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if re.match(r"^[-•*]\s+|^\d+[.)]\s+", stripped):
|
||||||
|
bullet_lines.append(stripped)
|
||||||
|
if bullet_lines:
|
||||||
|
content = "\n".join(bullet_lines)
|
||||||
|
else:
|
||||||
|
# Last resort: take the last paragraph
|
||||||
|
paragraphs = [p.strip() for p in rc.split("\n\n") if p.strip()]
|
||||||
|
content = paragraphs[-1] if paragraphs else rc[:500]
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return {"success": False, "error": "Empty response from model"}
|
||||||
|
|
||||||
# Cache the summary if we have a message_id
|
# Cache the summary if we have a message_id
|
||||||
mid = data.get("message_id", "")
|
mid = data.get("message_id", "")
|
||||||
@@ -4869,15 +4876,8 @@ def setup_email_routes():
|
|||||||
|
|
||||||
return {"success": True, "summary": content, "model_used": model}
|
return {"success": True, "summary": content, "model_used": model}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(f"Failed to summarize: {e}")
|
||||||
"Email summary route failed %s",
|
return {"success": False, "error": "Mail operation failed"}
|
||||||
_email_summary_failure_log_detail(e),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
|
|
||||||
"error_code": EMAIL_SUMMARY_ERROR_CODE,
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/translate")
|
@router.post("/translate")
|
||||||
async def translate_email(data: dict, owner: str = Depends(require_owner)):
|
async def translate_email(data: dict, owner: str = Depends(require_owner)):
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def _strip_list_prefix(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
||||||
|
|
||||||
from services.memory import MemoryManager, MemoryStoreUnreadable
|
from services.memory import MemoryManager
|
||||||
from core.session_manager import SessionManager
|
from core.session_manager import SessionManager
|
||||||
from src.request_models import MemoryAddRequest
|
from src.request_models import MemoryAddRequest
|
||||||
from core.database import SessionLocal
|
from core.database import SessionLocal
|
||||||
@@ -35,22 +35,6 @@ from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _load_for_update(memory_manager) -> List[Dict[str, Any]]:
|
|
||||||
"""Load the whole store for a read-modify-write cycle.
|
|
||||||
|
|
||||||
A transient read failure must not look like an empty store: the caller
|
|
||||||
would append to ``[]`` and save that back, atomically destroying every
|
|
||||||
existing memory (issue #5673). Surface it as a 503 and change nothing.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return memory_manager.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
logger.error("Refusing to rewrite the memory store: %s", e)
|
|
||||||
raise HTTPException(
|
|
||||||
503, "Memory store is temporarily unreadable — no changes were made."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
||||||
"""Set up memory-related routes."""
|
"""Set up memory-related routes."""
|
||||||
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
||||||
@@ -132,7 +116,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
|
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
|
||||||
if memory_data.session_id:
|
if memory_data.session_id:
|
||||||
new_entry["session_id"] = memory_data.session_id
|
new_entry["session_id"] = memory_data.session_id
|
||||||
all_mem = _load_for_update(memory_manager)
|
all_mem = memory_manager.load_all()
|
||||||
all_mem.append(new_entry)
|
all_mem.append(new_entry)
|
||||||
memory_manager.save(all_mem)
|
memory_manager.save(all_mem)
|
||||||
# Sync vector index
|
# Sync vector index
|
||||||
@@ -503,7 +487,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
|
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
|
||||||
"""Pin or unpin a memory. Pinned memories are always included in context."""
|
"""Pin or unpin a memory. Pinned memories are always included in context."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
all_mem = _load_for_update(memory_manager)
|
all_mem = memory_manager.load_all()
|
||||||
for i, memory in enumerate(all_mem):
|
for i, memory in enumerate(all_mem):
|
||||||
if memory["id"] == memory_id:
|
if memory["id"] == memory_id:
|
||||||
_verify_memory_owner(memory, user)
|
_verify_memory_owner(memory, user)
|
||||||
@@ -528,7 +512,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)):
|
def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)):
|
||||||
"""Update an existing memory item with new text and optional category."""
|
"""Update an existing memory item with new text and optional category."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
all_mem = _load_for_update(memory_manager)
|
all_mem = memory_manager.load_all()
|
||||||
for i, memory in enumerate(all_mem):
|
for i, memory in enumerate(all_mem):
|
||||||
if memory["id"] == memory_id:
|
if memory["id"] == memory_id:
|
||||||
_verify_memory_owner(memory, user)
|
_verify_memory_owner(memory, user)
|
||||||
@@ -550,7 +534,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
def delete_memory(request: Request, memory_id: str):
|
def delete_memory(request: Request, memory_id: str):
|
||||||
"""Delete a memory item by its ID."""
|
"""Delete a memory item by its ID."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
all_mem = _load_for_update(memory_manager)
|
all_mem = memory_manager.load_all()
|
||||||
|
|
||||||
# Find and verify ownership before deleting
|
# Find and verify ownership before deleting
|
||||||
target = next((m for m in all_mem if m["id"] == memory_id), None)
|
target = next((m for m in all_mem if m["id"] == memory_id), None)
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Search route domain package (slice 2j, #4082/#4071).
|
|
||||||
|
|
||||||
Contains search_routes.py, migrated from the flat routes/ directory.
|
|
||||||
Backward-compat shim at routes/search_routes.py re-exports from here.
|
|
||||||
"""
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""Search routes — /api/search/config GET, /api/search POST."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
|
|
||||||
from services.search.core import _call_provider
|
|
||||||
from services.search.providers import _get_provider_key, _get_search_instance
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def _request_values(request: Request) -> Dict[str, Any]:
|
|
||||||
"""Accept JSON, form data, or query params for search endpoints.
|
|
||||||
|
|
||||||
The browser UI posts FormData, while the agent's generic app_api tool
|
|
||||||
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
|
|
||||||
runs, which made the model think SearXNG was broken.
|
|
||||||
"""
|
|
||||||
values: Dict[str, Any] = dict(request.query_params)
|
|
||||||
content_type = (request.headers.get("content-type") or "").lower()
|
|
||||||
try:
|
|
||||||
if "application/json" in content_type:
|
|
||||||
body = await request.json()
|
|
||||||
if isinstance(body, dict):
|
|
||||||
values.update(body)
|
|
||||||
else:
|
|
||||||
form = await request.form()
|
|
||||||
values.update(dict(form))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def setup_search_routes(config) -> APIRouter:
|
|
||||||
router = APIRouter(tags=["search"])
|
|
||||||
|
|
||||||
@router.get("/api/search/config")
|
|
||||||
async def get_search_settings() -> Dict[str, Any]:
|
|
||||||
return get_search_config()
|
|
||||||
|
|
||||||
@router.post("/api/search")
|
|
||||||
async def do_web_search(request: Request) -> Dict[str, Any]:
|
|
||||||
"""Standalone web search — returns context string + source list.
|
|
||||||
|
|
||||||
Used by Compare mode to pre-search once and share results across panes.
|
|
||||||
"""
|
|
||||||
values = await _request_values(request)
|
|
||||||
query = str(values.get("query") or values.get("q") or "").strip()
|
|
||||||
if not query:
|
|
||||||
return {"context": "", "sources": [], "error": "query is required"}
|
|
||||||
time_filter = values.get("time_filter") or values.get("freshness")
|
|
||||||
if time_filter is not None:
|
|
||||||
time_filter = str(time_filter).strip() or None
|
|
||||||
try:
|
|
||||||
context, sources = comprehensive_web_search(
|
|
||||||
query, return_sources=True, time_filter=time_filter,
|
|
||||||
)
|
|
||||||
return {"context": context, "sources": sources}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Standalone web search failed: {e}")
|
|
||||||
return {"context": "", "sources": [], "error": str(e)}
|
|
||||||
|
|
||||||
@router.get("/api/search/providers")
|
|
||||||
async def list_search_providers():
|
|
||||||
"""Return available search providers with config status."""
|
|
||||||
providers = []
|
|
||||||
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
|
|
||||||
if pid == "disabled":
|
|
||||||
continue
|
|
||||||
available = True
|
|
||||||
if needs_key and not _get_provider_key(pid):
|
|
||||||
available = False
|
|
||||||
if needs_url and pid == "searxng" and not _get_search_instance():
|
|
||||||
available = False
|
|
||||||
providers.append({
|
|
||||||
"id": pid,
|
|
||||||
"label": label,
|
|
||||||
"available": available,
|
|
||||||
})
|
|
||||||
return providers
|
|
||||||
|
|
||||||
@router.post("/api/search/query")
|
|
||||||
async def search_with_provider(request: Request) -> Dict[str, Any]:
|
|
||||||
"""Search using a specific provider. Used by compare search mode."""
|
|
||||||
values = await _request_values(request)
|
|
||||||
query = str(values.get("query") or values.get("q") or "").strip()
|
|
||||||
provider = str(values.get("provider") or "").strip()
|
|
||||||
try:
|
|
||||||
count = int(values.get("count") or values.get("limit") or 10)
|
|
||||||
except Exception:
|
|
||||||
count = 10
|
|
||||||
if not query:
|
|
||||||
return {"results": [], "provider": provider, "error": "query is required"}
|
|
||||||
if provider not in PROVIDER_INFO or provider == "disabled":
|
|
||||||
return {"results": [], "provider": provider, "error": "Unknown provider"}
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
results = _call_provider(provider, query, min(count, 20))
|
|
||||||
elapsed = round(time.time() - t0, 2)
|
|
||||||
return {"results": results, "provider": provider, "time": elapsed}
|
|
||||||
except Exception as e:
|
|
||||||
elapsed = round(time.time() - t0, 2)
|
|
||||||
logger.error(f"Search provider {provider} failed: {e}")
|
|
||||||
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
|
|
||||||
|
|
||||||
return router
|
|
||||||
+107
-9
@@ -1,13 +1,111 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/search/search_routes.py.
|
"""Search routes — /api/search/config GET, /api/search POST."""
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
import logging
|
||||||
that ``import routes.search_routes`` and ``from routes.search_routes import X``
|
from typing import Dict, Any
|
||||||
keep resolving to the canonical module. Keeps existing import paths working
|
|
||||||
after slice 2j (#4082/#4071).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys as _sys
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
from routes.search import search_routes as _canonical # noqa: F401
|
import time
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
|
||||||
|
from services.search.core import _call_provider
|
||||||
|
from services.search.providers import _get_provider_key, _get_search_instance
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_values(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Accept JSON, form data, or query params for search endpoints.
|
||||||
|
|
||||||
|
The browser UI posts FormData, while the agent's generic app_api tool
|
||||||
|
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
|
||||||
|
runs, which made the model think SearXNG was broken.
|
||||||
|
"""
|
||||||
|
values: Dict[str, Any] = dict(request.query_params)
|
||||||
|
content_type = (request.headers.get("content-type") or "").lower()
|
||||||
|
try:
|
||||||
|
if "application/json" in content_type:
|
||||||
|
body = await request.json()
|
||||||
|
if isinstance(body, dict):
|
||||||
|
values.update(body)
|
||||||
|
else:
|
||||||
|
form = await request.form()
|
||||||
|
values.update(dict(form))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def setup_search_routes(config) -> APIRouter:
|
||||||
|
router = APIRouter(tags=["search"])
|
||||||
|
|
||||||
|
@router.get("/api/search/config")
|
||||||
|
async def get_search_settings() -> Dict[str, Any]:
|
||||||
|
return get_search_config()
|
||||||
|
|
||||||
|
@router.post("/api/search")
|
||||||
|
async def do_web_search(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Standalone web search — returns context string + source list.
|
||||||
|
|
||||||
|
Used by Compare mode to pre-search once and share results across panes.
|
||||||
|
"""
|
||||||
|
values = await _request_values(request)
|
||||||
|
query = str(values.get("query") or values.get("q") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return {"context": "", "sources": [], "error": "query is required"}
|
||||||
|
time_filter = values.get("time_filter") or values.get("freshness")
|
||||||
|
if time_filter is not None:
|
||||||
|
time_filter = str(time_filter).strip() or None
|
||||||
|
try:
|
||||||
|
context, sources = comprehensive_web_search(
|
||||||
|
query, return_sources=True, time_filter=time_filter,
|
||||||
|
)
|
||||||
|
return {"context": context, "sources": sources}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Standalone web search failed: {e}")
|
||||||
|
return {"context": "", "sources": [], "error": str(e)}
|
||||||
|
|
||||||
|
@router.get("/api/search/providers")
|
||||||
|
async def list_search_providers():
|
||||||
|
"""Return available search providers with config status."""
|
||||||
|
providers = []
|
||||||
|
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
|
||||||
|
if pid == "disabled":
|
||||||
|
continue
|
||||||
|
available = True
|
||||||
|
if needs_key and not _get_provider_key(pid):
|
||||||
|
available = False
|
||||||
|
if needs_url and pid == "searxng" and not _get_search_instance():
|
||||||
|
available = False
|
||||||
|
providers.append({
|
||||||
|
"id": pid,
|
||||||
|
"label": label,
|
||||||
|
"available": available,
|
||||||
|
})
|
||||||
|
return providers
|
||||||
|
|
||||||
|
@router.post("/api/search/query")
|
||||||
|
async def search_with_provider(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Search using a specific provider. Used by compare search mode."""
|
||||||
|
values = await _request_values(request)
|
||||||
|
query = str(values.get("query") or values.get("q") or "").strip()
|
||||||
|
provider = str(values.get("provider") or "").strip()
|
||||||
|
try:
|
||||||
|
count = int(values.get("count") or values.get("limit") or 10)
|
||||||
|
except Exception:
|
||||||
|
count = 10
|
||||||
|
if not query:
|
||||||
|
return {"results": [], "provider": provider, "error": "query is required"}
|
||||||
|
if provider not in PROVIDER_INFO or provider == "disabled":
|
||||||
|
return {"results": [], "provider": provider, "error": "Unknown provider"}
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
results = _call_provider(provider, query, min(count, 20))
|
||||||
|
elapsed = round(time.time() - t0, 2)
|
||||||
|
return {"results": results, "provider": provider, "time": elapsed}
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = round(time.time() - t0, 2)
|
||||||
|
logger.error(f"Search provider {provider} failed: {e}")
|
||||||
|
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|||||||
@@ -1409,7 +1409,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
|||||||
|
|
||||||
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
|
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
|
||||||
# session's model. Fall back to the caller's session model only if unset.
|
# session's model. Fall back to the caller's session model only if unset.
|
||||||
url, model, headers = resolve_endpoint("utility", owner=user)
|
url, model, headers = resolve_endpoint("default", owner=user)
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
||||||
model = model or ((body.get("model") or "").strip() or None)
|
model = model or ((body.get("model") or "").strip() or None)
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Vault route domain package (slice 2k, #4082/#4071).
|
|
||||||
|
|
||||||
Contains vault_routes.py, migrated from the flat routes/ directory.
|
|
||||||
Backward-compat shim at routes/vault_routes.py re-exports from here.
|
|
||||||
"""
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"""
|
|
||||||
vault_routes.py
|
|
||||||
|
|
||||||
Vaultwarden / Bitwarden CLI integration — config and unlock endpoints.
|
|
||||||
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from core.middleware import require_admin
|
|
||||||
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
|
||||||
from src.constants import VAULT_FILE as _VAULT_FILE
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
VAULT_FILE = Path(_VAULT_FILE)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_bw() -> str:
|
|
||||||
"""Locate the bw binary, checking PATH and common npm-global locations.
|
|
||||||
|
|
||||||
On Windows the Bitwarden CLI shim is `bw.cmd`/`bw.exe`, resolved by
|
|
||||||
which_tool via PATHEXT.
|
|
||||||
"""
|
|
||||||
p = which_tool("bw")
|
|
||||||
if p:
|
|
||||||
return p
|
|
||||||
if IS_WINDOWS:
|
|
||||||
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
|
|
||||||
for candidate in (
|
|
||||||
os.path.join(appdata, "npm", "bw.cmd"),
|
|
||||||
os.path.join(appdata, "npm", "bw.exe"),
|
|
||||||
):
|
|
||||||
if os.path.isfile(candidate):
|
|
||||||
return candidate
|
|
||||||
return "bw"
|
|
||||||
home = os.path.expanduser("~")
|
|
||||||
for candidate in (
|
|
||||||
f"{home}/.npm-global/bin/bw",
|
|
||||||
f"{home}/.nvm/versions/node/*/bin/bw",
|
|
||||||
"/usr/local/bin/bw",
|
|
||||||
"/opt/homebrew/bin/bw",
|
|
||||||
):
|
|
||||||
if "*" in candidate:
|
|
||||||
import glob
|
|
||||||
for m in glob.glob(candidate):
|
|
||||||
if os.path.isfile(m) and os.access(m, os.X_OK):
|
|
||||||
return m
|
|
||||||
elif os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
||||||
return candidate
|
|
||||||
return "bw" # fall back to PATH lookup (will FileNotFoundError, handled below)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_config() -> dict:
|
|
||||||
if VAULT_FILE.exists():
|
|
||||||
try:
|
|
||||||
data = json.loads(VAULT_FILE.read_text(encoding="utf-8"))
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _save_config(cfg: dict):
|
|
||||||
VAULT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
VAULT_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
||||||
# POSIX: restrict the BW_SESSION store to 0o600. Windows: no-op (profile dir
|
|
||||||
# is ACL-restricted already).
|
|
||||||
safe_chmod(str(VAULT_FILE), 0o600)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_bw(args: list, session: str = None, input_text: str = None,
|
|
||||||
bw_password: str = None) -> tuple:
|
|
||||||
env = {}
|
|
||||||
env.update(os.environ)
|
|
||||||
if session:
|
|
||||||
env["BW_SESSION"] = session
|
|
||||||
# Secrets must never be passed as argv — process arguments are world-readable
|
|
||||||
# via `ps` / `/proc/<pid>/cmdline` to any local user. Keep --passwordenv
|
|
||||||
# support for bw commands that need it; unlock/login callers should prefer
|
|
||||||
# stdin so the master password is not left in the child environment either.
|
|
||||||
if bw_password is not None:
|
|
||||||
env["BW_PASSWORD"] = bw_password
|
|
||||||
bw_path = _find_bw()
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
bw_path, *args,
|
|
||||||
stdin=asyncio.subprocess.PIPE if input_text else None,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "", "bw CLI not installed (install `nodejs-bitwarden-cli` or `bitwarden-cli`)", 127
|
|
||||||
except Exception as e:
|
|
||||||
return "", f"Failed to launch bw: {e}", 1
|
|
||||||
try:
|
|
||||||
stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
|
|
||||||
except Exception as e:
|
|
||||||
return "", f"bw subprocess error: {e}", 1
|
|
||||||
return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
|
|
||||||
|
|
||||||
|
|
||||||
class VaultConfig(BaseModel):
|
|
||||||
server_url: str = ""
|
|
||||||
email: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class VaultUnlockRequest(BaseModel):
|
|
||||||
master_password: str
|
|
||||||
|
|
||||||
|
|
||||||
class VaultLoginRequest(BaseModel):
|
|
||||||
email: str
|
|
||||||
master_password: str
|
|
||||||
|
|
||||||
|
|
||||||
def setup_vault_routes():
|
|
||||||
router = APIRouter(prefix="/api/vault", tags=["vault"])
|
|
||||||
|
|
||||||
@router.get("/config")
|
|
||||||
async def get_config(request: Request):
|
|
||||||
"""Return vault config (no sensitive fields)."""
|
|
||||||
require_admin(request)
|
|
||||||
cfg = _load_config()
|
|
||||||
return {
|
|
||||||
"server_url": cfg.get("server_url", ""),
|
|
||||||
"email": cfg.get("email", ""),
|
|
||||||
"unlocked": bool(cfg.get("session")),
|
|
||||||
"unlocked_at": cfg.get("unlocked_at", ""),
|
|
||||||
"bw_installed": await _check_bw_installed(),
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/config")
|
|
||||||
async def save_config(req: VaultConfig, request: Request):
|
|
||||||
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
|
|
||||||
require_admin(request)
|
|
||||||
cfg = _load_config()
|
|
||||||
cfg["server_url"] = req.server_url.strip().rstrip("/")
|
|
||||||
cfg["email"] = req.email.strip()
|
|
||||||
|
|
||||||
if cfg["server_url"]:
|
|
||||||
_, stderr, rc = await _run_bw(["config", "server", cfg["server_url"]])
|
|
||||||
if rc != 0:
|
|
||||||
return {"ok": False, "error": f"bw config failed: {stderr[:300]}"}
|
|
||||||
|
|
||||||
_save_config(cfg)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
async def login(req: VaultLoginRequest, request: Request):
|
|
||||||
"""Log in to Vaultwarden (required once per account)."""
|
|
||||||
require_admin(request)
|
|
||||||
cfg = _load_config()
|
|
||||||
# Update email
|
|
||||||
cfg["email"] = req.email
|
|
||||||
_save_config(cfg)
|
|
||||||
|
|
||||||
stdout, stderr, rc = await _run_bw(
|
|
||||||
["login", req.email, "--raw"],
|
|
||||||
input_text=req.master_password + "\n",
|
|
||||||
)
|
|
||||||
if rc != 0:
|
|
||||||
# Already logged in is OK
|
|
||||||
if "already logged in" in stderr.lower():
|
|
||||||
return {"ok": True, "already": True}
|
|
||||||
return {"ok": False, "error": f"Login failed: {stderr[:300]}"}
|
|
||||||
# bw login --raw prints session key on success (when 2FA disabled)
|
|
||||||
if stdout:
|
|
||||||
cfg["session"] = stdout
|
|
||||||
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
|
||||||
_save_config(cfg)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@router.post("/unlock")
|
|
||||||
async def unlock(req: VaultUnlockRequest, request: Request):
|
|
||||||
"""Unlock the vault and save the session key."""
|
|
||||||
require_admin(request)
|
|
||||||
# Pass the master password on stdin, not argv. argv is visible through
|
|
||||||
# `ps` / /proc/<pid>/cmdline; stdin also avoids leaving the secret in
|
|
||||||
# the child process environment.
|
|
||||||
stdout, stderr, rc = await _run_bw(
|
|
||||||
["unlock", "--raw"],
|
|
||||||
input_text=req.master_password + "\n",
|
|
||||||
)
|
|
||||||
if rc != 0:
|
|
||||||
return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
|
|
||||||
session = stdout.strip()
|
|
||||||
if not session:
|
|
||||||
return {"ok": False, "error": "bw returned empty session"}
|
|
||||||
cfg = _load_config()
|
|
||||||
cfg["session"] = session
|
|
||||||
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
|
||||||
_save_config(cfg)
|
|
||||||
return {"ok": True, "message": "Vault unlocked"}
|
|
||||||
|
|
||||||
@router.post("/lock")
|
|
||||||
async def lock(request: Request):
|
|
||||||
"""Lock the vault (clear session from config)."""
|
|
||||||
require_admin(request)
|
|
||||||
cfg = _load_config()
|
|
||||||
cfg.pop("session", None)
|
|
||||||
cfg.pop("unlocked_at", None)
|
|
||||||
_save_config(cfg)
|
|
||||||
# Also tell bw to lock
|
|
||||||
await _run_bw(["lock"])
|
|
||||||
return {"ok": True, "message": "Vault locked"}
|
|
||||||
|
|
||||||
@router.post("/logout")
|
|
||||||
async def logout(request: Request):
|
|
||||||
"""Log out of the Bitwarden CLI completely."""
|
|
||||||
require_admin(request)
|
|
||||||
await _run_bw(["logout"])
|
|
||||||
cfg = _load_config()
|
|
||||||
cfg.pop("session", None)
|
|
||||||
cfg.pop("email", None)
|
|
||||||
cfg.pop("unlocked_at", None)
|
|
||||||
_save_config(cfg)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_bw_installed() -> bool:
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
_find_bw(), "--version",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
await proc.communicate()
|
|
||||||
return proc.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
+237
-9
@@ -1,14 +1,242 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/vault/vault_routes.py.
|
"""
|
||||||
|
vault_routes.py
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
Vaultwarden / Bitwarden CLI integration — config and unlock endpoints.
|
||||||
that ``import routes.vault_routes``, ``from routes.vault_routes import X``,
|
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
|
||||||
and the ``import ... as vr`` + ``monkeypatch.setattr(vr, ...)`` pattern used
|
|
||||||
by test_vault_password_not_in_argv.py all operate on the *same* object.
|
|
||||||
Keeps existing import paths working after slice 2k (#4082/#4071).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys as _sys
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from routes.vault import vault_routes as _canonical # noqa: F401
|
from core.middleware import require_admin
|
||||||
|
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
||||||
|
from src.constants import VAULT_FILE as _VAULT_FILE
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VAULT_FILE = Path(_VAULT_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_bw() -> str:
|
||||||
|
"""Locate the bw binary, checking PATH and common npm-global locations.
|
||||||
|
|
||||||
|
On Windows the Bitwarden CLI shim is `bw.cmd`/`bw.exe`, resolved by
|
||||||
|
which_tool via PATHEXT.
|
||||||
|
"""
|
||||||
|
p = which_tool("bw")
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
if IS_WINDOWS:
|
||||||
|
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
|
||||||
|
for candidate in (
|
||||||
|
os.path.join(appdata, "npm", "bw.cmd"),
|
||||||
|
os.path.join(appdata, "npm", "bw.exe"),
|
||||||
|
):
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
return "bw"
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
for candidate in (
|
||||||
|
f"{home}/.npm-global/bin/bw",
|
||||||
|
f"{home}/.nvm/versions/node/*/bin/bw",
|
||||||
|
"/usr/local/bin/bw",
|
||||||
|
"/opt/homebrew/bin/bw",
|
||||||
|
):
|
||||||
|
if "*" in candidate:
|
||||||
|
import glob
|
||||||
|
for m in glob.glob(candidate):
|
||||||
|
if os.path.isfile(m) and os.access(m, os.X_OK):
|
||||||
|
return m
|
||||||
|
elif os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||||
|
return candidate
|
||||||
|
return "bw" # fall back to PATH lookup (will FileNotFoundError, handled below)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> dict:
|
||||||
|
if VAULT_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(VAULT_FILE.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_config(cfg: dict):
|
||||||
|
VAULT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
VAULT_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||||
|
# POSIX: restrict the BW_SESSION store to 0o600. Windows: no-op (profile dir
|
||||||
|
# is ACL-restricted already).
|
||||||
|
safe_chmod(str(VAULT_FILE), 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_bw(args: list, session: str = None, input_text: str = None,
|
||||||
|
bw_password: str = None) -> tuple:
|
||||||
|
env = {}
|
||||||
|
env.update(os.environ)
|
||||||
|
if session:
|
||||||
|
env["BW_SESSION"] = session
|
||||||
|
# Secrets must never be passed as argv — process arguments are world-readable
|
||||||
|
# via `ps` / `/proc/<pid>/cmdline` to any local user. Keep --passwordenv
|
||||||
|
# support for bw commands that need it; unlock/login callers should prefer
|
||||||
|
# stdin so the master password is not left in the child environment either.
|
||||||
|
if bw_password is not None:
|
||||||
|
env["BW_PASSWORD"] = bw_password
|
||||||
|
bw_path = _find_bw()
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
bw_path, *args,
|
||||||
|
stdin=asyncio.subprocess.PIPE if input_text else None,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "", "bw CLI not installed (install `nodejs-bitwarden-cli` or `bitwarden-cli`)", 127
|
||||||
|
except Exception as e:
|
||||||
|
return "", f"Failed to launch bw: {e}", 1
|
||||||
|
try:
|
||||||
|
stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
|
||||||
|
except Exception as e:
|
||||||
|
return "", f"bw subprocess error: {e}", 1
|
||||||
|
return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
|
||||||
|
|
||||||
|
|
||||||
|
class VaultConfig(BaseModel):
|
||||||
|
server_url: str = ""
|
||||||
|
email: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class VaultUnlockRequest(BaseModel):
|
||||||
|
master_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class VaultLoginRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
master_password: str
|
||||||
|
|
||||||
|
|
||||||
|
def setup_vault_routes():
|
||||||
|
router = APIRouter(prefix="/api/vault", tags=["vault"])
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_config(request: Request):
|
||||||
|
"""Return vault config (no sensitive fields)."""
|
||||||
|
require_admin(request)
|
||||||
|
cfg = _load_config()
|
||||||
|
return {
|
||||||
|
"server_url": cfg.get("server_url", ""),
|
||||||
|
"email": cfg.get("email", ""),
|
||||||
|
"unlocked": bool(cfg.get("session")),
|
||||||
|
"unlocked_at": cfg.get("unlocked_at", ""),
|
||||||
|
"bw_installed": await _check_bw_installed(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/config")
|
||||||
|
async def save_config(req: VaultConfig, request: Request):
|
||||||
|
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
|
||||||
|
require_admin(request)
|
||||||
|
cfg = _load_config()
|
||||||
|
cfg["server_url"] = req.server_url.strip().rstrip("/")
|
||||||
|
cfg["email"] = req.email.strip()
|
||||||
|
|
||||||
|
if cfg["server_url"]:
|
||||||
|
_, stderr, rc = await _run_bw(["config", "server", cfg["server_url"]])
|
||||||
|
if rc != 0:
|
||||||
|
return {"ok": False, "error": f"bw config failed: {stderr[:300]}"}
|
||||||
|
|
||||||
|
_save_config(cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(req: VaultLoginRequest, request: Request):
|
||||||
|
"""Log in to Vaultwarden (required once per account)."""
|
||||||
|
require_admin(request)
|
||||||
|
cfg = _load_config()
|
||||||
|
# Update email
|
||||||
|
cfg["email"] = req.email
|
||||||
|
_save_config(cfg)
|
||||||
|
|
||||||
|
stdout, stderr, rc = await _run_bw(
|
||||||
|
["login", req.email, "--raw"],
|
||||||
|
input_text=req.master_password + "\n",
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
# Already logged in is OK
|
||||||
|
if "already logged in" in stderr.lower():
|
||||||
|
return {"ok": True, "already": True}
|
||||||
|
return {"ok": False, "error": f"Login failed: {stderr[:300]}"}
|
||||||
|
# bw login --raw prints session key on success (when 2FA disabled)
|
||||||
|
if stdout:
|
||||||
|
cfg["session"] = stdout
|
||||||
|
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
||||||
|
_save_config(cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.post("/unlock")
|
||||||
|
async def unlock(req: VaultUnlockRequest, request: Request):
|
||||||
|
"""Unlock the vault and save the session key."""
|
||||||
|
require_admin(request)
|
||||||
|
# Pass the master password on stdin, not argv. argv is visible through
|
||||||
|
# `ps` / /proc/<pid>/cmdline; stdin also avoids leaving the secret in
|
||||||
|
# the child process environment.
|
||||||
|
stdout, stderr, rc = await _run_bw(
|
||||||
|
["unlock", "--raw"],
|
||||||
|
input_text=req.master_password + "\n",
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
|
||||||
|
session = stdout.strip()
|
||||||
|
if not session:
|
||||||
|
return {"ok": False, "error": "bw returned empty session"}
|
||||||
|
cfg = _load_config()
|
||||||
|
cfg["session"] = session
|
||||||
|
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
||||||
|
_save_config(cfg)
|
||||||
|
return {"ok": True, "message": "Vault unlocked"}
|
||||||
|
|
||||||
|
@router.post("/lock")
|
||||||
|
async def lock(request: Request):
|
||||||
|
"""Lock the vault (clear session from config)."""
|
||||||
|
require_admin(request)
|
||||||
|
cfg = _load_config()
|
||||||
|
cfg.pop("session", None)
|
||||||
|
cfg.pop("unlocked_at", None)
|
||||||
|
_save_config(cfg)
|
||||||
|
# Also tell bw to lock
|
||||||
|
await _run_bw(["lock"])
|
||||||
|
return {"ok": True, "message": "Vault locked"}
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Log out of the Bitwarden CLI completely."""
|
||||||
|
require_admin(request)
|
||||||
|
await _run_bw(["logout"])
|
||||||
|
cfg = _load_config()
|
||||||
|
cfg.pop("session", None)
|
||||||
|
cfg.pop("email", None)
|
||||||
|
cfg.pop("unlocked_at", None)
|
||||||
|
_save_config(cfg)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_bw_installed() -> bool:
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
_find_bw(), "--version",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
await proc.communicate()
|
||||||
|
return proc.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Webhook route domain package (slice 2l, #4082/#4071).
|
|
||||||
|
|
||||||
Contains webhook_routes.py, migrated from the flat routes/ directory.
|
|
||||||
Backward-compat shim at routes/webhook_routes.py re-exports from here.
|
|
||||||
"""
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
"""Webhook, API Token, and sync chat routes."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Form
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from core.database import SessionLocal, Webhook, ModelEndpoint
|
|
||||||
from src.auth_helpers import owner_filter
|
|
||||||
from src.url_security import validate_public_http_url
|
|
||||||
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["webhooks"])
|
|
||||||
|
|
||||||
# Input limits
|
|
||||||
MAX_NAME_LEN = 100
|
|
||||||
MAX_URL_LEN = 2048
|
|
||||||
MAX_SECRET_LEN = 256
|
|
||||||
MAX_MESSAGE_LEN = 32_000
|
|
||||||
|
|
||||||
|
|
||||||
from core.middleware import require_admin as _require_admin
|
|
||||||
|
|
||||||
|
|
||||||
def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
|
|
||||||
"""First enabled ModelEndpoint visible to token_owner — their own rows plus
|
|
||||||
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
|
||||||
let a chat-scoped token fall back onto another user's private endpoint and
|
|
||||||
silently spend that owner's API key/quota. Prefer owner rows before shared
|
|
||||||
rows. Fails closed to null-owner rows only when token_owner is absent.
|
|
||||||
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
|
||||||
"""
|
|
||||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
|
||||||
if token_owner:
|
|
||||||
query = owner_filter(query, ModelEndpoint, token_owner)
|
|
||||||
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
|
||||||
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
|
|
||||||
|
|
||||||
|
|
||||||
def _caller_owns_session(sess_owner, caller) -> bool:
|
|
||||||
"""Strict session-ownership gate for the token-authenticated sync-chat
|
|
||||||
endpoint (`POST /api/v1/chat`).
|
|
||||||
|
|
||||||
Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
|
|
||||||
gates in notes/calendar/gallery: a caller may resume a session ONLY when
|
|
||||||
its owner matches them exactly. A null/empty session owner (legacy or
|
|
||||||
migrated rows) is deliberately NOT resumable by an arbitrary token — the
|
|
||||||
old ``sess_owner and sess_owner != caller`` form skipped the check whenever
|
|
||||||
``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
|
|
||||||
device) could resume such a session, inject a message, and read back its
|
|
||||||
history and reuse the owner's endpoint credentials. Fail closed: an
|
|
||||||
unresolvable caller also returns False.
|
|
||||||
"""
|
|
||||||
if not caller:
|
|
||||||
return False
|
|
||||||
return sess_owner == caller
|
|
||||||
|
|
||||||
|
|
||||||
def setup_webhook_routes(
|
|
||||||
webhook_manager: WebhookManager,
|
|
||||||
auth_manager,
|
|
||||||
session_manager=None,
|
|
||||||
api_key_manager=None,
|
|
||||||
) -> APIRouter:
|
|
||||||
|
|
||||||
@router.get("/webhooks")
|
|
||||||
def list_webhooks(request: Request):
|
|
||||||
_require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
hooks = db.query(Webhook).all()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": w.id,
|
|
||||||
"name": w.name,
|
|
||||||
"url": w.url,
|
|
||||||
"has_secret": bool(w.secret),
|
|
||||||
"events": w.events.split(",") if w.events else [],
|
|
||||||
"is_active": w.is_active,
|
|
||||||
"last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
|
|
||||||
"last_status_code": w.last_status_code,
|
|
||||||
"last_error": w.last_error,
|
|
||||||
"created_at": w.created_at.isoformat() if w.created_at else None,
|
|
||||||
}
|
|
||||||
for w in hooks
|
|
||||||
]
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.post("/webhooks")
|
|
||||||
def create_webhook(
|
|
||||||
request: Request,
|
|
||||||
name: str = Form(""),
|
|
||||||
url: str = Form(""),
|
|
||||||
secret: str = Form(""),
|
|
||||||
events: str = Form(""),
|
|
||||||
):
|
|
||||||
_require_admin(request)
|
|
||||||
name = name.strip()[:MAX_NAME_LEN]
|
|
||||||
if not name:
|
|
||||||
raise HTTPException(400, "Webhook name is required")
|
|
||||||
try:
|
|
||||||
url = validate_webhook_url(url)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(400, str(e))
|
|
||||||
try:
|
|
||||||
events = validate_events(events)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(400, str(e))
|
|
||||||
|
|
||||||
secret_val = secret.strip()[:MAX_SECRET_LEN] or None
|
|
||||||
# Encrypt the secret at rest using the same Fernet key as API keys
|
|
||||||
encrypted_secret = None
|
|
||||||
if secret_val and api_key_manager:
|
|
||||||
encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
|
|
||||||
elif secret_val:
|
|
||||||
encrypted_secret = secret_val # Fallback if no encryption available
|
|
||||||
|
|
||||||
webhook_id = str(uuid.uuid4())[:8]
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
db.add(Webhook(
|
|
||||||
id=webhook_id,
|
|
||||||
name=name,
|
|
||||||
url=url,
|
|
||||||
secret=encrypted_secret,
|
|
||||||
events=events,
|
|
||||||
is_active=True,
|
|
||||||
))
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return {"id": webhook_id, "name": name}
|
|
||||||
|
|
||||||
@router.post("/webhooks/{webhook_id}/test")
|
|
||||||
async def test_webhook(request: Request, webhook_id: str):
|
|
||||||
_require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
|
||||||
if not wh:
|
|
||||||
raise HTTPException(404, "Webhook not found")
|
|
||||||
url, secret = wh.url, wh.secret
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
await webhook_manager.deliver_test(webhook_id, url, secret)
|
|
||||||
return {"status": "sent"}
|
|
||||||
|
|
||||||
@router.patch("/webhooks/{webhook_id}")
|
|
||||||
def toggle_webhook(request: Request, webhook_id: str):
|
|
||||||
_require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
|
||||||
if not wh:
|
|
||||||
raise HTTPException(404, "Webhook not found")
|
|
||||||
wh.is_active = not wh.is_active
|
|
||||||
db.commit()
|
|
||||||
return {"id": webhook_id, "is_active": wh.is_active}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
@router.delete("/webhooks/{webhook_id}")
|
|
||||||
def delete_webhook(request: Request, webhook_id: str):
|
|
||||||
_require_admin(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
|
|
||||||
db.commit()
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(404, "Webhook not found")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
return {"status": "deleted"}
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# Sync Chat Endpoint (for n8n / Make / Activepieces)
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
# Known provider base URLs — auto-resolved from api_key prefix or model name
|
|
||||||
KNOWN_PROVIDERS = {
|
|
||||||
"deepseek": "https://api.deepseek.com/v1",
|
|
||||||
"openai": "https://api.openai.com/v1",
|
|
||||||
"mistral": "https://api.mistral.ai/v1",
|
|
||||||
"groq": "https://api.groq.com/openai/v1",
|
|
||||||
"together": "https://api.together.xyz/v1",
|
|
||||||
"openrouter": "https://openrouter.ai/api/v1",
|
|
||||||
"ollama": "https://ollama.com/api",
|
|
||||||
"opencode-zen": "https://opencode.ai/zen/v1",
|
|
||||||
"opencode-go": "https://opencode.ai/zen/go/v1",
|
|
||||||
"fireworks": "https://api.fireworks.ai/inference/v1",
|
|
||||||
"venice": "https://api.venice.ai/api/v1",
|
|
||||||
"kimi-code": "https://api.kimi.com/coding/v1",
|
|
||||||
"kimicode": "https://api.kimi.com/coding/v1",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Model prefix → provider mapping for auto-detection
|
|
||||||
MODEL_PROVIDER_MAP = {
|
|
||||||
"deepseek": "deepseek",
|
|
||||||
"gpt-": "openai",
|
|
||||||
"o1": "openai",
|
|
||||||
"o3": "openai",
|
|
||||||
"o4": "openai",
|
|
||||||
"mistral": "mistral",
|
|
||||||
"llama": "groq",
|
|
||||||
"mixtral": "groq",
|
|
||||||
"kimi-for-coding": "kimi-code",
|
|
||||||
"kimi": "kimi-code",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
|
||||||
"""Try to auto-resolve a base URL from provider name or model prefix."""
|
|
||||||
if provider and provider.lower() in KNOWN_PROVIDERS:
|
|
||||||
return KNOWN_PROVIDERS[provider.lower()]
|
|
||||||
if model:
|
|
||||||
model_lower = model.lower()
|
|
||||||
for prefix, prov in MODEL_PROVIDER_MAP.items():
|
|
||||||
if model_lower.startswith(prefix):
|
|
||||||
return KNOWN_PROVIDERS[prov]
|
|
||||||
return None
|
|
||||||
|
|
||||||
class SyncChatRequest(BaseModel):
|
|
||||||
message: str = Field(..., max_length=MAX_MESSAGE_LEN)
|
|
||||||
model: Optional[str] = Field(None, max_length=200)
|
|
||||||
session: Optional[str] = Field(None, max_length=100)
|
|
||||||
api_key: Optional[str] = Field(None, max_length=256)
|
|
||||||
base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
|
|
||||||
provider: Optional[str] = Field(None, max_length=50)
|
|
||||||
|
|
||||||
@router.post("/v1/chat")
|
|
||||||
async def sync_chat(request: Request, body: SyncChatRequest):
|
|
||||||
if not getattr(request.state, "api_token", False):
|
|
||||||
raise HTTPException(403, "This endpoint requires an API token")
|
|
||||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
|
||||||
if "chat" not in scopes:
|
|
||||||
raise HTTPException(403, "API token is not scoped for chat")
|
|
||||||
token_owner = getattr(request.state, "api_token_owner", None)
|
|
||||||
|
|
||||||
from core.models import ChatMessage
|
|
||||||
from src.llm_core import llm_call_async
|
|
||||||
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
|
||||||
|
|
||||||
message = body.message.strip()
|
|
||||||
if not message:
|
|
||||||
raise HTTPException(400, "Message is required")
|
|
||||||
|
|
||||||
session_id = body.session
|
|
||||||
sess = None
|
|
||||||
|
|
||||||
# --- Case 1: Resume an existing session ---
|
|
||||||
if session_id and session_manager:
|
|
||||||
try:
|
|
||||||
sess = session_manager.get_session(session_id)
|
|
||||||
except (KeyError, Exception):
|
|
||||||
raise HTTPException(404, "Session not found")
|
|
||||||
# SECURITY: verify the API-token's user owns this session — without
|
|
||||||
# this any token holder could resume any user's chat by passing its
|
|
||||||
# ID. The token's user is on request.state.user (set by API-token
|
|
||||||
# middleware); fall back to require_user if not present.
|
|
||||||
try:
|
|
||||||
from src.auth_helpers import get_current_user as _gcu
|
|
||||||
_tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
|
|
||||||
except Exception:
|
|
||||||
_tok_user = None
|
|
||||||
# Strict ownership (see _caller_owns_session): fail closed so a
|
|
||||||
# null-owner / cross-owner session can't be resumed by an arbitrary
|
|
||||||
# chat-scoped token.
|
|
||||||
_sess_owner = getattr(sess, "owner", None)
|
|
||||||
if not _caller_owns_session(_sess_owner, _tok_user):
|
|
||||||
raise HTTPException(404, "Session not found")
|
|
||||||
|
|
||||||
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
|
|
||||||
if not sess and body.api_key:
|
|
||||||
api_key = body.api_key.strip()
|
|
||||||
model = body.model or "deepseek-chat"
|
|
||||||
|
|
||||||
# Validate only token-supplied direct base_url; auto-resolved known-provider
|
|
||||||
# URLs are not subject to extra local/LAN blocking beyond existing provider logic.
|
|
||||||
direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
|
|
||||||
if direct_base_url:
|
|
||||||
try:
|
|
||||||
base_url = validate_public_http_url(direct_base_url)
|
|
||||||
except ValueError as e:
|
|
||||||
detail = str(e).replace("URL", "base_url", 1)
|
|
||||||
raise HTTPException(400, detail)
|
|
||||||
else:
|
|
||||||
base_url = _resolve_base_url(model, body.provider)
|
|
||||||
if not base_url:
|
|
||||||
raise HTTPException(400,
|
|
||||||
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
|
|
||||||
"or provider ('deepseek', 'openai', 'groq', etc.)")
|
|
||||||
base_url = normalize_base(base_url)
|
|
||||||
endpoint_url = build_chat_url(base_url)
|
|
||||||
|
|
||||||
if not session_manager:
|
|
||||||
raise HTTPException(500, "Session manager not available")
|
|
||||||
|
|
||||||
sid = str(uuid.uuid4())
|
|
||||||
sess = session_manager.create_session(
|
|
||||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
|
||||||
model=model, owner=token_owner,
|
|
||||||
)
|
|
||||||
sess.headers = build_headers(api_key, base_url)
|
|
||||||
session_manager.save_sessions()
|
|
||||||
session_id = sid
|
|
||||||
|
|
||||||
# --- Case 3: Fall back to first configured ModelEndpoint ---
|
|
||||||
if not sess:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
ep = _select_api_chat_fallback_endpoint(db, token_owner)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if not ep:
|
|
||||||
raise HTTPException(400,
|
|
||||||
"No session, api_key, or configured endpoints. "
|
|
||||||
"Pass api_key + model, or configure an endpoint in Admin.")
|
|
||||||
|
|
||||||
base_url = normalize_base(ep.base_url)
|
|
||||||
endpoint_url = build_chat_url(base_url)
|
|
||||||
model = body.model or "auto"
|
|
||||||
api_key = ep.api_key
|
|
||||||
if getattr(ep, "provider_auth_id", None):
|
|
||||||
try:
|
|
||||||
from src.endpoint_resolver import resolve_endpoint_runtime
|
|
||||||
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
|
||||||
endpoint_url = build_chat_url(base_url)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(500, "Could not resolve endpoint credentials")
|
|
||||||
|
|
||||||
if model == "auto":
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
|
||||||
models_url = build_models_url(base_url)
|
|
||||||
hdrs = build_headers(api_key, base_url)
|
|
||||||
if models_url:
|
|
||||||
resp = await client.get(models_url, headers=hdrs)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
items = data if isinstance(data, list) else (data.get("data") or [])
|
|
||||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
|
||||||
if not ids and isinstance(data, dict):
|
|
||||||
ids = [
|
|
||||||
m.get("name") or m.get("model")
|
|
||||||
for m in (data.get("models") or [])
|
|
||||||
if m.get("name") or m.get("model")
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
import json as _json
|
|
||||||
ids = _json.loads(ep.cached_models or "[]")
|
|
||||||
model = ids[0] if ids else "auto"
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(500, "Could not discover models from endpoint")
|
|
||||||
|
|
||||||
if not session_manager:
|
|
||||||
raise HTTPException(500, "Session manager not available")
|
|
||||||
|
|
||||||
sid = str(uuid.uuid4())
|
|
||||||
sess = session_manager.create_session(
|
|
||||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
|
||||||
model=model, owner=token_owner,
|
|
||||||
)
|
|
||||||
if api_key:
|
|
||||||
sess.headers = build_headers(api_key, base_url)
|
|
||||||
session_manager.save_sessions()
|
|
||||||
session_id = sid
|
|
||||||
|
|
||||||
# --- Send message and get response ---
|
|
||||||
sess.add_message(ChatMessage("user", message))
|
|
||||||
|
|
||||||
messages = [{"role": m.role, "content": m.content} for m in sess.history]
|
|
||||||
|
|
||||||
reply = await llm_call_async(
|
|
||||||
sess.endpoint_url, sess.model, messages,
|
|
||||||
headers=sess.headers, timeout=120,
|
|
||||||
)
|
|
||||||
sess.add_message(ChatMessage("assistant", reply))
|
|
||||||
session_manager.save_sessions()
|
|
||||||
|
|
||||||
webhook_manager.fire_and_forget("chat.completed", {
|
|
||||||
"session_id": session_id, "model": sess.model,
|
|
||||||
"user_message": message[:2000], "response": reply[:2000],
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"response": reply, "session_id": session_id, "model": sess.model}
|
|
||||||
|
|
||||||
return router
|
|
||||||
+391
-12
@@ -1,16 +1,395 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/webhook/webhook_routes.py.
|
"""Webhook, API Token, and sync chat routes."""
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
import uuid
|
||||||
that ``import routes.webhook_routes``, ``from routes.webhook_routes import X``,
|
import logging
|
||||||
``importlib.import_module("routes.webhook_routes")``, and the
|
from typing import Optional
|
||||||
``__import__("routes.webhook_routes", fromlist=[...])`` + ``setattr(wh_mod,
|
|
||||||
...)`` pattern used by test_null_owner_gates.py all operate on the *same*
|
|
||||||
object. Keeps existing import paths working after slice 2l (#4082/#4071).
|
|
||||||
Source-introspection tests read the canonical file by path.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys as _sys
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Form
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from routes.webhook import webhook_routes as _canonical # noqa: F401
|
from core.database import SessionLocal, Webhook, ModelEndpoint
|
||||||
|
from src.auth_helpers import owner_filter
|
||||||
|
from src.url_security import validate_public_http_url
|
||||||
|
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["webhooks"])
|
||||||
|
|
||||||
|
# Input limits
|
||||||
|
MAX_NAME_LEN = 100
|
||||||
|
MAX_URL_LEN = 2048
|
||||||
|
MAX_SECRET_LEN = 256
|
||||||
|
MAX_MESSAGE_LEN = 32_000
|
||||||
|
|
||||||
|
|
||||||
|
from core.middleware import require_admin as _require_admin
|
||||||
|
|
||||||
|
|
||||||
|
def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
|
||||||
|
"""First enabled ModelEndpoint visible to token_owner — their own rows plus
|
||||||
|
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
||||||
|
let a chat-scoped token fall back onto another user's private endpoint and
|
||||||
|
silently spend that owner's API key/quota. Prefer owner rows before shared
|
||||||
|
rows. Fails closed to null-owner rows only when token_owner is absent.
|
||||||
|
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
||||||
|
"""
|
||||||
|
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||||
|
if token_owner:
|
||||||
|
query = owner_filter(query, ModelEndpoint, token_owner)
|
||||||
|
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
||||||
|
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
|
||||||
|
|
||||||
|
|
||||||
|
def _caller_owns_session(sess_owner, caller) -> bool:
|
||||||
|
"""Strict session-ownership gate for the token-authenticated sync-chat
|
||||||
|
endpoint (`POST /api/v1/chat`).
|
||||||
|
|
||||||
|
Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
|
||||||
|
gates in notes/calendar/gallery: a caller may resume a session ONLY when
|
||||||
|
its owner matches them exactly. A null/empty session owner (legacy or
|
||||||
|
migrated rows) is deliberately NOT resumable by an arbitrary token — the
|
||||||
|
old ``sess_owner and sess_owner != caller`` form skipped the check whenever
|
||||||
|
``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
|
||||||
|
device) could resume such a session, inject a message, and read back its
|
||||||
|
history and reuse the owner's endpoint credentials. Fail closed: an
|
||||||
|
unresolvable caller also returns False.
|
||||||
|
"""
|
||||||
|
if not caller:
|
||||||
|
return False
|
||||||
|
return sess_owner == caller
|
||||||
|
|
||||||
|
|
||||||
|
def setup_webhook_routes(
|
||||||
|
webhook_manager: WebhookManager,
|
||||||
|
auth_manager,
|
||||||
|
session_manager=None,
|
||||||
|
api_key_manager=None,
|
||||||
|
) -> APIRouter:
|
||||||
|
|
||||||
|
@router.get("/webhooks")
|
||||||
|
def list_webhooks(request: Request):
|
||||||
|
_require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
hooks = db.query(Webhook).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": w.id,
|
||||||
|
"name": w.name,
|
||||||
|
"url": w.url,
|
||||||
|
"has_secret": bool(w.secret),
|
||||||
|
"events": w.events.split(",") if w.events else [],
|
||||||
|
"is_active": w.is_active,
|
||||||
|
"last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
|
||||||
|
"last_status_code": w.last_status_code,
|
||||||
|
"last_error": w.last_error,
|
||||||
|
"created_at": w.created_at.isoformat() if w.created_at else None,
|
||||||
|
}
|
||||||
|
for w in hooks
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.post("/webhooks")
|
||||||
|
def create_webhook(
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(""),
|
||||||
|
url: str = Form(""),
|
||||||
|
secret: str = Form(""),
|
||||||
|
events: str = Form(""),
|
||||||
|
):
|
||||||
|
_require_admin(request)
|
||||||
|
name = name.strip()[:MAX_NAME_LEN]
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "Webhook name is required")
|
||||||
|
try:
|
||||||
|
url = validate_webhook_url(url)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
try:
|
||||||
|
events = validate_events(events)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
secret_val = secret.strip()[:MAX_SECRET_LEN] or None
|
||||||
|
# Encrypt the secret at rest using the same Fernet key as API keys
|
||||||
|
encrypted_secret = None
|
||||||
|
if secret_val and api_key_manager:
|
||||||
|
encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
|
||||||
|
elif secret_val:
|
||||||
|
encrypted_secret = secret_val # Fallback if no encryption available
|
||||||
|
|
||||||
|
webhook_id = str(uuid.uuid4())[:8]
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.add(Webhook(
|
||||||
|
id=webhook_id,
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
secret=encrypted_secret,
|
||||||
|
events=events,
|
||||||
|
is_active=True,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {"id": webhook_id, "name": name}
|
||||||
|
|
||||||
|
@router.post("/webhooks/{webhook_id}/test")
|
||||||
|
async def test_webhook(request: Request, webhook_id: str):
|
||||||
|
_require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||||
|
if not wh:
|
||||||
|
raise HTTPException(404, "Webhook not found")
|
||||||
|
url, secret = wh.url, wh.secret
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
await webhook_manager.deliver_test(webhook_id, url, secret)
|
||||||
|
return {"status": "sent"}
|
||||||
|
|
||||||
|
@router.patch("/webhooks/{webhook_id}")
|
||||||
|
def toggle_webhook(request: Request, webhook_id: str):
|
||||||
|
_require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||||
|
if not wh:
|
||||||
|
raise HTTPException(404, "Webhook not found")
|
||||||
|
wh.is_active = not wh.is_active
|
||||||
|
db.commit()
|
||||||
|
return {"id": webhook_id, "is_active": wh.is_active}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.delete("/webhooks/{webhook_id}")
|
||||||
|
def delete_webhook(request: Request, webhook_id: str):
|
||||||
|
_require_admin(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
|
||||||
|
db.commit()
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(404, "Webhook not found")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Sync Chat Endpoint (for n8n / Make / Activepieces)
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
# Known provider base URLs — auto-resolved from api_key prefix or model name
|
||||||
|
KNOWN_PROVIDERS = {
|
||||||
|
"deepseek": "https://api.deepseek.com/v1",
|
||||||
|
"openai": "https://api.openai.com/v1",
|
||||||
|
"mistral": "https://api.mistral.ai/v1",
|
||||||
|
"groq": "https://api.groq.com/openai/v1",
|
||||||
|
"together": "https://api.together.xyz/v1",
|
||||||
|
"openrouter": "https://openrouter.ai/api/v1",
|
||||||
|
"ollama": "https://ollama.com/api",
|
||||||
|
"opencode-zen": "https://opencode.ai/zen/v1",
|
||||||
|
"opencode-go": "https://opencode.ai/zen/go/v1",
|
||||||
|
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||||
|
"venice": "https://api.venice.ai/api/v1",
|
||||||
|
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||||
|
"kimicode": "https://api.kimi.com/coding/v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Model prefix → provider mapping for auto-detection
|
||||||
|
MODEL_PROVIDER_MAP = {
|
||||||
|
"deepseek": "deepseek",
|
||||||
|
"gpt-": "openai",
|
||||||
|
"o1": "openai",
|
||||||
|
"o3": "openai",
|
||||||
|
"o4": "openai",
|
||||||
|
"mistral": "mistral",
|
||||||
|
"llama": "groq",
|
||||||
|
"mixtral": "groq",
|
||||||
|
"kimi-for-coding": "kimi-code",
|
||||||
|
"kimi": "kimi-code",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
||||||
|
"""Try to auto-resolve a base URL from provider name or model prefix."""
|
||||||
|
if provider and provider.lower() in KNOWN_PROVIDERS:
|
||||||
|
return KNOWN_PROVIDERS[provider.lower()]
|
||||||
|
if model:
|
||||||
|
model_lower = model.lower()
|
||||||
|
for prefix, prov in MODEL_PROVIDER_MAP.items():
|
||||||
|
if model_lower.startswith(prefix):
|
||||||
|
return KNOWN_PROVIDERS[prov]
|
||||||
|
return None
|
||||||
|
|
||||||
|
class SyncChatRequest(BaseModel):
|
||||||
|
message: str = Field(..., max_length=MAX_MESSAGE_LEN)
|
||||||
|
model: Optional[str] = Field(None, max_length=200)
|
||||||
|
session: Optional[str] = Field(None, max_length=100)
|
||||||
|
api_key: Optional[str] = Field(None, max_length=256)
|
||||||
|
base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
|
||||||
|
provider: Optional[str] = Field(None, max_length=50)
|
||||||
|
|
||||||
|
@router.post("/v1/chat")
|
||||||
|
async def sync_chat(request: Request, body: SyncChatRequest):
|
||||||
|
if not getattr(request.state, "api_token", False):
|
||||||
|
raise HTTPException(403, "This endpoint requires an API token")
|
||||||
|
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||||
|
if "chat" not in scopes:
|
||||||
|
raise HTTPException(403, "API token is not scoped for chat")
|
||||||
|
token_owner = getattr(request.state, "api_token_owner", None)
|
||||||
|
|
||||||
|
from core.models import ChatMessage
|
||||||
|
from src.llm_core import llm_call_async
|
||||||
|
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
||||||
|
|
||||||
|
message = body.message.strip()
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(400, "Message is required")
|
||||||
|
|
||||||
|
session_id = body.session
|
||||||
|
sess = None
|
||||||
|
|
||||||
|
# --- Case 1: Resume an existing session ---
|
||||||
|
if session_id and session_manager:
|
||||||
|
try:
|
||||||
|
sess = session_manager.get_session(session_id)
|
||||||
|
except (KeyError, Exception):
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
# SECURITY: verify the API-token's user owns this session — without
|
||||||
|
# this any token holder could resume any user's chat by passing its
|
||||||
|
# ID. The token's user is on request.state.user (set by API-token
|
||||||
|
# middleware); fall back to require_user if not present.
|
||||||
|
try:
|
||||||
|
from src.auth_helpers import get_current_user as _gcu
|
||||||
|
_tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
|
||||||
|
except Exception:
|
||||||
|
_tok_user = None
|
||||||
|
# Strict ownership (see _caller_owns_session): fail closed so a
|
||||||
|
# null-owner / cross-owner session can't be resumed by an arbitrary
|
||||||
|
# chat-scoped token.
|
||||||
|
_sess_owner = getattr(sess, "owner", None)
|
||||||
|
if not _caller_owns_session(_sess_owner, _tok_user):
|
||||||
|
raise HTTPException(404, "Session not found")
|
||||||
|
|
||||||
|
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
|
||||||
|
if not sess and body.api_key:
|
||||||
|
api_key = body.api_key.strip()
|
||||||
|
model = body.model or "deepseek-chat"
|
||||||
|
|
||||||
|
# Validate only token-supplied direct base_url; auto-resolved known-provider
|
||||||
|
# URLs are not subject to extra local/LAN blocking beyond existing provider logic.
|
||||||
|
direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
|
||||||
|
if direct_base_url:
|
||||||
|
try:
|
||||||
|
base_url = validate_public_http_url(direct_base_url)
|
||||||
|
except ValueError as e:
|
||||||
|
detail = str(e).replace("URL", "base_url", 1)
|
||||||
|
raise HTTPException(400, detail)
|
||||||
|
else:
|
||||||
|
base_url = _resolve_base_url(model, body.provider)
|
||||||
|
if not base_url:
|
||||||
|
raise HTTPException(400,
|
||||||
|
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
|
||||||
|
"or provider ('deepseek', 'openai', 'groq', etc.)")
|
||||||
|
base_url = normalize_base(base_url)
|
||||||
|
endpoint_url = build_chat_url(base_url)
|
||||||
|
|
||||||
|
if not session_manager:
|
||||||
|
raise HTTPException(500, "Session manager not available")
|
||||||
|
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
sess = session_manager.create_session(
|
||||||
|
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||||
|
model=model, owner=token_owner,
|
||||||
|
)
|
||||||
|
sess.headers = build_headers(api_key, base_url)
|
||||||
|
session_manager.save_sessions()
|
||||||
|
session_id = sid
|
||||||
|
|
||||||
|
# --- Case 3: Fall back to first configured ModelEndpoint ---
|
||||||
|
if not sess:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ep = _select_api_chat_fallback_endpoint(db, token_owner)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not ep:
|
||||||
|
raise HTTPException(400,
|
||||||
|
"No session, api_key, or configured endpoints. "
|
||||||
|
"Pass api_key + model, or configure an endpoint in Admin.")
|
||||||
|
|
||||||
|
base_url = normalize_base(ep.base_url)
|
||||||
|
endpoint_url = build_chat_url(base_url)
|
||||||
|
model = body.model or "auto"
|
||||||
|
api_key = ep.api_key
|
||||||
|
if getattr(ep, "provider_auth_id", None):
|
||||||
|
try:
|
||||||
|
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||||
|
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
||||||
|
endpoint_url = build_chat_url(base_url)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(500, "Could not resolve endpoint credentials")
|
||||||
|
|
||||||
|
if model == "auto":
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
models_url = build_models_url(base_url)
|
||||||
|
hdrs = build_headers(api_key, base_url)
|
||||||
|
if models_url:
|
||||||
|
resp = await client.get(models_url, headers=hdrs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
items = data if isinstance(data, list) else (data.get("data") or [])
|
||||||
|
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||||
|
if not ids and isinstance(data, dict):
|
||||||
|
ids = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
import json as _json
|
||||||
|
ids = _json.loads(ep.cached_models or "[]")
|
||||||
|
model = ids[0] if ids else "auto"
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(500, "Could not discover models from endpoint")
|
||||||
|
|
||||||
|
if not session_manager:
|
||||||
|
raise HTTPException(500, "Session manager not available")
|
||||||
|
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
sess = session_manager.create_session(
|
||||||
|
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||||
|
model=model, owner=token_owner,
|
||||||
|
)
|
||||||
|
if api_key:
|
||||||
|
sess.headers = build_headers(api_key, base_url)
|
||||||
|
session_manager.save_sessions()
|
||||||
|
session_id = sid
|
||||||
|
|
||||||
|
# --- Send message and get response ---
|
||||||
|
sess.add_message(ChatMessage("user", message))
|
||||||
|
|
||||||
|
messages = [{"role": m.role, "content": m.content} for m in sess.history]
|
||||||
|
|
||||||
|
reply = await llm_call_async(
|
||||||
|
sess.endpoint_url, sess.model, messages,
|
||||||
|
headers=sess.headers, timeout=120,
|
||||||
|
)
|
||||||
|
sess.add_message(ChatMessage("assistant", reply))
|
||||||
|
session_manager.save_sessions()
|
||||||
|
|
||||||
|
webhook_manager.fire_and_forget("chat.completed", {
|
||||||
|
"session_id": session_id, "model": sess.model,
|
||||||
|
"user_message": message[:2000], "response": reply[:2000],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"response": reply, "session_id": session_id, "model": sess.model}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""Memory service — persistent memory storage and retrieval."""
|
"""Memory service — persistent memory storage and retrieval."""
|
||||||
|
|
||||||
from .service import MemoryService, Memory, MemorySearchResult
|
from .service import MemoryService, Memory, MemorySearchResult
|
||||||
from .memory import MemoryManager, MemoryStoreUnreadable
|
from .memory import MemoryManager
|
||||||
from .memory_vector import MemoryVectorStore
|
from .memory_vector import MemoryVectorStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -10,6 +10,5 @@ __all__ = [
|
|||||||
"Memory",
|
"Memory",
|
||||||
"MemorySearchResult",
|
"MemorySearchResult",
|
||||||
"MemoryManager",
|
"MemoryManager",
|
||||||
"MemoryStoreUnreadable",
|
|
||||||
"MemoryVectorStore",
|
"MemoryVectorStore",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,16 +5,6 @@ application runtime instantiates ``src.memory.MemoryManager``, so keeping a
|
|||||||
parallel implementation here risks silent drift between import paths.
|
parallel implementation here risks silent drift between import paths.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from src.memory import (
|
from src.memory import MemoryManager, get_text_similarity, tokenize
|
||||||
MemoryManager,
|
|
||||||
MemoryStoreUnreadable,
|
|
||||||
get_text_similarity,
|
|
||||||
tokenize,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["MemoryManager", "get_text_similarity", "tokenize"]
|
||||||
"MemoryManager",
|
|
||||||
"MemoryStoreUnreadable",
|
|
||||||
"get_text_similarity",
|
|
||||||
"tokenize",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from src.memory import MemoryStoreUnreadable
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -389,13 +387,7 @@ async def extract_and_store(
|
|||||||
# Get owner from session
|
# Get owner from session
|
||||||
_owner = getattr(session, 'owner', None)
|
_owner = getattr(session, 'owner', None)
|
||||||
|
|
||||||
# Strict load: this is a read-modify-write. Degrading to [] here would
|
existing = memory_manager.load_all()
|
||||||
# save only the newly extracted facts and drop the entire store.
|
|
||||||
try:
|
|
||||||
existing = memory_manager.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
logger.error("Skipping auto memory extraction, store unreadable: %s", e)
|
|
||||||
return
|
|
||||||
added = 0
|
added = 0
|
||||||
|
|
||||||
for fact in facts:
|
for fact in facts:
|
||||||
@@ -634,18 +626,7 @@ async def audit_memories(
|
|||||||
|
|
||||||
# Merge audited entries back with other users' entries
|
# Merge audited entries back with other users' entries
|
||||||
if owner:
|
if owner:
|
||||||
# Strict load: the merge below reconstructs the whole file. If this
|
all_entries = memory_manager.load_all()
|
||||||
# degraded to [] we would save only this owner's audited slice and
|
|
||||||
# destroy every other tenant's memories.
|
|
||||||
try:
|
|
||||||
all_entries = memory_manager.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
logger.error("Aborting memory audit save, store unreadable: %s", e)
|
|
||||||
return {
|
|
||||||
"before": before_count,
|
|
||||||
"after": before_count,
|
|
||||||
"error": "store_unreadable",
|
|
||||||
}
|
|
||||||
audited_ids = {e["id"] for e in final_entries}
|
audited_ids = {e["id"] for e in final_entries}
|
||||||
other_entries = [e for e in all_entries if e.get("owner") != owner and (e.get("owner") is not None)]
|
other_entries = [e for e in all_entries if e.get("owner") != owner and (e.get("owner") is not None)]
|
||||||
# Also keep legacy entries that weren't part of this audit
|
# Also keep legacy entries that weren't part of this audit
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -441,4 +441,4 @@ class Skill:
|
|||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
|
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
|
||||||
import wave
|
import wave
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -43,11 +42,6 @@ class TTSService:
|
|||||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self._kokoro = None # lazy-init
|
self._kokoro = None # lazy-init
|
||||||
|
|
||||||
try:
|
|
||||||
self.max_cache_bytes = int(os.getenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", 500 * 1024 * 1024))
|
|
||||||
except ValueError:
|
|
||||||
self.max_cache_bytes = 500 * 1024 * 1024
|
|
||||||
|
|
||||||
# ── Settings ──
|
# ── Settings ──
|
||||||
|
|
||||||
def _load_settings(self) -> dict:
|
def _load_settings(self) -> dict:
|
||||||
@@ -95,53 +89,6 @@ class TTSService:
|
|||||||
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
|
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
|
||||||
(self.cache_dir / f"{key}{ext}").write_bytes(data)
|
(self.cache_dir / f"{key}{ext}").write_bytes(data)
|
||||||
|
|
||||||
self._enforce_cache_limit()
|
|
||||||
|
|
||||||
def _enforce_cache_limit(self):
|
|
||||||
"""Evicts oldest files if the cache exceeds the configured byte limit."""
|
|
||||||
if self.max_cache_bytes <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
files = []
|
|
||||||
total_size = 0
|
|
||||||
|
|
||||||
# Safely scan files and sum sizes, ignoring files deleted mid-scan
|
|
||||||
for f in self.cache_dir.iterdir():
|
|
||||||
try:
|
|
||||||
if f.is_file() and f.suffix.lower() in (".mp3", ".wav"):
|
|
||||||
files.append(f)
|
|
||||||
total_size += f.stat().st_size
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if total_size > self.max_cache_bytes:
|
|
||||||
logger.info(
|
|
||||||
f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort files by modification time (oldest first)
|
|
||||||
try:
|
|
||||||
files.sort(key=lambda f: f.stat().st_mtime)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Failed to sort cache files by mtime: {e}")
|
|
||||||
|
|
||||||
# Trim down to 80% of max capacity
|
|
||||||
target_size = self.max_cache_bytes * 0.8
|
|
||||||
|
|
||||||
while files and total_size > target_size:
|
|
||||||
f = files.pop(0)
|
|
||||||
try:
|
|
||||||
size = f.stat().st_size
|
|
||||||
f.unlink()
|
|
||||||
total_size -= size
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Failed to evict cache file {f}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error enforcing TTS cache limit: {e}", exc_info=True)
|
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
count = 0
|
count = 0
|
||||||
for f in self.cache_dir.glob("*.*"):
|
for f in self.cache_dir.glob("*.*"):
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, AsyncGenerator, List, Dict, Optional, Set
|
from typing import AsyncGenerator, List, Dict, Optional, Set
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from src.llm_core import (
|
from src.llm_core import (
|
||||||
|
|||||||
+1
-10
@@ -22,7 +22,6 @@ import time
|
|||||||
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
||||||
|
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
from src.memory import MemoryStoreUnreadable
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -385,15 +384,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
|||||||
return {"error": "Memory text cannot be empty"}
|
return {"error": "Memory text cannot be empty"}
|
||||||
|
|
||||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
||||||
# Strict load: this is a read-modify-write, and it is the path an
|
memories = _memory_manager.load_all()
|
||||||
# ordinary "remember that I prefer X" takes. Degrading to [] here would
|
|
||||||
# save just this one entry over a store we only failed to read,
|
|
||||||
# atomically destroying every memory in it (issue #5673).
|
|
||||||
try:
|
|
||||||
memories = _memory_manager.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
logger.error("Refusing to add memory, store unreadable: %s", e)
|
|
||||||
return {"error": "Memory store is temporarily unreadable — nothing was saved."}
|
|
||||||
memories.append(entry)
|
memories.append(entry)
|
||||||
_memory_manager.save(memories)
|
_memory_manager.save(memories)
|
||||||
|
|
||||||
|
|||||||
+3
-172
@@ -1,14 +1,11 @@
|
|||||||
import ipaddress
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
from urllib.parse import urljoin, urlparse, urlunparse
|
from urllib.parse import urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
import httpcore
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -357,152 +354,6 @@ def _find_integration(identifier: str) -> Optional[Dict[str, Any]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# httpcore raises its own exception hierarchy; map the ones a simple request can
|
|
||||||
# surface back to their httpx equivalents so the caller's `except httpx.*` blocks
|
|
||||||
# below behave exactly as they did with the default transport.
|
|
||||||
_HTTPCORE_TO_HTTPX_EXC = {
|
|
||||||
httpcore.ConnectError: httpx.ConnectError,
|
|
||||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
|
||||||
httpcore.NetworkError: httpx.NetworkError,
|
|
||||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
|
||||||
httpcore.ProtocolError: httpx.ProtocolError,
|
|
||||||
httpcore.ReadError: httpx.ReadError,
|
|
||||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
|
||||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
|
||||||
httpcore.TimeoutException: httpx.TimeoutException,
|
|
||||||
httpcore.WriteError: httpx.WriteError,
|
|
||||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class _PinnedAsyncBackend(httpcore.AsyncNetworkBackend):
|
|
||||||
"""Network backend that connects only to the pre-validated IPs, in order.
|
|
||||||
|
|
||||||
Every address here came out of the single SSRF resolution, so moving to the
|
|
||||||
next one after a connect failure is not re-resolution — it's ordinary
|
|
||||||
multi-address fallback restricted to the set the guard already approved.
|
|
||||||
httpcore takes TLS SNI and the ``Host`` header from the request URL rather
|
|
||||||
than the connect host, so pinning the socket destination leaves certificate
|
|
||||||
validation and vhost routing pointed at the original hostname.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
|
||||||
self._ips = [str(ip) for ip in ips]
|
|
||||||
self._real = httpcore.AnyIOBackend()
|
|
||||||
|
|
||||||
async def connect_tcp(self, host, port, timeout=None, local_address=None,
|
|
||||||
socket_options=None):
|
|
||||||
# One shared connect budget: each attempt gets the time left until the
|
|
||||||
# original deadline, so N dead addresses can't stretch the connect
|
|
||||||
# phase to N * timeout.
|
|
||||||
deadline = None if timeout is None else time.monotonic() + timeout
|
|
||||||
last_exc: Optional[Exception] = None
|
|
||||||
for ip in self._ips:
|
|
||||||
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
||||||
try:
|
|
||||||
return await self._real.connect_tcp(
|
|
||||||
ip, port, remaining, local_address, socket_options
|
|
||||||
)
|
|
||||||
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
|
|
||||||
last_exc = exc
|
|
||||||
if deadline is not None and time.monotonic() >= deadline:
|
|
||||||
break
|
|
||||||
raise last_exc
|
|
||||||
|
|
||||||
async def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
|
||||||
return await self._real.connect_unix_socket(path, timeout, socket_options)
|
|
||||||
|
|
||||||
async def sleep(self, seconds: float) -> None:
|
|
||||||
return await self._real.sleep(seconds)
|
|
||||||
|
|
||||||
|
|
||||||
class _PinnedAsyncTransport(httpx.AsyncBaseTransport):
|
|
||||||
"""httpx transport that pins the TCP connect to the pre-resolved IP(s).
|
|
||||||
|
|
||||||
Kept local, mirroring the per-module pinned transports web fetch and
|
|
||||||
webhook delivery already carry, rather than coupling api_call to the
|
|
||||||
webhook subsystem. The request URL passes through unchanged, so SNI and the
|
|
||||||
``Host`` header stay the original hostname; only the socket destination is
|
|
||||||
pinned, which is what closes the rebinding window.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
|
||||||
self._pinned_ips = list(ips)
|
|
||||||
self._pool = httpcore.AsyncConnectionPool(
|
|
||||||
# Reuse the CA trust the default httpx client would build (certifi
|
|
||||||
# plus SSL_CERT_FILE / SSL_CERT_DIR when trust_env is set) so
|
|
||||||
# swapping in this transport doesn't quietly change which chains
|
|
||||||
# verify. ssl.create_default_context() would use system roots.
|
|
||||||
ssl_context=httpx.create_ssl_context(),
|
|
||||||
http1=True,
|
|
||||||
http2=False,
|
|
||||||
network_backend=_PinnedAsyncBackend(ips),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
||||||
core_req = httpcore.Request(
|
|
||||||
method=request.method,
|
|
||||||
url=httpcore.URL(
|
|
||||||
scheme=request.url.raw_scheme,
|
|
||||||
host=request.url.raw_host,
|
|
||||||
port=request.url.port,
|
|
||||||
target=request.url.raw_path,
|
|
||||||
),
|
|
||||||
headers=request.headers.raw,
|
|
||||||
content=request.stream,
|
|
||||||
extensions=request.extensions,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
core_resp = await self._pool.handle_async_request(core_req)
|
|
||||||
content = b"".join([chunk async for chunk in core_resp.aiter_stream()])
|
|
||||||
await core_resp.aclose()
|
|
||||||
except Exception as exc:
|
|
||||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
|
||||||
if mapped is not None:
|
|
||||||
raise mapped(str(exc)) from exc
|
|
||||||
raise
|
|
||||||
return httpx.Response(
|
|
||||||
status_code=core_resp.status,
|
|
||||||
headers=core_resp.headers,
|
|
||||||
content=content,
|
|
||||||
extensions=core_resp.extensions,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
await self._pool.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
|
|
||||||
"""Return every entry that parses as an IP address, de-duplicated, order
|
|
||||||
preserved.
|
|
||||||
|
|
||||||
check_outbound_url only reports ok when *all* of these classify as safe, so
|
|
||||||
the whole list is guard-approved and any of them is a legitimate connect
|
|
||||||
target. Skipping unparseable entries mirrors how the guard walks the same
|
|
||||||
resolver output.
|
|
||||||
|
|
||||||
De-duplication matters because the resolver is getaddrinfo(host, None) with
|
|
||||||
no socktype filter, so glibc reports the same address once per socktype
|
|
||||||
(SOCK_STREAM/SOCK_DGRAM/SOCK_RAW) — a single-homed host comes back three
|
|
||||||
times. Without this, the connect fallback would spend the shared deadline
|
|
||||||
retrying one dead address instead of moving on to a genuinely different one.
|
|
||||||
"""
|
|
||||||
ips: List[ipaddress._BaseAddress] = []
|
|
||||||
seen = set()
|
|
||||||
for raw in raw_ips:
|
|
||||||
if not isinstance(raw, str):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
ip = ipaddress.ip_address(raw.split("%")[0]) # strip IPv6 zone id
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if ip in seen:
|
|
||||||
continue
|
|
||||||
seen.add(ip)
|
|
||||||
ips.append(ip)
|
|
||||||
return ips
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_api_call(
|
async def execute_api_call(
|
||||||
integration_id: str,
|
integration_id: str,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -558,31 +409,13 @@ async def execute_api_call(
|
|||||||
# loopback for locked-down deployments. Private stays allowed by default
|
# loopback for locked-down deployments. Private stays allowed by default
|
||||||
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
|
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
|
||||||
# primary use case.
|
# primary use case.
|
||||||
from src.url_safety import check_outbound_url, _default_resolver
|
from src.url_safety import check_outbound_url
|
||||||
block_private = os.getenv(
|
block_private = os.getenv(
|
||||||
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
|
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
|
||||||
).lower() == "true"
|
).lower() == "true"
|
||||||
# Resolve the host exactly once and remember the IPs the guard validated so
|
ok, reason = check_outbound_url(url, block_private=block_private)
|
||||||
# the request below can be pinned to them. check_outbound_url only reports
|
|
||||||
# (ok, reason); a plain httpx client re-resolves the host at connect time,
|
|
||||||
# which reopens a DNS-rebinding TOCTOU — a base_url host that answers with a
|
|
||||||
# public IP for the guard and then flips to 169.254.169.254 for the connect
|
|
||||||
# would reach cloud metadata with the integration's auth headers attached.
|
|
||||||
resolved_ips: List[str] = []
|
|
||||||
|
|
||||||
def _recording_resolver(host: str) -> List[str]:
|
|
||||||
ips = _default_resolver(host)
|
|
||||||
resolved_ips[:] = ips
|
|
||||||
return ips
|
|
||||||
|
|
||||||
ok, reason = check_outbound_url(
|
|
||||||
url, block_private=block_private, resolver=_recording_resolver
|
|
||||||
)
|
|
||||||
if not ok:
|
if not ok:
|
||||||
return {"error": f"URL rejected: {reason}", "exit_code": 1}
|
return {"error": f"URL rejected: {reason}", "exit_code": 1}
|
||||||
pinned_ips = _validated_ips(resolved_ips)
|
|
||||||
if not pinned_ips:
|
|
||||||
return {"error": "URL rejected: host did not resolve to a usable address", "exit_code": 1}
|
|
||||||
|
|
||||||
method = method.upper()
|
method = method.upper()
|
||||||
|
|
||||||
@@ -622,9 +455,7 @@ async def execute_api_call(
|
|||||||
auth = httpx.BasicAuth(parts[0], parts[1])
|
auth = httpx.BasicAuth(parts[0], parts[1])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
timeout=30.0, transport=_PinnedAsyncTransport(pinned_ips)
|
|
||||||
) as client:
|
|
||||||
response = await client.request(
|
response = await client.request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
|
|||||||
+7
-19
@@ -1237,27 +1237,15 @@ def _anthropic_rejects_temperature(model: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
# `(?<![a-z])` anchors "opus" to a word boundary so a substring match like
|
# `(?<![a-z])` anchors "opus" to a word boundary so a substring match like
|
||||||
# `oct-opus`/`octopus-4-8` can't be read as Opus (it would otherwise strip
|
# `oct-opus`/`octopus-4-8` can't be read as Opus (it would otherwise strip
|
||||||
# temperature). Both version components are capped at 1-2 digits and forbid a
|
# temperature). Cap the minor at 1-2 digits and forbid a trailing digit so a
|
||||||
# trailing digit, so an 8-digit date can never be read as a version number:
|
# dated id like `claude-opus-4-20250514` (Opus 4.0) parses as major-only (no
|
||||||
# `claude-opus-4-20250514` (Opus 4.0) parses as major-only rather than reading
|
# minor match, kept) instead of reading the date `20250514` as a giant minor
|
||||||
# `20250514` as a giant minor, and `claude-3-opus-20240229` (legacy Claude 3
|
# that would falsely test >= 4.7. Dated 4.7+ snapshots (`claude-opus-4-7-
|
||||||
# Opus, date directly after "opus-") fails to match at all rather than reading
|
# 20260201`) keep their explicit minor and are still matched.
|
||||||
# the date as a giant major. Dated 4.7+ snapshots (`claude-opus-4-7-20260201`)
|
match = re.search(r"(?<![a-z])opus[-_]?(\d+)[-_.](\d{1,2})(?!\d)", model.lower())
|
||||||
# keep their explicit minor and are still matched.
|
|
||||||
#
|
|
||||||
# The minor is optional and a missing minor reads as `.0`, so major-only ids
|
|
||||||
# like `claude-opus-5` are correctly treated as >= 4.7 (issue #5753). Without
|
|
||||||
# this, every Opus 5 call kept `temperature` and failed with HTTP 400 — visible
|
|
||||||
# only on paths that pass a temperature, e.g. scheduled tasks inheriting
|
|
||||||
# `stream_agent_loop`'s 0.3 default, which returned empty responses.
|
|
||||||
match = re.search(
|
|
||||||
r"(?<![a-z])opus[-_]?(\d{1,2})(?!\d)(?:[-_.](\d{1,2})(?!\d))?", model.lower()
|
|
||||||
)
|
|
||||||
if not match:
|
if not match:
|
||||||
return False
|
return False
|
||||||
major = int(match.group(1))
|
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
|
||||||
minor = int(match.group(2)) if match.group(2) else 0
|
|
||||||
return (major, minor) >= (4, 7)
|
|
||||||
|
|
||||||
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
|
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
|
||||||
# API accepts "high", "medium", "low", "none" — see
|
# API accepts "high", "medium", "low", "none" — see
|
||||||
|
|||||||
+9
-79
@@ -10,18 +10,6 @@ from datetime import datetime
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MemoryStoreUnreadable(RuntimeError):
|
|
||||||
"""memory.json exists on disk but could not be read or parsed.
|
|
||||||
|
|
||||||
"The contents are unknown" is categorically different from "there are no
|
|
||||||
memories". A read-modify-write caller that conflates the two appends to an
|
|
||||||
empty view and then persists it, destroying the whole store — the writes
|
|
||||||
are atomic, so the loss is durable. Raised by
|
|
||||||
:meth:`MemoryManager.load_all_for_update` so those callers fail closed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def tokenize(text: str) -> List[str]:
|
def tokenize(text: str) -> List[str]:
|
||||||
"""Simple tokenizer that splits on whitespace and removes punctuation."""
|
"""Simple tokenizer that splits on whitespace and removes punctuation."""
|
||||||
return [word.strip('.,!?";') for word in text.split()]
|
return [word.strip('.,!?";') for word in text.split()]
|
||||||
@@ -122,69 +110,21 @@ class MemoryManager:
|
|||||||
with open(self.memory_file, 'w', encoding='utf-8') as f:
|
with open(self.memory_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump([], f, ensure_ascii=False, indent=2)
|
json.dump([], f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
def _read_entries(self) -> List[Dict]:
|
def load_all(self) -> List[Dict]:
|
||||||
"""Parse the store, or raise :class:`MemoryStoreUnreadable`.
|
"""Load all memory entries from JSON file (unfiltered)."""
|
||||||
|
|
||||||
Returns ``[]`` only when the file genuinely does not exist. Every other
|
|
||||||
failure mode raises, so callers can tell "no memories" apart from
|
|
||||||
"couldn't read the memories".
|
|
||||||
"""
|
|
||||||
if not os.path.exists(self.memory_file):
|
if not os.path.exists(self.memory_file):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.memory_file, "r", encoding="utf-8") as f:
|
with open(self.memory_file, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
except OSError as e:
|
if isinstance(data, list):
|
||||||
# PermissionError is an OSError (a scanner holding the file, a
|
return self._validate_entries(data)
|
||||||
# permissions problem, bad media).
|
except (json.JSONDecodeError, PermissionError) as e:
|
||||||
raise MemoryStoreUnreadable(
|
|
||||||
f"cannot read {self.memory_file}: {e}"
|
|
||||||
) from e
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
# This is the branch that actually destroyed stores: the file reads
|
|
||||||
# back fine, so nothing stops the save that follows. A truncated
|
|
||||||
# memory.json is reachable because core/database.py rewrites it with
|
|
||||||
# a plain open(..,"w") + json.dump during migration.
|
|
||||||
#
|
|
||||||
# Preserved behaviour: a corrupt store still gets one shot at the
|
|
||||||
# pre-JSON memory.txt migration. Only raise when that finds nothing,
|
|
||||||
# so we never report "empty" for a store we simply failed to parse.
|
|
||||||
legacy = self._migrate_from_legacy()
|
|
||||||
if legacy:
|
|
||||||
return legacy
|
|
||||||
raise MemoryStoreUnreadable(
|
|
||||||
f"{self.memory_file} is not valid JSON: {e}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
if not isinstance(data, list):
|
|
||||||
raise MemoryStoreUnreadable(
|
|
||||||
f"{self.memory_file} is not a JSON array (got {type(data).__name__})"
|
|
||||||
)
|
|
||||||
return self._validate_entries(data)
|
|
||||||
|
|
||||||
def load_all(self) -> List[Dict]:
|
|
||||||
"""Load all memory entries from JSON file (unfiltered).
|
|
||||||
|
|
||||||
Lenient by design: this feeds display, search, and context-injection
|
|
||||||
paths, so an unreadable store degrades to an empty list rather than
|
|
||||||
breaking chat. Never build a value from this that you intend to save
|
|
||||||
back — use :meth:`load_all_for_update` for that.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return self._read_entries()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
logger.error("Error loading memory.json: %s", e)
|
logger.error("Error loading memory.json: %s", e)
|
||||||
return []
|
return self._migrate_from_legacy()
|
||||||
|
|
||||||
def load_all_for_update(self) -> List[Dict]:
|
return []
|
||||||
"""Load for a read-modify-write cycle.
|
|
||||||
|
|
||||||
Propagates :class:`MemoryStoreUnreadable` instead of degrading to ``[]``
|
|
||||||
so a caller can never append to an empty view and persist it over a
|
|
||||||
store that was only temporarily unreadable (issue #5673).
|
|
||||||
"""
|
|
||||||
return self._read_entries()
|
|
||||||
|
|
||||||
def load(self, owner: str = None) -> List[Dict]:
|
def load(self, owner: str = None) -> List[Dict]:
|
||||||
"""Load memory entries, optionally filtered by owner."""
|
"""Load memory entries, optionally filtered by owner."""
|
||||||
@@ -195,12 +135,7 @@ class MemoryManager:
|
|||||||
|
|
||||||
def claim_ownerless(self, owner: str):
|
def claim_ownerless(self, owner: str):
|
||||||
"""Assign all ownerless memory entries to the given owner."""
|
"""Assign all ownerless memory entries to the given owner."""
|
||||||
try:
|
entries = self.load_all()
|
||||||
entries = self.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
# Skip the sweep rather than rewrite the store from an unknown view.
|
|
||||||
logger.error("Skipping ownerless claim, memory store unreadable: %s", e)
|
|
||||||
return
|
|
||||||
changed = False
|
changed = False
|
||||||
claimed = 0
|
claimed = 0
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -300,12 +235,7 @@ class MemoryManager:
|
|||||||
if not ids:
|
if not ids:
|
||||||
return
|
return
|
||||||
id_set = set(ids)
|
id_set = set(ids)
|
||||||
try:
|
entries = self.load_all()
|
||||||
entries = self.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
# Best-effort counter; never worth rewriting the store blind.
|
|
||||||
logger.error("Skipping uses bump, memory store unreadable: %s", e)
|
|
||||||
return
|
|
||||||
changed = False
|
changed = False
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if e.get("id") in id_set:
|
if e.get("id") in id_set:
|
||||||
|
|||||||
@@ -157,11 +157,7 @@ class NativeMemoryProvider(MemoryProvider):
|
|||||||
if metadata:
|
if metadata:
|
||||||
entry["metadata"] = dict(metadata)
|
entry["metadata"] = dict(metadata)
|
||||||
|
|
||||||
# Strict load: read-modify-write. `load_all` degrades an unreadable
|
memories = self.memory_manager.load_all()
|
||||||
# store to [], which would save this single entry over everything
|
|
||||||
# already stored (issue #5673). The provider API has no error channel,
|
|
||||||
# so MemoryStoreUnreadable propagates to the caller.
|
|
||||||
memories = self.memory_manager.load_all_for_update()
|
|
||||||
memories.append(entry)
|
memories.append(entry)
|
||||||
self.memory_manager.save(memories)
|
self.memory_manager.save(memories)
|
||||||
|
|
||||||
@@ -227,10 +223,7 @@ class NativeMemoryProvider(MemoryProvider):
|
|||||||
]
|
]
|
||||||
|
|
||||||
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
|
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
|
||||||
# Strict load for the same reason: `remaining` is derived from this
|
memories = self.memory_manager.load_all()
|
||||||
# list and saved back, so it must never be built from a store we
|
|
||||||
# failed to read.
|
|
||||||
memories = self.memory_manager.load_all_for_update()
|
|
||||||
remaining = []
|
remaining = []
|
||||||
deleted_id = None
|
deleted_id = None
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -187,12 +187,8 @@ _FUNCTION_MODEL_NAME_RE = re.compile(
|
|||||||
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
|
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
|
||||||
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"</parameters>", re.IGNORECASE)
|
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"</parameters>", re.IGNORECASE)
|
||||||
_QWEN_ROLE_MARKER_RE = re.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", re.IGNORECASE)
|
_QWEN_ROLE_MARKER_RE = re.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", re.IGNORECASE)
|
||||||
# At least one pipe is required around `end`. Both pipes used to be optional
|
|
||||||
# (`\|?end\|?`), which also matched a bare `end` on its own line and deleted it
|
|
||||||
# from ordinary prose and from Ruby/Lua/shell snippets that close blocks with
|
|
||||||
# one; see #5547. `|end`, `end|`, `|end|` and `/|end|` still strip as before.
|
|
||||||
_QWEN_BARE_MARKER_RE = re.compile(
|
_QWEN_BARE_MARKER_RE = re.compile(
|
||||||
r"(?:^|[\t\r\n ])(?:/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|"
|
r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
|
||||||
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
|
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-4
@@ -46,9 +46,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
action = (args.get("action") or "").strip().lower()
|
action = (args.get("action") or "").lower()
|
||||||
if not action:
|
|
||||||
return {"error": "action is required (list|view|view_ref|add|edit|patch|publish|delete|search)", "exit_code": 1}
|
|
||||||
from services.memory.skills import SkillsManager
|
from services.memory.skills import SkillsManager
|
||||||
from services.memory.skill_format import Skill, slugify
|
from services.memory.skill_format import Skill, slugify
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
@@ -57,7 +55,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
# Accept legacy `skill_id` as an alias for `name`.
|
# Accept legacy `skill_id` as an alias for `name`.
|
||||||
name = (args.get("name") or args.get("skill_id") or "").strip()
|
name = (args.get("name") or args.get("skill_id") or "").strip()
|
||||||
|
|
||||||
if action in ("list", "index"):
|
if action in ("list", "index", ""):
|
||||||
all_skills = sm.load(owner=owner)
|
all_skills = sm.load(owner=owner)
|
||||||
if not all_skills:
|
if not all_skills:
|
||||||
return {"results": "No skills yet. Create one with action='add'."}
|
return {"results": "No skills yet. Create one with action='add'."}
|
||||||
|
|||||||
+83
-16
@@ -10,14 +10,14 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
|
|||||||
import ragModule from './js/rag.js';
|
import ragModule from './js/rag.js';
|
||||||
import presetsModule from './js/presets.js';
|
import presetsModule from './js/presets.js';
|
||||||
import searchModule from './js/search.js';
|
import searchModule from './js/search.js';
|
||||||
import chatModule from './js/chat.js?v=20260801fix1';
|
import chatModule from './js/chat.js?v=20260722ctxheader4';
|
||||||
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
||||||
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
||||||
import searchChatModule from './js/search-chat.js';
|
import searchChatModule from './js/search-chat.js';
|
||||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||||
import markdownModule from './js/markdown.js';
|
import markdownModule from './js/markdown.js';
|
||||||
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
||||||
import sessionModule from './js/sessions.js';
|
import sessionModule from './js/sessions.js?v=20260722ctxheader4';
|
||||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||||
import censorModule from './js/censor.js';
|
import censorModule from './js/censor.js';
|
||||||
@@ -1689,20 +1689,12 @@ function initializeEventListeners() {
|
|||||||
|
|
||||||
const newMemoryInput = el('new-memory-input');
|
const newMemoryInput = el('new-memory-input');
|
||||||
if (newMemoryInput) {
|
if (newMemoryInput) {
|
||||||
// keydown, not the deprecated keypress: keypress is not guaranteed to
|
newMemoryInput.addEventListener('keypress', (e) => {
|
||||||
// fire for Enter everywhere, which left the Add Memory form with no
|
if (e.key === 'Enter') {
|
||||||
// working submit path (#5828).
|
|
||||||
newMemoryInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' && !e.isComposing) {
|
|
||||||
e.preventDefault();
|
|
||||||
memoryModule.addNewMemory();
|
memoryModule.addNewMemory();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const newMemoryAddBtn = el('new-memory-add-btn');
|
|
||||||
if (newMemoryAddBtn) {
|
|
||||||
newMemoryAddBtn.addEventListener('click', () => memoryModule.addNewMemory());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Voice recording is handled by the dual-purpose send/mic button (see below)
|
// Voice recording is handled by the dual-purpose send/mic button (see below)
|
||||||
|
|
||||||
@@ -3916,10 +3908,85 @@ function startOdysseusApp() {
|
|||||||
const messageInput = el('message');
|
const messageInput = el('message');
|
||||||
const modelPickerWrap = document.getElementById('model-picker-wrap');
|
const modelPickerWrap = document.getElementById('model-picker-wrap');
|
||||||
|
|
||||||
// ArrowUp/ArrowDown prompt recall on #message lives in
|
function _readComposerPromptHistory() {
|
||||||
// static/js/composerArrowUpRecall.js (wired from chat.js). Do not re-add a
|
const chatBox = document.getElementById('chat-history');
|
||||||
// copy here: two capture-phase listeners on the same textarea meant the one
|
if (!chatBox) return [];
|
||||||
// without the draft guard won and ate unsent multi-line prompts (#5862).
|
return Array.from(chatBox.querySelectorAll('.msg-user'))
|
||||||
|
.reverse()
|
||||||
|
.map(msg => {
|
||||||
|
const body = msg.querySelector('.body');
|
||||||
|
return msg.dataset?.raw || (body ? body.textContent : '') || '';
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageInput && !messageInput._odysseusPromptRecallCapture) {
|
||||||
|
messageInput._odysseusPromptRecallCapture = true;
|
||||||
|
let recallHistory = [];
|
||||||
|
let recallIndex = -1;
|
||||||
|
let lastRecalled = '';
|
||||||
|
const norm = (v) => String(v || '').replace(/\r\n/g, '\n').trimEnd();
|
||||||
|
messageInput.addEventListener('input', () => {
|
||||||
|
if (norm(messageInput.value) === norm(lastRecalled)) return;
|
||||||
|
recallHistory = [];
|
||||||
|
recallIndex = -1;
|
||||||
|
lastRecalled = '';
|
||||||
|
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
|
||||||
|
}, true);
|
||||||
|
messageInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
|
||||||
|
if (window._ghostAutocomplete?.isActive?.()) return;
|
||||||
|
const fresh = _readComposerPromptHistory();
|
||||||
|
const history = fresh.length ? fresh : recallHistory;
|
||||||
|
if (!history.length) return;
|
||||||
|
const current = norm(messageInput.value);
|
||||||
|
let currentIndex = current ? history.findIndex(item => norm(item) === current) : -1;
|
||||||
|
if (current && currentIndex < 0 && current === norm(lastRecalled)) currentIndex = recallIndex;
|
||||||
|
if (current && currentIndex < 0) {
|
||||||
|
const markedIndex = Number(messageInput.dataset.odysseusRecallIndex);
|
||||||
|
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
|
||||||
|
currentIndex = markedIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
if (currentIndex < 0) return;
|
||||||
|
const nextIndex = currentIndex - 1;
|
||||||
|
if (nextIndex < 0) {
|
||||||
|
recallHistory = history;
|
||||||
|
recallIndex = -1;
|
||||||
|
lastRecalled = '';
|
||||||
|
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
|
||||||
|
messageInput.value = '';
|
||||||
|
try { messageInput.selectionStart = messageInput.selectionEnd = 0; } catch {}
|
||||||
|
try { uiModule.autoResize(messageInput); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const recalled = history[nextIndex];
|
||||||
|
recallHistory = history;
|
||||||
|
recallIndex = nextIndex;
|
||||||
|
lastRecalled = recalled;
|
||||||
|
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
|
||||||
|
messageInput.value = recalled;
|
||||||
|
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
|
||||||
|
try { uiModule.autoResize(messageInput); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
||||||
|
const recalled = history[nextIndex];
|
||||||
|
if (!recalled) return;
|
||||||
|
recallHistory = history;
|
||||||
|
recallIndex = nextIndex;
|
||||||
|
lastRecalled = recalled;
|
||||||
|
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
|
||||||
|
messageInput.value = recalled;
|
||||||
|
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
|
||||||
|
try { uiModule.autoResize(messageInput); } catch {}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
const _sendIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
|
const _sendIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
|
||||||
const _micIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
const _micIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||||
|
|||||||
+5
-6
@@ -250,9 +250,9 @@
|
|||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
|
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
|
||||||
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
|
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
|
||||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
|
<link rel="modulepreload" href="/static/js/chat.js?v=20260722ctxheader4">
|
||||||
<link rel="modulepreload" href="/static/js/ui.js">
|
<link rel="modulepreload" href="/static/js/ui.js">
|
||||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
<link rel="modulepreload" href="/static/js/sessions.js?v=20260722ctxheader4">
|
||||||
<link rel="modulepreload" href="/static/js/markdown.js">
|
<link rel="modulepreload" href="/static/js/markdown.js">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -365,7 +365,6 @@
|
|||||||
<span class="skill-rich-ph"><span class="k">Add a memory</span> — e.g. 'I prefer concise replies' <svg class="k" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-left:4px;" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
<span class="skill-rich-ph"><span class="k">Add a memory</span> — e.g. 'I prefer concise replies' <svg class="k" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-left:4px;" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
||||||
</div>
|
</div>
|
||||||
<select id="new-memory-category" class="memory-edit-cat-select" aria-label="Memory category"></select>
|
<select id="new-memory-category" class="memory-edit-cat-select" aria-label="Memory category"></select>
|
||||||
<button type="button" id="new-memory-add-btn" class="theme-io-btn" title="Save this memory" style="flex:none;height:28px;font-size:12px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Add</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
@@ -1006,7 +1005,7 @@
|
|||||||
var tips = mobile ? phone : desktop;
|
var tips = mobile ? phone : desktop;
|
||||||
var el = document.getElementById('welcome-tip');
|
var el = document.getElementById('welcome-tip');
|
||||||
if (el) {
|
if (el) {
|
||||||
el.textContent = tips[Math.floor(Math.random() * tips.length)];
|
el.textContent = 'Pick a model if you want, or just type.';
|
||||||
}
|
}
|
||||||
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
|
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
|
||||||
if (d.version) window._appVersion = d.version;
|
if (d.version) window._appVersion = d.version;
|
||||||
@@ -2505,7 +2504,7 @@
|
|||||||
<script type="module" src="/static/js/ui.js"></script>
|
<script type="module" src="/static/js/ui.js"></script>
|
||||||
<script type="module" src="/static/js/markdown.js"></script>
|
<script type="module" src="/static/js/markdown.js"></script>
|
||||||
<script type="module" src="/static/js/dragSort.js"></script>
|
<script type="module" src="/static/js/dragSort.js"></script>
|
||||||
<script type="module" src="/static/js/sessions.js"></script>
|
<script type="module" src="/static/js/sessions.js?v=20260722ctxheader4"></script>
|
||||||
<script type="module" src="/static/js/memory.js?v=20260722memoryloading1"></script>
|
<script type="module" src="/static/js/memory.js?v=20260722memoryloading1"></script>
|
||||||
<script type="module" src="/static/js/skills.js"></script>
|
<script type="module" src="/static/js/skills.js"></script>
|
||||||
<script type="module" src="/static/js/tourHints.js"></script>
|
<script type="module" src="/static/js/tourHints.js"></script>
|
||||||
@@ -2523,7 +2522,7 @@
|
|||||||
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
|
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
|
||||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||||
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
|
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
|
||||||
<script type="module" src="/static/js/chat.js?v=20260801fix1"></script>
|
<script type="module" src="/static/js/chat.js?v=20260722ctxheader4"></script>
|
||||||
<script type="module" src="/static/js/cookbook.js"></script>
|
<script type="module" src="/static/js/cookbook.js"></script>
|
||||||
<script src="/static/js/cookbookSchedule.js"></script>
|
<script src="/static/js/cookbookSchedule.js"></script>
|
||||||
<script type="module" src="/static/js/search-chat.js"></script>
|
<script type="module" src="/static/js/search-chat.js"></script>
|
||||||
|
|||||||
+3
-9
@@ -349,9 +349,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||||||
|
|
||||||
async function _adoptOpenedSessionBeforeAutoCreate() {
|
async function _adoptOpenedSessionBeforeAutoCreate() {
|
||||||
if (!sessionModule || !sessionModule.getCurrentSessionId || sessionModule.getCurrentSessionId()) return true;
|
if (!sessionModule || !sessionModule.getCurrentSessionId || sessionModule.getCurrentSessionId()) return true;
|
||||||
// Don't adopt a stale session when the user explicitly started a New Chat
|
|
||||||
// (pending state set) — the send path must materialize the pending session.
|
|
||||||
if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) return false;
|
|
||||||
const activeRowId = document.querySelector('.list-item.active-session[data-session-id], .session-item.active[data-session-id]')?.dataset?.sessionId || '';
|
const activeRowId = document.querySelector('.list-item.active-session[data-session-id], .session-item.active[data-session-id]')?.dataset?.sessionId || '';
|
||||||
const hashId = _hashSessionCandidate();
|
const hashId = _hashSessionCandidate();
|
||||||
const lastSelectedId = String(window.__odysseusLastSelectedSessionId || '').trim();
|
const lastSelectedId = String(window.__odysseusLastSelectedSessionId || '').trim();
|
||||||
@@ -1406,8 +1403,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||||||
currentAccumulated = '';
|
currentAccumulated = '';
|
||||||
currentHolder = null;
|
currentHolder = null;
|
||||||
|
|
||||||
let abortCtrl = null;
|
|
||||||
let streamingTTS = false;
|
|
||||||
try {
|
try {
|
||||||
// Re-enable auto-scroll when user sends a message
|
// Re-enable auto-scroll when user sends a message
|
||||||
uiModule.setAutoScroll(true);
|
uiModule.setAutoScroll(true);
|
||||||
@@ -1721,7 +1716,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
abortCtrl = new AbortController();
|
const abortCtrl = new AbortController();
|
||||||
abortCtrl._reason = '';
|
abortCtrl._reason = '';
|
||||||
currentAbort = abortCtrl;
|
currentAbort = abortCtrl;
|
||||||
|
|
||||||
@@ -1902,7 +1897,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||||||
let isThinking = false;
|
let isThinking = false;
|
||||||
let thinkingStartTime = null;
|
let thinkingStartTime = null;
|
||||||
// Streaming TTS: synthesize sentence-by-sentence during streaming
|
// Streaming TTS: synthesize sentence-by-sentence during streaming
|
||||||
streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
|
const streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available);
|
||||||
if (streamingTTS) window.aiTTSManager.streamingStart();
|
if (streamingTTS) window.aiTTSManager.streamingStart();
|
||||||
// Multi-bubble agent tracking
|
// Multi-bubble agent tracking
|
||||||
let roundHolder = holder; // Current AI text bubble (changes per round)
|
let roundHolder = holder; // Current AI text bubble (changes per round)
|
||||||
@@ -4792,8 +4787,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
|
|||||||
if (msgIndex < 0) return;
|
if (msgIndex < 0) return;
|
||||||
|
|
||||||
const bodyEl = userMsgElement.querySelector('.body');
|
const bodyEl = userMsgElement.querySelector('.body');
|
||||||
let currentText = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
|
const currentText = bodyEl ? bodyEl.textContent.trim().replace(/\s*\[\d+ attachment\(s\)\]$/, '') : '';
|
||||||
currentText = currentText.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
|
|
||||||
|
|
||||||
// Replace body with an editable textarea
|
// Replace body with an editable textarea
|
||||||
const editor = document.createElement('textarea');
|
const editor = document.createElement('textarea');
|
||||||
|
|||||||
@@ -478,10 +478,7 @@ const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi;
|
|||||||
const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi;
|
const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi;
|
||||||
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
|
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
|
||||||
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
|
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
|
||||||
// Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one
|
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
|
||||||
// pipe is required around `end`: with both optional (`\|?end\|?`) this also ate
|
|
||||||
// a bare `end` on its own line, breaking Ruby/Lua/shell snippets (#5547).
|
|
||||||
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
|
|
||||||
// Self-narration about tool results (model echoing stdout/exit_code)
|
// Self-narration about tool results (model echoing stdout/exit_code)
|
||||||
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
|
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
|
||||||
|
|
||||||
|
|||||||
@@ -143,9 +143,9 @@ export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArrowUp walks older prompts. An unmatched draft already returned above,
|
// ArrowUp owns prompt history in the chat composer. If the current text
|
||||||
// so reaching here means the composer is empty or holds a recalled prompt
|
// is not already a recalled prompt, start from newest instead of letting
|
||||||
// — the caret-navigation case is never hijacked.
|
// the browser move the caret inside the textarea.
|
||||||
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
||||||
const recalled = history[nextIndex];
|
const recalled = history[nextIndex];
|
||||||
if (!recalled) {
|
if (!recalled) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { makeWindowDraggable } from './windowDrag.js';
|
|||||||
import {
|
import {
|
||||||
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
||||||
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
|
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
|
||||||
_sanitizeHtml, _renderEmailSummaryError,
|
_sanitizeHtml,
|
||||||
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
|
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
|
||||||
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
|
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
|
||||||
} from './emailLibrary/utils.js';
|
} from './emailLibrary/utils.js';
|
||||||
@@ -7259,11 +7259,12 @@ async function _generateSummary(reader, data, btn) {
|
|||||||
if (label) label.textContent = 'Summary';
|
if (label) label.textContent = 'Summary';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_renderEmailSummaryError(content, result);
|
content.innerHTML = `<span style="color:var(--red)">${_esc(result.error || 'Failed to summarize')}</span>`;
|
||||||
|
panel.remove();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sp.destroy();
|
sp.destroy();
|
||||||
_renderEmailSummaryError(content, null);
|
panel.remove();
|
||||||
if (uiModule) uiModule.showError?.('Failed to summarize');
|
if (uiModule) uiModule.showError?.('Failed to summarize');
|
||||||
} finally {
|
} finally {
|
||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
|
|||||||
@@ -30,25 +30,6 @@ export function _esc(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _EMAIL_SUMMARY_ERROR_MESSAGES = Object.freeze({
|
|
||||||
email_summary_missing_body: 'No email body to summarize',
|
|
||||||
email_summary_not_configured: 'No model configured for email summaries',
|
|
||||||
email_summary_empty: 'The model returned an empty summary',
|
|
||||||
email_summary_unavailable: 'Failed to summarize',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function _emailSummaryErrorMessage(result) {
|
|
||||||
const code = String(result?.error_code || '');
|
|
||||||
return _EMAIL_SUMMARY_ERROR_MESSAGES[code] || 'Failed to summarize';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function _renderEmailSummaryError(container, result) {
|
|
||||||
const message = container.ownerDocument.createElement('span');
|
|
||||||
message.style.color = 'var(--red)';
|
|
||||||
message.textContent = _emailSummaryErrorMessage(result);
|
|
||||||
container.replaceChildren(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _attrEsc(text) {
|
function _attrEsc(text) {
|
||||||
return String(text ?? '')
|
return String(text ?? '')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
|
|||||||
+7
-13
@@ -758,36 +758,30 @@ export function mdToHtml(src, opts) {
|
|||||||
// Remove empty paragraphs
|
// Remove empty paragraphs
|
||||||
s = s.replace(/<p><\/p>/g, '');
|
s = s.replace(/<p><\/p>/g, '');
|
||||||
|
|
||||||
// Every restore below passes a function replacer rather than the block string
|
|
||||||
// itself. With a string replacement, `String.replace` reads `$&`, `` $` ``,
|
|
||||||
// `$'` and `$$` in the *replacement* as substitution patterns, so a restored
|
|
||||||
// block containing them is corrupted: `$&` re-inserts the placeholder, `` $` ``
|
|
||||||
// and `$'` splice in the surrounding document, and `$$` collapses to `$`. Those
|
|
||||||
// sequences are ordinary content in fenced code (`perl -pe 's/x/$& y/'`,
|
|
||||||
// `echo "$$USD"`). A function replacer inserts its return value verbatim.
|
|
||||||
|
|
||||||
// CRITICAL: Restore allowed HTML blocks first
|
// CRITICAL: Restore allowed HTML blocks first
|
||||||
allowedHtmlBlocks.forEach((block, index) => {
|
allowedHtmlBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___ALLOWED_HTML_${index}___`, () => block);
|
s = s.replace(`___ALLOWED_HTML_${index}___`, block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore math blocks
|
// Restore math blocks
|
||||||
mathBlocks.forEach((block, index) => {
|
mathBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___MATH_BLOCK_${index}___`, () => block);
|
s = s.replace(`___MATH_BLOCK_${index}___`, block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore mermaid diagram blocks
|
// Restore mermaid diagram blocks
|
||||||
mermaidBlocks.forEach((block, index) => {
|
mermaidBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___MERMAID_BLOCK_${index}___`, () => block);
|
s = s.replace(`___MERMAID_BLOCK_${index}___`, block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// CRITICAL: Restore code blocks at the end
|
// CRITICAL: Restore code blocks at the end
|
||||||
codeBlocks.forEach((block, index) => {
|
codeBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___CODE_BLOCK_${index}___`, () => block);
|
s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore inline code spans last, so placeholders carried inside restored
|
// Restore inline code spans last, so placeholders carried inside restored
|
||||||
// <a>/allowed-HTML blocks are resolved too.
|
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
|
||||||
|
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
|
||||||
|
// as a regex back-reference.
|
||||||
inlineCodeBlocks.forEach((block, index) => {
|
inlineCodeBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1847,10 +1847,6 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||||||
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
|
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
|
||||||
if (!_isTransientChat) {
|
if (!_isTransientChat) {
|
||||||
Storage.set('lastSessionId', id);
|
Storage.set('lastSessionId', id);
|
||||||
// Update URL hash without triggering hashchange handler
|
|
||||||
if (window.location.hash !== '#' + id) {
|
|
||||||
history.replaceState(null, '', '#' + id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Restore character preset for persistent chats
|
// Restore character preset for persistent chats
|
||||||
try {
|
try {
|
||||||
@@ -2317,7 +2313,6 @@ export async function materializePendingSession() {
|
|||||||
currentSessionId = payload.id;
|
currentSessionId = payload.id;
|
||||||
if (!isIncognito) {
|
if (!isIncognito) {
|
||||||
Storage.set('lastSessionId', payload.id);
|
Storage.set('lastSessionId', payload.id);
|
||||||
history.replaceState(null, '', '#' + payload.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload the sidebar in the background. Awaiting this used to block the first
|
// Reload the sidebar in the background. Awaiting this used to block the first
|
||||||
|
|||||||
+22
-25
@@ -3031,14 +3031,12 @@ async function initEmailAccountsSettings() {
|
|||||||
const body = {
|
const body = {
|
||||||
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
|
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
|
||||||
from_address: el('eaf-from').value.trim(),
|
from_address: el('eaf-from').value.trim(),
|
||||||
display_name: el('eaf-display-name').value.trim(),
|
|
||||||
imap_host: el('eaf-imap-host').value.trim(),
|
imap_host: el('eaf-imap-host').value.trim(),
|
||||||
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
||||||
imap_user: el('eaf-imap-user').value.trim(),
|
imap_user: el('eaf-imap-user').value.trim(),
|
||||||
imap_starttls: el('eaf-imap-starttls').checked,
|
imap_starttls: el('eaf-imap-starttls').checked,
|
||||||
smtp_host: el('eaf-smtp-host').value.trim(),
|
smtp_host: el('eaf-smtp-host').value.trim(),
|
||||||
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
|
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
|
||||||
smtp_security: el('eaf-smtp-security').value,
|
|
||||||
smtp_user: el('eaf-imap-user').value.trim(),
|
smtp_user: el('eaf-imap-user').value.trim(),
|
||||||
};
|
};
|
||||||
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
||||||
@@ -5790,30 +5788,29 @@ export function close() {
|
|||||||
window.history.replaceState(null, '', clean);
|
window.history.replaceState(null, '', clean);
|
||||||
const success = sp.has('email_oauth_success');
|
const success = sp.has('email_oauth_success');
|
||||||
const errMsg = sp.get('email_oauth_error') || '';
|
const errMsg = sp.get('email_oauth_error') || '';
|
||||||
// Open settings → integrations once the document is ready. This module owns
|
// Open settings → integrations after the app has initialised.
|
||||||
// the open() API, so it does not need to wait for a window-level alias.
|
function _tryOpen() {
|
||||||
function _showResult() {
|
if (window.settingsModule && typeof window.settingsModule.open === 'function') {
|
||||||
open('integrations');
|
window.settingsModule.open('integrations');
|
||||||
// Brief toast-style banner.
|
// Brief toast-style banner.
|
||||||
const banner = document.createElement('div');
|
const banner = document.createElement('div');
|
||||||
banner.textContent = success
|
banner.textContent = success
|
||||||
? 'Google account connected — email is ready'
|
? '✓ Google account connected — email is ready'
|
||||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||||
Object.assign(banner.style, {
|
Object.assign(banner.style, {
|
||||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||||
});
|
});
|
||||||
document.body.appendChild(banner);
|
document.body.appendChild(banner);
|
||||||
setTimeout(() => banner.remove(), 4000);
|
setTimeout(() => banner.remove(), 4000);
|
||||||
}
|
} else {
|
||||||
if (document.readyState === 'loading') {
|
setTimeout(_tryOpen, 100);
|
||||||
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
|
}
|
||||||
} else {
|
|
||||||
_showResult();
|
|
||||||
}
|
}
|
||||||
|
_tryOpen();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||||
|
|||||||
+5
-3
@@ -83,9 +83,11 @@ export async function loadSkills(cascade = false) {
|
|||||||
// Play the domino-in entrance on this load (set when the tab is opened,
|
// Play the domino-in entrance on this load (set when the tab is opened,
|
||||||
// not for the silent re-loads after an edit/delete).
|
// not for the silent re-loads after an edit/delete).
|
||||||
if (cascade) _cascadeNext = true;
|
if (cascade) _cascadeNext = true;
|
||||||
// Always re-fetch when the tab is explicitly opened — the cascade
|
if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
|
||||||
// animation is handled inside renderSkillsList() via _cascadeNext.
|
_cascadeNext = false;
|
||||||
// Skipping the fetch here caused stale data on panel close/reopen (#5870).
|
updateCount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_loadPromise) return _loadPromise;
|
if (_loadPromise) return _loadPromise;
|
||||||
_loadPromise = (async () => {
|
_loadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def _load_webhook_routes_for_test(monkeypatch):
|
|||||||
module_name = "routes.webhook_routes_under_test"
|
module_name = "routes.webhook_routes_under_test"
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
module_name,
|
module_name,
|
||||||
Path(__file__).resolve().parent.parent / "routes" / "webhook" / "webhook_routes.py",
|
Path(__file__).resolve().parent.parent / "routes" / "webhook_routes.py",
|
||||||
)
|
)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ def _setup(monkeypatch, store, user="alice"):
|
|||||||
|
|
||||||
mem = MagicMock()
|
mem = MagicMock()
|
||||||
mem.load_all.return_value = list(store)
|
mem.load_all.return_value = list(store)
|
||||||
# import_data reads through the strict loader so a store it cannot read is
|
|
||||||
# never overwritten (#5673); the double has to offer the same entry point.
|
|
||||||
mem.load_all_for_update.return_value = list(store)
|
|
||||||
saved = {}
|
saved = {}
|
||||||
mem.save.side_effect = lambda entries: saved.__setitem__("entries", entries)
|
mem.save.side_effect = lambda entries: saved.__setitem__("entries", entries)
|
||||||
|
|
||||||
|
|||||||
@@ -306,24 +306,3 @@ def test_integration_recalls_from_chat_history_dom():
|
|||||||
)
|
)
|
||||||
assert proc.returncode == 0, proc.stderr
|
assert proc.returncode == 0, proc.stderr
|
||||||
assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}
|
assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_recall_is_not_duplicated_in_app_js():
|
|
||||||
"""Only composerArrowUpRecall.js may own ArrowUp on #message (issue #5862).
|
|
||||||
|
|
||||||
static/app.js once carried a near-verbatim copy of this recall logic, wired
|
|
||||||
as a second capture-phase listener on the same textarea. That copy lacked
|
|
||||||
the draft guard here, and because it called stopImmediatePropagation it won
|
|
||||||
regardless of registration order — so a typed multi-line prompt was replaced
|
|
||||||
by the last sent one instead of the caret moving up a line.
|
|
||||||
"""
|
|
||||||
app_js = (_REPO / "static" / "app.js").read_text(encoding="utf-8")
|
|
||||||
for marker in (
|
|
||||||
"_odysseusPromptRecallCapture",
|
|
||||||
"_readComposerPromptHistory",
|
|
||||||
"odysseusRecallIndex",
|
|
||||||
):
|
|
||||||
assert marker not in app_js, (
|
|
||||||
f"static/app.js reintroduces prompt recall ({marker!r}); "
|
|
||||||
"it belongs to static/js/composerArrowUpRecall.js alone"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"""Regression test for the document route shim (slice 2m, #4082/#4071).
|
|
||||||
|
|
||||||
The backward-compat shims at ``routes/document_routes.py`` and
|
|
||||||
``routes/document_helpers.py`` use ``sys.modules`` replacement so the legacy
|
|
||||||
import paths and the canonical ``routes.document.*`` paths resolve to the
|
|
||||||
*same* module objects. This is required because multiple tests do
|
|
||||||
``import routes.document_routes as droutes`` followed by
|
|
||||||
``droutes.SessionLocal = ...`` / ``monkeypatch.setattr(droutes, ...)`` and
|
|
||||||
``sys.modules.pop("routes.document_helpers")`` + re-import — for those to
|
|
||||||
take effect at runtime, the legacy and canonical module objects must be
|
|
||||||
identical.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
import routes.document_routes as _shim_routes # noqa: F401
|
|
||||||
import routes.document_helpers as _shim_helpers # noqa: F401
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_and_canonical_routes_are_same_object():
|
|
||||||
legacy = importlib.import_module("routes.document_routes")
|
|
||||||
canonical = importlib.import_module("routes.document.document_routes")
|
|
||||||
assert legacy is canonical
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_and_canonical_helpers_are_same_object():
|
|
||||||
legacy = importlib.import_module("routes.document_helpers")
|
|
||||||
canonical = importlib.import_module("routes.document.document_helpers")
|
|
||||||
assert legacy is canonical
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""Regression coverage for SMTP security saved before Google OAuth."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_email_tab_oauth_connect_persists_selected_smtp_security():
|
|
||||||
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
|
||||||
start = source.index("el('eaf-oauth-btn').addEventListener")
|
|
||||||
handler_body = source[start:source.index("if (!body.name)", start)]
|
|
||||||
|
|
||||||
assert "smtp_security: el('eaf-smtp-security').value" in handler_body
|
|
||||||
assert "display_name: el('eaf-display-name').value.trim()" in handler_body
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Regression coverage for the settings UI after Google OAuth redirects."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_oauth_redirect_uses_the_module_local_settings_api():
|
|
||||||
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
|
||||||
handler = source[
|
|
||||||
source.index("(function _handleOauthRedirect"):
|
|
||||||
source.index("const settingsModule =")
|
|
||||||
]
|
|
||||||
|
|
||||||
assert "open('integrations');" in handler
|
|
||||||
assert "window.settingsModule" not in handler
|
|
||||||
assert "window.__odysseusAppStarted" not in handler
|
|
||||||
assert "document.addEventListener('DOMContentLoaded', _showResult, { once: true })" in handler
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import json
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parent.parent
|
|
||||||
_UTILS = (_REPO / "static" / "js" / "emailLibrary" / "utils.js").as_posix()
|
|
||||||
_HAS_NODE = shutil.which("node") is not None
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
|
|
||||||
|
|
||||||
def test_email_summary_renderer_ignores_untrusted_provider_error_text():
|
|
||||||
secret = (
|
|
||||||
"endpoint=https://private.example.internal/v1 provider=ollama "
|
|
||||||
"model=private-model response_body=private-response "
|
|
||||||
"Authorization: Bearer token-secret-value"
|
|
||||||
)
|
|
||||||
script = f"""
|
|
||||||
import {{ _renderEmailSummaryError }} from '{_UTILS}';
|
|
||||||
const host = {{
|
|
||||||
ownerDocument: {{
|
|
||||||
createElement() {{ return {{ style: {{}}, textContent: '' }}; }},
|
|
||||||
}},
|
|
||||||
replaceChildren(node) {{ this.child = node; }},
|
|
||||||
}};
|
|
||||||
_renderEmailSummaryError(host, {{
|
|
||||||
error_code: 'email_summary_unavailable',
|
|
||||||
error: {json.dumps(secret)},
|
|
||||||
}});
|
|
||||||
console.log(JSON.stringify({{
|
|
||||||
text: host.child.textContent,
|
|
||||||
color: host.child.style.color,
|
|
||||||
}}));
|
|
||||||
"""
|
|
||||||
|
|
||||||
proc = subprocess.run(
|
|
||||||
["node", "--input-type=module"],
|
|
||||||
input=script,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=str(_REPO),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert proc.returncode == 0, proc.stderr
|
|
||||||
rendered = json.loads(proc.stdout)
|
|
||||||
assert rendered == {"text": "Failed to summarize", "color": "var(--red)"}
|
|
||||||
assert secret not in proc.stdout
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
_TMP_DATA = Path(tempfile.mkdtemp(prefix="odysseus-email-summary-"))
|
|
||||||
os.environ.setdefault("DATA_DIR", str(_TMP_DATA))
|
|
||||||
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TMP_DATA / 'app.db'}")
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
def _route_endpoint(router, path: str, method: str):
|
|
||||||
method = method.upper()
|
|
||||||
for route in router.routes:
|
|
||||||
if route.path == path and method in getattr(route, "methods", set()):
|
|
||||||
return route.endpoint
|
|
||||||
raise AssertionError(f"route not found: {method} {path}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_generate_email_summary_uses_shared_llm_adapter(monkeypatch):
|
|
||||||
import routes.email_helpers as email_helpers
|
|
||||||
import src.llm_core as llm_core
|
|
||||||
|
|
||||||
calls = {}
|
|
||||||
|
|
||||||
async def fake_llm_call_async(url, model, messages, **kwargs):
|
|
||||||
calls["url"] = url
|
|
||||||
calls["model"] = model
|
|
||||||
calls["messages"] = messages
|
|
||||||
calls["kwargs"] = kwargs
|
|
||||||
return "thinking before marker\n<<<SUMMARY>>>\n- Pay the invoice by Friday.\n<<<END>>>"
|
|
||||||
|
|
||||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
|
|
||||||
|
|
||||||
summary = await email_helpers._generate_email_summary(
|
|
||||||
url="https://chatgpt.com/backend-api/codex/responses",
|
|
||||||
model="gpt-5.5",
|
|
||||||
sender="Billing <billing@example.com>",
|
|
||||||
subject="Invoice due",
|
|
||||||
body_for_llm="Please pay invoice 123 by Friday.",
|
|
||||||
headers={"Authorization": "Bearer test"},
|
|
||||||
max_tokens=1234,
|
|
||||||
timeout=45,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert summary == "- Pay the invoice by Friday."
|
|
||||||
assert calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
|
|
||||||
assert calls["model"] == "gpt-5.5"
|
|
||||||
assert calls["kwargs"]["headers"] == {"Authorization": "Bearer test"}
|
|
||||||
assert calls["kwargs"]["temperature"] == 0.3
|
|
||||||
assert calls["kwargs"]["max_tokens"] == 1234
|
|
||||||
assert calls["kwargs"]["timeout"] == 45
|
|
||||||
assert calls["kwargs"]["workload"] == "foreground"
|
|
||||||
assert calls["messages"][0]["role"] == "system"
|
|
||||||
assert calls["messages"][1]["role"] == "user"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scheduled_email_summary_uses_background_fallback_chain(monkeypatch):
|
|
||||||
import routes.email_helpers as email_helpers
|
|
||||||
import src.llm_core as llm_core
|
|
||||||
import src.task_endpoint as task_endpoint
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
("http://primary.invalid/v1", "primary-model", {"X-Candidate": "primary"}),
|
|
||||||
("http://fallback.invalid/v1", "fallback-model", {"X-Candidate": "fallback"}),
|
|
||||||
]
|
|
||||||
resolve_calls = []
|
|
||||||
wait_calls = []
|
|
||||||
llm_calls = []
|
|
||||||
|
|
||||||
def fake_resolve_task_candidates(**kwargs):
|
|
||||||
resolve_calls.append(kwargs)
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
async def fake_wait_for_interactive_quiet(label):
|
|
||||||
wait_calls.append(label)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def fake_llm_call_async(url, model, messages, **kwargs):
|
|
||||||
llm_calls.append((url, model, messages, kwargs))
|
|
||||||
if model == "primary-model":
|
|
||||||
raise RuntimeError("primary unavailable")
|
|
||||||
return "<<<SUMMARY>>>\n- Used the fallback model.\n<<<END>>>"
|
|
||||||
|
|
||||||
monkeypatch.setattr(task_endpoint, "resolve_task_candidates", fake_resolve_task_candidates)
|
|
||||||
monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", fake_wait_for_interactive_quiet)
|
|
||||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm_call_async)
|
|
||||||
|
|
||||||
summary = await email_helpers._generate_scheduled_email_summary(
|
|
||||||
url="http://caller-fallback.invalid/v1",
|
|
||||||
model="caller-fallback-model",
|
|
||||||
sender="Sender <sender@example.com>",
|
|
||||||
subject="Scheduled subject",
|
|
||||||
body_for_llm="Please summarize this scheduled email.",
|
|
||||||
headers={"Authorization": "Bearer test"},
|
|
||||||
owner="alice",
|
|
||||||
max_tokens=321,
|
|
||||||
timeout=54,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert summary == "- Used the fallback model."
|
|
||||||
assert resolve_calls == [{
|
|
||||||
"fallback_url": "http://caller-fallback.invalid/v1",
|
|
||||||
"fallback_model": "caller-fallback-model",
|
|
||||||
"fallback_headers": {"Authorization": "Bearer test"},
|
|
||||||
"owner": "alice",
|
|
||||||
}]
|
|
||||||
assert wait_calls == ["background task LLM"]
|
|
||||||
assert [call[1] for call in llm_calls] == ["primary-model", "fallback-model"]
|
|
||||||
assert all(call[3]["workload"] == "background" for call in llm_calls)
|
|
||||||
assert all(call[3]["max_tokens"] == 321 for call in llm_calls)
|
|
||||||
assert all(call[3]["timeout"] == 54 for call in llm_calls)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scheduled_local_summary_is_preempted_by_foreground_call(monkeypatch):
|
|
||||||
import routes.email_helpers as email_helpers
|
|
||||||
import src.llm_core as llm_core
|
|
||||||
import src.task_endpoint as task_endpoint
|
|
||||||
|
|
||||||
local_url = "http://127.0.0.1:11434/v1/chat/completions"
|
|
||||||
background_started = asyncio.Event()
|
|
||||||
never_release = asyncio.Event()
|
|
||||||
observed_workloads = []
|
|
||||||
|
|
||||||
monkeypatch.setenv("ODYSSEUS_LOCAL_MODEL_GATE", "true")
|
|
||||||
monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "false")
|
|
||||||
monkeypatch.setattr(llm_core, "_LOCAL_MODEL_LOCK", asyncio.Lock())
|
|
||||||
monkeypatch.setattr(llm_core, "_LOCAL_MODEL_CURRENT", {})
|
|
||||||
monkeypatch.setattr(llm_core, "_LOCAL_MODEL_WAITING_FOREGROUND", 0)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
task_endpoint,
|
|
||||||
"resolve_task_candidates",
|
|
||||||
lambda **_kwargs: [(local_url, "scheduled-model", {})],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fake_wait_for_interactive_quiet(_label):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def gated_llm_call(url, model, messages, **kwargs):
|
|
||||||
assert messages
|
|
||||||
workload = kwargs.get("workload")
|
|
||||||
observed_workloads.append(workload)
|
|
||||||
async with llm_core._local_model_slot(url, model, workload=workload):
|
|
||||||
background_started.set()
|
|
||||||
await never_release.wait()
|
|
||||||
return "unreachable"
|
|
||||||
|
|
||||||
monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", fake_wait_for_interactive_quiet)
|
|
||||||
monkeypatch.setattr(llm_core, "llm_call_async", gated_llm_call)
|
|
||||||
|
|
||||||
background_task = asyncio.create_task(email_helpers._generate_scheduled_email_summary(
|
|
||||||
url=local_url,
|
|
||||||
model="scheduled-model",
|
|
||||||
sender="Sender",
|
|
||||||
subject="Scheduled",
|
|
||||||
body_for_llm="Scheduled body",
|
|
||||||
owner="alice",
|
|
||||||
))
|
|
||||||
foreground_task = None
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(background_started.wait(), timeout=1)
|
|
||||||
|
|
||||||
async def run_foreground():
|
|
||||||
async with llm_core._local_model_slot(
|
|
||||||
local_url,
|
|
||||||
"interactive-model",
|
|
||||||
workload="foreground",
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
|
|
||||||
foreground_task = asyncio.create_task(run_foreground())
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await asyncio.wait_for(background_task, timeout=1)
|
|
||||||
assert await asyncio.wait_for(foreground_task, timeout=1) is True
|
|
||||||
assert observed_workloads == ["background"]
|
|
||||||
finally:
|
|
||||||
for task in (background_task, foreground_task):
|
|
||||||
if task is not None and not task.done():
|
|
||||||
task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_manual_email_summary_uses_shared_helper_and_caches(tmp_path, monkeypatch):
|
|
||||||
import routes.email_helpers as email_helpers
|
|
||||||
import routes.email_routes as email_routes
|
|
||||||
import src.endpoint_resolver as endpoint_resolver
|
|
||||||
|
|
||||||
db_path = tmp_path / "scheduled_emails.db"
|
|
||||||
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
|
||||||
monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
|
|
||||||
email_helpers._init_scheduled_db()
|
|
||||||
|
|
||||||
resolve_calls = []
|
|
||||||
|
|
||||||
def fake_resolve_endpoint(kind, owner=None):
|
|
||||||
resolve_calls.append((kind, owner))
|
|
||||||
assert kind == "utility"
|
|
||||||
assert owner == "alice"
|
|
||||||
return (
|
|
||||||
"https://chatgpt.com/backend-api/codex/responses",
|
|
||||||
"gpt-5.5",
|
|
||||||
{"Authorization": "Bearer test"},
|
|
||||||
)
|
|
||||||
|
|
||||||
helper_calls = {}
|
|
||||||
|
|
||||||
async def fake_generate_email_summary(**kwargs):
|
|
||||||
helper_calls.update(kwargs)
|
|
||||||
return "- Manual summary"
|
|
||||||
|
|
||||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
|
||||||
monkeypatch.setattr(email_routes, "_generate_email_summary", fake_generate_email_summary)
|
|
||||||
|
|
||||||
router = email_routes.setup_email_routes()
|
|
||||||
summarize = _route_endpoint(router, "/api/email/summarize", "POST")
|
|
||||||
|
|
||||||
result = await summarize(
|
|
||||||
{
|
|
||||||
"body": "This is a long enough email body for manual summary.",
|
|
||||||
"subject": "Manual subject",
|
|
||||||
"from": "Sender <sender@example.com>",
|
|
||||||
"message_id": "<manual@example.com>",
|
|
||||||
"folder": "INBOX",
|
|
||||||
},
|
|
||||||
owner="alice",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"success": True,
|
|
||||||
"summary": "- Manual summary",
|
|
||||||
"model_used": "gpt-5.5",
|
|
||||||
}
|
|
||||||
assert resolve_calls == [("utility", "alice")]
|
|
||||||
assert helper_calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
|
|
||||||
assert helper_calls["model"] == "gpt-5.5"
|
|
||||||
assert helper_calls["headers"]["Authorization"] == "Bearer test"
|
|
||||||
assert helper_calls["headers"]["Content-Type"] == "application/json"
|
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT owner, summary, model_used FROM email_summaries WHERE message_id=?",
|
|
||||||
("<manual@example.com>",),
|
|
||||||
).fetchone()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
assert row == ("alice", "- Manual summary", "gpt-5.5")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("exception_kind", ["http", "runtime"])
|
|
||||||
async def test_manual_email_summary_never_exposes_provider_exception(
|
|
||||||
monkeypatch,
|
|
||||||
caplog,
|
|
||||||
exception_kind,
|
|
||||||
):
|
|
||||||
from fastapi import HTTPException
|
|
||||||
import routes.email_routes as email_routes
|
|
||||||
import src.endpoint_resolver as endpoint_resolver
|
|
||||||
|
|
||||||
secret_detail = (
|
|
||||||
"endpoint=https://private.example.internal/v1 provider=ollama "
|
|
||||||
"model=private-model response_body=private-response "
|
|
||||||
"Authorization: Bearer token-secret-value"
|
|
||||||
)
|
|
||||||
|
|
||||||
def fake_resolve_endpoint(kind, owner=None):
|
|
||||||
assert kind == "utility"
|
|
||||||
assert owner == "alice"
|
|
||||||
return (
|
|
||||||
"https://private.example.internal/v1",
|
|
||||||
"private-model",
|
|
||||||
{"Authorization": "Bearer token-secret-value"},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fail_summary(**_kwargs):
|
|
||||||
if exception_kind == "http":
|
|
||||||
raise HTTPException(status_code=502, detail=secret_detail)
|
|
||||||
raise RuntimeError(secret_detail)
|
|
||||||
|
|
||||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
|
||||||
monkeypatch.setattr(email_routes, "_generate_email_summary", fail_summary)
|
|
||||||
caplog.set_level(logging.WARNING, logger=email_routes.__name__)
|
|
||||||
|
|
||||||
router = email_routes.setup_email_routes()
|
|
||||||
summarize = _route_endpoint(router, "/api/email/summarize", "POST")
|
|
||||||
result = await summarize(
|
|
||||||
{
|
|
||||||
"body": "This email body is long enough to summarize.",
|
|
||||||
"subject": "Sensitive provider failure",
|
|
||||||
"from": "Sender <sender@example.com>",
|
|
||||||
},
|
|
||||||
owner="alice",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"success": False,
|
|
||||||
"error": "Failed to summarize",
|
|
||||||
"error_code": "email_summary_unavailable",
|
|
||||||
}
|
|
||||||
exposed = json.dumps(result) + caplog.text
|
|
||||||
for marker in (
|
|
||||||
"private.example.internal",
|
|
||||||
"ollama",
|
|
||||||
"private-model",
|
|
||||||
"private-response",
|
|
||||||
"token-secret-value",
|
|
||||||
):
|
|
||||||
assert marker not in exposed
|
|
||||||
assert f"type={'HTTPException' if exception_kind == 'http' else 'RuntimeError'}" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scheduled_email_summary_uses_shared_helper_and_caches(tmp_path, monkeypatch):
|
|
||||||
import routes.email_helpers as email_helpers
|
|
||||||
import routes.email_pollers as email_pollers
|
|
||||||
|
|
||||||
db_path = tmp_path / "scheduled_emails.db"
|
|
||||||
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
|
||||||
monkeypatch.setattr(email_pollers, "SCHEDULED_DB", db_path)
|
|
||||||
email_helpers._init_scheduled_db()
|
|
||||||
|
|
||||||
raw_email = (
|
|
||||||
b"From: Sender <sender@example.com>\r\n"
|
|
||||||
b"To: Alice <alice@example.com>\r\n"
|
|
||||||
b"Subject: Scheduled subject\r\n"
|
|
||||||
b"Message-ID: <scheduled@example.com>\r\n"
|
|
||||||
b"Date: Tue, 01 Jan 2026 12:00:00 +0000\r\n"
|
|
||||||
b"Content-Type: text/plain; charset=utf-8\r\n"
|
|
||||||
b"\r\n"
|
|
||||||
+ (b"Please review this scheduled summary email. " * 8)
|
|
||||||
)
|
|
||||||
|
|
||||||
class FakeImap:
|
|
||||||
def __init__(self):
|
|
||||||
self.logout_calls = 0
|
|
||||||
|
|
||||||
def select(self, _folder, readonly=True):
|
|
||||||
return "OK", []
|
|
||||||
|
|
||||||
def uid(self, command, *args):
|
|
||||||
if command == "SEARCH":
|
|
||||||
return "OK", [b"1"]
|
|
||||||
if command == "FETCH":
|
|
||||||
return "OK", [(b"1 (RFC822)", raw_email)]
|
|
||||||
raise AssertionError(f"unexpected uid command: {command!r} {args!r}")
|
|
||||||
|
|
||||||
def logout(self):
|
|
||||||
self.logout_calls += 1
|
|
||||||
|
|
||||||
fake_conn = FakeImap()
|
|
||||||
|
|
||||||
def fake_resolve_task_candidates(owner=None):
|
|
||||||
assert owner == "alice"
|
|
||||||
return [(
|
|
||||||
"https://chatgpt.com/backend-api/codex/responses",
|
|
||||||
"gpt-5.5",
|
|
||||||
{"Authorization": "Bearer test"},
|
|
||||||
)]
|
|
||||||
|
|
||||||
helper_calls = {}
|
|
||||||
|
|
||||||
async def fake_generate_email_summary(**kwargs):
|
|
||||||
helper_calls.update(kwargs)
|
|
||||||
return "- Scheduled summary"
|
|
||||||
|
|
||||||
monkeypatch.setattr(email_pollers, "_load_settings", lambda: {"email_auto_summarize": True})
|
|
||||||
monkeypatch.setattr(email_pollers, "_owner_for_email_account", lambda _account_id: "alice")
|
|
||||||
monkeypatch.setattr(email_pollers, "_imap_connect", lambda account_id=None, owner="": fake_conn)
|
|
||||||
monkeypatch.setattr(email_pollers, "_get_email_config", lambda account_id=None, owner="": {"from_address": "alice@example.com"})
|
|
||||||
monkeypatch.setattr(email_pollers, "resolve_task_candidates", fake_resolve_task_candidates)
|
|
||||||
monkeypatch.setattr(email_pollers, "_generate_scheduled_email_summary", fake_generate_email_summary)
|
|
||||||
|
|
||||||
result = await email_pollers._auto_summarize_pass_single(account_id="acct-alice")
|
|
||||||
|
|
||||||
assert "summarized 1" in result
|
|
||||||
assert "summary failed" not in result
|
|
||||||
assert helper_calls["url"] == "https://chatgpt.com/backend-api/codex/responses"
|
|
||||||
assert helper_calls["model"] == "gpt-5.5"
|
|
||||||
assert helper_calls["headers"]["Authorization"] == "Bearer test"
|
|
||||||
assert helper_calls["headers"]["Content-Type"] == "application/json"
|
|
||||||
assert helper_calls["owner"] == "alice"
|
|
||||||
assert fake_conn.logout_calls == 1
|
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT owner, summary, model_used FROM email_summaries WHERE message_id=?",
|
|
||||||
("<scheduled@example.com>",),
|
|
||||||
).fetchone()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
assert row == ("alice", "- Scheduled summary", "gpt-5.5")
|
|
||||||
@@ -87,7 +87,7 @@ def test_known_imap_mailbox_call_sites_are_quoted():
|
|||||||
assert "conn.select(sent_name" not in pollers
|
assert "conn.select(sent_name" not in pollers
|
||||||
assert "imap.append(sent_folder" not in pollers
|
assert "imap.append(sent_folder" not in pollers
|
||||||
|
|
||||||
document_routes = Path("routes/document/document_routes.py").read_text()
|
document_routes = Path("routes/document_routes.py").read_text()
|
||||||
assert "conn.select(doc.source_email_folder" not in document_routes
|
assert "conn.select(doc.source_email_folder" not in document_routes
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,8 @@ link-local/metadata is always rejected; RFC-1918/loopback only when
|
|||||||
INTEGRATION_API_BLOCK_PRIVATE_IPS=true (LAN integrations are the primary
|
INTEGRATION_API_BLOCK_PRIVATE_IPS=true (LAN integrations are the primary
|
||||||
use case, so private stays allowed by default).
|
use case, so private stays allowed by default).
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import ipaddress
|
|
||||||
import ssl
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import httpcore
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import integrations
|
from src import integrations
|
||||||
@@ -102,238 +97,3 @@ async def test_private_base_url_allowed_by_default_blocked_with_knob(monkeypatch
|
|||||||
assert result["exit_code"] == 1
|
assert result["exit_code"] == 1
|
||||||
assert "rejected" in result["error"].lower()
|
assert "rejected" in result["error"].lower()
|
||||||
client.request.assert_not_called()
|
client.request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
async def _call_capturing_transport(base_url, path="/items"):
|
|
||||||
"""Drive execute_api_call and return (result, transport) where transport is
|
|
||||||
the object passed to httpx.AsyncClient(transport=...)."""
|
|
||||||
resp = MagicMock()
|
|
||||||
resp.status_code = 200
|
|
||||||
resp.headers = {"content-type": "application/json"}
|
|
||||||
resp.json.return_value = {"ok": True}
|
|
||||||
resp.text = '{"ok": true}'
|
|
||||||
|
|
||||||
client = AsyncMock()
|
|
||||||
client.__aenter__ = AsyncMock(return_value=client)
|
|
||||||
client.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
client.request = AsyncMock(return_value=resp)
|
|
||||||
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def _fake_async_client(*args, **kwargs):
|
|
||||||
captured.update(kwargs)
|
|
||||||
return client
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(integrations, "_find_integration",
|
|
||||||
return_value=_integration(base_url)),
|
|
||||||
patch("httpx.AsyncClient", side_effect=_fake_async_client),
|
|
||||||
):
|
|
||||||
result = await integrations.execute_api_call("test_integ", "GET", path)
|
|
||||||
return result, captured.get("transport"), client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connection_is_pinned_to_the_validated_ip(monkeypatch):
|
|
||||||
"""DNS-rebinding defense: the guard resolves the host once to a benign
|
|
||||||
public IP, and the request must be pinned to *that* IP so a host that
|
|
||||||
rebinds to the metadata range at connect time can't be reached with the
|
|
||||||
integration's auth headers. Static resolution passing the guard is not
|
|
||||||
enough — a plain client would re-resolve at connect."""
|
|
||||||
monkeypatch.setattr("src.url_safety._default_resolver",
|
|
||||||
lambda host: ["93.184.216.34"])
|
|
||||||
result, transport, client = await _call_capturing_transport(
|
|
||||||
"http://rebinding.attacker.example")
|
|
||||||
|
|
||||||
assert result.get("exit_code") == 0
|
|
||||||
client.request.assert_called_once()
|
|
||||||
assert isinstance(transport, integrations._PinnedAsyncTransport)
|
|
||||||
assert [str(ip) for ip in transport._pinned_ips] == ["93.184.216.34"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pin_carries_the_whole_validated_ip_set(monkeypatch):
|
|
||||||
"""When a host resolves to several records the transport keeps all of them
|
|
||||||
(check_outbound_url validated every one), in resolver order, so it can fall
|
|
||||||
back past a dead first address instead of failing the whole call."""
|
|
||||||
monkeypatch.setattr("src.url_safety._default_resolver",
|
|
||||||
lambda host: ["93.184.216.34", "198.51.100.7"])
|
|
||||||
result, transport, _ = await _call_capturing_transport("http://multi.example")
|
|
||||||
|
|
||||||
assert result.get("exit_code") == 0
|
|
||||||
assert [str(ip) for ip in transport._pinned_ips] == ["93.184.216.34", "198.51.100.7"]
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeStream:
|
|
||||||
"""Stand-in for the connected socket the real backend returns."""
|
|
||||||
|
|
||||||
|
|
||||||
class _RecordingBackend:
|
|
||||||
"""Fake httpcore backend: connect_tcp fails for the addresses in `dead`
|
|
||||||
and succeeds for the rest, recording the order it was asked to connect."""
|
|
||||||
|
|
||||||
def __init__(self, dead):
|
|
||||||
self.dead = set(dead)
|
|
||||||
self.attempts = []
|
|
||||||
|
|
||||||
async def connect_tcp(self, host, port, timeout=None, local_address=None,
|
|
||||||
socket_options=None):
|
|
||||||
self.attempts.append((host, timeout))
|
|
||||||
if host in self.dead:
|
|
||||||
raise httpcore.ConnectError(f"connection refused: {host}")
|
|
||||||
return _FakeStream()
|
|
||||||
|
|
||||||
|
|
||||||
def _pinned_backend(ips, dead):
|
|
||||||
"""A _PinnedAsyncBackend whose underlying connect is the recording fake."""
|
|
||||||
backend = integrations._PinnedAsyncBackend(ips)
|
|
||||||
backend._real = _RecordingBackend(dead)
|
|
||||||
return backend
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_falls_back_from_dead_first_to_live_second():
|
|
||||||
"""first-dead / second-live: the pinned backend must try the next validated
|
|
||||||
address when the first refuses, rather than surfacing the failure. It also
|
|
||||||
ignores the `host` httpcore passes (the original hostname) and connects to
|
|
||||||
the pinned IPs, which is what keeps TLS SNI / Host on the real hostname."""
|
|
||||||
ips = [ipaddress.ip_address("203.0.113.10"), ipaddress.ip_address("198.51.100.7")]
|
|
||||||
backend = _pinned_backend(ips, dead={"203.0.113.10"})
|
|
||||||
|
|
||||||
stream = await backend.connect_tcp("original.hostname.example", 443, timeout=5.0)
|
|
||||||
|
|
||||||
assert isinstance(stream, _FakeStream)
|
|
||||||
# Tried the dead address first, then the live one — never the hostname.
|
|
||||||
assert [host for host, _ in backend._real.attempts] == ["203.0.113.10", "198.51.100.7"]
|
|
||||||
# Fallback shared one budget: the second attempt got the time left, not a fresh 5s.
|
|
||||||
assert backend._real.attempts[1][1] <= 5.0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connect_raises_when_every_validated_address_is_dead():
|
|
||||||
ips = [ipaddress.ip_address("203.0.113.10"), ipaddress.ip_address("198.51.100.7")]
|
|
||||||
backend = _pinned_backend(ips, dead={"203.0.113.10", "198.51.100.7"})
|
|
||||||
|
|
||||||
with pytest.raises(httpcore.ConnectError):
|
|
||||||
await backend.connect_tcp("original.hostname.example", 443, timeout=5.0)
|
|
||||||
assert [host for host, _ in backend._real.attempts] == ["203.0.113.10", "198.51.100.7"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pinned_transport_reuses_httpx_ca_trust(monkeypatch):
|
|
||||||
"""TLS trust must come from the same builder the default httpx client uses
|
|
||||||
(certifi + SSL_CERT_FILE / SSL_CERT_DIR via trust_env), not from
|
|
||||||
ssl.create_default_context()'s system roots — otherwise chains that verified
|
|
||||||
under the old default client can silently stop verifying."""
|
|
||||||
sentinel = ssl.create_default_context()
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def _fake_create(*args, **kwargs):
|
|
||||||
calls.append(kwargs)
|
|
||||||
return sentinel
|
|
||||||
|
|
||||||
monkeypatch.setattr(httpx, "create_ssl_context", _fake_create)
|
|
||||||
transport = integrations._PinnedAsyncTransport([ipaddress.ip_address("93.184.216.34")])
|
|
||||||
try:
|
|
||||||
assert calls, "transport did not build its context via httpx.create_ssl_context"
|
|
||||||
assert transport._pool._ssl_context is sentinel
|
|
||||||
finally:
|
|
||||||
await transport.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_real_socket_falls_back_from_dead_first_to_live_second():
|
|
||||||
"""End-to-end over real loopback sockets: pin [127.0.0.2 (nothing
|
|
||||||
listening), 127.0.0.1 (live)], and the request must succeed by falling back
|
|
||||||
to the second address while the Host header stays the original hostname —
|
|
||||||
i.e. only the socket destination moved, vhost/SNI routing did not."""
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def handle(reader, writer):
|
|
||||||
request = await reader.read(4096)
|
|
||||||
for line in request.split(b"\r\n"):
|
|
||||||
if line.lower().startswith(b"host:"):
|
|
||||||
captured["host"] = line.split(b":", 1)[1].strip().decode()
|
|
||||||
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi")
|
|
||||||
await writer.drain()
|
|
||||||
writer.close()
|
|
||||||
|
|
||||||
server = await asyncio.start_server(handle, "127.0.0.1", 0)
|
|
||||||
port = server.sockets[0].getsockname()[1]
|
|
||||||
async with server:
|
|
||||||
await server.start_serving()
|
|
||||||
transport = integrations._PinnedAsyncTransport(
|
|
||||||
[ipaddress.ip_address("127.0.0.2"), ipaddress.ip_address("127.0.0.1")]
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(transport=transport) as client:
|
|
||||||
resp = await client.get(f"http://pinned.example:{port}/health")
|
|
||||||
finally:
|
|
||||||
await transport.aclose()
|
|
||||||
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.text == "hi"
|
|
||||||
assert captured.get("host") == f"pinned.example:{port}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ip_literal_base_url_still_pins_and_is_not_rejected():
|
|
||||||
"""A base_url that is already an IP has nothing to rebind, but it must not
|
|
||||||
trip the "did not resolve" guard either.
|
|
||||||
|
|
||||||
check_outbound_url resolves even a literal (getaddrinfo returns the address
|
|
||||||
itself), so the captured list is populated and the pin is a no-op rather
|
|
||||||
than a rejection. Uses the real resolver on purpose — no monkeypatch — so
|
|
||||||
this would catch the fail-closed branch firing on a literal.
|
|
||||||
"""
|
|
||||||
result, transport, client = await _call_capturing_transport(
|
|
||||||
"http://93.184.216.34")
|
|
||||||
|
|
||||||
assert result.get("exit_code") == 0
|
|
||||||
assert isinstance(transport, integrations._PinnedAsyncTransport)
|
|
||||||
assert [str(ip) for ip in transport._pinned_ips] == ["93.184.216.34"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ipv6_base_url_pins_every_validated_address(monkeypatch):
|
|
||||||
"""IPv6 goes down the same path as v4.
|
|
||||||
|
|
||||||
Resolution is stubbed rather than using a literal so this doesn't depend on
|
|
||||||
the runner having IPv6 configured.
|
|
||||||
"""
|
|
||||||
v6 = "2606:2800:220:1:248:1893:25c8:1946"
|
|
||||||
monkeypatch.setattr("src.url_safety._default_resolver", lambda host: [v6])
|
|
||||||
result, transport, client = await _call_capturing_transport("http://v6.example")
|
|
||||||
|
|
||||||
assert result.get("exit_code") == 0
|
|
||||||
assert isinstance(transport, integrations._PinnedAsyncTransport)
|
|
||||||
assert [str(ip) for ip in transport._pinned_ips] == [v6]
|
|
||||||
|
|
||||||
|
|
||||||
def test_validated_ips_strips_zone_id_and_drops_junk():
|
|
||||||
"""getaddrinfo can hand back a scoped v6 address like 'fe80::1%eth0'."""
|
|
||||||
got = integrations._validated_ips(
|
|
||||||
["93.184.216.34", "fe80::1%eth0", "not-an-ip", None, "2001:db8::5"]
|
|
||||||
)
|
|
||||||
assert [str(ip) for ip in got] == ["93.184.216.34", "fe80::1", "2001:db8::5"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_validated_ips_deduplicates_repeated_addresses():
|
|
||||||
"""The resolver is getaddrinfo(host, None) with no socktype filter, so glibc
|
|
||||||
returns one record per socktype and a single-homed host arrives three times
|
|
||||||
over. Duplicates must collapse (first-seen order kept) or the connect
|
|
||||||
fallback wastes its shared deadline retrying one dead address."""
|
|
||||||
got = integrations._validated_ips(
|
|
||||||
["93.184.216.34", "93.184.216.34", "93.184.216.34"]
|
|
||||||
)
|
|
||||||
assert [str(ip) for ip in got] == ["93.184.216.34"]
|
|
||||||
|
|
||||||
# Order is first-seen, and distinct addresses all survive.
|
|
||||||
got = integrations._validated_ips(
|
|
||||||
["198.51.100.7", "93.184.216.34", "198.51.100.7", "2001:db8::5"]
|
|
||||||
)
|
|
||||||
assert [str(ip) for ip in got] == ["198.51.100.7", "93.184.216.34", "2001:db8::5"]
|
|
||||||
|
|
||||||
# A zone-id variant is the same address once stripped, so it collapses too.
|
|
||||||
got = integrations._validated_ips(["fe80::1%eth0", "fe80::1%eth1", "fe80::1"])
|
|
||||||
assert [str(ip) for ip in got] == ["fe80::1"]
|
|
||||||
|
|||||||
@@ -83,10 +83,9 @@ async def _call(json_data, status=200):
|
|||||||
with (
|
with (
|
||||||
patch.object(integrations, "_find_integration", return_value=DUMMY_INTEGRATION),
|
patch.object(integrations, "_find_integration", return_value=DUMMY_INTEGRATION),
|
||||||
patch("httpx.AsyncClient", return_value=mock_client),
|
patch("httpx.AsyncClient", return_value=mock_client),
|
||||||
# api.example.com doesn't resolve. Point the resolver at a public
|
# api.example.com doesn't resolve; the SSRF guard would fail closed.
|
||||||
# address instead of stubbing the guard open, so the real check (and
|
# These tests are about truncation, so stub the guard open.
|
||||||
# the connect-IP pinning that reads its result) still runs.
|
patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
|
||||||
patch("src.url_safety._default_resolver", lambda host: ["93.184.216.34"]),
|
|
||||||
):
|
):
|
||||||
return await integrations.execute_api_call("test_integ", "GET", "/items")
|
return await integrations.execute_api_call("test_integ", "GET", "/items")
|
||||||
|
|
||||||
@@ -102,10 +101,9 @@ async def _call_with_integration(integration, path="/items"):
|
|||||||
with (
|
with (
|
||||||
patch.object(integrations, "_find_integration", return_value=integration),
|
patch.object(integrations, "_find_integration", return_value=integration),
|
||||||
patch("httpx.AsyncClient", return_value=mock_client),
|
patch("httpx.AsyncClient", return_value=mock_client),
|
||||||
# api.example.com doesn't resolve. Point the resolver at a public
|
# api.example.com doesn't resolve; the SSRF guard would fail closed.
|
||||||
# address instead of stubbing the guard open, so the real check (and
|
# These tests are about URL joining, so stub the guard open.
|
||||||
# the connect-IP pinning that reads its result) still runs.
|
patch("src.url_safety.check_outbound_url", return_value=(True, "ok")),
|
||||||
patch("src.url_safety._default_resolver", lambda host: ["93.184.216.34"]),
|
|
||||||
):
|
):
|
||||||
result = await integrations.execute_api_call("test_integ", "GET", path)
|
result = await integrations.execute_api_call("test_integ", "GET", path)
|
||||||
return result, mock_client
|
return result, mock_client
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"""Regression coverage for issue-description label lifecycle events."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parent.parent
|
|
||||||
_CHECKER = _REPO / ".github" / "scripts" / "check-issue-description.js"
|
|
||||||
_WORKFLOW = _REPO / ".github" / "workflows" / "issue-description-check.yml"
|
|
||||||
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
|
|
||||||
|
|
||||||
|
|
||||||
def _run_closed_issue(action):
|
|
||||||
harness = r"""
|
|
||||||
const checkIssueDescription = require(process.argv[1]);
|
|
||||||
const action = process.argv[2];
|
|
||||||
const calls = [];
|
|
||||||
const unexpected = (name) => async () => {
|
|
||||||
throw new Error(`${name} should not be called for a closed issue`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const github = {
|
|
||||||
rest: {
|
|
||||||
issues: {
|
|
||||||
removeLabel: async (params) => calls.push({ method: 'removeLabel', params }),
|
|
||||||
getLabel: unexpected('getLabel'),
|
|
||||||
addLabels: unexpected('addLabels'),
|
|
||||||
listComments: unexpected('listComments'),
|
|
||||||
createComment: unexpected('createComment'),
|
|
||||||
updateComment: unexpected('updateComment'),
|
|
||||||
deleteComment: unexpected('deleteComment'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const context = {
|
|
||||||
payload: {
|
|
||||||
action,
|
|
||||||
issue: { number: 42, state: 'closed', body: '', labels: [] },
|
|
||||||
},
|
|
||||||
repo: { owner: 'odysseus-dev', repo: 'odysseus' },
|
|
||||||
};
|
|
||||||
const core = {
|
|
||||||
warning: unexpected('core.warning'),
|
|
||||||
setFailed: unexpected('core.setFailed'),
|
|
||||||
};
|
|
||||||
|
|
||||||
checkIssueDescription({ github, context, core })
|
|
||||||
.then(() => process.stdout.write(JSON.stringify(calls)))
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
"""
|
|
||||||
proc = subprocess.run(
|
|
||||||
["node", "-e", harness, str(_CHECKER), action],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=str(_REPO),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
assert proc.returncode == 0, proc.stderr
|
|
||||||
return json.loads(proc.stdout)
|
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_handles_issue_closures():
|
|
||||||
workflow = _WORKFLOW.read_text()
|
|
||||||
assert "types: [opened, edited, reopened, closed]" in workflow
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("action", ["closed", "edited"])
|
|
||||||
def test_closed_issue_only_drops_ready_for_review(action):
|
|
||||||
assert _run_closed_issue(action) == [
|
|
||||||
{
|
|
||||||
"method": "removeLabel",
|
|
||||||
"params": {
|
|
||||||
"owner": "odysseus-dev",
|
|
||||||
"repo": "odysseus",
|
|
||||||
"issue_number": 42,
|
|
||||||
"name": "ready for review",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -29,13 +29,6 @@ from src.llm_core import _anthropic_rejects_temperature, _build_anthropic_payloa
|
|||||||
"anthropic/claude-opus-4-7", # tolerate a provider-prefixed id
|
"anthropic/claude-opus-4-7", # tolerate a provider-prefixed id
|
||||||
"claude-opus-4-10", # future minor still >= 4.7
|
"claude-opus-4-10", # future minor still >= 4.7
|
||||||
"claude-opus-5-0", # future major
|
"claude-opus-5-0", # future major
|
||||||
# Major-only ids: a missing minor reads as `.0`, so these are >= 4.7 too
|
|
||||||
# (issue #5753). Before the fix the version pattern required a minor, so
|
|
||||||
# these fell through to "accepts temperature" and every call 400'd.
|
|
||||||
"claude-opus-5",
|
|
||||||
"claude-opus-5-20260101", # major-only + dated snapshot
|
|
||||||
"anthropic/claude-opus-5", # major-only behind a provider prefix
|
|
||||||
"claude-opus-6", # future major-only
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_opus_47_plus_rejects_temperature(model):
|
def test_opus_47_plus_rejects_temperature(model):
|
||||||
@@ -55,10 +48,7 @@ def test_opus_47_plus_rejects_temperature(model):
|
|||||||
"claude-opus-4-6-20251201", # dated 4.6 snapshot — older, still keeps temperature
|
"claude-opus-4-6-20251201", # dated 4.6 snapshot — older, still keeps temperature
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
"claude-3-5-sonnet",
|
"claude-3-5-sonnet",
|
||||||
"claude-3-opus-20240229", # legacy Claude 3 Opus — date directly after
|
"claude-3-opus-20240229", # legacy Claude 3 Opus — no opus-N-M pattern, kept
|
||||||
# "opus-", so the major must not swallow it as version 20240229 (that is
|
|
||||||
# what makes capping the major at 1-2 digits necessary once the minor
|
|
||||||
# became optional in #5753).
|
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5",
|
||||||
"claude-x",
|
"claude-x",
|
||||||
"octopus-4-8", # "opus" only as a substring of another word — must not match
|
"octopus-4-8", # "opus" only as a substring of another word — must not match
|
||||||
@@ -97,20 +87,6 @@ def test_payload_keeps_temperature_for_older_models():
|
|||||||
assert _payload("claude-3-5-sonnet", 1.2)["temperature"] == 1.0
|
assert _payload("claude-3-5-sonnet", 1.2)["temperature"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
def test_payload_omits_temperature_for_major_only_opus_5():
|
|
||||||
# Issue #5753: the scheduled-task path calls stream_agent_loop() without a
|
|
||||||
# temperature and inherits its 0.3 default, so `claude-opus-5` 400'd on every
|
|
||||||
# run and surfaced as "the model returned an empty response". Interactive chat
|
|
||||||
# leaves temperature None and never hit it.
|
|
||||||
assert "temperature" not in _payload("claude-opus-5", 0.3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_payload_keeps_temperature_for_legacy_claude_3_opus():
|
|
||||||
# Guards the major-digit cap: `opus-20240229` must not parse as version
|
|
||||||
# 20240229, or Claude 3 Opus would silently lose the caller's temperature.
|
|
||||||
assert _payload("claude-3-opus-20240229", 0.5)["temperature"] == 0.5
|
|
||||||
|
|
||||||
|
|
||||||
def test_payload_keeps_temperature_for_dated_opus_4_0():
|
def test_payload_keeps_temperature_for_dated_opus_4_0():
|
||||||
# Anthropic's dated id for Opus 4.0 (claude-opus-4-20250514) is in this repo's
|
# Anthropic's dated id for Opus 4.0 (claude-opus-4-20250514) is in this repo's
|
||||||
# ANTHROPIC_MODELS list. The date must not be misread as a >= 4.7 minor, or the
|
# ANTHROPIC_MODELS list. The date must not be misread as a >= 4.7 minor, or the
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.tools.system import do_manage_skills
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"payload",
|
|
||||||
[
|
|
||||||
{},
|
|
||||||
{"action": ""},
|
|
||||||
{"action": " "},
|
|
||||||
{"name": "demo", "description": "x", "procedure": ["step"]},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_manage_skills_requires_action(payload):
|
|
||||||
result = await do_manage_skills(json.dumps(payload), owner="test")
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"error": "action is required (list|view|view_ref|add|edit|patch|publish|delete|search)",
|
|
||||||
"exit_code": 1,
|
|
||||||
}
|
|
||||||
@@ -214,50 +214,6 @@ def test_inline_code_content_is_html_escaped(node_available):
|
|||||||
assert "<b>" not in html
|
assert "<b>" not in html
|
||||||
|
|
||||||
|
|
||||||
def test_fenced_code_keeps_dollar_ampersand(node_available):
|
|
||||||
# Issue #5663: the block-restore pass used a string replacement, so `$&` in a
|
|
||||||
# restored block was read as "the matched text" and re-inserted the
|
|
||||||
# placeholder. `perl -pe 's/world/$& again/'` rendered as
|
|
||||||
# "s/world/___CODE_BLOCK_0___amp; again/" — the trailing "amp;" is the orphan
|
|
||||||
# left behind after `$&` consumed the `$&` of the escaped `$&`.
|
|
||||||
html = _run_markdown_case(
|
|
||||||
"```sh\necho \"hello world\" | perl -pe 's/world/$& again/'\n```"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "___CODE_BLOCK_" not in html
|
|
||||||
assert "s/world/$& again/" in html
|
|
||||||
assert "amp; again" not in html.replace("$& again", "")
|
|
||||||
|
|
||||||
|
|
||||||
def test_fenced_code_keeps_dollar_backtick_and_quote(node_available):
|
|
||||||
# `` $` `` and `$'` splice the text before/after the placeholder into the
|
|
||||||
# block. Unlike `$&` these leave no placeholder behind — the characters just
|
|
||||||
# vanish — so assert the content survives verbatim.
|
|
||||||
html = _run_markdown_case("```sh\nsed \"s/$`/x/\" && sed \"s/$'/y/\"\n```")
|
|
||||||
|
|
||||||
assert "___CODE_BLOCK_" not in html
|
|
||||||
assert "s/$`/x/" in html
|
|
||||||
assert "s/$'/y/" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_fenced_code_keeps_double_dollar(node_available):
|
|
||||||
# `$$` collapsed to a single `$` in the restored block.
|
|
||||||
html = _run_markdown_case('```sh\necho "$$USD and $$"\n```')
|
|
||||||
|
|
||||||
assert "$$USD and $$" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_mermaid_block_keeps_dollar_ampersand(node_available):
|
|
||||||
# The mermaid restore site had the same hazard: a node label containing `$&`
|
|
||||||
# re-inserted the ___MERMAID_BLOCK_n___ placeholder into the diagram source,
|
|
||||||
# which then fails to parse. The math and allowed-HTML sites are fixed the
|
|
||||||
# same way; they need KaTeX/sanitizer conditions this harness doesn't set up.
|
|
||||||
html = _run_markdown_case('```mermaid\ngraph TD; A["$&"] --> B;\n```')
|
|
||||||
|
|
||||||
assert "___MERMAID_BLOCK_" not in html
|
|
||||||
assert "$&" in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_currency_dollar_amounts_are_not_rendered_as_math(node_available):
|
def test_currency_dollar_amounts_are_not_rendered_as_math(node_available):
|
||||||
# "$5 to $10" used to pair the two dollar signs as inline-math delimiters
|
# "$5 to $10" used to pair the two dollar signs as inline-math delimiters
|
||||||
# and render "5 to" through KaTeX. Pandoc-style rules now reject it: the
|
# and render "5 to" through KaTeX. Pandoc-style rules now reject it: the
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
"""Regression coverage for the built-in MCP servers' SDK compatibility line."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
REQUIREMENTS = Path(__file__).resolve().parents[1] / "requirements.txt"
|
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_requirement_excludes_breaking_v2_sdk():
|
|
||||||
requirements = [
|
|
||||||
line.split("#", 1)[0].strip().replace(" ", "")
|
|
||||||
for line in REQUIREMENTS.read_text(encoding="utf-8").splitlines()
|
|
||||||
]
|
|
||||||
|
|
||||||
assert "mcp<2" in requirements
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""The Brain > Add Memory form must be submittable (#5828).
|
|
||||||
|
|
||||||
The form previously had no submit button and relied on a deprecated
|
|
||||||
``keypress`` listener for Enter, which is not guaranteed to fire on all
|
|
||||||
platforms — leaving the form with no working submit path. Pins:
|
|
||||||
|
|
||||||
- a visible, keyboard-accessible submit button next to the category select;
|
|
||||||
- the button wired to ``memoryModule.addNewMemory()``;
|
|
||||||
- Enter handled via ``keydown`` with ``preventDefault()`` (and no lingering
|
|
||||||
``keypress`` handler on the input).
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
APP_JS = Path("static/app.js")
|
|
||||||
INDEX_HTML = Path("static/index.html")
|
|
||||||
|
|
||||||
|
|
||||||
def _add_memory_row(html):
|
|
||||||
start = html.index('id="new-memory-input"')
|
|
||||||
end = html.index("</div>", html.index('id="new-memory-add-btn"', start))
|
|
||||||
return html[start:end]
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_memory_form_renders_a_submit_button():
|
|
||||||
html = INDEX_HTML.read_text()
|
|
||||||
row = _add_memory_row(html)
|
|
||||||
|
|
||||||
assert 'id="new-memory-category"' in row, "button must sit in the same row as the form fields"
|
|
||||||
btn_start = row.index('id="new-memory-add-btn"')
|
|
||||||
btn_tag = row[row.rindex("<button", 0, btn_start):row.index(">", btn_start)]
|
|
||||||
assert 'type="button"' in btn_tag, "must not rely on implicit submit semantics"
|
|
||||||
|
|
||||||
|
|
||||||
def _new_memory_wiring_block(source):
|
|
||||||
start = source.index("const newMemoryInput = el('new-memory-input');")
|
|
||||||
end = source.index("// Voice recording", start)
|
|
||||||
return source[start:end]
|
|
||||||
|
|
||||||
|
|
||||||
def test_submit_button_is_wired_to_add_new_memory():
|
|
||||||
block = _new_memory_wiring_block(APP_JS.read_text())
|
|
||||||
|
|
||||||
assert "el('new-memory-add-btn')" in block
|
|
||||||
assert "addEventListener('click', () => memoryModule.addNewMemory())" in block
|
|
||||||
|
|
||||||
|
|
||||||
def test_enter_uses_keydown_with_prevent_default():
|
|
||||||
block = _new_memory_wiring_block(APP_JS.read_text())
|
|
||||||
|
|
||||||
assert "addEventListener('keydown'" in block
|
|
||||||
assert "addEventListener('keypress'" not in block, "keypress is deprecated and unreliable for Enter"
|
|
||||||
assert "e.preventDefault();" in block
|
|
||||||
assert "!e.isComposing" in block, "IME composition must not submit the form"
|
|
||||||
assert "memoryModule.addNewMemory();" in block
|
|
||||||
@@ -67,12 +67,6 @@ class FakeMemoryManager:
|
|||||||
def load_all(self):
|
def load_all(self):
|
||||||
return list(self.rows)
|
return list(self.rows)
|
||||||
|
|
||||||
def load_all_for_update(self):
|
|
||||||
# Mirrors the real MemoryManager: extraction is a read-modify-write and
|
|
||||||
# goes through the strict loader (#5673). A healthy store behaves the
|
|
||||||
# same as load_all.
|
|
||||||
return list(self.rows)
|
|
||||||
|
|
||||||
def load(self, owner=None):
|
def load(self, owner=None):
|
||||||
return [r for r in self.rows if r.get("owner") == owner]
|
return [r for r in self.rows if r.get("owner") == owner]
|
||||||
|
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
"""A memory store that cannot be READ must never be overwritten (issue #5673).
|
|
||||||
|
|
||||||
`MemoryManager.save` is atomic, and the add/import/extract paths are all
|
|
||||||
read-modify-write: load the whole store, append, save it back. `load_all`
|
|
||||||
used to answer a *failed read* with `[]` — indistinguishable from "no
|
|
||||||
memories" — so a failed read turned into
|
|
||||||
|
|
||||||
load_all() -> [] -> [].append(new) -> save([new])
|
|
||||||
|
|
||||||
which atomically replaced the entire store with one entry.
|
|
||||||
|
|
||||||
The trigger that actually bites is a store that is **readable but not
|
|
||||||
parseable** — a truncated file, or one holding `{}` instead of `[]`. Nothing
|
|
||||||
obstructs the write, so the request succeeds with HTTP 200 and every existing
|
|
||||||
memory is destroyed silently. Truncation is reachable: `core/database.py`
|
|
||||||
rewrites memory.json during migration with a plain `open(..., "w")` +
|
|
||||||
`json.dump`, which is not atomic.
|
|
||||||
|
|
||||||
A live exclusive lock is NOT the dangerous case: it blocks the read and the
|
|
||||||
`os.replace` alike, so the save fails too and the store survives (verified
|
|
||||||
end-to-end — clean dev returns 500 there and loses nothing).
|
|
||||||
|
|
||||||
`load_all_for_update` is the strict loader those callers now use: it raises
|
|
||||||
`MemoryStoreUnreadable` rather than reporting an empty store.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import builtins
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.memory import MemoryManager, MemoryStoreUnreadable
|
|
||||||
|
|
||||||
_SEED = [
|
|
||||||
{"id": "m1", "text": "user prefers dark mode", "owner": "alice"},
|
|
||||||
{"id": "m2", "text": "user lives in Berlin", "owner": "alice"},
|
|
||||||
{"id": "m3", "text": "bob's cat is called Mila", "owner": "bob"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _seeded(tmp_path):
|
|
||||||
m = MemoryManager(str(tmp_path))
|
|
||||||
m.save([dict(e) for e in _SEED])
|
|
||||||
return m
|
|
||||||
|
|
||||||
|
|
||||||
def _break_reads_of(monkeypatch, target, exc):
|
|
||||||
"""Make open() raise `exc` for `target` only, leaving every other path alone."""
|
|
||||||
real_open = builtins.open
|
|
||||||
|
|
||||||
def fake_open(file, mode="r", *args, **kwargs):
|
|
||||||
if os.path.abspath(str(file)) == os.path.abspath(target) and "r" in mode:
|
|
||||||
raise exc
|
|
||||||
return real_open(file, mode, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(builtins, "open", fake_open)
|
|
||||||
|
|
||||||
|
|
||||||
# ── the strict loader signals, rather than reporting "empty" ──────────────
|
|
||||||
|
|
||||||
def test_strict_load_raises_on_permission_error(tmp_path, monkeypatch):
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
_break_reads_of(monkeypatch, m.memory_file, PermissionError(13, "locked"))
|
|
||||||
with pytest.raises(MemoryStoreUnreadable):
|
|
||||||
m.load_all_for_update()
|
|
||||||
|
|
||||||
|
|
||||||
def test_strict_load_raises_on_corrupt_json(tmp_path):
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
with open(m.memory_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write('[{"id": "m1", "text": "truncated mid-writ')
|
|
||||||
with pytest.raises(MemoryStoreUnreadable):
|
|
||||||
m.load_all_for_update()
|
|
||||||
|
|
||||||
|
|
||||||
def test_strict_load_raises_when_store_is_not_a_list(tmp_path):
|
|
||||||
# A file holding `{}` or `null` is not an empty store, it is a broken one.
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
with open(m.memory_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump({}, f)
|
|
||||||
with pytest.raises(MemoryStoreUnreadable):
|
|
||||||
m.load_all_for_update()
|
|
||||||
|
|
||||||
|
|
||||||
def test_strict_load_returns_entries_when_healthy(tmp_path):
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
assert {e["id"] for e in m.load_all_for_update()} == {"m1", "m2", "m3"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_strict_load_returns_empty_when_file_genuinely_absent(tmp_path):
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
os.remove(m.memory_file)
|
|
||||||
# Absent is the one case that legitimately means "no memories yet".
|
|
||||||
assert m.load_all_for_update() == []
|
|
||||||
|
|
||||||
|
|
||||||
# ── read paths stay lenient, so an unreadable store can't break chat ──────
|
|
||||||
|
|
||||||
def test_read_path_still_degrades_to_empty(tmp_path, monkeypatch):
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
_break_reads_of(monkeypatch, m.memory_file, PermissionError(13, "locked"))
|
|
||||||
# Context injection / search must not raise; they just see nothing.
|
|
||||||
assert m.load_all() == []
|
|
||||||
assert m.load(owner="alice") == []
|
|
||||||
|
|
||||||
|
|
||||||
# ── the actual #5673 regression: the store survives ───────────────────────
|
|
||||||
|
|
||||||
def test_add_cycle_under_transient_read_error_does_not_wipe(tmp_path, monkeypatch):
|
|
||||||
"""Mirrors routes/memory/memory_routes.py api_add_memory exactly."""
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
new_entry = m.add_entry("a brand new fact", owner="alice")
|
|
||||||
|
|
||||||
with monkeypatch.context() as mp:
|
|
||||||
_break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
|
|
||||||
with pytest.raises(MemoryStoreUnreadable):
|
|
||||||
all_mem = m.load_all_for_update()
|
|
||||||
all_mem.append(new_entry)
|
|
||||||
m.save(all_mem)
|
|
||||||
|
|
||||||
# Reads work again; every original memory is still there and the file was
|
|
||||||
# never replaced by the single new entry.
|
|
||||||
assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_merge_cannot_drop_other_tenants(tmp_path, monkeypatch):
|
|
||||||
"""The audit path rebuilds the whole file from load_all + one owner's slice.
|
|
||||||
|
|
||||||
Reading [] there would save only the audited owner's entries and destroy
|
|
||||||
every other tenant's memories, so it has to fail closed too.
|
|
||||||
"""
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
alice_slice = [e for e in _SEED if e["owner"] == "alice"]
|
|
||||||
|
|
||||||
with monkeypatch.context() as mp:
|
|
||||||
_break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
|
|
||||||
with pytest.raises(MemoryStoreUnreadable):
|
|
||||||
all_entries = m.load_all_for_update()
|
|
||||||
others = [e for e in all_entries if e.get("owner") != "alice"]
|
|
||||||
m.save(alice_slice + others)
|
|
||||||
|
|
||||||
assert any(e["id"] == "m3" for e in m.load_all()), "bob's memory was destroyed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_uses_bump_skips_write_when_unreadable(tmp_path, monkeypatch):
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
with monkeypatch.context() as mp:
|
|
||||||
_break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
|
|
||||||
m.increment_uses(["m1"]) # must not raise, must not write
|
|
||||||
assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_ownerless_skips_write_when_unreadable(tmp_path, monkeypatch):
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
with monkeypatch.context() as mp:
|
|
||||||
_break_reads_of(mp, m.memory_file, PermissionError(13, "locked"))
|
|
||||||
m.claim_ownerless("alice")
|
|
||||||
assert {e["id"] for e in m.load_all()} == {"m1", "m2", "m3"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── the add sinks users actually reach ────────────────────────────────────
|
|
||||||
#
|
|
||||||
# The tests above replay the read-modify-write shape. These drive the real
|
|
||||||
# entry points end to end, because those are what #5673 reports: "remember
|
|
||||||
# that I prefer X" in ordinary chat (src/ai_interaction.py do_manage_memory,
|
|
||||||
# routed from src/tool_execution.py) and the built-in memory MCP server
|
|
||||||
# (mcp_servers/memory_server.py, registered in src/builtin_mcp.py).
|
|
||||||
#
|
|
||||||
# They use a truncated store rather than a read error on purpose: it reads
|
|
||||||
# fine, so nothing stops the save, which is the case that silently destroyed
|
|
||||||
# stores. The assertion is that the file is left byte-identical — still broken,
|
|
||||||
# but still holding the user's memories, so it can be repaired by hand.
|
|
||||||
|
|
||||||
|
|
||||||
def _truncated_store(tmp_path):
|
|
||||||
"""Seed a store that reads back fine but no longer parses."""
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
good = json.dumps([dict(e) for e in _SEED], indent=2)
|
|
||||||
with open(m.memory_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write(good[:good.rindex("]")]) # drop the closing bracket only
|
|
||||||
with open(m.memory_file, "rb") as f:
|
|
||||||
return m, f.read()
|
|
||||||
|
|
||||||
|
|
||||||
def _on_disk(manager) -> bytes:
|
|
||||||
with open(manager.memory_file, "rb") as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_memory_add_does_not_overwrite_unreadable_store(tmp_path, monkeypatch):
|
|
||||||
"""src/ai_interaction.py do_manage_memory, action "add"."""
|
|
||||||
from src import ai_interaction
|
|
||||||
|
|
||||||
manager, before = _truncated_store(tmp_path)
|
|
||||||
monkeypatch.setattr(ai_interaction, "_memory_manager", manager)
|
|
||||||
monkeypatch.setattr(ai_interaction, "_memory_vector", None)
|
|
||||||
|
|
||||||
result = asyncio.run(ai_interaction.do_manage_memory("add\nuser prefers tabs"))
|
|
||||||
|
|
||||||
assert _on_disk(manager) == before, "the unreadable store was overwritten"
|
|
||||||
assert b"m3" in _on_disk(manager)
|
|
||||||
assert "error" in result, "the add reported success over an unreadable store"
|
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_memory_add_does_not_overwrite_unreadable_store(tmp_path, monkeypatch):
|
|
||||||
"""mcp_servers/memory_server.py, action "add"."""
|
|
||||||
import mcp_servers.memory_server as memory_server
|
|
||||||
|
|
||||||
manager, before = _truncated_store(tmp_path)
|
|
||||||
monkeypatch.setattr(memory_server, "_memory_manager", manager)
|
|
||||||
monkeypatch.setattr(memory_server, "_memory_vector", None)
|
|
||||||
monkeypatch.setattr(memory_server, "_initialized", True)
|
|
||||||
for key in memory_server._OWNER_ENV_KEYS:
|
|
||||||
monkeypatch.delenv(key, raising=False)
|
|
||||||
|
|
||||||
result = asyncio.run(memory_server.call_tool(
|
|
||||||
"manage_memory", {"action": "add", "text": "user prefers tabs"}
|
|
||||||
))
|
|
||||||
|
|
||||||
assert _on_disk(manager) == before, "the unreadable store was overwritten"
|
|
||||||
assert b"m3" in _on_disk(manager)
|
|
||||||
assert result[0].text.startswith("Error:")
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_provider_remember_does_not_overwrite_unreadable_store(tmp_path):
|
|
||||||
"""src/memory_provider.py NativeMemoryProvider.remember.
|
|
||||||
|
|
||||||
Registered into app state in src/app_initializer.py but not yet consumed
|
|
||||||
outside tests, so this is the pattern held in place before it goes live.
|
|
||||||
"""
|
|
||||||
from src.memory_provider import NativeMemoryProvider
|
|
||||||
|
|
||||||
manager, before = _truncated_store(tmp_path)
|
|
||||||
provider = NativeMemoryProvider(manager)
|
|
||||||
|
|
||||||
with pytest.raises(MemoryStoreUnreadable):
|
|
||||||
asyncio.run(provider.remember("user prefers tabs", owner="alice"))
|
|
||||||
|
|
||||||
assert _on_disk(manager) == before
|
|
||||||
|
|
||||||
|
|
||||||
# ── the legacy memory.txt migration is preserved ──────────────────────────
|
|
||||||
|
|
||||||
def test_corrupt_store_still_migrates_from_legacy_txt(tmp_path):
|
|
||||||
m = _seeded(tmp_path)
|
|
||||||
with open(m.memory_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write("{ not json")
|
|
||||||
legacy = os.path.join(str(tmp_path), "memory.txt")
|
|
||||||
with open(legacy, "w", encoding="utf-8") as f:
|
|
||||||
f.write("recovered fact one\nrecovered fact two\n")
|
|
||||||
|
|
||||||
entries = m.load_all_for_update()
|
|
||||||
assert [e["text"] for e in entries] == ["recovered fact one", "recovered fact two"]
|
|
||||||
@@ -14,7 +14,7 @@ def _function_source(path: str, name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def test_document_ai_tidy_resolves_with_owner_scope():
|
def test_document_ai_tidy_resolves_with_owner_scope():
|
||||||
body = _function_source("routes/document/document_routes.py", "ai_tidy_documents")
|
body = _function_source("routes/document_routes.py", "ai_tidy_documents")
|
||||||
assert "resolve_task_endpoint(owner=user or None)" in body
|
assert "resolve_task_endpoint(owner=user or None)" in body
|
||||||
assert 'resolve_endpoint("default", owner=user or None)' in body
|
assert 'resolve_endpoint("default", owner=user or None)' in body
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
"""Regression test for the search route shim (slice 2j, #4082/#4071)."""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
import routes.search_routes as _shim_search # noqa: F401
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_and_canonical_search_module_are_same_object():
|
|
||||||
legacy = importlib.import_module("routes.search_routes")
|
|
||||||
canonical = importlib.import_module("routes.search.search_routes")
|
|
||||||
assert legacy is canonical
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user