mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-05 00:29:46 +00:00
release: Ouroboros v6.32.0 — multi-project (shtab i proekty) + full project awareness
One agent, parallel durable projects. The single self stays unified (one identity, constitution, evolution; BIBLE P1) while gaining an owner-facing project layer. - Projects registry (data/state/projects.json, boot-reconciled, never age-pruned, deterministic per-project chat ids). Conversation lane stays free; real work routes to first-class pooled tasks via the LLM-first promote_chat_to_task tool. Project chats ride the one WebSocket with chat_id-stamped frames + per-thread history; owner-mailbox steering of a project running task; one-writer-per-project lease (swarms exempt); agent-restart drain protects running project tasks; optional invisible-git working folders; /api/projects (+ /api/projects/from-task) and durable task-to-project bindings. - Per-project memory: journal (journal_write/read), workpad (workpad_*), and bounded context injection beside project knowledge. - Full project awareness: the one mind sees ALL threads in its unified memory — recent dialogue, dialogue_blocks consolidation, and chat_history (only A2A virtual transport excluded). A project TASK gets a FOCUSED working context, not memory isolation. No silent prefix-slicing of cognitive artifacts (workpad/journal/digest/WORLD): full content or a visible pointer; durable writes reject over-limit. - UI: design-system shell — left #primary-sidebar of rows with a Projects section; a project opens as a right split panel (desktop) / overlay (mobile) hosting a full chat instance over the one shared WS (fan-out by chat_id); a backend-created project pushes a projects_changed frame; header restart/panic + a More menu (consciousness/evolve/ review); chips above the composer. Reviewed across 16 triad+scope rounds to convergence (gpt-5.5 scope reviewer unchanged). The final confirming run was waived by the owner due to OpenRouter credit exhaustion; the post-convergence diff is one small, regression-tested fix (bound-task media routing).
This commit is contained in:
parent
eb33abd491
commit
5c0dcba60b
55 changed files with 4375 additions and 331 deletions
|
|
@ -7,7 +7,7 @@
|
|||
[](https://github.com/razzant/ouroboros/releases)
|
||||
[](https://github.com/razzant/ouroboros/releases)
|
||||
[](https://github.com/razzant/OuroborosHub)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
|
||||
A self-modifying AI agent that writes its own code, rewrites its own mind, and evolves autonomously. Born February 16, 2026.
|
||||
|
||||
|
|
@ -523,12 +523,12 @@ the contribution guide only routes to those sources.
|
|||
|
||||
| Version | Date | Description |
|
||||
|---------|------|-------------|
|
||||
| 6.32.0 | 2026-06-13 | **feat(multi-project): штаб и проекты — one agent, parallel durable projects.** The single agent gains owner-facing projects while identity/constitution/evolution stay unified: a durable registry (`data/state/projects.json`, boot-reconciled, never age-pruned) with deterministic per-project chat ids; the conversation lane stays free while real work routes to first-class pooled tasks via the LLM-first `promote_chat_to_task` tool; project chats ride the same WebSocket with `chat_id`-stamped frames, per-thread history (`/api/chat/history?chat_id=`), and owner-mailbox steering of the project's running task. Per-project memory grows journal (`journal_write`/`journal_read`: start/checkpoint/blocked/done), workpad (`workpad_*`), and bounded context injection beside project knowledge; finished project tasks append journal rows and emit sanitized `project_digest` observations to consciousness (no raw-fact leaks). One-writer-per-project lease in task assignment (subagent swarms exempt); agent-requested restarts drain heartbeat-fresh tasks up to `OUROBOROS_RESTART_DRAIN_MAX_SEC`; optional invisible-git working folders via the genesis machinery; `/api/projects` list/create/sleep/wake. UI: sidebar Projects list with a visually distinct Main chat; a project opens as a right split panel (desktop) / overlay (mobile) hosting a full chat instance (`createChatInstance` factory — per-instance DOM/storage/thread filter; global evolve/panic/budget controls stay main-chat-only). |
|
||||
| 6.31.0 | 2026-06-13 | **feat(skills): unix_computer_use overhaul + native launcher-seed trust.** The bundled desktop skill is renamed `computer_use` → `unix_computer_use` (Windows stays a future separate skill) and gains coordinate normalization (screenshots downscale to WXGA and persist the exact image→input transform; input tools auto-remap, `raw=true` bypasses), Wayland backends (`grim`/`ydotool`/`wtype` beside X11 `xdotool`/`scrot`), new actions (`left_click_drag`, `mouse_down`/`mouse_up`, `triple_click`, `hold_key`, `cursor_position`, `wait`), a real set-of-marks `ax_tree` on macOS (numbered interactive elements of the frontmost window with honest degradation), and extended cross-platform key aliases. Launcher-seeded native skills now carry a named, hash-pinned, audited trust verdict (`OUROBOROS_TRUST_NATIVE_SEEDED_SKILLS`, default-on; CHECKLISTS §Skills): the launcher stamps `review.json` `status=clean` (`repo_commit_gate`/`native_seed`) at bootstrap/new-seed/version-resync because the payload bytes passed the repo triad+scope gate; zero-grant skills (tool/subprocess surface only) also auto-enable, externally-facing ones (e.g. weather: net/route/widget) stay disabled. Lifecycle control files (`.seed-origin`, top-level, native bucket only) leave the payload hash with a one-shot re-pin migration for unedited legacy-hash reviews. Upgrade note: installs that previously received the old `computer_use` seed keep it as a reclassified non-launcher skill next to `unix_computer_use` — delete `data/skills/native/computer_use/` to drop the duplicate toolset. |
|
||||
| 6.30.0 | 2026-06-12 | **refactor(evolution): campaign lifecycle module, solve-capability ledger, deterministic cycle cleanup, identical-diff review cap.** Evolution campaign state and the transaction lifecycle move from `supervisor/queue.py` into `supervisor/evolution_lifecycle.py` (queue keeps queueing only). A solve-capability ledger tags post-restart absorb/abandon resolutions into `evolution_checkpoints.jsonl` (`kind="cycle_outcome"`, join key `task_id`) and feeds an absorbed-vs-failed objective digest (with explicit omission notes, never silent truncation) into the post-task promotion chooser. A no_op/abandoned cycle deterministically restores the worktree to the transaction's `base_head`: dirty files stashed (`evolution-cycle-cleanup-<tx>`), an ahead HEAD preserved as a local `evolution-leftover-*` branch, every action/skip recorded on the transaction; the reset is skipped while other tasks share the worktree, under pytest against the live repo, or via the `OUROBOROS_EVOLUTION_CYCLE_CLEANUP=false` kill-switch. `commit_reviewed` refuses to spend another triad+scope run after 3 genuine review-verdict blocks of a byte-identical staged diff — diff-scoped (a new task cannot reset the streak), preflight blocks neither count nor break, `review_rebuttal` or any diff change lifts the cap. The hard-kill path now cleans the task owner-mailbox so a stale `finalize_now` can never instantly force-finalize a same-id subagent retry. Window-fit context: the emergency compaction threshold derives from the active model's real window (small-window remote models also get routine compaction in max mode), evolution tasks read `docs/ARCHITECTURE.md` as an on-demand navigation map instead of inlining it, and the post-task evolution chooser moves to the main-model slot with a bias toward small targeted solve-helpers. `plan_task` degrades capacity-class swarm failures (`saturated`/`ceiling`/<2 workers) to one labeled inline critique pass instead of failing closed; the scope-review input cap is window-aware per reviewer model, and a provider oversize 400 after a passed estimate gate downgrades to the same non-blocking `budget_exceeded` advisory skip (every other provider error stays fail-closed). Scope prompt assembly is guaranteed-fit: a deterministic degradation ladder (full atlas → compact atlas → required atlas files to manifest entries → largest touched files to diff-only with explicit disclosure notes) makes scope review actually run instead of skipping; only the irreducible checklist+docs+diff prompt failing to fit blocks fail-closed (`fixed_overflow`). |
|
||||
| 6.29.0 | 2026-06-12 | **feat(outcomes): typed best-effort outcomes, cooperative deadline finalization, reviewer completion-coach, FINAL ANSWER protocol, capability-acquisition doctrine.** Forced finalization (deadline grace / budget / round limit) that still extracts a real answer lands on the typed `best_effort` execution shelf instead of `failed` (deterministic gate: forced reason code + non-empty non-error text). The supervisor now sends a typed `finalize_now` mailbox control when the deadline grace window opens; the loop routes it to a tool-less cooperative final answer, so a deadline never returns emptiness; hard kills additionally salvage the last persisted assistant text into the terminal result. Budget exhaustion attempts one bounded tool-less best-effort extraction before rejecting. Task acceptance review becomes a completion coach: reviewers classify the deliverable tier (`solved`/`best_effort`/`blocked_with_evidence`) and name the highest-value next change, while keeping the veto over false `solved` claims; the objective axis consumes the tier. Final messages can carry a machine-readable `FINAL ANSWER:` line extracted into a typed `final_answer` result field (exact-match deliverables). SYSTEM.md adds the three-tier outcome-honesty doctrine and a capability-acquisition clause (install legitimate dependencies / switch interpreter / try an alternative tool before declaring inability; installing a real dependency is not a shim). |
|
||||
| 6.28.0 | 2026-06-12 | **feat(loop): per-class transient LLM retry, encrypted-reasoning strip-retry, and robust context compaction.** Transient provider failures (`finish_reason=null` glitches, 429/5xx/overloaded) now retry the SAME model with a larger deadline-bounded attempt budget (`OUROBOROS_TRANSIENT_RETRY_MAX`, default 6) instead of dying as `No viable fallback model configured` on deliberate single-model setups; permanent classes still fail fast, and no cross-model fallback is introduced. OpenRouter/gpt-5-style 400s about encrypted reasoning items replayed from long transcripts now reuse the existing strip-and-retry path (matcher extended; same model, one retry). Context compaction is robust on trial-and-error coding transcripts: per-batch isolation (one failed batch no longer discards every successful summary), per-round degradation instead of the all-or-nothing completeness error, a structured `emit_round_summaries` tool protocol with text-protocol fallback for local light models, autocorrect-prefixed `⚠️` warnings correctly protected while `SHELL_EXIT_ERROR` rounds become compactable (first error line preserved by instruction), emergency compaction adapts `keep_recent` below the span count so oversized transcripts with few huge rounds actually compact, and spend from failed compaction batches is accounted. |
|
||||
| 6.27.1 | 2026-06-12 | **fix(review/skills): break the skill-publish convergence loop and make the deep-self-review pack always fit.** Skill publication now accepts a fresh review with no blockers (`clean` or advisory-only `warnings`) instead of demanding `clean` — open-ended `bug_hunting` rotates new advisory findings every round on large payloads, so the old gate structurally never converged; advisory findings are disclosed in the PR body (`## Known advisory findings`). The deep-self-review OMITTED-files section is now bounded (counts per reason + capped sample; full coverage stays in the persisted atlas manifest) and reserved inside the atlas budget, with a compact-manifest retry and a deterministic final-shrink rebuild replacing the fatal `Review pack too large` error; deep review also ranks atlas file selection by import-graph centrality (additive, deep-review-only; scope/plan selection unchanged). Skill-review convergence adds a structural consecutive-`warnings` counter (status-based, signature-independent); the `skill_review` tool result drops its redundant raw-JSON duplicate (forensics stay in `review.json`); severity-driven checklist items (e.g. multi-bug `bug_hunting`) no longer trip false advisory contract warnings. Zombie state heals: `review_job.json` and orphaned `running` task results are reconciled at boot and on a periodic supervisor tick (liveness-gated). |
|
||||
| 6.27.0 | 2026-06-11 | **feat(benchmarks/evolution): benchmark harness reliability and cross-task self-evolution hardening.** Terminal-Bench installed runs now preserve verifier-needed services, use required task acceptance and deadline context, keep safety/web-search lanes robust, and allow legitimate task-local git in external workspaces while protecting Ouroboros runtime paths. OSWorld and SWE-bench Pro E1v2 adapters move into tracked devtools with native screenshot attachments, pkill-safe shell rendering, Method-C patch capture, learning-curve helpers, and shared benchmark README guidance. Evolution lifecycle now reconciles dangling reviewed commits at boot, auto-requests restart after evolution commits, treats no-op cycles cleanly, and lets project-scoped tasks feed global improvement backlog/promotion without leaking project facts. Adds a reviewed computer_use skill substrate for macOS/Linux screenshots and basic input. |
|
||||
Older releases are preserved in Git tags and GitHub releases. Older 6.x rows, the 5.2.0 through 5.33.0-rc.6 rows, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
|
||||
|
||||
---
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
6.31.0
|
||||
6.32.0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Ouroboros v6.31.0 — Architecture & Reference
|
||||
# Ouroboros v6.32.0 — Architecture & Reference
|
||||
|
||||
This file is NOT a changelog. Version history lives in README.md, git tags, and commit log.
|
||||
|
||||
|
|
@ -59,7 +59,9 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
├── consciousness.py ← Background thinking loop (with progress emission)
|
||||
├── consolidator.py ← Block-wise dialogue consolidation (dialogue_blocks.json)
|
||||
├── memory.py ← Scratchpad, identity, chat history
|
||||
├── project_facts.py ← Thin per-project facts store (Phase 3b): project_id resolution (explicit `--project-id` or stable workspace-path hash) + a per-project knowledge dir under the canonical data dir (`projects/<id>/knowledge`), isolated from `memory/knowledge` and from the forked seed
|
||||
├── project_facts.py ← Thin per-project facts store (Phase 3b): project_id resolution (explicit `--project-id` or stable workspace-path hash) + a per-project knowledge dir under the canonical data dir (`projects/<id>/knowledge`), isolated from `memory/knowledge` and from the forked seed; v6.32.0 adds per-project journal/workpad path helpers
|
||||
├── projects_registry.py ← Multi-project registry (v6.32.0): durable `data/state/projects.json` (id/name/status/chat_id/folder), create/sleep/wake, boot reconcile (never age-prunes), `registered_project_chat_ids` membership SSOT, `ensure_project_workspace`
|
||||
├── project_lease.py ← One-writer-per-project lease (v6.32.0): `assign_tasks` serializes top-level tasks of the same STORED `project_id`; same-project subagent swarm exempt; `project_id==""` is no lane
|
||||
├── context.py ← LLM context builder (public API for consciousness)
|
||||
├── context_budget.py ← Context-window budget SSOT for low/max profiles, raw-tail sizing, compaction thresholds, and static section limits
|
||||
├── context_layout.py ← Reference-doc layout SSOT: max/low doc tiers, ARCHITECTURE navigation map, DEVELOPMENT full/pointer policy, README/CHECKLISTS on-demand pointers
|
||||
|
|
@ -151,6 +153,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
│ ├── mcp.py ← MCP Settings API surface backed by the shared MCPManager
|
||||
│ ├── host_service.py ← Loopback-only Host Service API for reviewed skill callbacks
|
||||
│ ├── history.py ← Chat history + cost breakdown endpoint factories
|
||||
│ ├── projects.py ← Multi-project CRUD surface (v6.32.0): GET /api/projects, POST /api/projects, POST /api/projects/<id>/sleep, POST /api/projects/<id>/wake, POST /api/projects/from-task (bind an existing task to a new project)
|
||||
│ └── _helpers.py ← shared HTTP request root helpers, coercion, and JSON error envelope
|
||||
├── tools/ ← Auto-discovered tool plugins
|
||||
│ ├── extension_dispatch.py ← Extension tool dispatch helper extracted from registry.py; preserves liveness, safety, async, and out-of-process error contracts
|
||||
|
|
@ -175,6 +178,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
|
|||
│ ├── skill_exec.py ← Phase 3 external-skill surface: list_skills, skill_review, toggle_skill, skill_exec (subprocess runner with cwd confinement, env scrubbing, timeout, runtime allowlist python/python3/bash/node/deno/ruby/go; gated by enabled + fresh executable review + fresh content hash — v5.1.2 Frame A: runtime_mode no longer blocks execution)
|
||||
│ ├── skill_publish.py ← Agent-callable `submit_skill_to_hub` tool: validates a fresh no-blocker review — `clean` or advisory-only `warnings` (v6.27.1; advisory findings are disclosed in the PR body under `## Known advisory findings`; blockers/pending/stale still refuse) — for a local skill (sources `external`/`self_authored`/`user_repo`/`ouroboroshub`/`clawhub`; `native` only when no `.seed-origin` marker), infers OuroborosHub from `OUROBOROS_HUB_CATALOG_URL`, commits payload + catalog update to the user's fork via GitHub GraphQL, and opens a PR without mutating the local Ouroboros repo. For marketplace-managed sources the generated PR body is force-prefixed with a `## Provenance` block read from the local sidecar (`.ouroboroshub.json` slug / `.clawhub.json` clawhub_slug); when no sidecar exists the source is reclassified as `external` by skill_loader and submit proceeds without the block.
|
||||
│ ├── skill_preflight.py ← v5.7.0 heal-safe, read-only skill payload preflight validator (manifest parse + Python compile() / node --check / bash -n; no review-state mutation)
|
||||
│ ├── project_journal.py ← Thin per-project journal/workpad tools (v6.32.0): journal_write/read (durable milestone memory), workpad_read/write (scratch page), journal_tail_digest (context injection); over-limit writes are rejected, never silently sliced
|
||||
│ └── subagent_integration.py ← integrate_subagent_patch: parent's manifest-first integration of an acting subagent's workspace.patch. For self_worktree children it applies into ctx.active_repo_dir() (sha256-verified, 3-way --index, protected-path gated, top-only lineage check, genesis refused), stages but never commits. For external_workspace children it verifies the child wrote in the same active external workspace and records an audited verdict without re-applying the patch. Also compare_subagent_patches: read-only best-of-N helper that shows several children's candidate patches side by side for LLM-first synthesis
|
||||
└── platform_layer.py ← Cross-platform process/path/locking helpers
|
||||
|
||||
|
|
@ -570,6 +574,8 @@ finalization states.
|
|||
│ │ ├── post_task_evolution_request.json ← Durable post-task self-evolution promotion signal (worker-written on the canonical drive; the supervisor idle tick consumes it to set the campaign objective + enable evolution, then deletes it; one-shot)
|
||||
│ │ ├── post_task_evolution_counter.json ← Per-drive task counter for the post-task evolution `every_n` cadence
|
||||
│ │ ├── scheduled_tasks.json ← Queue-backed cron schedules (5-field cron, timezone, last/next run, task template)
|
||||
│ │ ├── projects.json ← Multi-project registry (v6.32.0): id/name/status (active/sleeping/archived)/deterministic chat_id/optional working folder; boot-reconciled from `data/projects/<id>/`, never age-pruned
|
||||
│ │ ├── project_task_bindings.json ← Task→project bindings (v6.32.0): `POST /api/projects/from-task` records {task_id → project_id, project_chat_id} so an existing task/live-card's history backfill + future events resolve to its project thread
|
||||
│ │ ├── queue_snapshot.json
|
||||
│ │ ├── extension_companions.json ← Runtime snapshot for live extension companion processes
|
||||
│ │ ├── extension_reconcile/ ← Worker-written extension reconcile markers consumed by the server lifespan pickup task
|
||||
|
|
@ -787,7 +793,7 @@ The Web UI is a vanilla-JS SPA (`web/index.html`, `web/style.css`, `web/settings
|
|||
|
||||
### Navigation
|
||||
|
||||
The left rail has six pages: Chat, Files, Skills, Widgets, Dashboard, Settings. On narrow screens it becomes a fixed bottom bar. About is a Settings sub-tab, not a top-level page.
|
||||
A left `#primary-sidebar` of ROWS (`.nav-row`, not an icon rail): Chat (Main), a data-driven Projects section (`.nav-section-toggle` expanding per-project rows from `/api/state`), Files, Skills, Widgets, Dashboard, Settings. `web/app.js::syncNavigationState` keeps the active row, the Projects expand/collapse, and any open project panel in sync. A project opens as a right split panel (desktop) / full-width overlay with backdrop (mobile) hosting a full chat instance over the ONE shared WebSocket (client-side fan-out by `chat_id`). On narrow screens the sidebar collapses behind an "Open navigation" drawer (not a fixed bottom bar). About is a Settings sub-tab, not a top-level page.
|
||||
|
||||
### Shared UI primitives
|
||||
|
||||
|
|
@ -925,6 +931,11 @@ the old pass-through behavior was a root escape.
|
|||
| POST | `/api/update/apply` | `gateway.control.api_update_apply` |
|
||||
| GET | `/api/cost-breakdown` | `gateway.history.make_cost_breakdown_endpoint` |
|
||||
| GET | `/api/evolution-data` | `gateway.control.api_evolution_data` |
|
||||
| GET | `/api/projects` | `gateway.projects.api_projects_list` |
|
||||
| POST | `/api/projects` | `gateway.projects.api_projects_create` |
|
||||
| POST | `/api/projects/{project_id}/sleep` | `gateway.projects.api_project_sleep` |
|
||||
| POST | `/api/projects/{project_id}/wake` | `gateway.projects.api_project_wake` |
|
||||
| POST | `/api/projects/from-task` | `gateway.projects.api_project_from_task` |
|
||||
| GET | `/api/chat/history` | `gateway.history.make_chat_history_endpoint` |
|
||||
| GET | `/api/logs/{name}` | `gateway.logs.api_logs_tail` |
|
||||
| POST | `/api/chat/upload` | `gateway.files.api_chat_upload` |
|
||||
|
|
@ -1287,6 +1298,18 @@ local/no-1M setups, not a commit gate.
|
|||
|
||||
Experience Review closes the learning loop: the reflection LLM may append a `MEMORY_ACTIONS_JSON` block whose validated actions (`scratchpad_append`, `knowledge_write`, `identity_update_candidate`) are auto-applied via `reflection.apply_memory_actions` through the existing provenance-preserving memory/knowledge paths (`Memory.append_scratchpad_block`, `knowledge._knowledge_write`). Identity is deliberately conservative — an `identity_update_candidate` is only recorded in the scratchpad for review, never auto-written to `identity.md`, so autonomous learning cannot silently drift the personality. `agent_task_pipeline._apply_reflection_memory_actions` runs this in post-task processing for both the child env and, for forked/workspace tasks, the parent (`budget_drive_root`) drive, so learnings land on the canonical drive rather than a discarded child drive. EXCEPTION (Phase 3b, per-project facts): a project-scoped task has this canonical dual-run SUPPRESSED. A root external/workspace or `--project-id` task derives a resolved `project_id` (explicit id, else a stable workspace-path hash); subagents INHERIT the parent's resolved scope and never derive their own (a subagent of an unscoped parent stays unscoped). For a project-scoped task the dual-run is SUPPRESSED, so its facts never contaminate the global memory or another project; instead the agent's `knowledge_write` and the context loader redirect to the per-project store (`projects/<id>/knowledge` under the canonical data dir via `ouroboros/project_facts.py`), which persists across forked/empty child drives. There is no per-project identity, and only the current project's facts are loaded at context build (red-team R3.1 leak guard).
|
||||
|
||||
Multi-project ("штаб и проекты", v6.32.0) builds the owner-facing layer on this substrate while identity, constitution, and evolution stay UNIFIED in the one agent (BIBLE P1):
|
||||
|
||||
- **Full project awareness (one mind, focused rooms).** Ouroboros is ONE awareness across direct chat, project rooms, and background consciousness (BIBLE P1), so its unified memory — the recent-dialogue tail, the consolidated `dialogue_blocks.json`, and `chat_history` recall — spans ALL threads (main + projects); only A2A virtual transport is excluded. A project is therefore a focused ROOM, not an isolated sub-mind: an individual project TASK gets a FOCUSED context (its own thread + its own journal/workpad/knowledge) to reduce cross-project interference while executing, but this is working focus, not memory isolation from the one identity. The UI organizes threads into panels (project raw chat in its panel; progress mirrored to the штаб), but that is presentation, not a cognition boundary.
|
||||
|
||||
- **Projects registry** (`ouroboros/projects_registry.py`, `data/state/projects.json`): id + name + status (active/sleeping/archived) + deterministic per-project `chat_id` (`contracts/chat_id_policy.project_chat_id`, positive range ≥1000) + optional working folder. Boot reconcile registers pre-existing `data/projects/<id>/` stores and NEVER age-prunes. Optional working folders are provisioned once via the genesis-project machinery (`ensure_project_workspace` → `subagent_worktrees.provision_genesis_project`: standalone invisible git repo under the durable projects root). File-less projects (research, presentations) are first-class.
|
||||
- **Chat plane**: the conversation lane stays fast and free; real work spawns a first-class pooled task via the `promote_chat_to_task` tool (LLM-first structural decision — never a keyword gate; the supervisor handler enqueues a normal owner task with a live card). Project chats are the SAME web owner (user_id stays 1, owner binding never re-binds) on a positive `chat_id`; `ChatInbound` carries optional `chat_id`/`project_id`, outbound WS frames are `chat_id`-stamped, and `/api/chat/history?chat_id=` filters per thread (legacy rows without `chat_id` belong to the main chat). A PROJECT thread with an ACTIVE pooled task steers it through the task's owner-mailbox (drained every round); the MAIN chat never blocks on a running task.
|
||||
- **Per-project memory**: beside `knowledge/`, each project store carries `journal.jsonl` (milestones via `journal_write`/`journal_read`: start/checkpoint/blocked/done/note), `workpad.md` (`workpad_read`/`workpad_write`), and a thread mirror. The project task's FOCUSED context injects these via `build_knowledge_sections` WITHOUT silent prefix-slicing (BIBLE P1): the workpad rides in full (a warning, not a clip, signals an oversized one), and the journal shows recent milestones in full with a VISIBLE `journal_read` index pointer for older entries. Generic data tools still cannot reach the store (`project_store_access_block`).
|
||||
- **One writer per project** (`ouroboros/project_lease.py`): `assign_tasks` skips a PENDING top-level task whose `project_id` is already RUNNING (its own subagent swarm is exempt — the parent IS the writer); `project_id==""` means no lane. Parallelism happens BETWEEN projects and within a task's swarm. The lease reads the task's STORED `project_id` (never re-derives), so a task that only DERIVES `proj_<hash>` from an external workspace path is project-scoped for memory/journal/digest but is intentionally NOT lease-serialized (project-store writes are atomic; re-deriving would break subagent scope inheritance).
|
||||
- **Letters home**: a project-scoped task's completion appends a journal milestone and emits a `project_digest` event (project_id + full objective + outcome statuses) that the supervisor injects into consciousness as a concise completion summary. This is a convenience digest, NOT an isolation boundary: under full project awareness the one identity already sees the project's chat thread in its unified dialogue memory; the digest just gives consciousness a crisp "task X finished" signal. A project's RAW per-cycle internal facts stay in the per-project knowledge/journal store (scoped tools, `project_store_access_block`), while the project conversation is part of the one mind's continuous memory.
|
||||
- **Restart drain**: agent-requested restarts wait up to `OUROBOROS_RESTART_DRAIN_MAX_SEC` for heartbeat-fresh RUNNING tasks before proceeding fail-closed, so an evolution restart does not chop parallel project tasks mid-flight.
|
||||
- **UI**: the left `#primary-sidebar` gains a data-driven Projects section (main chat stays visually distinct); a project opens as a right split panel (desktop) / overlay (mobile) hosting a full chat instance (`createChatInstance` factory in `web/modules/chat.js` — per-instance DOM ids/storage/thread filter over the ONE shared WebSocket with client-side fan-out by `chat_id`). A backend-created project (the agent's `promote_chat_to_task`) pushes a `projects_changed` WS frame so the fan-out learns the new thread immediately. Global controls (restart/panic + a "More" menu for evolve/consciousness/review) stay main-chat-only.
|
||||
|
||||
Rationale: Ouroboros learns from attempts, not just final answers. Compression must preserve what was tried, what changed, and why conclusions were reached.
|
||||
|
||||
### Skills and extensions
|
||||
|
|
@ -1402,6 +1425,7 @@ Runtime floors:
|
|||
| OUROBOROS_PLAN_TASK_SWARM_TIMEOUT_SEC | 120 | Poll-slice wait for required `plan_task` planning subagents; the wait extends progress-aware up to the max-wait ceiling |
|
||||
| OUROBOROS_PLAN_TASK_SWARM_MAX_WAIT_SEC | 900 | Generous ceiling for progress-aware planning-swarm waiting; keeps extending while a scout is RUNNING with a fresh heartbeat. Capacity-class endings (`saturated`/`ceiling`, or <2 workers) degrade to ONE inline light-lane critique pass explicitly labeled DEGRADED (v6.30.0); worker-health failures (`stalled`) and infra errors stay fail-closed. Lower values apply as-is; values above the default are clamped to the `plan_task` tool/wrapper budget (raise those module constants to extend the real ceiling). |
|
||||
| OUROBOROS_PLAN_TASK_SWARM_HEARTBEAT_STALE_SEC | 120 | A running planning scout with a queue-snapshot `heartbeat_lag_sec` below this is treated as "progressing" (keeps the adaptive wait extending); at/above it the scout is stale. |
|
||||
| OUROBOROS_RESTART_DRAIN_MAX_SEC | 120 | Agent-requested restarts drain first: while any RUNNING task still heartbeats, the restart waits up to this many seconds before proceeding fail-closed (0 = restart immediately). Owner restarts are not drained. |
|
||||
| TOTAL_BUDGET | 10.0 | Total budget in USD |
|
||||
| OUROBOROS_PER_TASK_COST_USD | 20.0 | Per-task soft threshold in USD |
|
||||
| OUROBOROS_TOOL_TIMEOUT_SEC | 600 | Global tool timeout override (read live from settings.json on each tool call) |
|
||||
|
|
|
|||
|
|
@ -752,9 +752,23 @@ Do NOT introduce a separate `.chat-bottom-fade` (or analogous overlay)
|
|||
layer. A second fade layer compounds the gradient and can produce a visible
|
||||
"double dim" especially over short messages.
|
||||
|
||||
### Navigation rail spacing (v5.7.0+)
|
||||
### Navigation sidebar (v6.32.0 redesign)
|
||||
|
||||
The desktop `#nav-rail` uses Material 3 / Apple HIG navigation-rail
|
||||
The desktop navigation is a left `#primary-sidebar` of ROWS (not an icon
|
||||
rail): each destination is a `.nav-row` (icon + label) and the Projects group
|
||||
is a `.nav-section-toggle` that expands a data-driven list of project rows
|
||||
(`renderProjectsNav` in `web/app.js`, fed by `/api/state`). `syncNavigationState`
|
||||
keeps the active row, the Projects expand/collapse, and the open project panel
|
||||
in sync. A project opens as a right split panel on desktop and a full-width
|
||||
overlay with backdrop on mobile, hosting a full chat instance over the ONE
|
||||
shared WebSocket (client-side fan-out by `chat_id`). On mobile the sidebar
|
||||
collapses behind an "Open navigation" toggle (drawer), NOT a horizontal bottom
|
||||
bar. Spacing/typography come from the shared design tokens in `web/style.css`
|
||||
(no per-screen hardcoding); global controls (restart/panic + the "More" menu for
|
||||
consciousness/evolve/review) live in the chat header, not the sidebar.
|
||||
|
||||
<!-- Historical (pre-v6.32.0 icon rail; superseded by the sidebar above):
|
||||
The desktop `#nav-rail` used Material 3 / Apple HIG navigation-rail
|
||||
spacing norms: `padding: 28px 0 16px; gap: 10px;`. The previous
|
||||
`12px / 4px` was visibly cramped (the first button hugged the top edge
|
||||
of the viewport). Bump these values together when adding new nav
|
||||
|
|
@ -766,6 +780,7 @@ keeps the row centered when content fits and gracefully degrades to
|
|||
flex-start when content overflows on very narrow phones. `min-width:
|
||||
60px` per `.nav-btn` keeps labels like "Dashboard" from truncating in
|
||||
space-evenly mode.
|
||||
-->
|
||||
|
||||
The mobile `.scroll-tabs` pattern (settings/dashboard/skills) uses
|
||||
horizontal-scroll pills with `scrollIntoView({ inline: 'center' })`
|
||||
|
|
|
|||
|
|
@ -106,10 +106,19 @@ class OuroborosAgent:
|
|||
self._incoming_messages.put(text)
|
||||
|
||||
def _emit_live_log(self, event_type: str, **fields: Any) -> None:
|
||||
"""Send a session-only live log event to supervisor/UI."""
|
||||
"""Send a session-only live log event to supervisor/UI.
|
||||
|
||||
The active thread (``_current_chat_id``) rides along so the browser's
|
||||
per-thread fan-out can route the live card: a project panel builds /
|
||||
animates / finalizes ITS OWN card, and the main chat excludes project
|
||||
threads. A missing/None chat_id stays main-routed downstream.
|
||||
"""
|
||||
payload: Dict[str, Any] = {"type": event_type, "ts": utc_now_iso(), **fields}
|
||||
if self._current_chat_id is not None and "chat_id" not in payload:
|
||||
payload["chat_id"] = self._current_chat_id
|
||||
emit_log_event(
|
||||
self._event_queue,
|
||||
{"type": event_type, "ts": utc_now_iso(), **fields},
|
||||
payload,
|
||||
blocking=True,
|
||||
log_label="agent live",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -351,6 +351,9 @@ def emit_task_results(
|
|||
"type": "task_done",
|
||||
"task_id": task.get("id"),
|
||||
"task_type": task.get("type"),
|
||||
# Carry the thread so the terminal card finalizes in its project panel
|
||||
# (per-thread fan-out), not just the main chat.
|
||||
"chat_id": int(task.get("chat_id") or 0),
|
||||
"outcome_axes": outcome_axes,
|
||||
"reason_code": reason_code,
|
||||
"artifact_status": stored_result.get("artifact_status") or artifact_bundle.get("status") or "",
|
||||
|
|
@ -387,6 +390,57 @@ def emit_task_results(
|
|||
from ouroboros.project_facts import resolve_project_id
|
||||
|
||||
_project_scoped = bool(resolve_project_id(task))
|
||||
# A project THREAD conversation runs on the fast direct-chat lane. It is
|
||||
# project-scoped only for CONTEXT (it sees the project's knowledge/
|
||||
# journal), but it is NOT a pooled task completion: it must not block the
|
||||
# reply on LLM post-processing and must not write letters home (that
|
||||
# would turn every "как дела?" into a journal milestone + a consciousness
|
||||
# observation and stall the global chat lock). Only real pooled project
|
||||
# tasks get the letters-home + blocking treatment.
|
||||
_is_direct_chat = bool(task.get("_is_direct_chat"))
|
||||
_project_task = _project_scoped and not _is_direct_chat
|
||||
if _project_task:
|
||||
# Letters home (v6.32.0): record the cycle in the project's own
|
||||
# journal and emit a concise completion digest for consciousness
|
||||
# (project_id + full objective + outcome). Full project awareness:
|
||||
# this is a crisp "task finished" summary, not an isolation boundary —
|
||||
# the one mind already sees the project thread in its unified memory;
|
||||
# only per-cycle RAW internal facts stay in the per-project store.
|
||||
_pid = resolve_project_id(task)
|
||||
# The full objective IS the meaning of the cycle — carry it whole into
|
||||
# the journal milestone and the consciousness digest (BIBLE P1: no
|
||||
# silent/lossy clip of cognitive text). Objectives are concise by
|
||||
# nature; the task and task_results remain the durable record.
|
||||
_objective = str(
|
||||
task.get("objective") or task.get("description") or task.get("text") or ""
|
||||
)
|
||||
_exec_status = str((outcome_axes.get("execution") or {}).get("status") or "unknown")
|
||||
try:
|
||||
# Route through the shared bounded helper so the auto-milestone
|
||||
# honors the journal's durable per-row contract (over-limit gets a
|
||||
# VISIBLE pointer, never a silent slice or a raw unbounded append).
|
||||
from ouroboros.tools.project_journal import append_journal_milestone
|
||||
|
||||
append_journal_milestone(
|
||||
_pid,
|
||||
"done" if _exec_status in ("success", "best_effort") else "blocked",
|
||||
f"Task finished ({_exec_status}): {_objective}",
|
||||
task_id=str(task.get("id") or ""),
|
||||
)
|
||||
except Exception:
|
||||
log.debug("project journal task-done entry failed", exc_info=True)
|
||||
try:
|
||||
pending_events.append({
|
||||
"type": "project_digest",
|
||||
"project_id": _pid,
|
||||
"task_id": str(task.get("id") or ""),
|
||||
"objective": _objective,
|
||||
"execution_status": _exec_status,
|
||||
"objective_status": str((outcome_axes.get("objective") or {}).get("status") or "not_evaluated"),
|
||||
"ts": utc_now_iso(),
|
||||
})
|
||||
except Exception:
|
||||
log.debug("project digest emission failed", exc_info=True)
|
||||
# LLM-heavy memory work stays off the reply critical path.
|
||||
reflection_entry = _run_post_task_processing_async(
|
||||
env, task, post_usage, llm_trace, review_evidence, drive_logs,
|
||||
|
|
@ -394,7 +448,7 @@ def emit_task_results(
|
|||
str(task.get("type") or "") == "evolution"
|
||||
or bool(str(task.get("workspace_root") or "").strip())
|
||||
or bool(str(task.get("workspace_mode") or "").strip())
|
||||
or _project_scoped
|
||||
or _project_task
|
||||
),
|
||||
)
|
||||
budget_drive_root = str(task.get("budget_drive_root") or "").strip()
|
||||
|
|
@ -602,6 +656,7 @@ def _run_task_summary(env, llm, task, usage, llm_trace, drive_logs, review_evide
|
|||
append_jsonl(drive_logs / "chat.jsonl", {
|
||||
"ts": utc_now_iso(), "direction": "system",
|
||||
"type": "task_summary", "task_id": task_id, "text": summary_text,
|
||||
"chat_id": int(task.get("chat_id") or 0),
|
||||
"tool_calls": n_tool_calls, "rounds": rounds,
|
||||
"outcome_axes": outcome_axes, "reason_code": reason_code,
|
||||
})
|
||||
|
|
@ -644,6 +699,7 @@ def _run_task_summary(env, llm, task, usage, llm_trace, drive_logs, review_evide
|
|||
append_jsonl(drive_logs / "chat.jsonl", {
|
||||
"ts": utc_now_iso(), "direction": "system",
|
||||
"type": "task_summary", "task_id": task_id, "text": summary_text,
|
||||
"chat_id": int(task.get("chat_id") or 0),
|
||||
"tool_calls": n_tool_calls, "rounds": rounds,
|
||||
"outcome_axes": outcome_axes, "reason_code": reason_code,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ SETTINGS_DEFAULTS = {
|
|||
# zero-grant ones also auto-enable. Editing the payload still goes stale.
|
||||
# Owner opt-out: set to false to keep manual review for native seeds.
|
||||
"OUROBOROS_TRUST_NATIVE_SEEDED_SKILLS": "true",
|
||||
# Agent-requested restarts drain running tasks first: while any RUNNING
|
||||
# task still heartbeats, the restart waits up to this many seconds before
|
||||
# proceeding fail-closed (0 = no drain, restart immediately).
|
||||
"OUROBOROS_RESTART_DRAIN_MAX_SEC": 120,
|
||||
# Runtime mode: light | advanced | pro; pro still requires review gates.
|
||||
"OUROBOROS_RUNTIME_MODE": "advanced",
|
||||
# Context mode: low | max. Owner-only working-context size profile. max =
|
||||
|
|
@ -458,6 +462,18 @@ def get_plan_task_swarm_heartbeat_stale_sec() -> float:
|
|||
return max(0.0, parsed)
|
||||
|
||||
|
||||
def get_restart_drain_max_sec() -> int:
|
||||
raw = os.environ.get(
|
||||
"OUROBOROS_RESTART_DRAIN_MAX_SEC",
|
||||
SETTINGS_DEFAULTS["OUROBOROS_RESTART_DRAIN_MAX_SEC"],
|
||||
)
|
||||
try:
|
||||
parsed = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
parsed = int(SETTINGS_DEFAULTS["OUROBOROS_RESTART_DRAIN_MAX_SEC"])
|
||||
return max(0, parsed)
|
||||
|
||||
|
||||
def get_post_task_evolution_enabled() -> bool:
|
||||
"""V4 envelope: is owner-enabled post-task self-evolution on? Default OFF."""
|
||||
raw = str(os.environ.get(
|
||||
|
|
@ -1029,6 +1045,7 @@ def apply_settings_to_env(settings: dict) -> None:
|
|||
"OUROBOROS_REVIEW_MODELS", "OUROBOROS_REVIEW_ENFORCEMENT",
|
||||
"OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS",
|
||||
"OUROBOROS_TRUST_NATIVE_SEEDED_SKILLS",
|
||||
"OUROBOROS_RESTART_DRAIN_MAX_SEC",
|
||||
"OUROBOROS_SCOPE_REVIEW_MODELS", "OUROBOROS_SCOPE_REVIEW_MODEL",
|
||||
"OUROBOROS_SCOPE_REVIEW_DEGRADED",
|
||||
"OUROBOROS_TASK_REVIEW_MODE",
|
||||
|
|
|
|||
|
|
@ -370,6 +370,13 @@ def _count_lines(path: pathlib.Path) -> int:
|
|||
def _read_chat_entries(path: pathlib.Path) -> List[Dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return []
|
||||
# Full project awareness (v6.32.0): the one identity's consolidated dialogue
|
||||
# (dialogue_blocks.json) is its WHOLE conversation — main + project threads —
|
||||
# because Ouroboros is one awareness/biography across direct chat, project
|
||||
# rooms, and background consciousness (BIBLE P1). Only A2A virtual-transport
|
||||
# ids are excluded (machine-to-machine traffic, not the human dialogue). This
|
||||
# MUST match memory.read_jsonl_tail_after_offset so the shared consolidation
|
||||
# offset indexes the same stream.
|
||||
entries = []
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
|
|
|
|||
|
|
@ -270,6 +270,30 @@ def build_knowledge_sections(
|
|||
if warn_large and len(text) > _LARGE_CONTEXT_SECTION_CHARS:
|
||||
log.warning("context: %s is large (%d chars)", label, len(text))
|
||||
sections.append(f"{header}\n\n{text}")
|
||||
if pid:
|
||||
# Bounded per-project journal tail + workpad (multi-project, v6.32.0):
|
||||
# the project's durable progress memory rides along with its knowledge.
|
||||
try:
|
||||
from ouroboros.project_facts import project_workpad_path
|
||||
from ouroboros.tools.project_journal import journal_tail_digest
|
||||
|
||||
journal = journal_tail_digest(pid)
|
||||
if journal:
|
||||
sections.append(
|
||||
f"## Project journal ({pid}) — recent milestones\n\n{journal}\n\n"
|
||||
"(journal_read shows the full history; journal_write appends.)"
|
||||
)
|
||||
workpad = safe_read(project_workpad_path(pid))
|
||||
if workpad.strip():
|
||||
# Cognitive artifact: never silently prefix-slice (BIBLE P1 — that
|
||||
# is partial amnesia). The project's own working memory rides in
|
||||
# full; an oversized workpad is a workpad-discipline signal to
|
||||
# consolidate, not a reason to amputate context.
|
||||
if len(workpad) > _LARGE_CONTEXT_SECTION_CHARS:
|
||||
log.warning("context: project workpad (%s) is large (%d chars)", pid, len(workpad))
|
||||
sections.append(f"## Project workpad ({pid})\n\n{workpad}")
|
||||
except Exception:
|
||||
log.debug("project journal/workpad context injection failed", exc_info=True)
|
||||
return sections
|
||||
|
||||
|
||||
|
|
@ -289,7 +313,7 @@ def build_governance_sections(env: Any, *, warn_large: bool = False, warn_label:
|
|||
return sections
|
||||
|
||||
|
||||
_SECTION_BUDGETS = {"scratchpad": SCRATCHPAD_SECTION_BUDGET_CHARS, "identity": 80_000, "registry": 30_000}
|
||||
_SECTION_BUDGETS = {"scratchpad": SCRATCHPAD_SECTION_BUDGET_CHARS, "identity": 80_000, "registry": 30_000, "world": 16_000}
|
||||
|
||||
|
||||
def _warn_if_over_budget(name: str, content: str) -> None:
|
||||
|
|
@ -315,8 +339,11 @@ def build_memory_sections(memory: Memory, partition: str = "all") -> List[str]:
|
|||
sections.append("## Identity (from `memory/identity.md` — already loaded; do not re-read via read_file(root='runtime_data', path='memory/identity.md'))\n\n" + identity_raw)
|
||||
world_raw = memory.load_world_profile().strip()
|
||||
if world_raw:
|
||||
world_text = truncate_review_artifact(world_raw, limit=4096)
|
||||
sections.append("## Environment Profile (from `memory/WORLD.md` — already loaded; delete WORLD.md and restart to regenerate if the host environment changes)\n\n" + world_text)
|
||||
# Generated environment profile: include in FULL and warn rather than
|
||||
# silently prefix-slicing (BIBLE P1 no-silent-truncation). An oversized
|
||||
# WORLD.md is a generation-discipline bug, not a context-budget excuse.
|
||||
_warn_if_over_budget("world", world_raw)
|
||||
sections.append("## Environment Profile (from `memory/WORLD.md` — already loaded; delete WORLD.md and restart to regenerate if the host environment changes)\n\n" + world_raw)
|
||||
|
||||
if include_volatile:
|
||||
dialogue_blocks = memory.load_dialogue_blocks()
|
||||
|
|
@ -382,37 +409,89 @@ def _format_recent_reflections(entries: List[Dict[str, Any]], limit: int = 10) -
|
|||
return "\n\n".join(blocks)
|
||||
|
||||
|
||||
def build_recent_sections(memory: Memory, env: Any, task_id: str = "") -> List[str]:
|
||||
def _entry_chat_id(entry: Any) -> int:
|
||||
"""Best-effort chat_id of a chat.jsonl row (missing/blank -> 0 = main)."""
|
||||
try:
|
||||
return int((entry or {}).get("chat_id", 0) or 0)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return 0
|
||||
|
||||
|
||||
# How many trailing chat.jsonl rows to scan when reconstructing a single project
|
||||
# thread's own recent tail (project threads are bounded/recent, unlike the штаб's
|
||||
# fully-consolidated main dialogue).
|
||||
_PROJECT_THREAD_SCAN = 4000
|
||||
|
||||
|
||||
def build_recent_sections(
|
||||
memory: Memory, env: Any, task_id: str = "", thread_chat_id: int = 0
|
||||
) -> List[str]:
|
||||
sections = []
|
||||
|
||||
dialogue_meta = memory.load_dialogue_meta()
|
||||
# Full project awareness (v6.32.0): registry membership is the SSOT for "is
|
||||
# this a project thread" (a numeric range cannot disambiguate large external
|
||||
# transport ids). The one identity (main chat + background consciousness) sees
|
||||
# its WHOLE conversation, project threads included, because Ouroboros is one
|
||||
# awareness/biography (BIBLE P1). A project TASK gets a FOCUSED view of its own
|
||||
# thread as working context to reduce interference — focus, not isolation.
|
||||
try:
|
||||
consolidated_offset = int(dialogue_meta.get("last_consolidated_offset") or 0)
|
||||
except (TypeError, ValueError):
|
||||
consolidated_offset = 0
|
||||
if consolidated_offset > 0:
|
||||
expected_signature = dialogue_meta.get("chat_log_signature")
|
||||
current_signature = memory.jsonl_generation_signature("chat.jsonl")
|
||||
if not _chat_log_signature_matches(expected_signature, current_signature):
|
||||
log.warning(
|
||||
"Ignoring dialogue consolidation offset %s because chat log generation signature is missing or stale",
|
||||
consolidated_offset,
|
||||
)
|
||||
consolidated_offset = 0
|
||||
# Raw recent-dialogue tail: smaller in low context mode only when it cannot
|
||||
# silently drop unconsolidated dialogue. If a valid consolidation offset
|
||||
# exists, the older span is represented by dialogue_blocks.json and the whole
|
||||
# suffix after that offset remains raw (P1: horizon preserved, granularity
|
||||
# varies but unconsolidated dialogue is not cut away).
|
||||
from ouroboros.projects_registry import registered_project_chat_ids
|
||||
|
||||
_project_chat_ids = registered_project_chat_ids(memory.drive_root)
|
||||
except Exception:
|
||||
_project_chat_ids = set()
|
||||
|
||||
_context_mode = get_context_mode()
|
||||
_chat_tail = MAX_RECENT_CHAT_TAIL
|
||||
if _context_mode == "low" and consolidated_offset > 0:
|
||||
_chat_tail = 10**9
|
||||
chat_entries = memory.read_jsonl_tail_after_offset(
|
||||
"chat.jsonl",
|
||||
consolidated_offset,
|
||||
_chat_tail,
|
||||
)
|
||||
|
||||
if thread_chat_id and thread_chat_id in _project_chat_ids:
|
||||
# Project task: a FOCUSED working view of its OWN thread (reduces
|
||||
# cross-project interference while executing) — NOT isolation from the one
|
||||
# mind, which sees everything via the main/background path below. Read the
|
||||
# project's raw tail directly. Post-hoc bound tasks keep their original main
|
||||
# chat_id but belong to this project — include their rows via the binding.
|
||||
try:
|
||||
from ouroboros.projects_registry import all_task_bindings
|
||||
|
||||
_bound = all_task_bindings(memory.drive_root)
|
||||
except Exception:
|
||||
_bound = {}
|
||||
recent = memory.read_jsonl_tail("chat.jsonl", _PROJECT_THREAD_SCAN)
|
||||
chat_entries = [
|
||||
e for e in recent
|
||||
if _entry_chat_id(e) == thread_chat_id
|
||||
or _bound.get(str((e or {}).get("task_id") or "")) == thread_chat_id
|
||||
][-_chat_tail:]
|
||||
else:
|
||||
dialogue_meta = memory.load_dialogue_meta()
|
||||
try:
|
||||
consolidated_offset = int(dialogue_meta.get("last_consolidated_offset") or 0)
|
||||
except (TypeError, ValueError):
|
||||
consolidated_offset = 0
|
||||
if consolidated_offset > 0:
|
||||
expected_signature = dialogue_meta.get("chat_log_signature")
|
||||
current_signature = memory.jsonl_generation_signature("chat.jsonl")
|
||||
if not _chat_log_signature_matches(expected_signature, current_signature):
|
||||
log.warning(
|
||||
"Ignoring dialogue consolidation offset %s because chat log generation signature is missing or stale",
|
||||
consolidated_offset,
|
||||
)
|
||||
consolidated_offset = 0
|
||||
# Raw recent-dialogue tail: smaller in low context mode only when it cannot
|
||||
# silently drop unconsolidated dialogue. If a valid consolidation offset
|
||||
# exists, the older span is represented by dialogue_blocks.json and the whole
|
||||
# suffix after that offset remains raw (P1: horizon preserved, granularity
|
||||
# varies but unconsolidated dialogue is not cut away).
|
||||
if _context_mode == "low" and consolidated_offset > 0:
|
||||
_chat_tail = 10**9
|
||||
# read_jsonl_tail_after_offset returns the one identity's WHOLE dialogue
|
||||
# (main + project threads; only A2A virtual transport excluded), aligned
|
||||
# with the consolidator so the shared offset indexes the same stream.
|
||||
chat_entries = memory.read_jsonl_tail_after_offset(
|
||||
"chat.jsonl",
|
||||
consolidated_offset,
|
||||
_chat_tail,
|
||||
)
|
||||
# Pass the same tail intent down: summarize_chat's internal default cap
|
||||
# would silently re-cut the low-mode full-window read to 1000 lines.
|
||||
chat_summary = memory.summarize_chat(chat_entries, limit=_chat_tail)
|
||||
|
|
@ -950,7 +1029,9 @@ def build_llm_messages(
|
|||
except Exception:
|
||||
log.debug("Failed to build advisory review status section", exc_info=True)
|
||||
|
||||
dynamic_parts.extend(build_recent_sections(memory, env, task_id=task.get("id", "")))
|
||||
dynamic_parts.extend(build_recent_sections(
|
||||
memory, env, task_id=task.get("id", ""), thread_chat_id=int(task.get("chat_id") or 0)
|
||||
))
|
||||
|
||||
dynamic_text = "\n\n".join(dynamic_parts)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,17 @@
|
|||
The negative chat id range is reserved for synthetic agent-to-agent traffic
|
||||
that must not appear in human dialogue history or browser chat streams. Source
|
||||
attribution belongs in message payloads (``source``), not in the numeric id.
|
||||
|
||||
Positive ids above ``PROJECT_CHAT_ID_MIN`` are per-project chats (v6.32.0,
|
||||
multi-project): the SAME web owner (user_id stays 1 — owner binding is
|
||||
security-load-bearing and never re-binds) talking to the same single agent in
|
||||
a project-scoped thread. The main chat stays ``WEB_UI_CHAT_ID``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
WEB_UI_CHAT_ID = 1
|
||||
|
||||
# Reserved for A2A-like synthetic conversations. Legacy A2A generated
|
||||
|
|
@ -15,6 +22,10 @@ WEB_UI_CHAT_ID = 1
|
|||
A2A_CHAT_ID_MIN = -3999
|
||||
A2A_CHAT_ID_MAX = -1
|
||||
|
||||
# Project chats occupy a high positive range so they can never collide with
|
||||
# the main chat or small hand-assigned ids.
|
||||
PROJECT_CHAT_ID_MIN = 1000
|
||||
|
||||
|
||||
def is_a2a_chat_id(chat_id: object) -> bool:
|
||||
"""Return True when ``chat_id`` belongs to synthetic A2A traffic."""
|
||||
|
|
@ -30,10 +41,37 @@ def is_internal_chat_id(chat_id: object) -> bool:
|
|||
return is_a2a_chat_id(chat_id)
|
||||
|
||||
|
||||
def is_project_chat_id(chat_id: object) -> bool:
|
||||
"""Return True for per-project owner chats (positive high range)."""
|
||||
try:
|
||||
value = int(chat_id) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return value >= PROJECT_CHAT_ID_MIN
|
||||
|
||||
|
||||
def project_chat_id(project_id: str) -> int:
|
||||
"""Deterministic chat id for a project (stable across restarts/devices).
|
||||
|
||||
Derived from the sanitized project id so the registry, history rows, and
|
||||
WS frames all agree without a separate allocator file. 28 bits of hash
|
||||
above the floor keeps collision odds negligible for realistic project
|
||||
counts while staying inside JS safe-integer range.
|
||||
"""
|
||||
pid = str(project_id or "").strip()
|
||||
if not pid:
|
||||
return WEB_UI_CHAT_ID
|
||||
digest = hashlib.sha256(pid.encode("utf-8")).digest()
|
||||
return PROJECT_CHAT_ID_MIN + (int.from_bytes(digest[:4], "big") & 0x0FFFFFFF)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"A2A_CHAT_ID_MAX",
|
||||
"A2A_CHAT_ID_MIN",
|
||||
"PROJECT_CHAT_ID_MIN",
|
||||
"WEB_UI_CHAT_ID",
|
||||
"is_a2a_chat_id",
|
||||
"is_internal_chat_id",
|
||||
"is_project_chat_id",
|
||||
"project_chat_id",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ class ChatInbound(TypedDict):
|
|||
client_message_id: NotRequired[str]
|
||||
force_plan: NotRequired[bool]
|
||||
attachments: NotRequired[list] # list[ChatAttachmentInbound] (additive, v6.26.0)
|
||||
# Multi-project (additive, v6.32.0): per-project chat routing. The owner
|
||||
# stays user_id 1; chat_id selects the thread, project_id scopes memory.
|
||||
chat_id: NotRequired[int]
|
||||
project_id: NotRequired[str]
|
||||
|
||||
|
||||
class TaskConstraintInbound(TypedDict, total=False):
|
||||
|
|
@ -168,6 +172,9 @@ class TypingOutbound(TypedDict):
|
|||
|
||||
type: Literal["typing"]
|
||||
action: str
|
||||
# Multi-project: stamps the thread so the client fan-out routes a project
|
||||
# task's typing indicator to its panel instead of defaulting to main.
|
||||
chat_id: NotRequired[int]
|
||||
|
||||
|
||||
class LogOutbound(TypedDict):
|
||||
|
|
@ -175,6 +182,9 @@ class LogOutbound(TypedDict):
|
|||
|
||||
type: Literal["log"]
|
||||
data: Dict[str, Any]
|
||||
# Multi-project: surfaced at top level so live task progress routes to the
|
||||
# owning project panel (and mirrors into main) by thread.
|
||||
chat_id: NotRequired[int]
|
||||
|
||||
|
||||
class HeartbeatOutbound(TypedDict):
|
||||
|
|
@ -195,6 +205,17 @@ class ExtensionLifecycleOutbound(TypedDict):
|
|||
data: NotRequired[Dict[str, Any]]
|
||||
|
||||
|
||||
class ProjectsChangedOutbound(TypedDict):
|
||||
"""Outbound notice that the project registry changed server-side (e.g. the
|
||||
agent's ``promote_chat_to_task`` created/bound a project). The client refreshes
|
||||
its project nav + WS-fan-out ``projectChatIds`` on receipt; ``chat_id`` lets it
|
||||
learn the new project thread immediately, before the /api/state round-trip."""
|
||||
|
||||
type: Literal["projects_changed"]
|
||||
project_id: NotRequired[str]
|
||||
chat_id: NotRequired[int]
|
||||
|
||||
|
||||
class ErrorResponse(TypedDict):
|
||||
error: str
|
||||
|
||||
|
|
@ -222,7 +243,9 @@ class EvolutionStateSnapshot(TypedDict):
|
|||
owner_chat_bound: bool
|
||||
last_task_at: str
|
||||
consecutive_failures: int
|
||||
budget_remaining_usd: float
|
||||
# None when the remaining budget is infinite (unbudgeted): inf is not JSON
|
||||
# compliant, so the snapshot serializes it as null.
|
||||
budget_remaining_usd: Optional[float]
|
||||
budget_reserve_usd: float
|
||||
pending_count: int
|
||||
running_count: int
|
||||
|
|
@ -256,6 +279,12 @@ class StateResponse(TypedDict):
|
|||
context_mode: str
|
||||
skills_repo_configured: bool
|
||||
github_token_configured: bool
|
||||
# Multi-project sidebar feed (additive, v6.32.0): compact registered
|
||||
# projects [{id, name, status, chat_id, working_dir, last_active_at}].
|
||||
projects: list
|
||||
# COMPLETE (uncapped, all-status) registered project chat_ids — the live WS
|
||||
# fan-out isolation SSOT, distinct from the capped/filtered `projects` list.
|
||||
project_chat_ids: list
|
||||
|
||||
|
||||
class SettingsNetworkMeta(TypedDict):
|
||||
|
|
@ -538,6 +567,11 @@ HTTP_ENDPOINTS: tuple[str, ...] = (
|
|||
"POST /api/update/apply",
|
||||
"GET /api/cost-breakdown",
|
||||
"GET /api/evolution-data",
|
||||
"GET /api/projects",
|
||||
"POST /api/projects",
|
||||
"POST /api/projects/from-task",
|
||||
"POST /api/projects/{project_id}/sleep",
|
||||
"POST /api/projects/{project_id}/wake",
|
||||
"GET /api/chat/history",
|
||||
"GET /api/logs/{name}",
|
||||
"POST /api/chat/upload",
|
||||
|
|
@ -600,6 +634,7 @@ WS_MESSAGE_TYPES: tuple[str, ...] = (
|
|||
"log",
|
||||
"heartbeat",
|
||||
"extension_lifecycle",
|
||||
"projects_changed",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -616,6 +651,7 @@ __all__ = [
|
|||
"LogOutbound",
|
||||
"HeartbeatOutbound",
|
||||
"ExtensionLifecycleOutbound",
|
||||
"ProjectsChangedOutbound",
|
||||
"ErrorResponse",
|
||||
"StatusResponse",
|
||||
"HealthResponse",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
|
@ -153,6 +153,54 @@ def make_chat_history_endpoint(data_dir: pathlib.Path):
|
|||
# accepted for backward-compat but no longer governs the slice.)
|
||||
n_human = _int_param("n_human", 750, 1500)
|
||||
n_progress = _int_param("n_progress", 300, 600)
|
||||
# Multi-project thread filter (v6.32.0): each chat fetches its own
|
||||
# history. Default 1 = main chat (legacy rows without chat_id are main).
|
||||
# The filter only PARTITIONS when the requested thread is a registered
|
||||
# project chat; for the main chat (and any non-project chat_id, e.g. an
|
||||
# external-transport mirror) it keeps the historic behavior of showing
|
||||
# every non-project, non-A2A row so transport conversations stay visible.
|
||||
thread_id = _int_param("chat_id", 1, 2**31 - 1) or 1
|
||||
try:
|
||||
from ouroboros.projects_registry import registered_project_chat_ids
|
||||
|
||||
project_chat_ids = registered_project_chat_ids(data_dir)
|
||||
except Exception:
|
||||
project_chat_ids = set()
|
||||
bound_chat_cache: Dict[str, int] = {}
|
||||
|
||||
def _bound_project_chat(task_id: str) -> int:
|
||||
tid = str(task_id or "").strip()
|
||||
if not tid:
|
||||
return 0
|
||||
if tid in bound_chat_cache:
|
||||
return bound_chat_cache[tid]
|
||||
try:
|
||||
from ouroboros.projects_registry import project_chat_for_task
|
||||
|
||||
bound_chat_cache[tid] = int(project_chat_for_task(data_dir, tid) or 0)
|
||||
except Exception:
|
||||
bound_chat_cache[tid] = 0
|
||||
return bound_chat_cache[tid]
|
||||
|
||||
def _row_matches_thread(entry_chat: int, entry: Optional[dict] = None) -> bool:
|
||||
# A post-hoc bound task keeps its original (main) chat_id on its rows
|
||||
# but belongs to a project — classify by the durable binding too.
|
||||
bound_chat = (
|
||||
_bound_project_chat(str(entry.get("task_id") or "")) if isinstance(entry, dict) else 0
|
||||
)
|
||||
if thread_id in project_chat_ids:
|
||||
if bound_chat == thread_id:
|
||||
return True
|
||||
return entry_chat == thread_id
|
||||
# Main / non-project view: everything that is NOT another project. A
|
||||
# bound task's rows are project-owned, so mirror only its sanitized
|
||||
# progress/task_summary and exclude its raw chat (same as a native
|
||||
# project row), never leak raw project chat into the штаб.
|
||||
if entry_chat in project_chat_ids or bound_chat > 0:
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
return bool(entry.get("is_progress")) or str(entry.get("type") or "") == "task_summary"
|
||||
return entry_chat not in project_chat_ids
|
||||
|
||||
combined: list = []
|
||||
|
||||
|
|
@ -162,6 +210,12 @@ def make_chat_history_endpoint(data_dir: pathlib.Path):
|
|||
# Skip A2A virtual chat_ids so A2A task traffic does not appear in human chat history.
|
||||
if is_a2a_chat_id(entry.get("chat_id", 1)):
|
||||
continue
|
||||
try:
|
||||
entry_chat = int(entry.get("chat_id", 1) or 1)
|
||||
except (TypeError, ValueError):
|
||||
entry_chat = 1
|
||||
if not _row_matches_thread(entry_chat, entry):
|
||||
continue
|
||||
direction = str(entry.get("direction", "")).lower()
|
||||
role = {"in": "user", "out": "assistant", "system": "system"}.get(direction)
|
||||
if role is None:
|
||||
|
|
@ -199,6 +253,12 @@ def make_chat_history_endpoint(data_dir: pathlib.Path):
|
|||
# Skip A2A virtual chat_ids.
|
||||
if is_a2a_chat_id(entry.get("chat_id", 1)):
|
||||
continue
|
||||
try:
|
||||
entry_chat = int(entry.get("chat_id", 1) or 1)
|
||||
except (TypeError, ValueError):
|
||||
entry_chat = 1
|
||||
if not _row_matches_thread(entry_chat, {"is_progress": True, **entry}):
|
||||
continue
|
||||
text = str(entry.get("content", entry.get("text", "")))
|
||||
if not text:
|
||||
continue
|
||||
|
|
|
|||
124
ouroboros/gateway/projects.py
Normal file
124
ouroboros/gateway/projects.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Projects gateway handlers (multi-project, v6.32.0).
|
||||
|
||||
Thin transport over ``ouroboros.projects_registry`` — list/create/sleep/wake
|
||||
plus the per-project chat id the UI needs to open a project thread. No
|
||||
business logic here (Gateway Boundary rule).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from ouroboros.gateway._helpers import json_exception, request_drive_root, request_repo_dir
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def api_projects_list(request: Request) -> JSONResponse:
|
||||
try:
|
||||
from ouroboros.projects_registry import projects_summary
|
||||
|
||||
return JSONResponse({"projects": projects_summary(request_drive_root(request), limit=200)})
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
||||
|
||||
async def api_projects_create(request: Request) -> JSONResponse:
|
||||
try:
|
||||
from ouroboros.project_facts import explicit_project_id_ok, sanitize_project_id
|
||||
from ouroboros.projects_registry import create_project, ensure_project_workspace
|
||||
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse({"error": "body must be a JSON object"}, status_code=400)
|
||||
raw_id = str(body.get("id") or body.get("project_id") or "").strip()
|
||||
if not raw_id:
|
||||
return JSONResponse({"error": "id is required"}, status_code=400)
|
||||
if not explicit_project_id_ok(raw_id):
|
||||
return JSONResponse(
|
||||
{"error": f"id {raw_id!r} is not filesystem-clean (lowercase alphanumeric/_/-/., <=64 chars)"},
|
||||
status_code=400,
|
||||
)
|
||||
drive_root = request_drive_root(request)
|
||||
entry = create_project(
|
||||
drive_root,
|
||||
sanitize_project_id(raw_id),
|
||||
name=str(body.get("name") or "").strip(),
|
||||
origin="owner_ui",
|
||||
)
|
||||
if bool(body.get("with_workspace")):
|
||||
workspace = ensure_project_workspace(drive_root, entry["id"], request_repo_dir(request))
|
||||
if workspace:
|
||||
entry = dict(entry)
|
||||
entry["working_dir"] = workspace
|
||||
return JSONResponse({"project": entry})
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
||||
|
||||
async def api_project_from_task(request: Request) -> JSONResponse:
|
||||
"""Create/get a project from an existing task and bind the task to it."""
|
||||
try:
|
||||
from ouroboros.project_facts import explicit_project_id_ok, sanitize_project_id
|
||||
from ouroboros.projects_registry import bind_task_to_project, create_project, touch_project
|
||||
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse({"error": "body must be a JSON object"}, status_code=400)
|
||||
task_id = str(body.get("task_id") or "").strip()
|
||||
if not task_id:
|
||||
return JSONResponse({"error": "task_id is required"}, status_code=400)
|
||||
raw_id = str(body.get("id") or body.get("project_id") or f"task-{task_id}").strip()
|
||||
if not explicit_project_id_ok(raw_id):
|
||||
return JSONResponse(
|
||||
{"error": f"id {raw_id!r} is not filesystem-clean (lowercase alphanumeric/_/-/., <=64 chars)"},
|
||||
status_code=400,
|
||||
)
|
||||
drive_root = request_drive_root(request)
|
||||
project = create_project(
|
||||
drive_root,
|
||||
sanitize_project_id(raw_id),
|
||||
name=str(body.get("name") or "").strip(),
|
||||
origin="task_card",
|
||||
)
|
||||
binding = bind_task_to_project(drive_root, task_id, str(project["id"]), project.get("chat_id"))
|
||||
touch_project(drive_root, str(project["id"]))
|
||||
return JSONResponse({"project": project, "binding": binding})
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
||||
|
||||
async def api_project_sleep(request: Request) -> JSONResponse:
|
||||
try:
|
||||
from ouroboros.projects_registry import sleep_project
|
||||
|
||||
entry = sleep_project(request_drive_root(request), str(request.path_params.get("project_id") or ""))
|
||||
if entry is None:
|
||||
return JSONResponse({"error": "unknown project"}, status_code=404)
|
||||
return JSONResponse({"project": entry})
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
||||
|
||||
async def api_project_wake(request: Request) -> JSONResponse:
|
||||
try:
|
||||
from ouroboros.projects_registry import wake_project
|
||||
|
||||
entry = wake_project(request_drive_root(request), str(request.path_params.get("project_id") or ""))
|
||||
if entry is None:
|
||||
return JSONResponse({"error": "unknown project"}, status_code=404)
|
||||
return JSONResponse({"project": entry})
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"api_project_from_task",
|
||||
"api_project_sleep",
|
||||
"api_project_wake",
|
||||
"api_projects_create",
|
||||
"api_projects_list",
|
||||
]
|
||||
|
|
@ -86,6 +86,13 @@ def collect_routes(
|
|||
api_update_check,
|
||||
api_update_status,
|
||||
)
|
||||
from ouroboros.gateway.projects import (
|
||||
api_project_from_task,
|
||||
api_project_sleep,
|
||||
api_project_wake,
|
||||
api_projects_create,
|
||||
api_projects_list,
|
||||
)
|
||||
from ouroboros.gateway.state import api_health, api_state
|
||||
from ouroboros.gateway.tasks import (
|
||||
api_task_artifact,
|
||||
|
|
@ -178,6 +185,11 @@ def collect_routes(
|
|||
Route("/api/owner/auto-grant", endpoint=api_owner_auto_grant, methods=["POST"]),
|
||||
Route("/api/owner/context-mode", endpoint=api_owner_context_mode, methods=["POST"]),
|
||||
Route("/api/model-catalog", endpoint=api_model_catalog),
|
||||
Route("/api/projects", endpoint=api_projects_list, methods=["GET"]),
|
||||
Route("/api/projects", endpoint=api_projects_create, methods=["POST"]),
|
||||
Route("/api/projects/from-task", endpoint=api_project_from_task, methods=["POST"]),
|
||||
Route("/api/projects/{project_id}/sleep", endpoint=api_project_sleep, methods=["POST"]),
|
||||
Route("/api/projects/{project_id}/wake", endpoint=api_project_wake, methods=["POST"]),
|
||||
Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"]),
|
||||
Route("/api/tasks", endpoint=api_tasks_list, methods=["GET"]),
|
||||
Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"]),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from starlette.requests import Request
|
|||
from starlette.responses import JSONResponse
|
||||
|
||||
from ouroboros import get_version
|
||||
from ouroboros.gateway._helpers import json_exception
|
||||
from ouroboros.gateway._helpers import json_exception, request_drive_root
|
||||
|
||||
|
||||
def _state_attr(request: Request, name: str, default: Any = None) -> Any:
|
||||
|
|
@ -81,9 +81,34 @@ async def api_state(request: Request) -> JSONResponse:
|
|||
"context_mode": get_context_mode(),
|
||||
"skills_repo_configured": bool(get_skills_repo_path()),
|
||||
"github_token_configured": bool(github_token_from_env_or_settings()),
|
||||
"projects": _projects_summary_safe(request),
|
||||
"project_chat_ids": _project_chat_ids_safe(request),
|
||||
})
|
||||
except Exception as exc:
|
||||
return json_exception(exc)
|
||||
|
||||
|
||||
def _projects_summary_safe(request: Request) -> list:
|
||||
"""Compact registered-projects list for the sidebar (never raises)."""
|
||||
try:
|
||||
from ouroboros.projects_registry import projects_summary
|
||||
|
||||
return projects_summary(request_drive_root(request))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _project_chat_ids_safe(request: Request) -> list:
|
||||
"""COMPLETE (uncapped, all-status) registered project chat_ids for the live
|
||||
WS fan-out isolation SSOT — distinct from the capped/filtered sidebar list,
|
||||
so isolation never lapses for projects beyond the summary limit or hidden
|
||||
rows. Never raises."""
|
||||
try:
|
||||
from ouroboros.projects_registry import registered_project_chat_ids
|
||||
|
||||
return sorted(registered_project_chat_ids(request_drive_root(request)))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
__all__ = ["api_health", "api_state"]
|
||||
|
|
|
|||
|
|
@ -264,6 +264,10 @@ async def ws_endpoint(websocket: WebSocket) -> None:
|
|||
image_b64, image_mime, image_caption = _first_image_attachment(
|
||||
msg.get("attachments")
|
||||
)
|
||||
try:
|
||||
thread_id = int(msg.get("chat_id") or 1)
|
||||
except (TypeError, ValueError):
|
||||
thread_id = 1
|
||||
bridge.ui_send(
|
||||
payload,
|
||||
sender_session_id=str(msg.get("sender_session_id", "") or ""),
|
||||
|
|
@ -275,6 +279,8 @@ async def ws_endpoint(websocket: WebSocket) -> None:
|
|||
"force_plan": force_plan,
|
||||
"force_plan_source": "consilium" if force_plan else "",
|
||||
},
|
||||
chat_id=thread_id,
|
||||
project_id=str(msg.get("project_id", "") or ""),
|
||||
)
|
||||
else:
|
||||
bridge.ui_send(payload, broadcast=False)
|
||||
|
|
|
|||
|
|
@ -258,6 +258,12 @@ class Memory:
|
|||
return "(chat history is empty)"
|
||||
|
||||
try:
|
||||
# Full project awareness (v6.32.0): active recall spans the one
|
||||
# identity's WHOLE conversation — main + ALL project threads (BIBLE P1,
|
||||
# one awareness across direct chat, project rooms, and consciousness);
|
||||
# only A2A virtual transport is excluded. The project-task FOCUS lives
|
||||
# in the passive default context (build_recent_sections), NOT in this
|
||||
# explicit recall tool — the one mind can deliberately recall anything.
|
||||
entries = self._read_jsonl_entries("chat.jsonl", exclude_a2a=True)
|
||||
|
||||
if search:
|
||||
|
|
@ -306,6 +312,12 @@ class Memory:
|
|||
offset: int,
|
||||
max_entries: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
# Full project awareness (v6.32.0): the one identity's dialogue stream is
|
||||
# its WHOLE conversation — main + project threads alike — because Ouroboros
|
||||
# is one awareness across direct chat, project rooms, and background
|
||||
# consciousness (BIBLE P1). Only A2A virtual-transport ids are excluded
|
||||
# (machine-to-machine traffic, not the human dialogue). A project task's
|
||||
# OWN focused recent-chat view is built separately in build_recent_sections.
|
||||
entries = self._read_jsonl_entries(log_name, exclude_a2a=True)
|
||||
if offset <= 0:
|
||||
return entries[-max_entries:] if max_entries < len(entries) else entries
|
||||
|
|
|
|||
|
|
@ -114,3 +114,24 @@ def project_knowledge_dir(project_id: str) -> pathlib.Path:
|
|||
from ouroboros.config import DATA_DIR
|
||||
|
||||
return pathlib.Path(DATA_DIR) / "projects" / sanitize_project_id(project_id) / "knowledge"
|
||||
|
||||
|
||||
def _project_store_root(project_id: str) -> pathlib.Path:
|
||||
from ouroboros.config import DATA_DIR
|
||||
|
||||
return pathlib.Path(DATA_DIR) / "projects" / sanitize_project_id(project_id)
|
||||
|
||||
|
||||
def project_journal_path(project_id: str) -> pathlib.Path:
|
||||
"""Append-only project journal (milestones: start/blocked/checkpoint/done).
|
||||
|
||||
JSONL rows ``{ts, kind, text, task_id}`` — the durable per-project memory
|
||||
the agent (and the absorption digest) reads back. Same store subtree as
|
||||
project knowledge, so ``project_store_access_block`` already guards it.
|
||||
"""
|
||||
return _project_store_root(project_id) / "journal.jsonl"
|
||||
|
||||
|
||||
def project_workpad_path(project_id: str) -> pathlib.Path:
|
||||
"""Free-form per-project working notes (markdown scratchpad)."""
|
||||
return _project_store_root(project_id) / "workpad.md"
|
||||
|
|
|
|||
65
ouroboros/project_lease.py
Normal file
65
ouroboros/project_lease.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""One-writer-per-project lease helpers (multi-project, v6.32.0).
|
||||
|
||||
Pure functions consumed by ``supervisor/workers.py::assign_tasks`` under the
|
||||
queue lock: a PENDING task whose ``project_id`` is already RUNNING is skipped
|
||||
this assignment pass (projects serialize internally; parallelism happens
|
||||
BETWEEN projects and via subagent swarms WITHIN a task).
|
||||
|
||||
``project_id == ""`` means "no lane": ordinary unscoped tasks never serialize
|
||||
against each other. Subagents carry their parent's stored ``project_id`` but
|
||||
hold no lease of their own — the parent task IS the project's writer and its
|
||||
swarm must not deadlock against itself, so only top-level (non-subagent)
|
||||
tasks count as lane occupants.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, Set
|
||||
|
||||
|
||||
def _as_task(item: Any) -> Any:
|
||||
"""Unwrap the supervisor RUNNING meta shape ({"task": {...}, ...}) to the
|
||||
task dict; pass a bare task dict through unchanged."""
|
||||
if isinstance(item, dict) and isinstance(item.get("task"), dict):
|
||||
return item["task"]
|
||||
return item
|
||||
|
||||
|
||||
def _task_project_id(task: Any) -> str:
|
||||
task = _as_task(task)
|
||||
if not isinstance(task, dict):
|
||||
return ""
|
||||
return str(task.get("project_id") or "").strip()
|
||||
|
||||
|
||||
def _is_lane_occupant(task: Any) -> bool:
|
||||
"""Top-level project-scoped tasks occupy the lane; subagents do not."""
|
||||
task = _as_task(task)
|
||||
if not isinstance(task, dict):
|
||||
return False
|
||||
if str(task.get("delegation_role") or "") == "subagent":
|
||||
return False
|
||||
return bool(_task_project_id(task))
|
||||
|
||||
|
||||
def running_project_ids(running: Iterable[Any]) -> Set[str]:
|
||||
"""Project ids currently holding a writer lease.
|
||||
|
||||
``running`` is the supervisor's RUNNING mapping values (or any iterable of
|
||||
task dicts); read under the queue lock by the caller.
|
||||
"""
|
||||
out: Set[str] = set()
|
||||
for task in running or ():
|
||||
if _is_lane_occupant(task):
|
||||
out.add(_task_project_id(task))
|
||||
return out
|
||||
|
||||
|
||||
def candidate_is_leasable(candidate: Dict[str, Any], running_ids: Set[str]) -> bool:
|
||||
"""True when ``candidate`` may be assigned now under the one-writer rule."""
|
||||
if not _is_lane_occupant(candidate):
|
||||
return True
|
||||
return _task_project_id(candidate) not in running_ids
|
||||
|
||||
|
||||
__all__ = ["candidate_is_leasable", "running_project_ids"]
|
||||
435
ouroboros/projects_registry.py
Normal file
435
ouroboros/projects_registry.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
"""Durable registry of owner projects (multi-project, v6.32.0).
|
||||
|
||||
A project is a durable context the single agent works in: id + name +
|
||||
per-project memory (``data/projects/<id>/``) + chat thread (its own positive
|
||||
``chat_id``) + an OPTIONAL working folder (invisible auto-git under the
|
||||
durable projects root). File-less research projects are valid. Projects are
|
||||
NEVER age-pruned; the owner curates by archive/delete.
|
||||
|
||||
State lives in ``data/state/projects.json`` via the canonical durable-JSON
|
||||
pattern (mirrors ``subagent_worktrees.py``). The registry is data-plane
|
||||
bookkeeping only — identity, constitution, and evolution stay unified in the
|
||||
one agent (BIBLE P1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from ouroboros.contracts.chat_id_policy import project_chat_id
|
||||
from ouroboros.project_facts import sanitize_project_id
|
||||
from ouroboros.utils import atomic_write_json, iter_jsonl_objects, read_json_dict, utc_now_iso
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_REGISTRY_NAME = "projects.json"
|
||||
_BINDINGS_NAME = "project_task_bindings.json"
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _file_write_lock(target_path: pathlib.Path) -> Iterator[None]:
|
||||
"""Cross-process exclusive lock for a registry/bindings read-modify-write.
|
||||
|
||||
The registry is written from BOTH the server process (project create/bind,
|
||||
digest touch) AND worker processes (``project_journal`` touch_project), so a
|
||||
process-local ``threading.Lock`` cannot prevent lost updates. Flock a sidecar
|
||||
so the load→modify→atomic-write sequence is exclusive across processes; the
|
||||
in-process ``_LOCK`` is nested inside for thread-level serialization too.
|
||||
"""
|
||||
from ouroboros.platform_layer import (
|
||||
acquire_exclusive_file_lock,
|
||||
release_exclusive_file_lock,
|
||||
)
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = target_path.with_name(target_path.name + ".lock")
|
||||
fd = acquire_exclusive_file_lock(lock_path, timeout_sec=4.0)
|
||||
if fd is None:
|
||||
raise TimeoutError(f"projects_registry: could not lock {lock_path} in time")
|
||||
try:
|
||||
with _LOCK:
|
||||
yield
|
||||
finally:
|
||||
release_exclusive_file_lock(lock_path, fd)
|
||||
|
||||
STATUS_ACTIVE = "active"
|
||||
STATUS_SLEEPING = "sleeping"
|
||||
STATUS_ARCHIVED = "archived"
|
||||
_VALID_STATUSES = (STATUS_ACTIVE, STATUS_SLEEPING, STATUS_ARCHIVED)
|
||||
|
||||
|
||||
def _registry_path(drive_root: Any) -> pathlib.Path:
|
||||
return pathlib.Path(drive_root) / "state" / _REGISTRY_NAME
|
||||
|
||||
|
||||
def _bindings_path(drive_root: Any) -> pathlib.Path:
|
||||
return pathlib.Path(drive_root) / "state" / _BINDINGS_NAME
|
||||
|
||||
|
||||
def _load(drive_root: Any) -> Dict[str, Any]:
|
||||
data = read_json_dict(_registry_path(drive_root))
|
||||
if not isinstance(data, dict) or not isinstance(data.get("projects"), list):
|
||||
return {"projects": []}
|
||||
data["projects"] = [p for p in data["projects"] if isinstance(p, dict) and p.get("id")]
|
||||
return data
|
||||
|
||||
|
||||
def _save(drive_root: Any, data: Dict[str, Any]) -> None:
|
||||
path = _registry_path(drive_root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, data)
|
||||
|
||||
|
||||
def _load_bindings(drive_root: Any) -> Dict[str, Any]:
|
||||
data = read_json_dict(_bindings_path(drive_root))
|
||||
if not isinstance(data, dict) or not isinstance(data.get("bindings"), dict):
|
||||
return {"bindings": {}}
|
||||
return data
|
||||
|
||||
|
||||
def _save_bindings(drive_root: Any, data: Dict[str, Any]) -> None:
|
||||
path = _bindings_path(drive_root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, data)
|
||||
|
||||
|
||||
def bind_task_to_project(drive_root: Any, task_id: str, project_id: str, chat_id: Any = None) -> Dict[str, Any]:
|
||||
"""Durably bind an existing task/live card to a project thread.
|
||||
|
||||
This is the post-hoc "Turn into project" bridge: old audit logs remain in
|
||||
their original files, while history/live routing can resolve the task's
|
||||
project chat from this lightweight binding.
|
||||
"""
|
||||
tid = str(task_id or "").strip()
|
||||
pid = sanitize_project_id(project_id)
|
||||
if not tid:
|
||||
raise ValueError("task_id is required")
|
||||
if not pid:
|
||||
raise ValueError(f"unusable project id: {project_id!r}")
|
||||
project = get_project(drive_root, pid) or create_project(drive_root, pid)
|
||||
try:
|
||||
resolved_chat = int(chat_id if chat_id is not None else project.get("chat_id"))
|
||||
except (TypeError, ValueError):
|
||||
resolved_chat = project_chat_id(pid)
|
||||
row = {
|
||||
"task_id": tid,
|
||||
"project_id": pid,
|
||||
"project_chat_id": resolved_chat,
|
||||
"bound_at": utc_now_iso(),
|
||||
}
|
||||
with _file_write_lock(_bindings_path(drive_root)):
|
||||
data = _load_bindings(drive_root)
|
||||
data["bindings"][tid] = row
|
||||
_save_bindings(drive_root, data)
|
||||
touch_project(drive_root, pid)
|
||||
return dict(row)
|
||||
|
||||
|
||||
def all_task_bindings(drive_root: Any) -> Dict[str, int]:
|
||||
"""Map task_id -> project chat_id for ALL post-hoc 'Turn into project' bindings.
|
||||
|
||||
Cognition/history isolation consults this so a bound task's rows (which keep
|
||||
their ORIGINAL main chat_id) are still treated as project-owned. One bounded
|
||||
read; no per-row lock (atomic writes guarantee complete reads)."""
|
||||
out: Dict[str, int] = {}
|
||||
try:
|
||||
for tid, row in _load_bindings(drive_root).get("bindings", {}).items():
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
cid = int(row.get("project_chat_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if cid:
|
||||
out[str(tid)] = cid
|
||||
except Exception:
|
||||
log.debug("all_task_bindings failed", exc_info=True)
|
||||
return out
|
||||
|
||||
|
||||
def project_binding_for_task(drive_root: Any, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
tid = str(task_id or "").strip()
|
||||
if not tid:
|
||||
return None
|
||||
# Read needs no lock: atomic_write_json renames into place, so a reader
|
||||
# always sees a complete (old or new) bindings file, never a torn one.
|
||||
row = _load_bindings(drive_root)["bindings"].get(tid)
|
||||
return dict(row) if isinstance(row, dict) else None
|
||||
|
||||
|
||||
def project_chat_for_task(drive_root: Any, task_id: str) -> int:
|
||||
row = project_binding_for_task(drive_root, task_id)
|
||||
if not row:
|
||||
return 0
|
||||
try:
|
||||
return int(row.get("project_chat_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def list_projects(drive_root: Any) -> List[Dict[str, Any]]:
|
||||
"""All registered projects (most recently active first)."""
|
||||
with _LOCK:
|
||||
projects = _load(drive_root)["projects"]
|
||||
return sorted(
|
||||
projects,
|
||||
key=lambda p: str(p.get("last_active_at") or p.get("updated_at") or p.get("created_at") or ""),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def registered_project_chat_ids(drive_root: Any) -> set:
|
||||
"""The set of chat_ids owned by ALL registered projects (any status).
|
||||
|
||||
The TRUTH source for "is this chat a project thread" — a bare numeric range
|
||||
cannot disambiguate from large external-transport (e.g. Telegram) chat ids,
|
||||
so routing/history/UI classify by registry membership instead.
|
||||
|
||||
NOT an isolation boundary (full project awareness, v6.32.0): the one identity
|
||||
sees ALL threads in its unified memory. This classifier drives (a) the UI
|
||||
history/fan-out partition that organizes threads into panels, (b) message
|
||||
routing, and (c) the project TASK's FOCUSED passive context (build_recent_
|
||||
sections shows the task its own thread). ARCHIVED projects are INCLUDED so an
|
||||
archived thread still classifies consistently for routing/history; sidebar
|
||||
visibility of archived projects is a SEPARATE presentation concern (web/app.js).
|
||||
"""
|
||||
out = set()
|
||||
try:
|
||||
for project in list_projects(drive_root):
|
||||
try:
|
||||
out.add(int(project.get("chat_id") or 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
except Exception:
|
||||
log.debug("registered_project_chat_ids failed", exc_info=True)
|
||||
out.discard(0)
|
||||
return out
|
||||
|
||||
|
||||
def get_project(drive_root: Any, project_id: str) -> Optional[Dict[str, Any]]:
|
||||
pid = sanitize_project_id(project_id)
|
||||
if not pid:
|
||||
return None
|
||||
for project in list_projects(drive_root):
|
||||
if project.get("id") == pid:
|
||||
return dict(project)
|
||||
return None
|
||||
|
||||
|
||||
def create_project(
|
||||
drive_root: Any,
|
||||
project_id: str,
|
||||
*,
|
||||
name: str = "",
|
||||
working_dir: str = "",
|
||||
origin: str = "owner",
|
||||
) -> Dict[str, Any]:
|
||||
"""Register (or idempotently return) a project entry.
|
||||
|
||||
``working_dir`` is optional — file-less projects (research, presentations
|
||||
drafted in chat) are first-class. The per-project chat id is derived
|
||||
deterministically from the id (one allocator-free SSOT).
|
||||
"""
|
||||
pid = sanitize_project_id(project_id)
|
||||
if not pid:
|
||||
raise ValueError(f"unusable project id: {project_id!r}")
|
||||
with _file_write_lock(_registry_path(drive_root)):
|
||||
data = _load(drive_root)
|
||||
for existing in data["projects"]:
|
||||
if existing.get("id") == pid:
|
||||
return dict(existing)
|
||||
entry = {
|
||||
"id": pid,
|
||||
"name": str(name or "").strip() or pid,
|
||||
"status": STATUS_ACTIVE,
|
||||
"chat_id": project_chat_id(pid),
|
||||
"working_dir": str(working_dir or "").strip(),
|
||||
"origin": str(origin or "owner"),
|
||||
"created_at": utc_now_iso(),
|
||||
"last_active_at": utc_now_iso(),
|
||||
}
|
||||
data["projects"].append(entry)
|
||||
_save(drive_root, data)
|
||||
log.info("Project registered: %s (chat_id=%s)", pid, entry["chat_id"])
|
||||
return dict(entry)
|
||||
|
||||
|
||||
def update_project(drive_root: Any, project_id: str, **updates: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Update mutable fields (name/status/working_dir/last_active_at)."""
|
||||
pid = sanitize_project_id(project_id)
|
||||
if not pid:
|
||||
return None
|
||||
allowed = {"name", "status", "working_dir", "last_active_at"}
|
||||
with _file_write_lock(_registry_path(drive_root)):
|
||||
data = _load(drive_root)
|
||||
for entry in data["projects"]:
|
||||
if entry.get("id") != pid:
|
||||
continue
|
||||
for key, value in updates.items():
|
||||
if key not in allowed:
|
||||
continue
|
||||
if key == "status" and value not in _VALID_STATUSES:
|
||||
continue
|
||||
entry[key] = value
|
||||
_save(drive_root, data)
|
||||
return dict(entry)
|
||||
return None
|
||||
|
||||
|
||||
def touch_project(drive_root: Any, project_id: str) -> None:
|
||||
"""Record activity (never raises)."""
|
||||
try:
|
||||
update_project(drive_root, project_id, last_active_at=utc_now_iso())
|
||||
except Exception:
|
||||
log.debug("touch_project failed for %s", project_id, exc_info=True)
|
||||
|
||||
|
||||
def sleep_project(drive_root: Any, project_id: str) -> Optional[Dict[str, Any]]:
|
||||
return update_project(drive_root, project_id, status=STATUS_SLEEPING)
|
||||
|
||||
|
||||
def wake_project(drive_root: Any, project_id: str) -> Optional[Dict[str, Any]]:
|
||||
entry = update_project(drive_root, project_id, status=STATUS_ACTIVE)
|
||||
if entry:
|
||||
touch_project(drive_root, project_id)
|
||||
return entry
|
||||
|
||||
|
||||
def reconcile_projects(drive_root: Any) -> int:
|
||||
"""Boot reconcile: register projects whose memory store exists but whose
|
||||
registry row is missing (e.g. created before the registry shipped, or a
|
||||
workspace-derived ``proj_<hash>`` store). NEVER prunes — durable project
|
||||
dirs outlive any registry accident.
|
||||
"""
|
||||
added = 0
|
||||
try:
|
||||
projects_root = pathlib.Path(drive_root) / "projects"
|
||||
if not projects_root.is_dir():
|
||||
return 0
|
||||
with _file_write_lock(_registry_path(drive_root)):
|
||||
data = _load(drive_root)
|
||||
known = {p.get("id") for p in data["projects"]}
|
||||
for entry in sorted(projects_root.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
pid = sanitize_project_id(entry.name)
|
||||
if not pid or pid in known:
|
||||
continue
|
||||
data["projects"].append({
|
||||
"id": pid,
|
||||
"name": pid,
|
||||
"status": STATUS_ACTIVE,
|
||||
"chat_id": project_chat_id(pid),
|
||||
"working_dir": "",
|
||||
"origin": "reconcile",
|
||||
"created_at": utc_now_iso(),
|
||||
"last_active_at": utc_now_iso(),
|
||||
})
|
||||
known.add(pid)
|
||||
added += 1
|
||||
if added:
|
||||
_save(drive_root, data)
|
||||
log.info("Project registry reconcile: %d store(s) registered", added)
|
||||
except Exception:
|
||||
log.warning("Project registry reconcile failed", exc_info=True)
|
||||
return added
|
||||
|
||||
|
||||
def ensure_project_workspace(drive_root: Any, project_id: str, repo_dir: Any) -> str:
|
||||
"""Provision (once) an invisible-git working folder for a project.
|
||||
|
||||
Reuses the genesis-project machinery: a standalone git repo under the
|
||||
durable projects root (never GC-pruned, isolated from repo/ and data/).
|
||||
Returns the absolute path ("" when provisioning failed). File-less
|
||||
projects simply never call this.
|
||||
"""
|
||||
entry = get_project(drive_root, project_id)
|
||||
if entry is None:
|
||||
entry = create_project(drive_root, project_id)
|
||||
existing = str(entry.get("working_dir") or "").strip()
|
||||
if existing and pathlib.Path(existing).is_dir():
|
||||
return existing
|
||||
try:
|
||||
from ouroboros.subagent_worktrees import provision_genesis_project
|
||||
|
||||
handle = provision_genesis_project(
|
||||
repo_dir=repo_dir,
|
||||
task_id=f"project_{entry['id']}",
|
||||
data_dir=drive_root,
|
||||
)
|
||||
update_project(drive_root, entry["id"], working_dir=str(handle.path))
|
||||
return str(handle.path)
|
||||
except Exception:
|
||||
log.warning("Project workspace provisioning failed for %s", project_id, exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def projects_summary(drive_root: Any, *, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Compact list for /api/state and the sidebar."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
bindings = _load_bindings(drive_root).get("bindings", {})
|
||||
|
||||
def _has_thread_activity(project: Dict[str, Any]) -> bool:
|
||||
pid = str(project.get("id") or "")
|
||||
try:
|
||||
cid = int(project.get("chat_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cid = 0
|
||||
if any(isinstance(row, dict) and row.get("project_id") == pid for row in bindings.values()):
|
||||
return True
|
||||
if not cid:
|
||||
return False
|
||||
logs = pathlib.Path(drive_root) / "logs"
|
||||
for rel in ("chat.jsonl", "progress.jsonl"):
|
||||
path = logs / rel
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
for row in iter_jsonl_objects(path):
|
||||
try:
|
||||
if int(row.get("chat_id") or 1) == cid:
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
except Exception:
|
||||
log.debug("project activity scan failed for %s", path, exc_info=True)
|
||||
return False
|
||||
|
||||
for project in list_projects(drive_root)[: max(1, int(limit))]:
|
||||
out.append({
|
||||
"id": project.get("id"),
|
||||
"name": project.get("name"),
|
||||
"status": project.get("status"),
|
||||
"chat_id": project.get("chat_id"),
|
||||
"working_dir": project.get("working_dir") or "",
|
||||
"last_active_at": project.get("last_active_at") or "",
|
||||
"has_thread_activity": _has_thread_activity(project),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"STATUS_ACTIVE",
|
||||
"STATUS_ARCHIVED",
|
||||
"STATUS_SLEEPING",
|
||||
"all_task_bindings",
|
||||
"bind_task_to_project",
|
||||
"create_project",
|
||||
"ensure_project_workspace",
|
||||
"get_project",
|
||||
"list_projects",
|
||||
"project_binding_for_task",
|
||||
"project_chat_for_task",
|
||||
"registered_project_chat_ids",
|
||||
"projects_summary",
|
||||
"reconcile_projects",
|
||||
"sleep_project",
|
||||
"touch_project",
|
||||
"update_project",
|
||||
"wake_project",
|
||||
]
|
||||
|
|
@ -43,6 +43,8 @@ TOOL_POLICY: Dict[str, str] = {
|
|||
"recent_tasks": POLICY_SKIP,
|
||||
"knowledge_read": POLICY_SKIP,
|
||||
"knowledge_list": POLICY_SKIP,
|
||||
"journal_read": POLICY_SKIP,
|
||||
"workpad_read": POLICY_SKIP,
|
||||
"web_search": POLICY_SKIP,
|
||||
"codebase_digest": POLICY_SKIP,
|
||||
"codebase_health": POLICY_SKIP,
|
||||
|
|
@ -74,6 +76,9 @@ TOOL_POLICY: Dict[str, str] = {
|
|||
"commit_reviewed": POLICY_SKIP,
|
||||
"vcs_commit_reviewed": POLICY_SKIP,
|
||||
"knowledge_write": POLICY_SKIP,
|
||||
"journal_write": POLICY_SKIP,
|
||||
"workpad_write": POLICY_SKIP,
|
||||
"promote_chat_to_task": POLICY_SKIP,
|
||||
"update_scratchpad": POLICY_SKIP,
|
||||
"update_identity": POLICY_SKIP,
|
||||
"memory_update_registry": POLICY_SKIP,
|
||||
|
|
|
|||
|
|
@ -274,6 +274,64 @@ def _promote_to_stable(ctx: ToolContext, reason: str) -> str:
|
|||
return f"Promote to stable requested: {reason}"
|
||||
|
||||
|
||||
def _promote_chat_to_task(
|
||||
ctx: ToolContext,
|
||||
objective: str,
|
||||
expected_output: str = "",
|
||||
project_id: str = "",
|
||||
workspace_root: str = "",
|
||||
) -> str:
|
||||
"""Route real work out of the conversation lane into a supervised pooled task.
|
||||
|
||||
Option B of the multi-project chat plane (v6.32.0): the conversation stays
|
||||
in the fast in-process lane; ANY substantial work spawns a first-class
|
||||
pooled task with a live card. The decision is the model's own structural
|
||||
tool call (BIBLE P5 — no keyword routing). Follow-up owner messages reach
|
||||
the running task through its owner-mailbox.
|
||||
"""
|
||||
goal = str(objective or "").strip()
|
||||
if not goal:
|
||||
return "⚠️ TOOL_ARG_ERROR (promote_chat_to_task): objective is required"
|
||||
from ouroboros.project_facts import explicit_project_id_ok, sanitize_project_id
|
||||
|
||||
pid = ""
|
||||
if str(project_id or "").strip():
|
||||
if not explicit_project_id_ok(project_id):
|
||||
return (
|
||||
f"⚠️ TOOL_ARG_ERROR (promote_chat_to_task): project_id {project_id!r} is not "
|
||||
"filesystem-clean; use lowercase alphanumeric/_/-/. (<=64 chars)"
|
||||
)
|
||||
pid = sanitize_project_id(project_id)
|
||||
else:
|
||||
# No explicit arg: inherit the CURRENT project scope so a project-chat
|
||||
# task that promotes follow-up work stays in its own project (the model
|
||||
# still chose to promote — scope is contextual, never a keyword gate).
|
||||
pid = sanitize_project_id(getattr(ctx, "project_id", "") or "")
|
||||
try:
|
||||
current_chat_id = int(getattr(ctx, "current_chat_id", None) or 0)
|
||||
except (TypeError, ValueError):
|
||||
current_chat_id = 0
|
||||
tid = uuid.uuid4().hex[:8]
|
||||
evt: Dict[str, Any] = {
|
||||
"type": "promote_chat_to_task",
|
||||
"task_id": tid,
|
||||
"objective": goal,
|
||||
"expected_output": str(expected_output or "").strip(),
|
||||
"project_id": pid,
|
||||
"workspace_root": str(workspace_root or "").strip(),
|
||||
"chat_id": current_chat_id,
|
||||
"ts": utc_now_iso(),
|
||||
}
|
||||
mode = _emit_control_event(ctx, evt)
|
||||
scope_note = f" in project '{pid}'" if pid else ""
|
||||
return (
|
||||
f"OK: promoted to supervised task {tid}{scope_note} ({mode}). The conversation "
|
||||
"lane stays free; the owner sees a live task card and can steer the running "
|
||||
"task from chat (messages are delivered to its mailbox). Use wait_task/"
|
||||
"get_task_result to follow up if the result is needed in this conversation."
|
||||
)
|
||||
|
||||
|
||||
def _build_acting_constraint(
|
||||
*,
|
||||
write_surface: str,
|
||||
|
|
@ -694,6 +752,9 @@ def _request_deep_self_review(ctx: ToolContext, reason: str) -> str:
|
|||
def _chat_history(ctx: ToolContext, count: int = 100, offset: int = 0, search: str = "") -> str:
|
||||
from ouroboros.memory import Memory
|
||||
mem = Memory(drive_root=ctx.drive_root)
|
||||
# Full project awareness (v6.32.0): the one mind's active recall spans every
|
||||
# thread (main + projects). The project-task working FOCUS is applied to the
|
||||
# passive default context only, never to this deliberate recall tool.
|
||||
return mem.chat_history(count=count, offset=offset, search=search)
|
||||
|
||||
|
||||
|
|
@ -1001,6 +1062,27 @@ def get_tools() -> List[ToolEntry]:
|
|||
"description": "Promote ouroboros -> ouroboros-stable. Call when you consider the code stable.",
|
||||
"parameters": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]},
|
||||
}, _promote_to_stable),
|
||||
ToolEntry("promote_chat_to_task", {
|
||||
"name": "promote_chat_to_task",
|
||||
"description": (
|
||||
"Promote real work out of this conversation into a supervised pooled task "
|
||||
"with a live card (the conversation lane stays free for the owner). Use it "
|
||||
"whenever a chat request needs tools/files/multi-step work rather than a "
|
||||
"conversational answer. Optional project_id scopes the task to a project's "
|
||||
"memory/journal; optional workspace_root points at its working folder. "
|
||||
"Owner follow-ups in chat reach the running task via its mailbox."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"objective": {"type": "string", "description": "What the task must accomplish."},
|
||||
"expected_output": {"type": "string", "description": "What done looks like.", "default": ""},
|
||||
"project_id": {"type": "string", "description": "Optional project scope (filesystem-clean id).", "default": ""},
|
||||
"workspace_root": {"type": "string", "description": "Optional absolute working-folder path.", "default": ""},
|
||||
},
|
||||
"required": ["objective"],
|
||||
},
|
||||
}, _promote_chat_to_task),
|
||||
ToolEntry("schedule_subagent", {
|
||||
"name": "schedule_subagent",
|
||||
"description": (
|
||||
|
|
|
|||
|
|
@ -1164,7 +1164,7 @@ def _send_photo(ctx: ToolContext, file_path: str = "", image_base64: str = "",
|
|||
|
||||
ctx.pending_events.append({
|
||||
"type": "send_photo",
|
||||
"chat_id": ctx.current_chat_id,
|
||||
"chat_id": ctx.current_chat_id, "task_id": str(getattr(ctx, "task_id", "") or ""), # task_id -> bound-task project-panel routing
|
||||
"image_base64": actual_b64,
|
||||
"mime": mime,
|
||||
"caption": caption or "",
|
||||
|
|
@ -1210,7 +1210,7 @@ def _send_video(ctx: ToolContext, file_path: str = "", caption: str = "") -> str
|
|||
|
||||
ctx.pending_events.append({
|
||||
"type": "send_video",
|
||||
"chat_id": chat_id,
|
||||
"chat_id": chat_id, "task_id": str(getattr(ctx, "task_id", "") or ""), # task_id -> bound-task project-panel routing
|
||||
"video_base64": actual_b64,
|
||||
"mime": mime,
|
||||
"caption": caption or "",
|
||||
|
|
|
|||
286
ouroboros/tools/project_journal.py
Normal file
286
ouroboros/tools/project_journal.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"""Thin per-project journal/workpad tools (multi-project, v6.32.0).
|
||||
|
||||
The journal is the project's durable milestone memory (start / blocked /
|
||||
checkpoint / done / note rows); the workpad is a free-form scratch page. Both
|
||||
live in the per-project store (``data/projects/<id>/``), which generic data
|
||||
tools cannot reach (``project_store_access_block``) — these scoped tools are
|
||||
the only write path, exactly like project knowledge.
|
||||
|
||||
Tools resolve the project from the CURRENT task (``ctx.project_id``); an
|
||||
explicit ``project_id`` argument lets the main-chat agent annotate a specific
|
||||
project (e.g. when curating from the штаб).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ouroboros.project_facts import (
|
||||
project_journal_path,
|
||||
project_workpad_path,
|
||||
sanitize_project_id,
|
||||
)
|
||||
from ouroboros.tools.registry import ToolContext, ToolEntry
|
||||
from ouroboros.utils import append_jsonl, iter_jsonl_objects, utc_now_iso
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_JOURNAL_KINDS = ("start", "checkpoint", "blocked", "done", "note")
|
||||
_MAX_TEXT_CHARS = 4000
|
||||
_WORKPAD_MAX_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def _resolve_project_id(ctx: ToolContext, explicit: Any) -> str:
|
||||
own = sanitize_project_id(getattr(ctx, "project_id", "") or "")
|
||||
requested = sanitize_project_id(explicit) if explicit else ""
|
||||
# A project-scoped task may only touch ITS OWN project (no cross-project
|
||||
# journal/workpad writes); an explicit id is honored only from an unscoped
|
||||
# (main/штаб) context, where curating a specific project is legitimate.
|
||||
if own:
|
||||
return own
|
||||
return requested
|
||||
|
||||
|
||||
def _journal_write(ctx: ToolContext, kind: str, text: str, project_id: str = "") -> str:
|
||||
pid = _resolve_project_id(ctx, project_id)
|
||||
if not pid:
|
||||
return ("⚠️ TOOL_ARG_ERROR (journal_write): no project scope — this task is not "
|
||||
"project-scoped and no explicit project_id was given.")
|
||||
kind_norm = str(kind or "note").strip().lower()
|
||||
if kind_norm not in _JOURNAL_KINDS:
|
||||
return f"⚠️ TOOL_ARG_ERROR (journal_write): kind must be one of {_JOURNAL_KINDS}"
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return "⚠️ TOOL_ARG_ERROR (journal_write): text is required"
|
||||
# The journal is durable cognitive memory — never silently slice a stored
|
||||
# entry. Reject over-limit writes (same contract as workpad_write) so the
|
||||
# agent shortens the milestone or moves detail to the workpad/knowledge.
|
||||
if len(body) > _MAX_TEXT_CHARS:
|
||||
return (f"⚠️ TOOL_ARG_ERROR (journal_write): entry exceeds {_MAX_TEXT_CHARS} chars "
|
||||
f"({len(body)}) — a journal entry is a milestone note; keep it short and "
|
||||
"move long detail to workpad_write or knowledge_write.")
|
||||
path = project_journal_path(pid)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
row = {
|
||||
"ts": utc_now_iso(),
|
||||
"kind": kind_norm,
|
||||
"text": body,
|
||||
"task_id": str(getattr(ctx, "task_id", "") or ""),
|
||||
}
|
||||
append_jsonl(path, row)
|
||||
try:
|
||||
from ouroboros.config import DATA_DIR
|
||||
from ouroboros.projects_registry import touch_project
|
||||
|
||||
# Registry lives on the CANONICAL data dir (like project_journal_path),
|
||||
# not a forked child drive — touching ctx.drive_root would scatter
|
||||
# stray projects.json files onto subagent worktrees.
|
||||
touch_project(pathlib.Path(DATA_DIR), pid)
|
||||
except Exception:
|
||||
log.debug("journal touch_project failed", exc_info=True)
|
||||
return f"OK: journal[{pid}] += {kind_norm} entry ({len(body)} chars)."
|
||||
|
||||
|
||||
def append_journal_milestone(project_id: str, kind: str, text: str, task_id: str = "") -> None:
|
||||
"""Append an AUTOMATIC project journal milestone (e.g. task-completion 'letters
|
||||
home'), enforcing the SAME durable per-row contract as the journal_write tool.
|
||||
|
||||
The tool REJECTS over-limit input (it teaches the agent to keep milestones
|
||||
short); an automatic milestone MUST be recorded, so when the composed text
|
||||
exceeds ``_MAX_TEXT_CHARS`` it is bounded with a VISIBLE pointer instead of
|
||||
being silently sliced or dropped (the full text always survives in the task's
|
||||
task_results and the consciousness digest). Centralizing here keeps every
|
||||
project-journal append on one bounded path (no raw append_jsonl elsewhere)."""
|
||||
pid = sanitize_project_id(project_id)
|
||||
if not pid:
|
||||
return
|
||||
kind_norm = str(kind or "note").strip().lower()
|
||||
if kind_norm not in _JOURNAL_KINDS:
|
||||
kind_norm = "note"
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return
|
||||
if len(body) > _MAX_TEXT_CHARS:
|
||||
keep = _MAX_TEXT_CHARS - 80
|
||||
body = body[:keep] + f"… [+{len(body) - keep} chars; full text in this task's task_results / digest]"
|
||||
path = project_journal_path(pid)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
append_jsonl(path, {
|
||||
"ts": utc_now_iso(),
|
||||
"kind": kind_norm,
|
||||
"text": body,
|
||||
"task_id": str(task_id or ""),
|
||||
})
|
||||
try:
|
||||
from ouroboros.config import DATA_DIR
|
||||
from ouroboros.projects_registry import touch_project
|
||||
|
||||
touch_project(pathlib.Path(DATA_DIR), pid)
|
||||
except Exception:
|
||||
log.debug("append_journal_milestone touch_project failed", exc_info=True)
|
||||
|
||||
|
||||
def _journal_read(ctx: ToolContext, project_id: str = "", limit: int = 30) -> str:
|
||||
pid = _resolve_project_id(ctx, project_id)
|
||||
if not pid:
|
||||
return ("⚠️ TOOL_ARG_ERROR (journal_read): no project scope — this task is not "
|
||||
"project-scoped and no explicit project_id was given.")
|
||||
path = project_journal_path(pid)
|
||||
if not path.is_file():
|
||||
return f"(journal for project {pid} is empty)"
|
||||
rows: List[Dict[str, Any]] = [r for r in iter_jsonl_objects(path) if isinstance(r, dict)]
|
||||
take = max(1, min(int(limit or 30), 200))
|
||||
omitted = max(0, len(rows) - take)
|
||||
lines = []
|
||||
if omitted:
|
||||
lines.append(f"…[{omitted} earlier entries omitted — raise limit to see more]")
|
||||
for row in rows[-take:]:
|
||||
lines.append(
|
||||
f"[{str(row.get('ts') or '')[:19]}] {str(row.get('kind') or 'note').upper()}: "
|
||||
f"{str(row.get('text') or '')}"
|
||||
)
|
||||
return f"## Project journal ({pid})\n\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _workpad_read(ctx: ToolContext, project_id: str = "") -> str:
|
||||
pid = _resolve_project_id(ctx, project_id)
|
||||
if not pid:
|
||||
return "⚠️ TOOL_ARG_ERROR (workpad_read): no project scope."
|
||||
path = project_workpad_path(pid)
|
||||
if not path.is_file():
|
||||
return f"(workpad for project {pid} is empty)"
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
return f"⚠️ TOOL_ERROR (workpad_read): {exc}"
|
||||
|
||||
|
||||
def _workpad_write(ctx: ToolContext, content: str, project_id: str = "") -> str:
|
||||
pid = _resolve_project_id(ctx, project_id)
|
||||
if not pid:
|
||||
return "⚠️ TOOL_ARG_ERROR (workpad_write): no project scope."
|
||||
body = str(content or "")
|
||||
if len(body.encode("utf-8", errors="ignore")) > _WORKPAD_MAX_BYTES:
|
||||
return ("⚠️ TOOL_ARG_ERROR (workpad_write): workpad exceeds 256KB — keep it a "
|
||||
"working page; move durable facts to knowledge_write and history to journal_write.")
|
||||
path = project_workpad_path(pid)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
path.write_text(body, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
return f"⚠️ TOOL_ERROR (workpad_write): {exc}"
|
||||
return f"OK: workpad[{pid}] written ({len(body)} chars)."
|
||||
|
||||
|
||||
def journal_tail_digest(project_id: str, *, limit: int = 40) -> str:
|
||||
"""Recent project-journal milestones for context injection (no ctx needed).
|
||||
|
||||
Cognitive artifact (BIBLE P1): each milestone is shown in FULL — never
|
||||
per-row prefix-sliced. Older entries beyond the tail are represented by a
|
||||
VISIBLE index pointer to journal_read (horizon preserved via pointer,
|
||||
granularity varies), never silently dropped."""
|
||||
pid = sanitize_project_id(project_id)
|
||||
if not pid:
|
||||
return ""
|
||||
path = project_journal_path(pid)
|
||||
if not path.is_file():
|
||||
return ""
|
||||
rows = [r for r in iter_jsonl_objects(path) if isinstance(r, dict)]
|
||||
if not rows:
|
||||
return ""
|
||||
take = rows[-max(1, int(limit)):]
|
||||
omitted = len(rows) - len(take)
|
||||
lines = [
|
||||
f"- [{str(r.get('ts') or '')[:16]}] {str(r.get('kind') or 'note')}: {str(r.get('text') or '')}"
|
||||
for r in take
|
||||
]
|
||||
if omitted:
|
||||
lines.insert(0, f"- …[{omitted} earlier milestones via journal_read]")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_tools() -> List[ToolEntry]:
|
||||
common = {
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Explicit project id (defaults to the current task's project scope).",
|
||||
"default": "",
|
||||
},
|
||||
}
|
||||
return [
|
||||
ToolEntry(
|
||||
"journal_write",
|
||||
{
|
||||
"name": "journal_write",
|
||||
"description": (
|
||||
"Append a milestone entry to the current project's durable journal. "
|
||||
"kind: start | checkpoint | blocked | done | note. The journal is the "
|
||||
"project's long-term progress memory (survives task restarts; feeds "
|
||||
"the owner-visible project digest)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": list(_JOURNAL_KINDS)},
|
||||
"text": {"type": "string", "description": "Milestone text (<=4000 chars)."},
|
||||
**common,
|
||||
},
|
||||
"required": ["kind", "text"],
|
||||
},
|
||||
},
|
||||
lambda ctx, kind, text, project_id="": _journal_write(ctx, kind, text, project_id),
|
||||
timeout_sec=15,
|
||||
),
|
||||
ToolEntry(
|
||||
"journal_read",
|
||||
{
|
||||
"name": "journal_read",
|
||||
"description": "Read the tail of the current project's journal (newest last).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "default": 30, "description": "Max entries (<=200)."},
|
||||
**common,
|
||||
},
|
||||
},
|
||||
},
|
||||
lambda ctx, limit=30, project_id="": _journal_read(ctx, project_id, limit),
|
||||
timeout_sec=15,
|
||||
),
|
||||
ToolEntry(
|
||||
"workpad_read",
|
||||
{
|
||||
"name": "workpad_read",
|
||||
"description": "Read the current project's free-form workpad page.",
|
||||
"parameters": {"type": "object", "properties": dict(common)},
|
||||
},
|
||||
lambda ctx, project_id="": _workpad_read(ctx, project_id),
|
||||
timeout_sec=15,
|
||||
),
|
||||
ToolEntry(
|
||||
"workpad_write",
|
||||
{
|
||||
"name": "workpad_write",
|
||||
"description": (
|
||||
"Overwrite the current project's workpad page (<=256KB). A working "
|
||||
"page for plans/links/state — durable facts belong in knowledge_write, "
|
||||
"history in journal_write."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
**common,
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
},
|
||||
lambda ctx, content, project_id="": _workpad_write(ctx, content, project_id),
|
||||
timeout_sec=15,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["get_tools", "journal_tail_digest"]
|
||||
|
|
@ -343,6 +343,16 @@ _WORKSPACE_ALLOWED_TOOLS = frozenset({
|
|||
"knowledge_read",
|
||||
"knowledge_list",
|
||||
"knowledge_write",
|
||||
# Per-project durable MEMORY tools — usable inside project/workspace tasks
|
||||
# exactly like knowledge_*, else a project task cannot record/read its own
|
||||
# journal/workpad (multi-project, v6.32.0). NOTE: promote_chat_to_task is
|
||||
# deliberately NOT here — it spawns a top-level pooled task and belongs to the
|
||||
# conversational lane; a constrained workspace/subagent task must not escalate
|
||||
# by promoting an unconstrained task.
|
||||
"journal_read",
|
||||
"journal_write",
|
||||
"workpad_read",
|
||||
"workpad_write",
|
||||
"web_search",
|
||||
"browse_page",
|
||||
"browser_action",
|
||||
|
|
@ -629,7 +639,8 @@ class ToolRegistry:
|
|||
_FROZEN_TOOL_MODULES = [
|
||||
"browser", "ci", "claude_advisory_review", "compact_context", "control",
|
||||
"core", "evolution_stats", "git", "git_pr", "git_rollback", "github",
|
||||
"health", "knowledge", "memory_tools", "plan_review", "recent_tasks",
|
||||
"health", "knowledge", "memory_tools", "plan_review", "project_journal",
|
||||
"recent_tasks",
|
||||
"query_code", "review", "search", "services", "shell", "skill_exec", "skill_publish",
|
||||
"skill_preflight", "subagent_integration", "tool_discovery", "vision",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -108,6 +108,33 @@ their complete output with `get_task_result`, `wait_task`, or
|
|||
wide delegation chains casually: nested delegation is for focused readonly
|
||||
follow-up only and remains bounded by configured depth/cap limits.
|
||||
|
||||
In a CONVERSATION turn (the fast chat lane), real work — anything needing
|
||||
tools, files, or multiple steps — goes through `promote_chat_to_task`: the
|
||||
conversation stays free, the owner gets a live task card, and follow-up chat
|
||||
messages reach the running task's mailbox. Answer conversationally only when
|
||||
a conversational answer IS the deliverable.
|
||||
|
||||
## Projects
|
||||
|
||||
A project is a durable context I work in: per-project knowledge, journal,
|
||||
workpad, its own chat thread, and an optional working folder — while I stay
|
||||
ONE agent (one identity, one constitution, one evolution). I am one awareness
|
||||
across every thread: my unified memory (recent dialogue, consolidated history,
|
||||
chat_history) spans the main chat AND all project rooms — a project is a focused
|
||||
room, not a separate mind. When I run a project task I get a focused working
|
||||
context (that project's own thread, journal, workpad, knowledge) to avoid
|
||||
cross-project interference, but nothing project-related is hidden from me as the
|
||||
one identity. Scope work to a
|
||||
project by passing `project_id` to `promote_chat_to_task`; record milestones
|
||||
with `journal_write` (start / checkpoint / blocked / done), keep working notes
|
||||
in `workpad_write`, durable facts in `knowledge_write` — all auto-scoped to
|
||||
the current project. Projects serialize internally (one writer per project);
|
||||
parallelism happens between projects and via subagent swarms within a task.
|
||||
For multi-file builds, prefer a real git working folder (projects can
|
||||
provision one) and orchestrate acting children with patches instead of
|
||||
passing code as chat text. Evolution remains mine alone and waits until
|
||||
running project tasks finish.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Reactivity
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "ouroboros"
|
||||
version = "6.31.0"
|
||||
version = "6.32.0"
|
||||
description = "Self-creating AI agent with constitution, background consciousness, and persistent identity"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
|
|
|||
313
server.py
313
server.py
|
|
@ -176,6 +176,173 @@ def _start_supervisor_if_needed(settings: dict) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _route_project_chat_to_running_task(ctx: Any, chat_id: int, message: str) -> str:
|
||||
"""Steer a PROJECT chat message into its running pooled task's mailbox.
|
||||
|
||||
Multi-project (v6.32.0): a project thread with an ACTIVE pooled task
|
||||
steers that task (the loop drains the mailbox every round). The MAIN chat
|
||||
stays a free conversation lane — discussing or starting parallel work
|
||||
never blocks on a running task. Returns the routed task id or "".
|
||||
|
||||
A chat is a project thread by REGISTRY membership, not a bare numeric
|
||||
range — large external-transport (Telegram-style) chat ids must not be
|
||||
misclassified and have their owner messages swallowed.
|
||||
"""
|
||||
try:
|
||||
from ouroboros.projects_registry import registered_project_chat_ids
|
||||
|
||||
if int(chat_id or 0) not in registered_project_chat_ids(ctx.DRIVE_ROOT):
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
try:
|
||||
for tid, running in list(ctx.RUNNING.items()):
|
||||
if not isinstance(running, dict):
|
||||
continue
|
||||
task_obj = running.get("task") if isinstance(running.get("task"), dict) else running
|
||||
if int(task_obj.get("chat_id") or 0) != int(chat_id or 0):
|
||||
# A post-hoc "Turn into project" task keeps its original (main)
|
||||
# chat_id on the live object but belongs to this project thread;
|
||||
# match it via the durable binding so follow-ups still steer it.
|
||||
try:
|
||||
from ouroboros.projects_registry import project_chat_for_task
|
||||
|
||||
if int(project_chat_for_task(ctx.DRIVE_ROOT, tid) or 0) != int(chat_id or 0):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
if task_obj.get("_is_direct_chat"):
|
||||
continue
|
||||
if str(task_obj.get("delegation_role") or "") == "subagent":
|
||||
continue
|
||||
from ouroboros.owner_mailbox import write_owner_message
|
||||
|
||||
task_drive = str(task_obj.get("drive_root") or "") or str(ctx.DRIVE_ROOT)
|
||||
write_owner_message(pathlib.Path(task_drive), message, str(tid))
|
||||
return str(tid)
|
||||
except Exception:
|
||||
log.debug("Mailbox follow-up routing failed; falling back to direct lane", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def _scoped_task_metadata(project_id: str, task_metadata: Any) -> Any:
|
||||
"""Bind a chat frame's task_metadata to the thread's project via chat_id (the
|
||||
SSOT). A registered project chat scopes to its OWN project, overriding any
|
||||
client-supplied project_id; a non-project chat DROPS an untrusted client
|
||||
project_id (work is scoped to a project only via the promote_chat_to_task tool,
|
||||
never a raw ws frame). Prevents a stale/malformed frame (chat_id A + project_id
|
||||
B) from rendering in A while loading/writing project B's memory."""
|
||||
if project_id:
|
||||
return {**(task_metadata or {}), "project_id": project_id}
|
||||
if task_metadata and task_metadata.get("project_id"):
|
||||
return {k: v for k, v in task_metadata.items() if k != "project_id"}
|
||||
return task_metadata
|
||||
|
||||
|
||||
def _owner_binding_chat_id(ctx: Any, chat_id: int, is_external_transport: bool) -> int:
|
||||
"""The owner's canonical chat for owner-targeted notices (restart, supervisor
|
||||
death, consciousness). External transports bind to their own chat; a WEB owner
|
||||
always binds to MAIN (1), never a project panel — so if the first post-reset
|
||||
web message lands in a project room, owner notices still reach main."""
|
||||
if not is_external_transport and _project_id_for_registered_chat(ctx, chat_id):
|
||||
return 1
|
||||
try:
|
||||
return int(chat_id or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _project_id_for_registered_chat(ctx: Any, chat_id: int) -> str:
|
||||
"""Return the registered project id for a project chat_id, else ``""``.
|
||||
|
||||
NOT an isolation gate (full project awareness, v6.32.0): the one mind notices
|
||||
EVERY human message via inject_observation, project rooms included. This just
|
||||
classifies a chat as a project thread so the message is scoped to that project
|
||||
(task_metadata.project_id) and routed to its panel. Includes ARCHIVED projects
|
||||
so the classification stays consistent; archiving is a UI-visibility concern
|
||||
only (web/app.js filters it).
|
||||
"""
|
||||
try:
|
||||
from ouroboros.projects_registry import list_projects
|
||||
|
||||
cid = int(chat_id or 0)
|
||||
for project in list_projects(ctx.DRIVE_ROOT):
|
||||
try:
|
||||
if int(project.get("chat_id") or 0) == cid:
|
||||
return str(project.get("id") or "").strip()
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
except Exception:
|
||||
log.debug("Project chat_id lookup failed", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def _enqueue_project_chat_task(
|
||||
ctx: Any,
|
||||
*,
|
||||
chat_id: int,
|
||||
project_id: str,
|
||||
text: str,
|
||||
image_data: Optional[tuple] = None,
|
||||
task_constraint: Optional[dict] = None,
|
||||
task_metadata: Optional[dict] = None,
|
||||
) -> str:
|
||||
"""Hybrid B+ escape hatch: a busy global direct-chat lane must not block a
|
||||
project thread. Enqueue a normal project-scoped pooled task instead.
|
||||
|
||||
This keeps idle project chat conversational, while preserving actual
|
||||
parallelism when the main direct lane is occupied. The task enters the
|
||||
regular worker pool, carries the project chat_id, and is subject to the
|
||||
existing one-writer project lease.
|
||||
"""
|
||||
pid = str(project_id or "").strip()
|
||||
body = str(text or "").strip()
|
||||
if not pid or (not body and not image_data):
|
||||
return ""
|
||||
try:
|
||||
from ouroboros.contracts.task_contract import attach_task_contract
|
||||
from supervisor.state import budget_remaining, load_state
|
||||
|
||||
if budget_remaining(load_state()) <= 0:
|
||||
ctx.send_with_budget(chat_id, "🚫 Budget exhausted. Task rejected. Please increase TOTAL_BUDGET in settings.")
|
||||
return ""
|
||||
task_id = uuid.uuid4().hex[:8]
|
||||
task = {
|
||||
"id": task_id,
|
||||
"type": "task",
|
||||
"chat_id": int(chat_id or 0),
|
||||
"project_id": pid,
|
||||
"text": body or "(image attached)",
|
||||
"description": body[:200] or "(image attached)",
|
||||
"objective": body[:500] or "(image attached)",
|
||||
"source": "project_chat_busy_fallback",
|
||||
"metadata": {
|
||||
**(task_metadata or {}),
|
||||
"project_id": pid,
|
||||
"direct_lane_busy_fallback": True,
|
||||
},
|
||||
}
|
||||
if task_constraint:
|
||||
task["task_constraint"] = dict(task_constraint)
|
||||
if image_data:
|
||||
task["image_base64"] = image_data[0]
|
||||
task["image_mime"] = image_data[1]
|
||||
if len(image_data) > 2 and image_data[2]:
|
||||
task["image_caption"] = image_data[2]
|
||||
attach_task_contract(task)
|
||||
ctx.enqueue_task(task)
|
||||
try:
|
||||
from ouroboros.projects_registry import touch_project
|
||||
|
||||
touch_project(ctx.DRIVE_ROOT, pid)
|
||||
except Exception:
|
||||
log.debug("Busy project chat task touch_project failed", exc_info=True)
|
||||
return task_id
|
||||
except Exception:
|
||||
log.debug("Busy project chat task enqueue failed", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def _process_bridge_updates(bridge, offset: int, ctx: Any) -> int:
|
||||
from supervisor.message_bus import coerce_chat_identity
|
||||
|
||||
|
|
@ -254,7 +421,7 @@ def _process_bridge_updates(bridge, offset: int, ctx: Any) -> int:
|
|||
def _stamp_owner_activity(live: dict) -> None:
|
||||
if live.get("owner_id") is None and external_identity_present:
|
||||
live["owner_id"] = user_id
|
||||
live["owner_chat_id"] = chat_id
|
||||
live["owner_chat_id"] = _owner_binding_chat_id(ctx, chat_id, is_external_transport)
|
||||
live["last_owner_message_at"] = now_iso
|
||||
|
||||
ctx.update_state(_stamp_owner_activity)
|
||||
|
|
@ -410,8 +577,35 @@ def _process_bridge_updates(bridge, offset: int, ctx: Any) -> int:
|
|||
status = status_text(ctx.WORKERS, ctx.PENDING, ctx.RUNNING, SOFT_TIMEOUT_SEC, HARD_TIMEOUT_SEC)
|
||||
ctx.send_with_budget(chat_id, status)
|
||||
else:
|
||||
project_id = _project_id_for_registered_chat(ctx, chat_id)
|
||||
# Full project awareness (v6.32.0): the one mind notices EVERY human
|
||||
# message, including in a project room — project history is part of its
|
||||
# continuous awareness (BIBLE P1), not a separate isolated stream.
|
||||
ctx.consciousness.inject_observation(f"Message from my human: {log_text}")
|
||||
task_metadata = _scoped_task_metadata(project_id, task_metadata)
|
||||
routed_to_task = _route_project_chat_to_running_task(
|
||||
ctx, chat_id, text or image_caption
|
||||
)
|
||||
if routed_to_task:
|
||||
ctx.send_with_budget(
|
||||
chat_id,
|
||||
f"📨 Forwarded to the running task {routed_to_task} "
|
||||
"(it will see this on its next round).",
|
||||
)
|
||||
continue
|
||||
agent = ctx.get_chat_agent()
|
||||
if project_id and getattr(agent, "_busy", False):
|
||||
queued_task = _enqueue_project_chat_task(
|
||||
ctx,
|
||||
chat_id=chat_id,
|
||||
project_id=project_id,
|
||||
text=text or image_caption,
|
||||
image_data=image_data,
|
||||
task_constraint=task_constraint,
|
||||
task_metadata=task_metadata,
|
||||
)
|
||||
if queued_task:
|
||||
continue
|
||||
|
||||
def _run_constrained_or_resume(cid, txt, img, constraint, metadata, resume_consciousness: bool):
|
||||
try:
|
||||
|
|
@ -522,6 +716,11 @@ def _periodic_zombie_reconcile() -> None:
|
|||
reconcile_orphaned_running_tasks(DATA_DIR)
|
||||
except Exception:
|
||||
log.debug("Periodic orphaned running-task reconcile failed", exc_info=True)
|
||||
try:
|
||||
from ouroboros.projects_registry import reconcile_projects
|
||||
reconcile_projects(DATA_DIR)
|
||||
except Exception:
|
||||
log.debug("Project registry reconcile failed", exc_info=True)
|
||||
|
||||
|
||||
def _run_supervisor(settings: dict) -> None:
|
||||
|
|
@ -777,15 +976,21 @@ def _run_supervisor(settings: dict) -> None:
|
|||
if time.time() - _last_review_job_reconcile[0] > 300:
|
||||
_last_review_job_reconcile[0] = time.time()
|
||||
_periodic_zombie_reconcile()
|
||||
try:
|
||||
from ouroboros.post_task_evolution import apply_pending_request
|
||||
from supervisor import state as _pte_state
|
||||
# Loop-tick restart drain (no sleep, events keep flowing): while
|
||||
# draining a deferred restart, skip starting new work the restart
|
||||
# deadline would immediately chop (evolution / pending project tasks).
|
||||
if not _check_pending_restart_drain(_event_ctx):
|
||||
try:
|
||||
from ouroboros.post_task_evolution import apply_pending_request
|
||||
from supervisor import state as _pte_state
|
||||
|
||||
apply_pending_request(_pte_state.DRIVE_ROOT)
|
||||
except Exception:
|
||||
log.debug("Post-task evolution apply failed", exc_info=True)
|
||||
enqueue_evolution_task_if_needed()
|
||||
assign_tasks()
|
||||
apply_pending_request(_pte_state.DRIVE_ROOT)
|
||||
except Exception:
|
||||
log.debug("Post-task evolution apply failed", exc_info=True)
|
||||
enqueue_evolution_task_if_needed()
|
||||
assign_tasks()
|
||||
if _restart_requested.is_set():
|
||||
break # restart just triggered (drain done) — don't intake new bridge work as we exit
|
||||
persist_queue_snapshot(reason="main_loop")
|
||||
|
||||
offset = _process_bridge_updates(bridge, offset, _event_ctx)
|
||||
|
|
@ -820,14 +1025,90 @@ def _run_supervisor(settings: dict) -> None:
|
|||
_supervisor_thread = None
|
||||
|
||||
|
||||
# Deferred restart-drain state (multi-project, v6.32.0). The drain MUST NOT
|
||||
# sleep on the supervisor loop thread (it is the only thread that processes
|
||||
# heartbeats / task_done and shrinks RUNNING). Instead a restart with live
|
||||
# tasks is recorded here and re-checked every loop tick, so events keep
|
||||
# flowing and the drain actually observes tasks finishing.
|
||||
_pending_restart: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _live_running_task_ids(ctx: Any) -> list:
|
||||
"""RUNNING task ids with a fresh heartbeat — structured facts only.
|
||||
|
||||
Heartbeat-staleness reuses the swarm SSOT getter
|
||||
(``config.get_plan_task_swarm_heartbeat_stale_sec``) so there is one
|
||||
definition of "a worker is still alive", not a scattered magic cutoff.
|
||||
"""
|
||||
from ouroboros.config import get_plan_task_swarm_heartbeat_stale_sec
|
||||
|
||||
stale_sec = get_plan_task_swarm_heartbeat_stale_sec()
|
||||
now = time.time()
|
||||
live = []
|
||||
for tid, meta in dict(ctx.RUNNING or {}).items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
try:
|
||||
hb = float(meta.get("last_heartbeat_at") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
hb = 0.0
|
||||
if hb and (now - hb) < stale_sec:
|
||||
live.append(str(tid))
|
||||
return live
|
||||
|
||||
|
||||
def _handle_restart_in_supervisor(evt: Dict[str, Any], ctx: Any) -> None:
|
||||
"""Handle agent restart request via graceful shutdown + exit(42)."""
|
||||
"""Handle agent restart request: drain live tasks across loop ticks, then
|
||||
graceful shutdown + exit(42). Never sleeps on the dispatch thread."""
|
||||
st = ctx.load_state()
|
||||
if st.get("owner_chat_id"):
|
||||
ctx.send_with_budget(
|
||||
int(st["owner_chat_id"]),
|
||||
f"♻️ Restart requested by agent: {evt.get('reason')}",
|
||||
)
|
||||
from ouroboros.config import get_restart_drain_max_sec
|
||||
|
||||
max_wait = get_restart_drain_max_sec()
|
||||
live = _live_running_task_ids(ctx) if max_wait > 0 else []
|
||||
if live:
|
||||
# Defer: re-checked each tick by _check_pending_restart_drain so the
|
||||
# loop keeps draining events (heartbeats advance, RUNNING shrinks).
|
||||
_pending_restart.clear()
|
||||
_pending_restart.update({
|
||||
"reason": str(evt.get("reason") or "agent_restart_request"),
|
||||
"deadline": time.time() + min(max_wait, 1800),
|
||||
})
|
||||
if st.get("owner_chat_id"):
|
||||
ctx.send_with_budget(
|
||||
int(st["owner_chat_id"]),
|
||||
f"⏳ Restart drain: waiting up to {max_wait}s for running task(s) "
|
||||
f"{', '.join(sorted(live))} to finish.",
|
||||
)
|
||||
return
|
||||
_perform_supervisor_restart(ctx)
|
||||
|
||||
|
||||
def _check_pending_restart_drain(ctx: Any) -> bool:
|
||||
"""Loop-tick hook: complete a deferred restart once tasks drain or the
|
||||
deadline passes (proceeds fail-closed). Returns True while STILL draining, so
|
||||
the loop can skip starting new work that the restart would immediately chop."""
|
||||
if not _pending_restart:
|
||||
return False
|
||||
live = _live_running_task_ids(ctx)
|
||||
if live and time.time() < float(_pending_restart.get("deadline") or 0.0):
|
||||
return True # keep draining — events still flow each tick
|
||||
_pending_restart.clear()
|
||||
_perform_supervisor_restart(ctx)
|
||||
# Still "quiescing" this tick: _perform_supervisor_restart sets up the exit
|
||||
# (or fail-closed pauses) and returns to the loop — the process exits on the
|
||||
# next `while not _restart_requested` check. Returning True keeps the caller
|
||||
# from starting new enqueue/assign work on this final pre-exit tick.
|
||||
return True
|
||||
|
||||
|
||||
def _perform_supervisor_restart(ctx: Any) -> None:
|
||||
"""Graceful shutdown + exit(42) (the post-drain tail; never sleeps)."""
|
||||
st = ctx.load_state()
|
||||
ok, msg = ctx.safe_restart(
|
||||
reason="agent_restart_request", unsynced_policy="rescue_and_block",
|
||||
)
|
||||
|
|
@ -971,6 +1252,18 @@ async def lifespan(app):
|
|||
except Exception:
|
||||
log.warning("Native skills bootstrap failed", exc_info=True)
|
||||
|
||||
# Boot-reconcile the project registry BEFORE /api/state and context-building
|
||||
# can rely on registered_project_chat_ids (the multi-project isolation SSOT):
|
||||
# register any pre-existing data/projects/<id>/ store whose row is missing, so
|
||||
# an inherited project's raw chat is partitioned from turn one (not only after
|
||||
# the 300s periodic tick). Idempotent and never prunes.
|
||||
try:
|
||||
if not pytest_default_real_data_dir:
|
||||
from ouroboros.projects_registry import reconcile_projects
|
||||
reconcile_projects(lifespan_drive_root)
|
||||
except Exception:
|
||||
log.warning("Project registry boot reconcile failed", exc_info=True)
|
||||
|
||||
if has_startup_ready_provider(settings):
|
||||
_start_supervisor_if_needed(settings)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,19 @@ VALID_SUBAGENT_MEMORY_MODES = frozenset({"forked", "empty"})
|
|||
_GIT_UNBORN_HEAD = "(unborn)"
|
||||
|
||||
|
||||
def _bound_project_chat_id(ctx: Any, task_id: Any) -> int:
|
||||
"""Resolve project chat for a task that was post-hoc bound via UI."""
|
||||
tid = str(task_id or "").strip()
|
||||
if not tid:
|
||||
return 0
|
||||
try:
|
||||
from ouroboros.projects_registry import project_chat_for_task
|
||||
|
||||
return int(project_chat_for_task(ctx.DRIVE_ROOT, tid) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _is_active_subagent_task(task: Dict[str, Any], root_task_id: str) -> bool:
|
||||
if str(task.get("root_task_id") or "") != root_task_id:
|
||||
return False
|
||||
|
|
@ -436,12 +449,22 @@ def _handle_task_heartbeat(evt: Dict[str, Any], ctx: Any) -> None:
|
|||
task = meta.get("task") if isinstance(meta.get("task"), dict) else {}
|
||||
started_at = float(meta.get("started_at") or 0.0)
|
||||
runtime_sec = round(max(0.0, time.time() - started_at), 1) if started_at > 0 else None
|
||||
# Stamp the project thread so the live heartbeat routes to the project
|
||||
# panel (and not default-to-main); post-hoc bound tasks fall back to the
|
||||
# binding. Heartbeats themselves carry no chat_id from the worker. A
|
||||
# post-hoc bound task keeps its original (main) chat_id, so the binding
|
||||
# must take PRECEDENCE (same order as _handle_send_message/_handle_log_event).
|
||||
try:
|
||||
_hb_chat_id = _bound_project_chat_id(ctx, task_id) or int(task.get("chat_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
_hb_chat_id = 0
|
||||
try:
|
||||
ctx.bridge.push_log({
|
||||
"ts": evt.get("ts", utc_now_iso()),
|
||||
"type": "task_heartbeat",
|
||||
"task_id": task_id,
|
||||
"task_type": task.get("type"),
|
||||
"chat_id": _hb_chat_id,
|
||||
"phase": phase or meta.get("heartbeat_phase") or "running",
|
||||
"runtime_sec": runtime_sec,
|
||||
"subagent_event": evt.get("subagent_event", ""),
|
||||
|
|
@ -471,13 +494,16 @@ def _handle_send_message(evt: Dict[str, Any], ctx: Any) -> None:
|
|||
fmt = str(evt.get("format") or "")
|
||||
is_progress = bool(evt.get("is_progress"))
|
||||
raw_ts = evt.get("ts")
|
||||
task_id = str(evt.get("task_id") or "")
|
||||
bound_chat = _bound_project_chat_id(ctx, task_id)
|
||||
chat_id = bound_chat or int(evt["chat_id"])
|
||||
ctx.send_with_budget(
|
||||
int(evt["chat_id"]),
|
||||
chat_id,
|
||||
str(evt.get("text") or ""),
|
||||
log_text=(str(log_text) if isinstance(log_text, str) else None),
|
||||
fmt=fmt,
|
||||
is_progress=is_progress,
|
||||
task_id=str(evt.get("task_id") or ""),
|
||||
task_id=task_id,
|
||||
progress_meta=evt.get("progress_meta") if isinstance(evt.get("progress_meta"), dict) else None,
|
||||
ts=(str(raw_ts) if raw_ts else None),
|
||||
)
|
||||
|
|
@ -560,6 +586,13 @@ def _handle_task_done(evt: Dict[str, Any], ctx: Any) -> None:
|
|||
"type": "task_done",
|
||||
"task_id": task_id,
|
||||
"task_type": task_type,
|
||||
# Thread tag so the terminal card finalizes in its project panel.
|
||||
"chat_id": int(
|
||||
_bound_project_chat_id(ctx, task_id)
|
||||
or evt.get("chat_id")
|
||||
or (final_task_result.get("chat_id") if isinstance(final_task_result, dict) else 0)
|
||||
or 0
|
||||
),
|
||||
"status": str(final_task_result.get("status") or evt.get("status") or ""),
|
||||
"outcome_axes": outcome_axes,
|
||||
"reason_code": reason_code,
|
||||
|
|
@ -1216,6 +1249,65 @@ def _resolve_subagent_constraint(
|
|||
return constraint, resolved, "external_workspace", ""
|
||||
|
||||
|
||||
def _handle_project_digest(evt: Dict[str, Any], ctx: Any) -> None:
|
||||
"""Surface a concise per-project cycle completion digest to consciousness.
|
||||
|
||||
Full project awareness (v6.32.0): the one identity already sees the project's
|
||||
chat thread in its unified memory, so this is a crisp "task finished" summary
|
||||
(project_id + full objective + outcome statuses), NOT an isolation boundary.
|
||||
Per-cycle RAW internal facts stay in the per-project knowledge/journal store
|
||||
(scoped tools); the единый agent decides what to do with the digest — backlog,
|
||||
identity, or nothing (BIBLE P5).
|
||||
"""
|
||||
pid = str(evt.get("project_id") or "").strip()
|
||||
if not pid:
|
||||
return
|
||||
try:
|
||||
from ouroboros.projects_registry import touch_project
|
||||
|
||||
touch_project(ctx.DRIVE_ROOT, pid)
|
||||
except Exception:
|
||||
log.debug("project_digest touch failed", exc_info=True)
|
||||
try:
|
||||
# Digest into the штаб's consciousness: carry the objective WHOLE (BIBLE P1
|
||||
# — no silent/lossy clip of cognitive text). The one mind is aware of its
|
||||
# project work in full; only raw per-cycle facts stay in the project store.
|
||||
digest = (
|
||||
f"Project '{pid}' task {str(evt.get('task_id') or '')} finished: "
|
||||
f"execution={str(evt.get('execution_status') or 'unknown')}, "
|
||||
f"objective={str(evt.get('objective_status') or 'not_evaluated')}. "
|
||||
f"Goal: {str(evt.get('objective') or '')}"
|
||||
)
|
||||
consciousness = getattr(ctx, "consciousness", None)
|
||||
if consciousness is not None:
|
||||
consciousness.inject_observation(digest)
|
||||
except Exception:
|
||||
log.debug("project_digest consciousness injection failed", exc_info=True)
|
||||
|
||||
|
||||
def _handle_promote_chat_to_task(evt: Dict[str, Any], ctx: Any) -> None:
|
||||
"""Spawn a first-class pooled owner task from a conversation-lane promote.
|
||||
|
||||
Unlike ``schedule_subagent`` the child is NOT a subagent: it is a normal
|
||||
owner task (live card, canonical drive, project lease participation). The
|
||||
conversation lane that emitted the event stays free.
|
||||
"""
|
||||
from supervisor.workers import promote_chat_to_task
|
||||
|
||||
try:
|
||||
promote_chat_to_task(evt, ctx)
|
||||
except Exception:
|
||||
log.warning("promote_chat_to_task event failed", exc_info=True)
|
||||
ctx.append_jsonl(
|
||||
ctx.DRIVE_ROOT / "logs" / "supervisor.jsonl",
|
||||
{
|
||||
"ts": utc_now_iso(),
|
||||
"type": "promote_chat_to_task_failed",
|
||||
"event_repr": repr(evt)[:500],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _handle_schedule_task(evt: Dict[str, Any], ctx: Any) -> None:
|
||||
st = ctx.load_state()
|
||||
owner_chat_id = st.get("owner_chat_id")
|
||||
|
|
@ -1598,7 +1690,10 @@ def _handle_send_photo(evt: Dict[str, Any], ctx: Any) -> None:
|
|||
"""Send a photo to the owner's chat."""
|
||||
import base64 as b64mod
|
||||
try:
|
||||
chat_id = int(evt.get("chat_id") or 0)
|
||||
# Binding precedence (matches _handle_send_message/_handle_log_event): a
|
||||
# post-hoc bound task keeps its original main chat_id, so its media must
|
||||
# still route to the project panel.
|
||||
chat_id = _bound_project_chat_id(ctx, evt.get("task_id")) or int(evt.get("chat_id") or 0)
|
||||
image_b64 = str(evt.get("image_base64") or "")
|
||||
caption = str(evt.get("caption") or "")
|
||||
mime = str(evt.get("mime") or "image/png")
|
||||
|
|
@ -1629,10 +1724,13 @@ def _handle_send_video(evt: Dict[str, Any], ctx: Any) -> None:
|
|||
"""Send a video to the owner's chat."""
|
||||
import base64 as b64mod
|
||||
try:
|
||||
# Binding precedence (matches the sibling handlers): a post-hoc bound
|
||||
# task's media routes to its project panel, not the old main thread.
|
||||
bound_chat = _bound_project_chat_id(ctx, evt.get("task_id"))
|
||||
raw_chat_id = evt.get("chat_id")
|
||||
if raw_chat_id is None or raw_chat_id == "":
|
||||
if not bound_chat and (raw_chat_id is None or raw_chat_id == ""):
|
||||
return
|
||||
chat_id = int(raw_chat_id)
|
||||
chat_id = bound_chat or int(raw_chat_id)
|
||||
video_b64 = str(evt.get("video_base64") or "")
|
||||
caption = str(evt.get("caption") or "")
|
||||
mime = str(evt.get("mime") or "video/mp4")
|
||||
|
|
@ -1681,6 +1779,9 @@ def _handle_log_event(evt: Dict[str, Any], ctx: Any) -> None:
|
|||
"ts": data.get("ts", utc_now_iso()),
|
||||
**data,
|
||||
}
|
||||
bound_chat = _bound_project_chat_id(ctx, payload.get("task_id"))
|
||||
if bound_chat:
|
||||
payload["chat_id"] = bound_chat
|
||||
try:
|
||||
ctx.bridge.push_log(payload)
|
||||
except Exception:
|
||||
|
|
@ -1721,6 +1822,8 @@ EVENT_HANDLERS = {
|
|||
"promote_to_stable": _handle_promote_to_stable,
|
||||
"schedule_task": _handle_schedule_task,
|
||||
"schedule_subagent": _handle_schedule_task,
|
||||
"promote_chat_to_task": _handle_promote_chat_to_task,
|
||||
"project_digest": _handle_project_digest,
|
||||
"cancel_task": _handle_cancel_task,
|
||||
"send_photo": _handle_send_photo,
|
||||
"send_video": _handle_send_video,
|
||||
|
|
|
|||
|
|
@ -170,7 +170,19 @@ class LocalChatBridge:
|
|||
image_mime: str = "",
|
||||
image_caption: str = "",
|
||||
task_metadata: Optional[Dict[str, Any]] = None,
|
||||
chat_id: int = 1,
|
||||
project_id: str = "",
|
||||
) -> None:
|
||||
# Multi-project (v6.32.0): the web owner may address a project chat by
|
||||
# positive chat_id. The OWNER identity never changes (user_id stays 1 —
|
||||
# binding is security-load-bearing); only the thread id varies. A2A
|
||||
# negative ids are rejected here — they are not a web surface.
|
||||
try:
|
||||
thread_id = int(chat_id or 1)
|
||||
except (TypeError, ValueError):
|
||||
thread_id = 1
|
||||
if thread_id < 1:
|
||||
thread_id = 1
|
||||
clean_text = str(text or "").strip()
|
||||
if not clean_text and not image_base64:
|
||||
return
|
||||
|
|
@ -182,12 +194,16 @@ class LocalChatBridge:
|
|||
"content": clean_text,
|
||||
"ts": ts,
|
||||
"source": "web",
|
||||
"chat_id": thread_id,
|
||||
"sender_session_id": sender_session_id,
|
||||
"client_message_id": client_message_id,
|
||||
})
|
||||
metadata = dict(task_metadata or {})
|
||||
if str(project_id or "").strip():
|
||||
metadata.setdefault("project_id", str(project_id).strip())
|
||||
self.enqueue_local_message(
|
||||
clean_text,
|
||||
chat_id=1,
|
||||
chat_id=thread_id,
|
||||
user_id=1,
|
||||
source="web",
|
||||
sender_label="",
|
||||
|
|
@ -196,7 +212,7 @@ class LocalChatBridge:
|
|||
image_base64=image_base64,
|
||||
image_mime=image_mime,
|
||||
image_caption=image_caption,
|
||||
task_metadata=task_metadata,
|
||||
task_metadata=metadata or None,
|
||||
)
|
||||
|
||||
def enqueue_local_message(
|
||||
|
|
@ -287,6 +303,7 @@ class LocalChatBridge:
|
|||
"is_progress": bool(is_progress),
|
||||
"ts": message_ts,
|
||||
"task_id": str(task_id or ""),
|
||||
"chat_id": int(chat_id or 0),
|
||||
"transport": transport,
|
||||
}
|
||||
if meta:
|
||||
|
|
@ -312,7 +329,7 @@ class LocalChatBridge:
|
|||
if is_a2a_chat_id(chat_id):
|
||||
return True
|
||||
if self._broadcast_fn:
|
||||
self._broadcast_fn({"type": "typing", "action": action})
|
||||
self._broadcast_fn({"type": "typing", "action": action, "chat_id": int(chat_id or 0)})
|
||||
typing_transport = dict(self._chat_transports.get(int(chat_id or 0), {}) or {})
|
||||
publish_event(CHAT_TYPING, {"chat_id": int(chat_id or 0), "action": str(action or ""), "transport": typing_transport})
|
||||
return True
|
||||
|
|
@ -335,6 +352,7 @@ class LocalChatBridge:
|
|||
"mime": mime,
|
||||
"caption": caption,
|
||||
"ts": utc_now_iso(),
|
||||
"chat_id": int(chat_id or 0),
|
||||
}
|
||||
if self._broadcast_fn:
|
||||
self._broadcast_fn(msg)
|
||||
|
|
@ -367,6 +385,7 @@ class LocalChatBridge:
|
|||
"mime": mime,
|
||||
"caption": caption,
|
||||
"ts": utc_now_iso(),
|
||||
"chat_id": int(chat_id or 0),
|
||||
}
|
||||
if self._broadcast_fn:
|
||||
self._broadcast_fn(msg)
|
||||
|
|
@ -395,7 +414,10 @@ class LocalChatBridge:
|
|||
except queue.Full:
|
||||
pass
|
||||
if self._broadcast_fn:
|
||||
self._broadcast_fn({"type": "log", "data": event})
|
||||
# Surface the event's chat_id top-level so the browser's per-thread
|
||||
# fan-out (isMyThread) can route the live card to its project panel
|
||||
# instead of the main chat. Events without a chat_id default to main.
|
||||
self._broadcast_fn({"type": "log", "data": event, "chat_id": int(event.get("chat_id") or 0)})
|
||||
|
||||
def ui_poll_logs(self) -> list:
|
||||
"""Drain pending log events for the web UI."""
|
||||
|
|
@ -420,6 +442,8 @@ class LocalChatBridge:
|
|||
image_caption: str = "",
|
||||
task_constraint: Optional[Dict[str, Any]] = None,
|
||||
task_metadata: Optional[Dict[str, Any]] = None,
|
||||
chat_id: int = 1,
|
||||
project_id: str = "",
|
||||
):
|
||||
"""Accept a web UI message for the agent."""
|
||||
if broadcast:
|
||||
|
|
@ -431,6 +455,8 @@ class LocalChatBridge:
|
|||
image_mime=image_mime,
|
||||
image_caption=image_caption,
|
||||
task_metadata=task_metadata,
|
||||
chat_id=chat_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
return
|
||||
self.enqueue_local_message(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -1271,7 +1272,10 @@ def get_evolution_status_snapshot() -> Dict[str, Any]:
|
|||
"owner_chat_bound": bool(owner_chat_id),
|
||||
"last_task_at": str(st.get("last_evolution_task_at") or ""),
|
||||
"consecutive_failures": consecutive_failures,
|
||||
"budget_remaining_usd": remaining,
|
||||
# Unbounded budget (supervisor not initialized / TOTAL_BUDGET<=0)
|
||||
# is float('inf'), which strict JSON cannot carry — surface None so
|
||||
# /api/state stays serializable on onboarding installs.
|
||||
"budget_remaining_usd": remaining if math.isfinite(remaining) else None,
|
||||
"budget_reserve_usd": float(EVOLUTION_BUDGET_RESERVE),
|
||||
"pending_count": len(PENDING),
|
||||
"running_count": len(RUNNING),
|
||||
|
|
|
|||
|
|
@ -128,6 +128,81 @@ def _get_chat_agent():
|
|||
return _chat_agent
|
||||
|
||||
|
||||
def promote_chat_to_task(evt: dict, ctx: Any) -> None:
|
||||
"""Enqueue a first-class pooled owner task from a conversation-lane promote.
|
||||
|
||||
The task carries the originating ``chat_id`` (its live card and replies
|
||||
land in that thread) and the optional ``project_id`` scope; it competes for
|
||||
the project writer lease like any other top-level project task.
|
||||
"""
|
||||
from ouroboros.contracts.task_contract import attach_task_contract
|
||||
|
||||
tid = str(evt.get("task_id") or uuid.uuid4().hex[:8])
|
||||
objective = str(evt.get("objective") or "").strip()
|
||||
if not objective:
|
||||
return
|
||||
try:
|
||||
chat_id = int(evt.get("chat_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
chat_id = 0
|
||||
if not chat_id:
|
||||
st = ctx.load_state()
|
||||
try:
|
||||
chat_id = int(st.get("owner_chat_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
chat_id = 0
|
||||
expected_output = str(evt.get("expected_output") or "").strip()
|
||||
text = objective if not expected_output else f"{objective}\n\nExpected output: {expected_output}"
|
||||
task = {
|
||||
"id": tid,
|
||||
"type": "task",
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"description": objective,
|
||||
"objective": objective,
|
||||
"expected_output": expected_output,
|
||||
"source": "promote_chat_to_task",
|
||||
}
|
||||
pid = str(evt.get("project_id") or "").strip()
|
||||
if pid:
|
||||
task["project_id"] = pid
|
||||
try:
|
||||
from ouroboros.projects_registry import create_project, touch_project
|
||||
|
||||
project = create_project(DRIVE_ROOT, pid, origin="promote_chat_to_task")
|
||||
touch_project(DRIVE_ROOT, pid)
|
||||
# The promoted task runs in the PROJECT thread: route its live card +
|
||||
# owner mailbox to the project's chat_id (not the main chat it was
|
||||
# promoted from) so follow-ups steer to it via
|
||||
# _route_project_chat_to_running_task and its progress is visible in
|
||||
# the project panel.
|
||||
try:
|
||||
proj_chat = int((project or {}).get("chat_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
proj_chat = 0
|
||||
if proj_chat:
|
||||
task["chat_id"] = proj_chat
|
||||
# The agent just created/bound this project server-side (no client
|
||||
# round-trip, unlike the UI "Turn into project" flow). Tell the
|
||||
# frontend so it refreshes projectChatIds NOW — otherwise this new
|
||||
# project's live frames render in the main chat until the periodic
|
||||
# /api/state poll catches up (≤20s) and isMyThread misclassifies them.
|
||||
try:
|
||||
from supervisor.message_bus import get_bridge
|
||||
|
||||
get_bridge().broadcast({"type": "projects_changed", "project_id": pid, "chat_id": proj_chat})
|
||||
except Exception:
|
||||
log.debug("promote: projects_changed broadcast failed for %s", pid, exc_info=True)
|
||||
except Exception:
|
||||
log.debug("promote: project registration failed for %s", pid, exc_info=True)
|
||||
workspace_root = str(evt.get("workspace_root") or "").strip()
|
||||
if workspace_root:
|
||||
task["workspace_root"] = workspace_root
|
||||
task["workspace_mode"] = "external"
|
||||
attach_task_contract(task)
|
||||
ctx.enqueue_task(task)
|
||||
|
||||
|
||||
def handle_chat_direct(
|
||||
chat_id: int,
|
||||
text: str,
|
||||
|
|
@ -176,6 +251,11 @@ def _handle_chat_direct_locked(
|
|||
task["task_constraint"] = dict(task_constraint)
|
||||
if task_metadata:
|
||||
task["metadata"] = dict(task_metadata)
|
||||
# Project-thread conversations scope the direct lane to the
|
||||
# project's memory (knowledge/journal/workpad sections).
|
||||
pid = str(task_metadata.get("project_id") or "").strip()
|
||||
if pid:
|
||||
task["project_id"] = pid
|
||||
if image_data:
|
||||
# image_data is (base64, mime) or (base64, mime, caption).
|
||||
task["image_base64"] = image_data[0]
|
||||
|
|
@ -963,19 +1043,32 @@ def assign_tasks() -> None:
|
|||
send_with_budget(int(st["owner_chat_id"]), evo_block)
|
||||
queue.persist_queue_snapshot(reason="evolution_blocked_light")
|
||||
|
||||
from ouroboros.project_lease import candidate_is_leasable, running_project_ids
|
||||
|
||||
for w in WORKERS.values():
|
||||
if w.busy_task_id is None and PENDING:
|
||||
# Find first suitable task (skip over-budget evolution tasks)
|
||||
# One-writer-per-project lease: recompute per assignment so a
|
||||
# task assigned in THIS loop pass immediately occupies its lane.
|
||||
leased = running_project_ids(RUNNING.values())
|
||||
# Find first suitable task (skip over-budget evolution tasks
|
||||
# and project-leased candidates)
|
||||
chosen_idx = None
|
||||
for i, candidate in enumerate(PENDING):
|
||||
if str(candidate.get("type") or "") == "evolution" and remaining < EVOLUTION_BUDGET_RESERVE:
|
||||
continue
|
||||
if not candidate_is_leasable(candidate, leased):
|
||||
continue
|
||||
chosen_idx = i
|
||||
break
|
||||
if chosen_idx is None:
|
||||
# Only over-budget evolution tasks remain — clean them out
|
||||
PENDING[:] = [t for t in PENDING if str(t.get("type") or "") != "evolution"]
|
||||
queue.persist_queue_snapshot(reason="evolution_dropped_budget")
|
||||
# Nothing assignable: project-leased tasks WAIT in PENDING
|
||||
# for the next pass; only over-budget evolution tasks are
|
||||
# cleaned out.
|
||||
if remaining < EVOLUTION_BUDGET_RESERVE and any(
|
||||
str(t.get("type") or "") == "evolution" for t in PENDING
|
||||
):
|
||||
PENDING[:] = [t for t in PENDING if str(t.get("type") or "") != "evolution"]
|
||||
queue.persist_queue_snapshot(reason="evolution_dropped_budget")
|
||||
continue
|
||||
task = PENDING.pop(chosen_idx)
|
||||
if str(task.get("delegation_role") or "") == "subagent" and str(task.get("drive_root") or ""):
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -50,6 +50,29 @@ def test_task_summary_prefers_direct_model_when_openrouter_missing(tmp_path, mon
|
|||
assert payload["reason_code"] == "empty_final_text"
|
||||
|
||||
|
||||
def test_task_summary_row_carries_chat_id_for_trivial_task(tmp_path):
|
||||
"""A trivial task (no tools, <=1 round) skips the LLM summary but still
|
||||
stamps the project chat_id, so the summary row routes to its project
|
||||
thread on history reload instead of defaulting to the main chat."""
|
||||
drive_logs = tmp_path / "logs"
|
||||
drive_logs.mkdir(parents=True)
|
||||
pipeline._run_task_summary(
|
||||
env=None,
|
||||
llm=None,
|
||||
task={"id": "p1", "type": "task", "text": "hi", "chat_id": 1234},
|
||||
usage={"rounds": 1, "cost": 0.0},
|
||||
llm_trace={"tool_calls": [], "reasoning_notes": []},
|
||||
drive_logs=drive_logs,
|
||||
)
|
||||
rows = [
|
||||
json.loads(line)
|
||||
for line in (drive_logs / "chat.jsonl").read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
summaries = [r for r in rows if r.get("type") == "task_summary"]
|
||||
assert summaries and summaries[0]["chat_id"] == 1234
|
||||
|
||||
|
||||
def test_task_summary_keeps_openrouter_model_when_key_present(monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "test-openrouter-key")
|
||||
monkeypatch.setenv("OUROBOROS_MODEL_LIGHT", "openai::gpt-5.5-mini")
|
||||
|
|
|
|||
|
|
@ -112,6 +112,33 @@ def test_count_lines(tmp_paths):
|
|||
assert _count_lines(chat_path) == 15
|
||||
|
||||
|
||||
def test_read_chat_entries_includes_project_rows_full_awareness(tmp_path):
|
||||
"""Full project awareness (v6.32.0): the one identity's consolidated dialogue
|
||||
(dialogue_blocks.json) is its WHOLE conversation — main AND project threads —
|
||||
because Ouroboros is one awareness/biography (BIBLE P1). Only A2A virtual
|
||||
transport is excluded from the consolidation source."""
|
||||
from ouroboros.consolidator import _read_chat_entries
|
||||
from ouroboros.projects_registry import create_project
|
||||
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir(parents=True)
|
||||
proj = create_project(tmp_path, "racer")
|
||||
project_chat = int(proj["chat_id"])
|
||||
chat_path = logs / "chat.jsonl"
|
||||
rows = [
|
||||
{"chat_id": 1, "direction": "in", "text": "main-1"},
|
||||
{"chat_id": project_chat, "direction": "in", "text": "project-visible"},
|
||||
{"chat_id": 1, "direction": "out", "text": "main-2"},
|
||||
{"chat_id": -1001, "direction": "out", "text": "a2a-noise"},
|
||||
]
|
||||
chat_path.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
|
||||
|
||||
texts = [e.get("text") for e in _read_chat_entries(chat_path)]
|
||||
assert "main-1" in texts and "main-2" in texts
|
||||
assert "project-visible" in texts # full awareness: project chat IS part of identity memory
|
||||
assert "a2a-noise" not in texts # only A2A virtual transport is excluded
|
||||
|
||||
|
||||
def test_should_consolidate_handles_log_rotation(tmp_paths):
|
||||
chat_path, _, meta_path = tmp_paths
|
||||
_write_chat_entries(chat_path, BLOCK_SIZE + 5)
|
||||
|
|
|
|||
|
|
@ -685,6 +685,125 @@ def test_recent_chat_starts_after_consolidated_offset(tmp_path):
|
|||
assert "msg-4" in combined
|
||||
|
||||
|
||||
def test_recent_chat_main_includes_all_threads_full_awareness(tmp_path):
|
||||
"""Full project awareness (v6.32.0): the one identity's main/global context
|
||||
sees its WHOLE conversation — main + project threads alike (BIBLE P1, one
|
||||
awareness across direct chat, project rooms, and consciousness). Project chat
|
||||
is part of the one mind's memory, NOT partitioned out; only A2A virtual
|
||||
transport is excluded (covered elsewhere)."""
|
||||
from ouroboros.context import build_recent_sections
|
||||
from ouroboros.memory import Memory
|
||||
from ouroboros.projects_registry import create_project
|
||||
|
||||
logs_dir = tmp_path / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
project = create_project(tmp_path, "racer")
|
||||
project_chat = int(project["chat_id"])
|
||||
transport_chat = 555000111 # large NON-project id (e.g. a Telegram mirror)
|
||||
|
||||
entries = [
|
||||
{"chat_id": 1, "direction": "in", "username": "User", "text": "main-keep"},
|
||||
{"chat_id": project_chat, "direction": "in", "username": "User", "text": "project-visible"},
|
||||
{"chat_id": transport_chat, "direction": "in", "username": "User", "text": "transport-keep"},
|
||||
{"direction": "in", "username": "User", "text": "legacy-keep"}, # no chat_id -> main
|
||||
]
|
||||
(logs_dir / "chat.jsonl").write_text(
|
||||
"\n".join(json.dumps(entry) for entry in entries) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
combined = "\n\n".join(build_recent_sections(Memory(drive_root=tmp_path), env=None))
|
||||
|
||||
assert "main-keep" in combined
|
||||
assert "legacy-keep" in combined
|
||||
assert "transport-keep" in combined
|
||||
assert "project-visible" in combined # full awareness: the one mind sees project chat
|
||||
|
||||
|
||||
def test_recent_chat_for_project_thread_shows_only_its_own_thread(tmp_path):
|
||||
"""A project TASK gets a FOCUSED working view of its own thread (full
|
||||
awareness, v6.32.0): its "## Recent chat" is its own project thread, not the
|
||||
штаб's main chat nor a sibling project's chat, so cross-project noise does not
|
||||
bloat its working context. This is focus, not memory isolation — the one mind
|
||||
still sees everything via the main/background path. Pins that thread_chat_id
|
||||
selects the project's own raw tail rather than the main consolidation stream."""
|
||||
from ouroboros.context import build_recent_sections
|
||||
from ouroboros.memory import Memory
|
||||
from ouroboros.projects_registry import create_project
|
||||
|
||||
logs_dir = tmp_path / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
proj_a = create_project(tmp_path, "racer")
|
||||
proj_b = create_project(tmp_path, "research")
|
||||
chat_a = int(proj_a["chat_id"])
|
||||
chat_b = int(proj_b["chat_id"])
|
||||
|
||||
entries = [
|
||||
{"chat_id": 1, "direction": "in", "username": "User", "text": "main-stab-chat"},
|
||||
{"chat_id": chat_a, "direction": "in", "username": "User", "text": "project-a-own-thread"},
|
||||
{"chat_id": chat_b, "direction": "in", "username": "User", "text": "project-b-sibling"},
|
||||
]
|
||||
(logs_dir / "chat.jsonl").write_text(
|
||||
"\n".join(json.dumps(entry) for entry in entries) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
combined = "\n\n".join(build_recent_sections(
|
||||
Memory(drive_root=tmp_path), env=None, thread_chat_id=chat_a))
|
||||
|
||||
assert "project-a-own-thread" in combined # its own thread is visible
|
||||
assert "project-b-sibling" not in combined # sibling project not in focused view
|
||||
assert "main-stab-chat" not in combined # main chat not in focused project view
|
||||
|
||||
|
||||
def test_project_workpad_and_journal_not_silently_sliced(tmp_path, monkeypatch):
|
||||
"""BIBLE P1 (no silent truncation): project cognitive artifacts are not
|
||||
prefix-sliced into context. The workpad rides in FULL; journal milestones show
|
||||
full text (no per-row [:N]) with a visible journal_read pointer for older."""
|
||||
import types
|
||||
|
||||
monkeypatch.setattr("ouroboros.config.DATA_DIR", tmp_path)
|
||||
from ouroboros.context import build_knowledge_sections
|
||||
from ouroboros.project_facts import project_journal_path, project_workpad_path
|
||||
from ouroboros.utils import append_jsonl
|
||||
|
||||
pid = "builder"
|
||||
wp = project_workpad_path(pid)
|
||||
wp.parent.mkdir(parents=True, exist_ok=True)
|
||||
tail = "WORKPAD_TAIL_MARKER"
|
||||
wp.write_text("A" * 20_000 + tail, encoding="utf-8") # > old 12_000 slice
|
||||
append_jsonl(project_journal_path(pid), {
|
||||
"ts": "2026-06-14T00:00:00Z", "kind": "checkpoint", "text": "M" * 600, # > old 200 slice
|
||||
})
|
||||
|
||||
env = types.SimpleNamespace(drive_path=lambda rel: tmp_path / rel)
|
||||
combined = "\n\n".join(build_knowledge_sections(env, project_id=pid))
|
||||
|
||||
assert tail in combined # full workpad, not prefix-sliced to 12_000
|
||||
assert ("M" * 600) in combined # full journal milestone, not sliced to 200
|
||||
|
||||
|
||||
def test_append_journal_milestone_bounds_over_limit_with_pointer(tmp_path, monkeypatch):
|
||||
"""An AUTOMATIC completion milestone honors the journal's durable per-row cap:
|
||||
over-limit text is bounded with a VISIBLE pointer (recorded, never silently
|
||||
sliced nor dropped) — same _MAX_TEXT_CHARS contract as the journal_write tool,
|
||||
so emit_task_results cannot append a raw unbounded row."""
|
||||
monkeypatch.setattr("ouroboros.config.DATA_DIR", tmp_path)
|
||||
from ouroboros.project_facts import project_journal_path
|
||||
from ouroboros.tools.project_journal import _MAX_TEXT_CHARS, append_journal_milestone
|
||||
from ouroboros.utils import iter_jsonl_objects
|
||||
|
||||
pid = "lh"
|
||||
append_journal_milestone(pid, "done", "Z" * (_MAX_TEXT_CHARS + 500), task_id="t1")
|
||||
rows = [r for r in iter_jsonl_objects(project_journal_path(pid)) if isinstance(r, dict)]
|
||||
assert len(rows) == 1 # recorded (not dropped/rejected)
|
||||
txt = rows[0]["text"]
|
||||
assert len(txt) <= _MAX_TEXT_CHARS # honors the durable per-row contract
|
||||
assert "task_results" in txt # VISIBLE pointer to the full text
|
||||
|
||||
|
||||
def test_low_mode_preserves_full_unconsolidated_dialogue_suffix(tmp_path, monkeypatch):
|
||||
from ouroboros.context import build_recent_sections
|
||||
from ouroboros.memory import Memory
|
||||
|
|
|
|||
|
|
@ -74,3 +74,20 @@ def test_send_video_publishes_transport_event_with_payload(monkeypatch):
|
|||
assert payload["caption"] == "trailer"
|
||||
assert payload["mime"] == "video/mp4"
|
||||
|
||||
|
||||
def test_push_log_broadcast_surfaces_chat_id(monkeypatch):
|
||||
"""Live log frames surface the task's chat_id top-level so the browser's
|
||||
per-thread fan-out routes the live card to its project panel; events with
|
||||
no chat_id default to the main chat (0)."""
|
||||
bridge = _make_bridge(monkeypatch)
|
||||
frames = []
|
||||
bridge._broadcast_fn = frames.append
|
||||
|
||||
bridge.push_log({"type": "tool_call", "task_id": "t1", "chat_id": 1234})
|
||||
bridge.push_log({"type": "tool_call", "task_id": "t2"})
|
||||
|
||||
logs = [f for f in frames if f.get("type") == "log"]
|
||||
assert logs[0]["chat_id"] == 1234
|
||||
assert logs[0]["data"]["task_id"] == "t1"
|
||||
assert logs[1]["chat_id"] == 0
|
||||
|
||||
|
|
|
|||
141
tests/test_navigation_shell_static.py
Normal file
141
tests/test_navigation_shell_static.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Static contracts for the responsive navigation shell (v6.32.0).
|
||||
|
||||
These checks pin the high-risk parts of the multi-project navigation rewrite:
|
||||
desktop sidebar + mobile drawer share one DOM/state model, project rows use
|
||||
explicit slots, and the old bottom icon rail does not come back.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (REPO / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_navigation_shell_dom_has_sidebar_drawer_and_project_slots():
|
||||
html = _read("web/index.html")
|
||||
chat_js = _read("web/modules/chat.js")
|
||||
assert 'id="primary-sidebar"' in html
|
||||
assert 'data-mobile-nav-toggle' not in html
|
||||
assert 'id="nav-drawer-backdrop"' in html
|
||||
assert 'data-nav-page="chat"' in html
|
||||
assert 'class="nav-row-label"' in html
|
||||
assert 'id="nav-projects-list" class="nav-projects-list"' in html
|
||||
# The mobile trigger is shell-level (available on Skills/Dashboard/etc),
|
||||
# not a chat-only duplicate.
|
||||
assert 'data-mobile-nav-toggle' not in chat_js
|
||||
assert "data-mobile-nav-toggle" in _read("web/modules/page_header.js")
|
||||
|
||||
|
||||
def test_navigation_shell_replaces_legacy_rail_selectors():
|
||||
combined = "\n".join(
|
||||
_read(path)
|
||||
for path in (
|
||||
"web/index.html",
|
||||
"web/app.js",
|
||||
"web/style.css",
|
||||
"web/modules/page_icons.js",
|
||||
"web/modules/skills.js",
|
||||
"web/modules/marketplace.js",
|
||||
)
|
||||
)
|
||||
assert "#nav-rail" not in combined
|
||||
assert ".nav-btn" not in combined
|
||||
assert "data-page=" not in combined
|
||||
assert "--nav-width" not in combined
|
||||
|
||||
|
||||
def test_navigation_state_and_mobile_drawer_are_first_class():
|
||||
app_js = _read("web/app.js")
|
||||
css = _read("web/style.css")
|
||||
assert "const navState = {" in app_js
|
||||
assert "function syncNavigationState()" in app_js
|
||||
assert "mobileDrawerOpen" in app_js
|
||||
assert "navState.activeProjectId" in app_js
|
||||
assert "#primary-sidebar.open" in css
|
||||
assert ".nav-drawer-backdrop" in css
|
||||
assert ".mobile-nav-toggle" in css
|
||||
assert "grid-template-columns: var(--sidebar-width) minmax(0, 1fr) auto;" in css
|
||||
assert ".mobile-nav-toggle {\n position: fixed" not in css
|
||||
assert "flex: 0 0 44px;" in css
|
||||
|
||||
|
||||
def test_page_header_is_shared_foundation_for_top_pages():
|
||||
header_js = _read("web/modules/page_header.js")
|
||||
css = _read("web/style.css")
|
||||
assert "export function renderMobileNavToggle()" in header_js
|
||||
assert "toolbarHtml" in header_js
|
||||
assert "trailingHtml" in header_js
|
||||
assert "app-page-toolbar app-page-actions" in header_js
|
||||
assert "grid-template-areas:" in css
|
||||
assert '"leading title toolbar"' in css
|
||||
assert '"tabs tabs tabs"' in css
|
||||
assert "align-items: start;" in css
|
||||
for path in (
|
||||
"web/modules/chat.js",
|
||||
"web/modules/files.js",
|
||||
"web/modules/skills.js",
|
||||
"web/modules/widgets.js",
|
||||
"web/modules/dashboard.js",
|
||||
"web/modules/settings_ui.js",
|
||||
):
|
||||
assert "renderPageHeader({" in _read(path)
|
||||
|
||||
|
||||
def test_project_rows_use_slots_not_generic_spans():
|
||||
app_js = _read("web/app.js")
|
||||
css = _read("web/style.css")
|
||||
assert "PROJECTS_VISIBLE_LIMIT" not in app_js
|
||||
assert "Show more" not in app_js
|
||||
assert "className = 'nav-row nav-project-row'" in app_js
|
||||
# Project rows are a dotless, compact, indented list (no green status dot).
|
||||
assert "nav-project-dot" not in app_js
|
||||
assert "nav-project-dot" not in css
|
||||
assert "className = 'nav-row-label'" in app_js
|
||||
assert ".nav-project-btn span" not in css
|
||||
assert ".nav-projects {" in css and ".nav-projects-list" in css
|
||||
assert "p.has_thread_activity !== false" in app_js
|
||||
assert "#page-skills,\n#page-widgets {\n flex-direction: column;\n min-height: 0;\n overflow: hidden;" in css
|
||||
assert "#page-skills,\n#page-widgets {\n flex-direction: column;\n min-height: 0;\n padding:" not in css
|
||||
|
||||
|
||||
def test_project_panel_composer_and_welcome_contracts():
|
||||
chat_js = _read("web/modules/chat.js")
|
||||
css = _read("web/style.css")
|
||||
assert "if (!isMain) return;" in chat_js # ensureWelcomeMessage is main-only
|
||||
assert "padding: 10px 292px" not in css
|
||||
assert "right: 8px;\n bottom: 6px" not in css
|
||||
assert ".chat-text-row:focus-within" in css
|
||||
assert ".chat-toolbar-row {\n order: 1;" in css
|
||||
assert ".chat-text-row {\n order: 2;" in css
|
||||
assert "chat-header-more" in chat_js
|
||||
assert "chat-header-menu" in css
|
||||
assert "backdrop-filter: blur(22px)" in css
|
||||
assert 'id="project-panel-backdrop" class="project-panel-backdrop"' in _read("web/index.html")
|
||||
assert ".project-panel-backdrop" in css
|
||||
assert "transition: transform 180ms ease, opacity 180ms ease;" in css
|
||||
assert ".project-panel.open" in css
|
||||
assert "left: var(--sidebar-width);" in css # sidebar stays clickable under backdrop
|
||||
assert ".chat-header-actions {\n display: none;" not in css
|
||||
# Gateway Boundary: chat.js consumes the endpoint via the api_client wrapper,
|
||||
# and the raw route lives in api_client.js (not a raw fetch in chat.js).
|
||||
assert "projectFromTask" in chat_js
|
||||
assert "/api/projects/from-task" in _read("web/modules/api_client.js")
|
||||
|
||||
|
||||
def test_chat_header_controls_reorg_and_more_autodismiss():
|
||||
chat_js = _read("web/modules/chat.js")
|
||||
# Restart + Panic are promoted to always-visible header buttons...
|
||||
assert 'class="chat-header-btn" type="button" data-chat-command="restart"' in chat_js
|
||||
assert 'class="chat-header-btn danger" type="button" data-chat-command="panic"' in chat_js
|
||||
# ...and Consciousness / Evolve / Review move into the More overflow menu.
|
||||
menu = chat_js.split('class="chat-header-menu"', 1)[1].split("</details>", 1)[0]
|
||||
assert 'data-chat-command="bg"' in menu
|
||||
assert 'data-chat-command="evolve"' in menu
|
||||
assert 'data-chat-command="review"' in menu
|
||||
# The More <details> auto-collapses on an outside click or Escape (never sticks).
|
||||
assert "details.chat-header-more[open]" in chat_js
|
||||
assert "event.key === 'Escape'" in chat_js
|
||||
|
|
@ -158,7 +158,7 @@ def test_server_navigation_and_chat_static_contracts():
|
|||
assert '"evolution_state": evolution_state,' in state_source
|
||||
assert '"bg_consciousness_state": bg_state,' in state_source
|
||||
assert 'request.query_params.get("force")' in control_source
|
||||
assert "window.dispatchEvent(new CustomEvent('ouro:page-shown', { detail: { page: name } }));" in app_source
|
||||
assert "window.dispatchEvent(new CustomEvent('ouro:page-shown', { detail: { page: pageName } }));" in app_source
|
||||
assert "evo-runtime-detail" in evo_source
|
||||
assert "data?.evolution_state?.detail" in chat_source
|
||||
assert "data?.bg_consciousness_state?.detail" in chat_source
|
||||
|
|
|
|||
153
tests/test_project_lease.py
Normal file
153
tests/test_project_lease.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Multi-project lease + registry + chat-id policy (v6.32.0)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ouroboros.contracts.chat_id_policy import (
|
||||
PROJECT_CHAT_ID_MIN,
|
||||
WEB_UI_CHAT_ID,
|
||||
is_a2a_chat_id,
|
||||
is_project_chat_id,
|
||||
project_chat_id,
|
||||
)
|
||||
from ouroboros.project_lease import candidate_is_leasable, running_project_ids
|
||||
|
||||
|
||||
def _task(project_id="", role="", tid="t1"):
|
||||
task = {"id": tid, "type": "task"}
|
||||
if project_id:
|
||||
task["project_id"] = project_id
|
||||
if role:
|
||||
task["delegation_role"] = role
|
||||
return task
|
||||
|
||||
|
||||
def _meta(task):
|
||||
"""Production RUNNING value shape: meta dict wrapping the task."""
|
||||
return {"task": task, "worker_id": 0, "last_heartbeat_at": 1.0}
|
||||
|
||||
|
||||
def test_running_project_ids_counts_top_level_scoped_tasks_only():
|
||||
# Mix the PRODUCTION meta shape (workers.py RUNNING values) with bare task
|
||||
# dicts — running_project_ids must unwrap meta and still count both.
|
||||
running = [
|
||||
_meta(_task("alpha")), # production shape
|
||||
_task("beta"), # bare task dict
|
||||
_meta(_task("", tid="plain")), # unscoped: no lane
|
||||
_meta(_task("gamma", role="subagent")), # swarm member: no lease of its own
|
||||
"garbage",
|
||||
None,
|
||||
]
|
||||
assert running_project_ids(running) == {"alpha", "beta"}
|
||||
|
||||
|
||||
def test_running_project_ids_unwraps_production_meta_shape():
|
||||
"""Regression for the inert-lease bug: RUNNING.values() are meta dicts."""
|
||||
running = {"t1": _meta(_task("racer"))}.values()
|
||||
ids = running_project_ids(running)
|
||||
assert ids == {"racer"}
|
||||
assert candidate_is_leasable(_task("racer", tid="t2"), ids) is False
|
||||
|
||||
|
||||
def test_candidate_is_leasable_matrix():
|
||||
leased = {"alpha"}
|
||||
# Unscoped tasks never serialize.
|
||||
assert candidate_is_leasable(_task(""), leased) is True
|
||||
# A second writer for a leased project waits.
|
||||
assert candidate_is_leasable(_task("alpha"), leased) is False
|
||||
# A different project proceeds in parallel.
|
||||
assert candidate_is_leasable(_task("beta"), leased) is True
|
||||
# The leased project's OWN subagents must not deadlock the swarm.
|
||||
assert candidate_is_leasable(_task("alpha", role="subagent"), leased) is True
|
||||
|
||||
|
||||
def test_project_chat_id_policy():
|
||||
assert is_project_chat_id(WEB_UI_CHAT_ID) is False
|
||||
assert is_project_chat_id(-5) is False
|
||||
cid = project_chat_id("my-game")
|
||||
assert cid >= PROJECT_CHAT_ID_MIN
|
||||
assert is_project_chat_id(cid) is True
|
||||
assert is_a2a_chat_id(cid) is False
|
||||
# Deterministic and id-sensitive.
|
||||
assert project_chat_id("my-game") == cid
|
||||
assert project_chat_id("other") != cid
|
||||
# Empty scope falls back to the main chat.
|
||||
assert project_chat_id("") == WEB_UI_CHAT_ID
|
||||
|
||||
|
||||
def test_registry_create_idempotent_and_summary(tmp_path):
|
||||
from ouroboros.projects_registry import (
|
||||
STATUS_ACTIVE,
|
||||
STATUS_SLEEPING,
|
||||
create_project,
|
||||
get_project,
|
||||
list_projects,
|
||||
projects_summary,
|
||||
sleep_project,
|
||||
wake_project,
|
||||
)
|
||||
|
||||
entry = create_project(tmp_path, "racer", name="Cyber Racer")
|
||||
assert entry["id"] == "racer"
|
||||
assert entry["status"] == STATUS_ACTIVE
|
||||
assert entry["chat_id"] == project_chat_id("racer")
|
||||
|
||||
again = create_project(tmp_path, "racer", name="ignored on existing")
|
||||
assert again["name"] == "Cyber Racer"
|
||||
assert len(list_projects(tmp_path)) == 1
|
||||
|
||||
slept = sleep_project(tmp_path, "racer")
|
||||
assert slept and slept["status"] == STATUS_SLEEPING
|
||||
woke = wake_project(tmp_path, "racer")
|
||||
assert woke and woke["status"] == STATUS_ACTIVE
|
||||
|
||||
rows = projects_summary(tmp_path)
|
||||
assert rows and rows[0]["id"] == "racer" and rows[0]["chat_id"] == entry["chat_id"]
|
||||
assert get_project(tmp_path, "missing") is None
|
||||
|
||||
|
||||
def test_registry_reconcile_registers_existing_stores_never_prunes(tmp_path):
|
||||
from ouroboros.projects_registry import create_project, list_projects, reconcile_projects
|
||||
|
||||
create_project(tmp_path, "kept")
|
||||
(tmp_path / "projects" / "legacy-store" / "knowledge").mkdir(parents=True)
|
||||
|
||||
added = reconcile_projects(tmp_path)
|
||||
|
||||
assert added == 1
|
||||
ids = {p["id"] for p in list_projects(tmp_path)}
|
||||
assert ids == {"kept", "legacy-store"}
|
||||
# Second run is a no-op (idempotent) and nothing is pruned.
|
||||
assert reconcile_projects(tmp_path) == 0
|
||||
assert {p["id"] for p in list_projects(tmp_path)} == ids
|
||||
|
||||
|
||||
def test_journal_and_workpad_roundtrip(tmp_path, monkeypatch):
|
||||
import types
|
||||
|
||||
# Scope the project store to tmp_path WITHOUT importlib.reload(config): a
|
||||
# reload permanently rebinds ouroboros.config.DATA_DIR for the rest of the
|
||||
# pytest process (monkeypatch restores only the env var, not the reloaded
|
||||
# module), polluting later tests. project_facts reads config.DATA_DIR at call
|
||||
# time, so monkeypatch.setattr (auto-restored) is sufficient and isolated.
|
||||
monkeypatch.setattr("ouroboros.config.DATA_DIR", tmp_path)
|
||||
from ouroboros.tools import project_journal as pj
|
||||
|
||||
ctx = types.SimpleNamespace(project_id="racer", task_id="t-9", drive_root=tmp_path)
|
||||
tools = {t.name: t for t in pj.get_tools()}
|
||||
|
||||
out = tools["journal_write"].handler(ctx, kind="start", text="Bootstrapping the racer")
|
||||
assert out.startswith("OK:")
|
||||
out = tools["journal_write"].handler(ctx, kind="bogus", text="x")
|
||||
assert "TOOL_ARG_ERROR" in out
|
||||
listing = tools["journal_read"].handler(ctx)
|
||||
assert "Bootstrapping the racer" in listing and "START" in listing
|
||||
|
||||
assert tools["workpad_write"].handler(ctx, content="## plan\n- wheels").startswith("OK:")
|
||||
assert "wheels" in tools["workpad_read"].handler(ctx)
|
||||
|
||||
digest = pj.journal_tail_digest("racer")
|
||||
assert "Bootstrapping the racer" in digest
|
||||
|
||||
# Unscoped ctx without explicit id refuses honestly.
|
||||
bare = types.SimpleNamespace(project_id="", task_id="t", drive_root=tmp_path)
|
||||
assert "no project scope" in tools["journal_write"].handler(bare, kind="note", text="x")
|
||||
582
tests/test_promote_chat_flow.py
Normal file
582
tests/test_promote_chat_flow.py
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
"""promote_chat_to_task + project chat routing (multi-project, v6.32.0)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
|
||||
def test_promote_tool_emits_event_with_chat_and_project(tmp_path):
|
||||
from ouroboros.tools.control import _promote_chat_to_task
|
||||
|
||||
events = []
|
||||
ctx = types.SimpleNamespace(
|
||||
pending_events=events,
|
||||
event_queue=None,
|
||||
current_chat_id=1,
|
||||
drive_root=tmp_path,
|
||||
)
|
||||
out = _promote_chat_to_task(ctx, "Build the racer prototype", project_id="racer")
|
||||
assert out.startswith("OK: promoted to supervised task")
|
||||
assert len(events) == 1
|
||||
evt = events[0]
|
||||
assert evt["type"] == "promote_chat_to_task"
|
||||
assert evt["objective"] == "Build the racer prototype"
|
||||
assert evt["project_id"] == "racer"
|
||||
assert evt["chat_id"] == 1
|
||||
assert evt["task_id"]
|
||||
|
||||
|
||||
def test_promote_tool_rejects_dirty_project_id(tmp_path):
|
||||
from ouroboros.tools.control import _promote_chat_to_task
|
||||
|
||||
ctx = types.SimpleNamespace(
|
||||
pending_events=[], event_queue=None, current_chat_id=1, drive_root=tmp_path,
|
||||
)
|
||||
out = _promote_chat_to_task(ctx, "x", project_id="Bad Name!")
|
||||
assert "TOOL_ARG_ERROR" in out
|
||||
assert not ctx.pending_events
|
||||
|
||||
|
||||
def test_promote_event_enqueues_first_class_task(tmp_path, monkeypatch):
|
||||
"""The supervisor handler enqueues a pooled OWNER task (not a subagent),
|
||||
registers the project, and carries the chat thread."""
|
||||
import supervisor.workers as workers
|
||||
|
||||
monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path)
|
||||
enqueued = []
|
||||
ctx = types.SimpleNamespace(
|
||||
enqueue_task=lambda task: enqueued.append(task),
|
||||
load_state=lambda: {"owner_chat_id": 1},
|
||||
)
|
||||
evt = {
|
||||
"type": "promote_chat_to_task",
|
||||
"task_id": "abc12345",
|
||||
"objective": "Research the market",
|
||||
"expected_output": "A summary",
|
||||
"project_id": "research-1",
|
||||
"chat_id": 0, # falls back to owner chat
|
||||
}
|
||||
workers.promote_chat_to_task(evt, ctx)
|
||||
|
||||
assert len(enqueued) == 1
|
||||
task = enqueued[0]
|
||||
assert task["id"] == "abc12345"
|
||||
assert task["type"] == "task"
|
||||
assert task["project_id"] == "research-1"
|
||||
assert "delegation_role" not in task
|
||||
assert "_is_direct_chat" not in task
|
||||
assert "Expected output: A summary" in task["text"]
|
||||
# The project got registered as a side effect, and the promoted task runs in
|
||||
# the PROJECT thread: its chat_id is the project's deterministic chat_id (not
|
||||
# the main/owner fallback), so its live card + owner mailbox route to the panel.
|
||||
from ouroboros.contracts.chat_id_policy import project_chat_id
|
||||
from ouroboros.projects_registry import get_project
|
||||
|
||||
project = get_project(tmp_path, "research-1")
|
||||
assert project is not None
|
||||
assert task["chat_id"] == project["chat_id"] == project_chat_id("research-1")
|
||||
assert task["chat_id"] != 1 # not the owner-chat fallback
|
||||
|
||||
|
||||
def test_promote_chat_to_task_broadcasts_projects_changed(tmp_path, monkeypatch):
|
||||
"""Backend project creation pushes a projects_changed WS frame carrying the new
|
||||
chat_id, so the frontend fan-out learns the project thread IMMEDIATELY (no
|
||||
≤20s window where its live frames misroute into the main chat)."""
|
||||
import supervisor.message_bus as mbus
|
||||
import supervisor.workers as workers
|
||||
|
||||
monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path)
|
||||
broadcasts = []
|
||||
fake_bridge = types.SimpleNamespace(broadcast=lambda payload: broadcasts.append(payload))
|
||||
monkeypatch.setattr(mbus, "get_bridge", lambda: fake_bridge)
|
||||
ctx = types.SimpleNamespace(
|
||||
enqueue_task=lambda task: None,
|
||||
load_state=lambda: {"owner_chat_id": 1},
|
||||
)
|
||||
workers.promote_chat_to_task({
|
||||
"type": "promote_chat_to_task",
|
||||
"task_id": "pc1",
|
||||
"objective": "Build it",
|
||||
"project_id": "proj-x",
|
||||
"chat_id": 0,
|
||||
}, ctx)
|
||||
|
||||
from ouroboros.contracts.chat_id_policy import project_chat_id
|
||||
|
||||
changed = [b for b in broadcasts if b.get("type") == "projects_changed"]
|
||||
assert len(changed) == 1
|
||||
assert changed[0]["project_id"] == "proj-x"
|
||||
assert changed[0]["chat_id"] == project_chat_id("proj-x")
|
||||
|
||||
|
||||
def test_registered_project_chat_ids_includes_archived(tmp_path):
|
||||
"""Archiving changes UI visibility only — the isolation SSOT still recognizes
|
||||
an archived project's chat_id so its raw chat never re-leaks into the штаб's
|
||||
main context / dialogue consolidation / background consciousness (BIBLE P1)."""
|
||||
from ouroboros.projects_registry import (
|
||||
STATUS_ARCHIVED,
|
||||
create_project,
|
||||
registered_project_chat_ids,
|
||||
update_project,
|
||||
)
|
||||
|
||||
proj = create_project(tmp_path, "old-racer")
|
||||
chat_id = int(proj["chat_id"])
|
||||
assert chat_id in registered_project_chat_ids(tmp_path)
|
||||
update_project(tmp_path, "old-racer", status=STATUS_ARCHIVED)
|
||||
# Still a project thread for isolation purposes after archiving.
|
||||
assert chat_id in registered_project_chat_ids(tmp_path)
|
||||
|
||||
|
||||
def test_chat_history_tool_spans_all_threads_full_awareness(tmp_path):
|
||||
"""Full project awareness (v6.32.0): the chat_history TOOL is the one mind's
|
||||
DELIBERATE recall — it spans the WHOLE conversation (main + ALL project
|
||||
threads), only A2A virtual transport excluded. Project-task FOCUS lives in the
|
||||
passive default context (build_recent_sections), NOT in this recall tool, so
|
||||
the one identity can recall anything it chooses (BIBLE P1)."""
|
||||
import json
|
||||
|
||||
from ouroboros.memory import Memory
|
||||
from ouroboros.projects_registry import create_project
|
||||
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir(parents=True)
|
||||
a = create_project(tmp_path, "alpha")
|
||||
b = create_project(tmp_path, "beta")
|
||||
ca, cb = int(a["chat_id"]), int(b["chat_id"])
|
||||
rows = [
|
||||
{"direction": "in", "text": "main-msg", "chat_id": 1},
|
||||
{"direction": "in", "text": "alpha-msg", "chat_id": ca},
|
||||
{"direction": "in", "text": "beta-msg", "chat_id": cb},
|
||||
{"direction": "in", "text": "a2a-noise", "chat_id": -1001},
|
||||
]
|
||||
(logs / "chat.jsonl").write_text(
|
||||
"\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
|
||||
mem = Memory(drive_root=tmp_path)
|
||||
|
||||
view = mem.chat_history(count=50)
|
||||
assert "main-msg" in view and "alpha-msg" in view and "beta-msg" in view # all threads
|
||||
assert "a2a-noise" not in view # only A2A virtual transport excluded
|
||||
|
||||
|
||||
def test_recent_context_full_awareness_and_project_focus_with_bindings(tmp_path):
|
||||
"""Passive context (v6.32.0): the one identity's MAIN recent context sees
|
||||
EVERYTHING, including a post-hoc bound task's rows (one mind, BIBLE P1). A
|
||||
PROJECT task's recent context is FOCUSED on its own thread + rows of tasks
|
||||
bound to it; unrelated main chat is left out of the focused working view
|
||||
(focus in the passive default, not isolation)."""
|
||||
import json
|
||||
|
||||
from ouroboros.context import build_recent_sections
|
||||
from ouroboros.memory import Memory
|
||||
from ouroboros.projects_registry import bind_task_to_project, create_project
|
||||
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir(parents=True)
|
||||
proj = create_project(tmp_path, "promoted")
|
||||
pchat = int(proj["chat_id"])
|
||||
bind_task_to_project(tmp_path, "task-7", "promoted", pchat)
|
||||
rows = [
|
||||
{"direction": "in", "text": "plain-main", "chat_id": 1},
|
||||
{"direction": "out", "text": "bound-task-row", "chat_id": 1, "task_id": "task-7"},
|
||||
]
|
||||
(logs / "chat.jsonl").write_text(
|
||||
"\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
|
||||
mem = Memory(drive_root=tmp_path)
|
||||
|
||||
# Main passive context: full awareness sees everything.
|
||||
main_ctx = "\n".join(build_recent_sections(mem, env=None))
|
||||
assert "plain-main" in main_ctx and "bound-task-row" in main_ctx
|
||||
|
||||
# Project task passive context: focused on its own thread + bound-task rows.
|
||||
proj_ctx = "\n".join(build_recent_sections(mem, env=None, thread_chat_id=pchat))
|
||||
assert "bound-task-row" in proj_ctx
|
||||
assert "plain-main" not in proj_ctx
|
||||
|
||||
|
||||
def test_restart_drain_defers_then_completes_without_sleeping(tmp_path, monkeypatch):
|
||||
"""The drain must NOT sleep on the supervisor thread: a restart with live
|
||||
tasks defers (returns immediately), and a later loop-tick check completes
|
||||
it once tasks drain or the deadline passes."""
|
||||
import types
|
||||
|
||||
import server
|
||||
|
||||
monkeypatch.setenv("OUROBOROS_RESTART_DRAIN_MAX_SEC", "120")
|
||||
performed = []
|
||||
monkeypatch.setattr(server, "_perform_supervisor_restart", lambda ctx: performed.append(True))
|
||||
server._pending_restart.clear()
|
||||
|
||||
now = __import__("time").time()
|
||||
ctx = types.SimpleNamespace(
|
||||
RUNNING={"t1": {"task": {"id": "t1"}, "last_heartbeat_at": now}},
|
||||
load_state=lambda: {"owner_chat_id": 0},
|
||||
send_with_budget=lambda *a, **k: None,
|
||||
DRIVE_ROOT=tmp_path,
|
||||
)
|
||||
|
||||
# Live task -> defer, do NOT restart inline.
|
||||
server._handle_restart_in_supervisor({"reason": "evolution"}, ctx)
|
||||
assert performed == []
|
||||
assert server._pending_restart # recorded for the loop tick
|
||||
|
||||
# Tick while still live + before deadline -> keep waiting.
|
||||
server._check_pending_restart_drain(ctx)
|
||||
assert performed == []
|
||||
|
||||
# Task drained -> the next tick completes the restart.
|
||||
ctx.RUNNING = {}
|
||||
server._check_pending_restart_drain(ctx)
|
||||
assert performed == [True]
|
||||
assert not server._pending_restart
|
||||
|
||||
|
||||
def test_restart_drain_no_live_tasks_restarts_immediately(tmp_path, monkeypatch):
|
||||
import types
|
||||
|
||||
import server
|
||||
|
||||
monkeypatch.setenv("OUROBOROS_RESTART_DRAIN_MAX_SEC", "120")
|
||||
performed = []
|
||||
monkeypatch.setattr(server, "_perform_supervisor_restart", lambda ctx: performed.append(True))
|
||||
server._pending_restart.clear()
|
||||
|
||||
ctx = types.SimpleNamespace(
|
||||
RUNNING={},
|
||||
load_state=lambda: {"owner_chat_id": 0},
|
||||
send_with_budget=lambda *a, **k: None,
|
||||
DRIVE_ROOT=tmp_path,
|
||||
)
|
||||
server._handle_restart_in_supervisor({"reason": "x"}, ctx)
|
||||
assert performed == [True]
|
||||
assert not server._pending_restart
|
||||
|
||||
|
||||
def test_direct_chat_project_thread_skips_letters_home(tmp_path, monkeypatch):
|
||||
"""A project-thread CONVERSATION (direct chat) is project-scoped for context
|
||||
only: it must not block on post-processing or write journal/digest."""
|
||||
from ouroboros.project_lease import running_project_ids
|
||||
|
||||
# Sanity: a direct-chat task is never a lease occupant (no project lane),
|
||||
# and _is_direct_chat tasks are excluded from letters-home by the pipeline.
|
||||
direct = {"id": "d1", "type": "task", "project_id": "racer", "_is_direct_chat": True}
|
||||
# The lease only counts top-level project tasks; a direct-chat task still
|
||||
# carries project_id but the pipeline gates letters-home on _is_direct_chat.
|
||||
assert running_project_ids([{"task": direct}]) == {"racer"} # context scope is real
|
||||
# (full pipeline gating is covered by the agent_task_pipeline branch; this
|
||||
# pins the flag the branch reads.)
|
||||
assert direct.get("_is_direct_chat") is True
|
||||
|
||||
|
||||
def test_route_project_chat_ignores_non_registered_chat_ids(tmp_path):
|
||||
"""External-transport chat ids (large, non-project) must NOT be captured as
|
||||
project threads — only registered project chat_ids route to a task mailbox."""
|
||||
import types
|
||||
|
||||
import server
|
||||
from ouroboros.projects_registry import create_project
|
||||
|
||||
proj = create_project(tmp_path, "racer")
|
||||
project_chat = int(proj["chat_id"])
|
||||
transport_chat = 987654321 # Telegram-style id, NOT a project
|
||||
|
||||
ctx = types.SimpleNamespace(
|
||||
DRIVE_ROOT=tmp_path,
|
||||
RUNNING={
|
||||
"tp": {"task": {"id": "tp", "chat_id": transport_chat}, "last_heartbeat_at": 1.0},
|
||||
"pr": {"task": {"id": "pr", "chat_id": project_chat}, "last_heartbeat_at": 1.0},
|
||||
},
|
||||
)
|
||||
# Transport chat: not a project -> never routed (main free lane preserved).
|
||||
assert server._route_project_chat_to_running_task(ctx, transport_chat, "hi") == ""
|
||||
# Registered project chat with an active task -> routed to its mailbox.
|
||||
assert server._route_project_chat_to_running_task(ctx, project_chat, "steer") == "pr"
|
||||
|
||||
|
||||
def test_busy_direct_lane_project_chat_enqueues_pooled_task(tmp_path, monkeypatch):
|
||||
"""Hybrid B+: project chat must not wait behind the singleton direct-chat
|
||||
lock when the main direct lane is busy; it becomes a normal project task."""
|
||||
import server
|
||||
from ouroboros.projects_registry import create_project
|
||||
|
||||
proj = create_project(tmp_path, "market-research")
|
||||
project_chat = int(proj["chat_id"])
|
||||
enqueued = []
|
||||
sent = []
|
||||
|
||||
monkeypatch.setattr("supervisor.message_bus.log_chat", lambda *a, **k: None)
|
||||
monkeypatch.setattr("supervisor.state.load_state", lambda: {"budget_limit": 100, "spent_usd": 0})
|
||||
monkeypatch.setattr("supervisor.state.budget_remaining", lambda _state: 100)
|
||||
|
||||
class _Bridge:
|
||||
def get_updates(self, offset=0, timeout=0):
|
||||
return [{
|
||||
"update_id": offset,
|
||||
"message": {
|
||||
"chat": {"id": project_chat},
|
||||
"from": {"id": 1},
|
||||
"text": "сколько будет 2+2?",
|
||||
"source": "web",
|
||||
"task_metadata": {"project_id": "market-research"},
|
||||
},
|
||||
}]
|
||||
|
||||
class _Consciousness:
|
||||
def inject_observation(self, _text):
|
||||
return None
|
||||
|
||||
ctx = types.SimpleNamespace(
|
||||
DRIVE_ROOT=tmp_path,
|
||||
RUNNING={},
|
||||
load_state=lambda: {"owner_id": 1, "owner_chat_id": 1},
|
||||
update_state=lambda fn: fn({"owner_id": 1, "owner_chat_id": 1}),
|
||||
consciousness=_Consciousness(),
|
||||
get_chat_agent=lambda: types.SimpleNamespace(_busy=True),
|
||||
handle_chat_direct=lambda *a, **k: (_ for _ in ()).throw(AssertionError("direct chat should not block")),
|
||||
enqueue_task=lambda task: enqueued.append(task),
|
||||
send_with_budget=lambda *a, **k: sent.append((a, k)),
|
||||
)
|
||||
|
||||
assert server._process_bridge_updates(_Bridge(), 0, ctx) == 1
|
||||
assert len(enqueued) == 1
|
||||
task = enqueued[0]
|
||||
assert task["chat_id"] == project_chat
|
||||
assert task["project_id"] == "market-research"
|
||||
assert task["source"] == "project_chat_busy_fallback"
|
||||
assert task.get("_is_direct_chat") is None
|
||||
assert "сколько будет 2+2?" in task["text"]
|
||||
|
||||
|
||||
def test_project_from_task_endpoint_creates_binding(tmp_path):
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from ouroboros.gateway.projects import api_project_from_task
|
||||
from ouroboros.projects_registry import get_project, project_binding_for_task
|
||||
|
||||
class _Req:
|
||||
def __init__(self):
|
||||
self.app = types.SimpleNamespace(state=types.SimpleNamespace(drive_root=tmp_path, repo_dir=tmp_path))
|
||||
|
||||
async def json(self):
|
||||
return {"task_id": "abc123", "id": "task-abc123", "name": "Research thread"}
|
||||
|
||||
resp = asyncio.run(api_project_from_task(_Req()))
|
||||
payload = json.loads(resp.body)
|
||||
assert resp.status_code == 200
|
||||
assert payload["project"]["id"] == "task-abc123"
|
||||
assert payload["project"]["name"] == "Research thread"
|
||||
assert payload["binding"]["task_id"] == "abc123"
|
||||
assert get_project(tmp_path, "task-abc123") is not None
|
||||
assert project_binding_for_task(tmp_path, "abc123")["project_id"] == "task-abc123"
|
||||
|
||||
|
||||
def test_bound_project_history_backfills_task_progress(tmp_path):
|
||||
"""A task converted into a project after it started keeps its original log
|
||||
rows, but project history resolves them through the binding."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from ouroboros.gateway.history import make_chat_history_endpoint
|
||||
from ouroboros.projects_registry import bind_task_to_project, create_project
|
||||
|
||||
project = create_project(tmp_path, "bound-progress", name="Bound progress")
|
||||
project_chat = int(project["chat_id"])
|
||||
bind_task_to_project(tmp_path, "task-1", "bound-progress", project_chat)
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir(parents=True)
|
||||
with open(logs / "chat.jsonl", "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({"ts": "2026-01-01T00:00:00Z", "direction": "out", "text": "final answer", "chat_id": 1, "task_id": "task-1"}) + "\n")
|
||||
fh.write(json.dumps({"ts": "2026-01-01T00:00:01Z", "direction": "in", "text": "raw project chat", "chat_id": project_chat}) + "\n")
|
||||
with open(logs / "progress.jsonl", "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({"ts": "2026-01-01T00:00:02Z", "type": "send_message", "content": "working", "text": "working", "is_progress": True, "chat_id": 1, "task_id": "task-1", "format": "markdown"}) + "\n")
|
||||
|
||||
api = make_chat_history_endpoint(tmp_path)
|
||||
|
||||
class _Req:
|
||||
def __init__(self, params):
|
||||
self.query_params = params
|
||||
|
||||
project_resp = json.loads(asyncio.run(api(_Req({"chat_id": str(project_chat)}))).body)
|
||||
project_texts = [m["text"] for m in project_resp["messages"]]
|
||||
assert "final answer" in project_texts
|
||||
assert "working" in project_texts
|
||||
assert "raw project chat" in project_texts
|
||||
|
||||
main_resp = json.loads(asyncio.run(api(_Req({}))).body)
|
||||
main_texts = [m["text"] for m in main_resp["messages"]]
|
||||
assert "working" in main_texts # main mirrors sanitized progress
|
||||
assert "raw project chat" not in main_texts
|
||||
# The bound task's RAW final-answer row (still stored with main chat_id 1) is
|
||||
# project-owned via the binding and must NOT leak into the штаб's main history.
|
||||
assert "final answer" not in main_texts
|
||||
|
||||
|
||||
def test_bound_task_heartbeat_routes_to_project_panel(tmp_path):
|
||||
"""A post-hoc bound task's heartbeat routes to its PROJECT panel: the durable
|
||||
binding takes PRECEDENCE over the task's original (main) chat_id, matching the
|
||||
send_message/log handlers (UI routing for a "Turn into project" running task)."""
|
||||
import time
|
||||
|
||||
from ouroboros.projects_registry import bind_task_to_project, create_project
|
||||
from supervisor.events import _handle_task_heartbeat
|
||||
|
||||
project = create_project(tmp_path, "hb-proj")
|
||||
project_chat = int(project["chat_id"])
|
||||
bind_task_to_project(tmp_path, "task-hb", "hb-proj", project_chat)
|
||||
|
||||
pushed = []
|
||||
ctx = types.SimpleNamespace(
|
||||
DRIVE_ROOT=tmp_path,
|
||||
RUNNING={"task-hb": {"task": {"id": "task-hb", "type": "task", "chat_id": 1}, "started_at": time.time()}},
|
||||
bridge=types.SimpleNamespace(push_log=lambda payload: pushed.append(payload)),
|
||||
)
|
||||
_handle_task_heartbeat({"task_id": "task-hb", "phase": "running"}, ctx)
|
||||
assert pushed
|
||||
assert pushed[0]["chat_id"] == project_chat # binding precedence, not the original main 1
|
||||
|
||||
|
||||
def test_bound_task_media_routes_to_project_panel(tmp_path):
|
||||
"""A post-hoc bound task's media (send_photo/send_video) routes to its PROJECT
|
||||
panel via the durable binding, not the task's original (main) chat_id —
|
||||
same precedence as the send_message/log/heartbeat handlers."""
|
||||
import base64
|
||||
|
||||
from ouroboros.projects_registry import bind_task_to_project, create_project
|
||||
from supervisor.events import _handle_send_photo, _handle_send_video
|
||||
|
||||
project = create_project(tmp_path, "media-proj")
|
||||
project_chat = int(project["chat_id"])
|
||||
bind_task_to_project(tmp_path, "task-m", "media-proj", project_chat)
|
||||
|
||||
photo_sent, video_sent = [], []
|
||||
ctx = types.SimpleNamespace(
|
||||
DRIVE_ROOT=tmp_path,
|
||||
append_jsonl=lambda *a, **k: None,
|
||||
bridge=types.SimpleNamespace(
|
||||
send_photo=lambda cid, data, caption="", mime="": (photo_sent.append(cid) or (True, "")),
|
||||
send_video=lambda cid, data, caption="", mime="": (video_sent.append(cid) or (True, "")),
|
||||
),
|
||||
)
|
||||
blob = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"0" * 64).decode()
|
||||
_handle_send_photo({"task_id": "task-m", "chat_id": 1, "image_base64": blob, "mime": "image/png"}, ctx)
|
||||
_handle_send_video({"task_id": "task-m", "chat_id": 1, "video_base64": blob, "mime": "video/mp4"}, ctx)
|
||||
assert photo_sent == [project_chat] # binding precedence, not the original main 1
|
||||
assert video_sent == [project_chat]
|
||||
|
||||
|
||||
def test_bound_task_send_message_routes_future_events_to_project(tmp_path):
|
||||
from ouroboros.projects_registry import bind_task_to_project, create_project
|
||||
from supervisor.events import _handle_send_message
|
||||
|
||||
project = create_project(tmp_path, "future-events")
|
||||
project_chat = int(project["chat_id"])
|
||||
bind_task_to_project(tmp_path, "task-9", "future-events", project_chat)
|
||||
sent = []
|
||||
ctx = types.SimpleNamespace(
|
||||
DRIVE_ROOT=tmp_path,
|
||||
send_with_budget=lambda *args, **kwargs: sent.append((args, kwargs)),
|
||||
append_jsonl=lambda *a, **k: None,
|
||||
)
|
||||
_handle_send_message({
|
||||
"chat_id": 1,
|
||||
"task_id": "task-9",
|
||||
"text": "future progress",
|
||||
"is_progress": True,
|
||||
"format": "markdown",
|
||||
}, ctx)
|
||||
assert sent
|
||||
assert sent[0][0][0] == project_chat
|
||||
|
||||
|
||||
def test_chat_history_filters_by_thread(tmp_path):
|
||||
"""api_chat_history returns only the requested thread's rows."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from ouroboros.gateway.history import make_chat_history_endpoint
|
||||
|
||||
# Register a project so its chat_id partitions out of the main view; a
|
||||
# large NON-project chat_id (transport mirror) must STAY in the main view.
|
||||
from ouroboros.projects_registry import create_project
|
||||
|
||||
proj = create_project(tmp_path, "racer")
|
||||
project_chat = int(proj["chat_id"])
|
||||
transport_chat = 555000111
|
||||
|
||||
logs = tmp_path / "logs"
|
||||
logs.mkdir(parents=True)
|
||||
rows = [
|
||||
{"ts": "2026-06-13T00:00:01Z", "direction": "in", "text": "main hello", "chat_id": 1},
|
||||
{"ts": "2026-06-13T00:00:02Z", "direction": "out", "text": "main reply", "chat_id": 1},
|
||||
{"ts": "2026-06-13T00:00:03Z", "direction": "in", "text": "project hello", "chat_id": project_chat},
|
||||
{"ts": "2026-06-13T00:00:033Z", "direction": "system", "type": "task_summary", "text": "project summary", "chat_id": project_chat, "task_id": "pt"},
|
||||
{"ts": "2026-06-13T00:00:035Z", "direction": "in", "text": "transport mirror", "chat_id": transport_chat},
|
||||
{"ts": "2026-06-13T00:00:04Z", "direction": "out", "text": "a2a noise", "chat_id": -1001},
|
||||
{"ts": "2026-06-13T00:00:05Z", "direction": "out", "text": "legacy row (no chat_id)"},
|
||||
]
|
||||
with open(logs / "chat.jsonl", "w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row) + "\n")
|
||||
|
||||
api = make_chat_history_endpoint(tmp_path)
|
||||
|
||||
class _Req:
|
||||
def __init__(self, params):
|
||||
self.query_params = params
|
||||
|
||||
main = json.loads(asyncio.run(api(_Req({}))).body)
|
||||
main_texts = [m["text"] for m in main["messages"]]
|
||||
assert "main hello" in main_texts and "main reply" in main_texts
|
||||
assert "legacy row (no chat_id)" in main_texts # legacy rows are main-chat
|
||||
assert "transport mirror" in main_texts # non-project transport stays visible
|
||||
assert "project hello" not in main_texts # registered project partitions out
|
||||
assert "project summary" in main_texts # штаб mirrors project summaries/progress
|
||||
assert "a2a noise" not in main_texts
|
||||
|
||||
proj_resp = json.loads(asyncio.run(api(_Req({"chat_id": str(project_chat)}))).body)
|
||||
proj_texts = [m["text"] for m in proj_resp["messages"]]
|
||||
assert proj_texts and "project hello" in proj_texts
|
||||
assert "project summary" in proj_texts
|
||||
assert "main hello" not in proj_texts
|
||||
assert "transport mirror" not in proj_texts
|
||||
|
||||
|
||||
def test_project_media_and_typing_broadcasts_carry_chat_id():
|
||||
"""Photo/video/typing WS frames must carry chat_id so the client fan-out
|
||||
routes project-thread media to its panel (default-to-main would hide them)."""
|
||||
from supervisor.message_bus import LocalChatBridge
|
||||
|
||||
bridge = LocalChatBridge()
|
||||
frames = []
|
||||
bridge._broadcast_fn = lambda payload: frames.append(payload)
|
||||
|
||||
project_chat = 1234 # positive project-range id (not A2A, which is negative)
|
||||
bridge.send_chat_action(project_chat, "typing")
|
||||
bridge.send_photo(project_chat, b"img-bytes", caption="shot")
|
||||
bridge.send_video(project_chat, b"vid-bytes", caption="clip", mime="video/mp4")
|
||||
|
||||
by_type = {f.get("type"): f for f in frames}
|
||||
assert by_type["typing"]["chat_id"] == project_chat
|
||||
assert by_type["photo"]["chat_id"] == project_chat
|
||||
assert by_type["video"]["chat_id"] == project_chat
|
||||
|
||||
|
||||
def test_journal_write_rejects_over_limit_instead_of_truncating(tmp_path, monkeypatch):
|
||||
"""A durable journal entry is never silently sliced: over-limit writes are
|
||||
rejected (the workpad_write contract), so cognitive memory stays whole."""
|
||||
import types
|
||||
|
||||
# Project store paths resolve via config.DATA_DIR (NOT ctx.drive_root); isolate
|
||||
# it to tmp_path so a plain local pytest run (no OUROBOROS_DATA_DIR set) never
|
||||
# writes into the real data dir.
|
||||
monkeypatch.setattr("ouroboros.config.DATA_DIR", tmp_path)
|
||||
from ouroboros.tools.project_journal import _MAX_TEXT_CHARS, _journal_read, _journal_write
|
||||
|
||||
ctx = types.SimpleNamespace(project_id="journal-reject-test", task_id="t1", drive_root=tmp_path)
|
||||
assert _journal_write(ctx, "note", "hello milestone", "").startswith("OK:")
|
||||
over = _journal_write(ctx, "note", "Z" * (_MAX_TEXT_CHARS + 50), "")
|
||||
assert "TOOL_ARG_ERROR" in over and "exceeds" in over
|
||||
body = _journal_read(ctx, "", 30)
|
||||
assert "hello milestone" in body
|
||||
assert "Z" * 200 not in body # the rejected over-limit text was never stored
|
||||
|
|
@ -118,6 +118,8 @@ EXPECTED_TOOLS = [
|
|||
"list_github_issues", "get_github_issue", "comment_on_issue",
|
||||
"close_github_issue", "create_github_issue",
|
||||
"codebase_health", "knowledge_read", "knowledge_write", "knowledge_list",
|
||||
"journal_read", "journal_write", "workpad_read", "workpad_write",
|
||||
"promote_chat_to_task",
|
||||
"memory_map", "memory_update_registry",
|
||||
"plan_task", "recent_tasks", "task_acceptance_review", "web_search",
|
||||
"start_service", "service_status", "service_logs", "stop_service",
|
||||
|
|
|
|||
|
|
@ -141,3 +141,18 @@ def test_visible_repair_command_can_retry_after_short_dedupe_window(monkeypatch)
|
|||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_scoped_task_metadata_derives_project_from_chat_id():
|
||||
"""chat_id is the SSOT for thread→project (full project awareness, v6.32.0):
|
||||
a registered project chat scopes task_metadata to its OWN project, OVERRIDING
|
||||
any client-supplied project_id; a non-project chat DROPS an untrusted client
|
||||
project_id; metadata is otherwise preserved (None stays None)."""
|
||||
# Registered project chat: override a mismatched client project_id.
|
||||
assert server._scoped_task_metadata("proj_a", {"project_id": "proj_b", "x": 1}) == {"project_id": "proj_a", "x": 1}
|
||||
# Registered project chat with no client value: set it.
|
||||
assert server._scoped_task_metadata("proj_a", None) == {"project_id": "proj_a"}
|
||||
# Non-project chat: drop an untrusted client project_id, keep the rest.
|
||||
assert server._scoped_task_metadata("", {"project_id": "proj_b", "x": 1}) == {"x": 1}
|
||||
# Non-project chat, nothing to scope: unchanged (None preserved).
|
||||
assert server._scoped_task_metadata("", None) is None
|
||||
|
|
|
|||
272
web/app.js
272
web/app.js
|
|
@ -1,8 +1,9 @@
|
|||
/** Web UI orchestrator: shared state, navigation, page init, WS startup. */
|
||||
|
||||
import { createWS } from './modules/ws.js';
|
||||
import { apiFetch } from './modules/api_client.js';
|
||||
import { loadVersion, initMatrixRain } from './modules/utils.js';
|
||||
import { initChat } from './modules/chat.js';
|
||||
import { initChat, createChatInstance } from './modules/chat.js';
|
||||
import { initFiles } from './modules/files.js';
|
||||
|
||||
import { initLogs } from './modules/logs.js';
|
||||
|
|
@ -27,6 +28,10 @@ const state = {
|
|||
settingsActiveSubtab: 'providers',
|
||||
dashboardActiveSubtab: 'logs',
|
||||
beforePageLeave: null,
|
||||
// Project-thread isolation SSOT for the live WS fan-out. Initialized to an
|
||||
// empty Set (never undefined) so chat.js::isMyThread is deterministic before
|
||||
// the first /api/state response; populated by renderProjectsNav.
|
||||
projectChatIds: new Set(),
|
||||
};
|
||||
|
||||
// Connect only after modules register listeners.
|
||||
|
|
@ -34,23 +39,53 @@ const ws = createWS();
|
|||
const beforePageLeaveHandlers = [];
|
||||
let settingsControls = null;
|
||||
let dashboardControls = null;
|
||||
const navState = {
|
||||
activeProjectId: null,
|
||||
projectsExpanded: true,
|
||||
mobileDrawerOpen: false,
|
||||
};
|
||||
const primarySidebar = document.getElementById('primary-sidebar');
|
||||
const navDrawerBackdrop = document.getElementById('nav-drawer-backdrop');
|
||||
const projectPanelBackdrop = document.getElementById('project-panel-backdrop');
|
||||
const projectPanel = document.getElementById('project-panel');
|
||||
const projectPanelBody = document.getElementById('project-panel-body');
|
||||
const projectPanelTitle = document.getElementById('project-panel-title');
|
||||
const navProjects = document.getElementById('nav-projects');
|
||||
const navProjectsToggle = document.getElementById('nav-projects-toggle');
|
||||
const navProjectsCount = document.getElementById('nav-projects-count');
|
||||
const navProjectsList = document.getElementById('nav-projects-list');
|
||||
const projectInstances = new Map();
|
||||
let knownProjectsJson = '';
|
||||
let lastProjectRows = [];
|
||||
let projectPanelHideTimer = null;
|
||||
|
||||
async function showPage(name) {
|
||||
if (state.activePage === name) return;
|
||||
for (const handler of beforePageLeaveHandlers) {
|
||||
const canLeave = await handler({ from: state.activePage, to: name });
|
||||
if (canLeave === false) return;
|
||||
}
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(`page-${name}`)?.classList.add('active');
|
||||
document.querySelector(`.nav-btn[data-page="${name}"]`)?.classList.add('active');
|
||||
state.activePage = name;
|
||||
window.dispatchEvent(new CustomEvent('ouro:page-shown', { detail: { page: name } }));
|
||||
if (name === 'chat') {
|
||||
state.unreadCount = 0;
|
||||
updateUnreadBadge();
|
||||
function setMobileDrawerOpen(open, { sync = true } = {}) {
|
||||
navState.mobileDrawerOpen = Boolean(open);
|
||||
if (sync) syncNavigationState();
|
||||
}
|
||||
|
||||
async function showPage(name, options = {}) {
|
||||
const pageName = String(name || '').trim();
|
||||
if (!pageName) return false;
|
||||
const changingPage = state.activePage !== pageName;
|
||||
if (changingPage) {
|
||||
for (const handler of beforePageLeaveHandlers) {
|
||||
const canLeave = await handler({ from: state.activePage, to: pageName });
|
||||
if (canLeave === false) return false;
|
||||
}
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById(`page-${pageName}`)?.classList.add('active');
|
||||
state.activePage = pageName;
|
||||
window.dispatchEvent(new CustomEvent('ouro:page-shown', { detail: { page: pageName } }));
|
||||
if (pageName === 'chat') {
|
||||
state.unreadCount = 0;
|
||||
updateUnreadBadge();
|
||||
}
|
||||
}
|
||||
if (options.closeProject !== false) closeProjectPanel({ sync: false });
|
||||
if (options.closeDrawer !== false) navState.mobileDrawerOpen = false;
|
||||
syncNavigationState();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openSettingsTab(tabName) {
|
||||
|
|
@ -68,7 +103,7 @@ async function openDashboardTab(tabName) {
|
|||
}
|
||||
|
||||
function updateUnreadBadge() {
|
||||
const btn = document.querySelector('.nav-btn[data-page="chat"]');
|
||||
const btn = document.querySelector('[data-nav-page="chat"]');
|
||||
let badge = btn?.querySelector('.unread-badge');
|
||||
if (state.unreadCount > 0 && state.activePage !== 'chat') {
|
||||
if (!badge) {
|
||||
|
|
@ -82,11 +117,67 @@ function updateUnreadBadge() {
|
|||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.nav-btn').forEach(btn => {
|
||||
function syncNavigationState() {
|
||||
const activeProjectId = navState.activeProjectId;
|
||||
const drawerOpen = Boolean(navState.mobileDrawerOpen);
|
||||
document.body.classList.toggle('nav-drawer-open', drawerOpen);
|
||||
primarySidebar?.classList.toggle('open', drawerOpen);
|
||||
document.querySelectorAll('[data-mobile-nav-toggle]').forEach((button) => {
|
||||
button.setAttribute('aria-expanded', drawerOpen ? 'true' : 'false');
|
||||
});
|
||||
if (navDrawerBackdrop) navDrawerBackdrop.hidden = !drawerOpen;
|
||||
|
||||
document.querySelectorAll('[data-nav-page]').forEach((button) => {
|
||||
const isActive = !activeProjectId && button.dataset.navPage === state.activePage;
|
||||
button.classList.toggle('active', isActive);
|
||||
if (isActive) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
navProjectsToggle?.classList.toggle('active', Boolean(activeProjectId));
|
||||
navProjectsToggle?.setAttribute('aria-expanded', navState.projectsExpanded ? 'true' : 'false');
|
||||
navProjectsList.hidden = !navState.projectsExpanded;
|
||||
document.querySelectorAll('[data-project-id]').forEach((button) => {
|
||||
const isActive = button.dataset.projectId === activeProjectId;
|
||||
button.classList.toggle('active', isActive);
|
||||
if (isActive) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
if (projectPanel) {
|
||||
if (projectPanelHideTimer) {
|
||||
clearTimeout(projectPanelHideTimer);
|
||||
projectPanelHideTimer = null;
|
||||
}
|
||||
if (activeProjectId) {
|
||||
projectPanel.hidden = false;
|
||||
if (projectPanelBackdrop) projectPanelBackdrop.hidden = false;
|
||||
requestAnimationFrame(() => {
|
||||
projectPanel.classList.add('open');
|
||||
projectPanelBackdrop?.classList.add('open');
|
||||
});
|
||||
} else {
|
||||
projectPanel.classList.remove('open');
|
||||
projectPanelBackdrop?.classList.remove('open');
|
||||
projectPanelHideTimer = setTimeout(() => {
|
||||
projectPanel.hidden = true;
|
||||
if (projectPanelBackdrop) projectPanelBackdrop.hidden = true;
|
||||
projectPanelHideTimer = null;
|
||||
}, 220);
|
||||
}
|
||||
document.body.classList.toggle('project-panel-open', Boolean(activeProjectId));
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-nav-page]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
showPage(btn.dataset.page);
|
||||
showPage(btn.dataset.navPage);
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', (event) => {
|
||||
const toggle = event.target.closest('[data-mobile-nav-toggle]');
|
||||
if (!toggle) return;
|
||||
setMobileDrawerOpen(!navState.mobileDrawerOpen);
|
||||
});
|
||||
navDrawerBackdrop?.addEventListener('click', () => setMobileDrawerOpen(false));
|
||||
hydrateNavIcons();
|
||||
|
||||
const ctx = {
|
||||
|
|
@ -108,6 +199,138 @@ const ctx = {
|
|||
|
||||
initChat(ctx);
|
||||
initFiles(ctx);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-project navigation + right thread panel (v6.32.0). Projects come from
|
||||
// /api/state; each opens as a chat instance bound to its project chat_id.
|
||||
// Navigation is one state machine now: page, project, and mobile drawer are
|
||||
// synchronized together so Utilities and Projects can't remain active at once.
|
||||
// ---------------------------------------------------------------------------
|
||||
function closeProjectPanel({ sync = true } = {}) {
|
||||
navState.activeProjectId = null;
|
||||
for (const inst of projectInstances.values()) inst.page.hidden = true;
|
||||
if (sync) syncNavigationState();
|
||||
}
|
||||
|
||||
async function openProjectPanel(project, { closeDrawer = true } = {}) {
|
||||
if (!project?.id) return;
|
||||
if (navState.activeProjectId === project.id) {
|
||||
closeProjectPanel();
|
||||
return;
|
||||
}
|
||||
const movedToChat = await showPage('chat', { closeProject: false, closeDrawer: false });
|
||||
if (movedToChat === false) return;
|
||||
navState.activeProjectId = project.id;
|
||||
projectPanelTitle.textContent = project.name || project.id;
|
||||
let inst = projectInstances.get(project.id);
|
||||
if (!inst) {
|
||||
inst = createChatInstance({
|
||||
...ctx,
|
||||
chatId: Number(project.chat_id) || 1,
|
||||
projectId: project.id,
|
||||
idPrefix: `pchat-${project.id}`,
|
||||
mountEl: projectPanelBody,
|
||||
asPanel: true,
|
||||
title: project.name || project.id,
|
||||
});
|
||||
projectInstances.set(project.id, inst);
|
||||
}
|
||||
for (const [pid, other] of projectInstances) other.page.hidden = pid !== project.id;
|
||||
if (closeDrawer) navState.mobileDrawerOpen = false;
|
||||
syncNavigationState();
|
||||
}
|
||||
|
||||
document.getElementById('project-panel-close')?.addEventListener('click', () => closeProjectPanel());
|
||||
projectPanelBackdrop?.addEventListener('click', () => closeProjectPanel());
|
||||
navProjectsToggle?.addEventListener('click', () => {
|
||||
navState.projectsExpanded = !navState.projectsExpanded;
|
||||
syncNavigationState();
|
||||
});
|
||||
|
||||
function renderProjectsNav(projects, projectChatIds) {
|
||||
const all = projects || [];
|
||||
// Isolation fan-out SSOT: recognize EVERY registered project chat_id (incl.
|
||||
// archived / file-less / no-activity / beyond the sidebar summary cap),
|
||||
// matching the backend registered_project_chat_ids, so chat.js::isMyThread
|
||||
// never treats a project frame as a main-thread frame on the live WS path.
|
||||
// Prefer the COMPLETE /api/state `project_chat_ids` (uncapped); fall back to
|
||||
// the (capped) projects array only if that field is absent. Sidebar
|
||||
// visibility is a SEPARATE concern (the filtered `rows` below).
|
||||
const completeChatIds = Array.isArray(projectChatIds)
|
||||
? projectChatIds
|
||||
: all.map(p => Number(p && p.chat_id) || 0);
|
||||
state.projectChatIds = new Set(completeChatIds.map(Number).filter(Boolean));
|
||||
const rows = all
|
||||
.filter(p => p && p.id && p.status !== 'archived' && p.has_thread_activity !== false)
|
||||
.sort((a, b) => String(b.last_active_at || b.updated_at || b.created_at || '')
|
||||
.localeCompare(String(a.last_active_at || a.updated_at || a.created_at || '')));
|
||||
const json = JSON.stringify(rows.map(p => [p.id, p.name, p.status, p.chat_id]));
|
||||
if (json === knownProjectsJson) return;
|
||||
knownProjectsJson = json;
|
||||
lastProjectRows = rows;
|
||||
paintProjectsNav();
|
||||
syncNavigationState();
|
||||
}
|
||||
|
||||
// Paint the collapsible, scrollable projects list from the cached rows.
|
||||
function paintProjectsNav() {
|
||||
const rows = lastProjectRows;
|
||||
navProjectsList.textContent = '';
|
||||
navProjects.hidden = false;
|
||||
if (navProjectsCount) navProjectsCount.textContent = rows.length ? String(rows.length) : '';
|
||||
for (const project of rows) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'nav-row nav-project-row';
|
||||
btn.dataset.projectId = project.id;
|
||||
btn.title = `${project.name || project.id} (${project.status})`;
|
||||
const label = document.createElement('span');
|
||||
label.className = 'nav-row-label';
|
||||
label.textContent = project.name || project.id;
|
||||
btn.appendChild(label);
|
||||
if (project.status === 'sleeping') {
|
||||
btn.classList.add('sleeping');
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'nav-row-meta';
|
||||
meta.textContent = 'sleep';
|
||||
btn.appendChild(meta);
|
||||
}
|
||||
if (project.id === navState.activeProjectId) btn.classList.add('active');
|
||||
btn.addEventListener('click', () => openProjectPanel(project));
|
||||
navProjectsList.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProjectsNav() {
|
||||
try {
|
||||
const resp = await apiFetch('/api/state', { cache: 'no-store' });
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
renderProjectsNav(data.projects || [], data.project_chat_ids);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
window.addEventListener('ouro:project-created', async (event) => {
|
||||
const project = event?.detail?.project;
|
||||
knownProjectsJson = '';
|
||||
await refreshProjectsNav();
|
||||
if (project?.id) {
|
||||
const resolved = lastProjectRows.find((item) => item.id === project.id) || project;
|
||||
openProjectPanel(resolved);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('open', refreshProjectsNav);
|
||||
// A backend-created project (e.g. the agent's promote_chat_to_task tool) pushes
|
||||
// this so the live WS fan-out learns the new project chat_id immediately, instead
|
||||
// of waiting for the periodic poll and misrouting early frames into the main chat.
|
||||
// Add the chat_id SYNCHRONOUSLY from the payload so fan-out is correct before the
|
||||
// async /api/state refresh returns; then refresh the full nav/list.
|
||||
ws.on('projects_changed', (msg) => {
|
||||
const cid = Number(msg && msg.chat_id) || 0;
|
||||
if (cid) state.projectChatIds.add(cid);
|
||||
refreshProjectsNav();
|
||||
});
|
||||
setInterval(refreshProjectsNav, 20000);
|
||||
settingsControls = initSettings(ctx);
|
||||
dashboardControls = initDashboard(ctx);
|
||||
initLogs({ ...ctx, mount: document.getElementById('dashboard-panel-logs') });
|
||||
|
|
@ -121,6 +344,7 @@ initOnboardingOverlay();
|
|||
|
||||
initMatrixRain();
|
||||
loadVersion();
|
||||
syncNavigationState();
|
||||
|
||||
// Mobile soft-keyboard handling: --vvh + keyboard-open without inline styles.
|
||||
(function () {
|
||||
|
|
@ -135,9 +359,13 @@ loadVersion();
|
|||
function findScrollableKeyboardNode(target) {
|
||||
let el = target;
|
||||
while (el && el !== document.body) {
|
||||
// Class twins cover secondary chat instances (project panels);
|
||||
// the main chat keeps its historic ids.
|
||||
if (
|
||||
el.id === 'chat-messages'
|
||||
|| el.id === 'chat-input'
|
||||
|| el.classList?.contains('chat-messages')
|
||||
|| el.classList?.contains('chat-input')
|
||||
|| el.classList?.contains('chat-live-timeline')
|
||||
) return el;
|
||||
el = el.parentElement;
|
||||
|
|
@ -225,4 +453,8 @@ loadVersion();
|
|||
updateVvh();
|
||||
}());
|
||||
|
||||
ws.connect();
|
||||
// Populate the project-thread isolation set BEFORE opening the socket so the live
|
||||
// fan-out never misclassifies an early project frame as main-chat traffic during
|
||||
// startup (chat.js::isMyThread relies on state.projectChatIds). Connect even if
|
||||
// the prefetch fails, then ws.on('open') keeps it fresh.
|
||||
refreshProjectsNav().finally(() => ws.connect());
|
||||
|
|
|
|||
|
|
@ -13,40 +13,67 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav id="nav-rail">
|
||||
<div id="nav-logo"></div>
|
||||
<button class="nav-btn active" data-page="chat" title="Chat">
|
||||
<div id="nav-drawer-backdrop" class="nav-drawer-backdrop" hidden></div>
|
||||
<aside id="primary-sidebar" class="primary-sidebar" aria-label="Ouroboros navigation">
|
||||
<div class="sidebar-scroll">
|
||||
<button class="nav-row nav-row-main active" data-nav-page="chat" title="Main Ouroboros chat">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/><path d="M7 11h10"/><path d="M7 15h6"/><path d="M7 7h8"/></svg>
|
||||
<span>Chat</span>
|
||||
<span class="nav-row-label">Main Chat</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="files" title="Files">
|
||||
<section id="nav-projects" class="nav-section nav-projects" hidden>
|
||||
<button id="nav-projects-toggle" class="nav-section-toggle" type="button" aria-expanded="true" aria-controls="nav-projects-list">
|
||||
<span class="nav-row-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h5l2 2h11v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7V5a2 2 0 0 1 2-2h3l2 2h5"/></svg>
|
||||
</span>
|
||||
<span class="nav-row-label">Projects</span>
|
||||
<span id="nav-projects-count" class="nav-row-meta"></span>
|
||||
<span class="nav-chevron" aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
<div id="nav-projects-list" class="nav-projects-list"></div>
|
||||
</section>
|
||||
<div class="nav-section nav-utilities">
|
||||
<div class="nav-section-label">Utilities</div>
|
||||
<button class="nav-row" data-nav-page="files" title="Files">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>
|
||||
<span>Files</span>
|
||||
<span class="nav-row-label">Files</span>
|
||||
</button>
|
||||
|
||||
<button class="nav-btn" data-page="skills" title="Skills">
|
||||
<button class="nav-row" data-nav-page="skills" title="Skills">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg>
|
||||
<span>Skills</span>
|
||||
<span class="nav-row-label">Skills</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="widgets" title="Widgets">
|
||||
<button class="nav-row" data-nav-page="widgets" title="Widgets">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
<span>Widgets</span>
|
||||
<span class="nav-row-label">Widgets</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="dashboard" title="Dashboard">
|
||||
<button class="nav-row" data-nav-page="dashboard" title="Dashboard">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
|
||||
<span>Dashboard</span>
|
||||
<span class="nav-row-label">Dashboard</span>
|
||||
</button>
|
||||
<button class="nav-btn" data-page="settings" title="Settings">
|
||||
<button class="nav-row" data-nav-page="settings" title="Settings">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span>Settings</span>
|
||||
<span class="nav-row-label">Settings</span>
|
||||
</button>
|
||||
<div class="nav-spacer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="nav-version"></span>
|
||||
</nav>
|
||||
</aside>
|
||||
<div id="divider"></div>
|
||||
<main id="content">
|
||||
<!-- Pages injected by JS -->
|
||||
</main>
|
||||
<div id="project-panel-backdrop" class="project-panel-backdrop" hidden></div>
|
||||
<aside id="project-panel" class="project-panel" hidden>
|
||||
<div class="project-panel-bar app-page-header project-inspector-header">
|
||||
<div class="app-page-title-block">
|
||||
<div class="app-page-title-row">
|
||||
<h2 id="project-panel-title" class="project-panel-title app-page-title"></h2>
|
||||
</div>
|
||||
</div>
|
||||
<button id="project-panel-close" class="project-panel-close" type="button" title="Close project thread" aria-label="Close project thread">×</button>
|
||||
</div>
|
||||
<div id="project-panel-body" class="project-panel-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<div id="reconnect-overlay">
|
||||
<div class="spinner"></div>
|
||||
|
|
|
|||
|
|
@ -82,4 +82,5 @@ export const apiClient = {
|
|||
}),
|
||||
skillGrants: (skill, items) => jsonPost(`/api/skills/${encodeURIComponent(skill)}/grants`, { items }),
|
||||
chatHistory: (limit = 1000) => fetchJson(`/api/chat/history?limit=${encodeURIComponent(limit)}`, { cache: 'no-store' }),
|
||||
projectFromTask: (taskId, id, name) => jsonPost('/api/projects/from-task', { task_id: taskId, id, name }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
* @property {string} context_mode
|
||||
* @property {boolean} skills_repo_configured
|
||||
* @property {boolean} github_token_configured
|
||||
* @property {Array<Object>} projects // [{id, name, status, chat_id, working_dir, last_active_at}] (v6.32.0)
|
||||
* @property {Array<number>} project_chat_ids // complete (uncapped, all-status) project chat_ids — WS fan-out isolation SSOT (v6.32.0)
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -62,6 +64,8 @@
|
|||
* @property {string=} client_message_id
|
||||
* @property {boolean=} force_plan
|
||||
* @property {Array<Object>=} attachments // [{filename, display_name, mime}] — image uploads become native blocks (v6.26.0)
|
||||
* @property {number=} chat_id // multi-project thread routing (v6.32.0); main chat = 1
|
||||
* @property {string=} project_id // per-project memory scope (v6.32.0)
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -158,6 +162,14 @@
|
|||
* @typedef {Object} LogOutbound
|
||||
* @property {"log"} type
|
||||
* @property {Object} data
|
||||
* @property {number=} chat_id // multi-project thread routing (v6.32.0); main chat = 1
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProjectsChangedOutbound
|
||||
* @property {"projects_changed"} type
|
||||
* @property {string=} project_id
|
||||
* @property {number=} chat_id // new project thread; client learns it before /api/state (v6.32.0)
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -314,4 +326,4 @@
|
|||
* @property {boolean=} ok
|
||||
*/
|
||||
|
||||
export const GATEWAY_CONTRACT_VERSION = '6.31.0';
|
||||
export const GATEWAY_CONTRACT_VERSION = '6.32.0';
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ function getOrCreateChatSessionId() {
|
|||
}
|
||||
}
|
||||
|
||||
function projectIdFromTask(taskId = '') {
|
||||
const seed = String(taskId || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_.-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return (seed ? `task-${seed}` : `task-${Date.now().toString(36)}`).slice(0, 64);
|
||||
}
|
||||
|
||||
function loadInputHistory() {
|
||||
try {
|
||||
const raw = JSON.parse(sessionStorage.getItem(CHAT_INPUT_HISTORY_KEY) || '[]');
|
||||
|
|
@ -47,26 +55,49 @@ function saveInputHistory(entries) {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDashboardTab }) {
|
||||
const container = document.getElementById('content');
|
||||
export function initChat(ctx) {
|
||||
// Back-compat main-chat entry: one full-page instance bound to chat 1.
|
||||
return createChatInstance(ctx);
|
||||
}
|
||||
|
||||
export function createChatInstance({
|
||||
ws, state, updateUnreadBadge, openSettingsTab, openDashboardTab,
|
||||
chatId = 1, projectId = '', idPrefix = 'chat', mountEl = null,
|
||||
asPanel = false, title = 'Chat',
|
||||
}) {
|
||||
const container = mountEl || document.getElementById('content');
|
||||
const chatSessionId = getOrCreateChatSessionId();
|
||||
const isMain = chatId === 1;
|
||||
// Per-thread storage so a project thread never bleeds into the main chat.
|
||||
const storeKey = (base) => (isMain ? base : `${base}:${chatId}`);
|
||||
|
||||
const page = document.createElement('div');
|
||||
page.id = 'page-chat';
|
||||
page.className = 'page active';
|
||||
page.innerHTML = `
|
||||
${renderPageHeader({
|
||||
title: 'Chat',
|
||||
page.id = asPanel ? `panel-${idPrefix}` : 'page-chat';
|
||||
page.className = asPanel ? 'chat-instance-panel' : 'page active';
|
||||
// A project panel reuses the lean `.project-panel-bar` (title + close) from
|
||||
// index.html, so it renders a minimal status-only header — NOT the overlay
|
||||
// page header (that would duplicate the title and drag in the GLOBAL
|
||||
// Evolve/Restart/Panic/budget chrome, which belongs to the one agent, not a
|
||||
// single project thread). The main chat keeps the full overlay header.
|
||||
const headerHtml = asPanel
|
||||
? `<div class="chat-panel-statusbar"><span id="chat-status" class="status-badge offline">Connecting...</span></div>`
|
||||
: renderPageHeader({
|
||||
title: title,
|
||||
icon: PAGE_ICONS.chat,
|
||||
variant: 'overlay',
|
||||
className: 'chat-page-header',
|
||||
actionsHtml: `
|
||||
<div class="chat-header-actions" id="chat-header-actions">
|
||||
<button class="chat-header-btn" type="button" data-chat-command="evolve" title="Toggle evolution mode">Evolve</button>
|
||||
<button class="chat-header-btn" type="button" data-chat-command="bg" title="Toggle background consciousness">Consciousness</button>
|
||||
<button class="chat-header-btn" type="button" data-chat-command="review" title="Run review now">Review</button>
|
||||
<button class="chat-header-btn" type="button" data-chat-command="restart" title="Restart agent">Restart</button>
|
||||
<button class="chat-header-btn danger" type="button" data-chat-command="panic" title="Stop all workers">Panic</button>
|
||||
<details class="chat-header-more">
|
||||
<summary class="chat-header-btn" title="More agent controls">More</summary>
|
||||
<div class="chat-header-menu">
|
||||
<button class="chat-header-menu-item" type="button" data-chat-command="bg" title="Toggle background consciousness">Consciousness</button>
|
||||
<button class="chat-header-menu-item" type="button" data-chat-command="evolve" title="Toggle evolution mode">Evolve</button>
|
||||
<button class="chat-header-menu-item" type="button" data-chat-command="review" title="Run review now">Review</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<button class="chat-budget-pill" id="chat-budget-pill" type="button" title="Open budget controls" aria-label="Open budget controls">
|
||||
<span class="chat-budget-text" id="chat-budget-text">$0 / $0</span>
|
||||
|
|
@ -76,7 +107,9 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
</button>
|
||||
<span id="chat-status" class="status-badge offline">Connecting...</span>
|
||||
`,
|
||||
})}
|
||||
});
|
||||
page.innerHTML = `
|
||||
${headerHtml}
|
||||
<div id="chat-messages"></div>
|
||||
<div id="chat-input-area">
|
||||
<div id="chat-attachment-preview" class="chat-attachment-preview"></div>
|
||||
|
|
@ -103,18 +136,29 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
</div>
|
||||
</div>
|
||||
`;
|
||||
if (idPrefix !== 'chat') {
|
||||
// Instance-namespaced ids + mirror classes so the shared #chat-* CSS
|
||||
// (extended with .chat-* twins) keeps styling secondary instances.
|
||||
page.querySelectorAll('[id]').forEach((el) => {
|
||||
if (el.id.startsWith('chat-')) {
|
||||
el.classList.add(el.id);
|
||||
el.id = idPrefix + '-' + el.id.slice(5);
|
||||
}
|
||||
});
|
||||
}
|
||||
container.appendChild(page);
|
||||
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
const input = document.getElementById('chat-input');
|
||||
const inputArea = document.getElementById('chat-input-area');
|
||||
const sendBtn = document.getElementById('chat-send');
|
||||
const statusBadge = document.getElementById('chat-status');
|
||||
const headerActions = document.getElementById('chat-header-actions');
|
||||
const budgetPill = document.getElementById('chat-budget-pill');
|
||||
const attachBtn = document.getElementById('chat-attach');
|
||||
const fileInput = document.getElementById('chat-file-input');
|
||||
const attachmentPreview = document.getElementById('chat-attachment-preview');
|
||||
const byId = (suffix) => page.querySelector(`[id="${idPrefix}-${suffix}"]`);
|
||||
const messagesDiv = byId('messages');
|
||||
const input = byId('input');
|
||||
const inputArea = byId('input-area');
|
||||
const sendBtn = byId('send');
|
||||
const statusBadge = byId('status');
|
||||
const headerActions = byId('header-actions');
|
||||
const budgetPill = byId('budget-pill');
|
||||
const attachBtn = byId('attach');
|
||||
const fileInput = byId('file-input');
|
||||
const attachmentPreview = byId('attachment-preview');
|
||||
let pendingAttachments = [];
|
||||
let attachmentsUploading = false;
|
||||
let nestedSubagentsExpanded = false;
|
||||
|
|
@ -458,7 +502,14 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
if (data?.bg_consciousness_state?.detail) button.title = data.bg_consciousness_state.detail;
|
||||
}
|
||||
});
|
||||
const ctxBtn = document.getElementById('chat-context-mode');
|
||||
// Evolve/Consciousness now live inside the More menu; surface a small dot
|
||||
// on the More summary so an active mode stays visible without opening it.
|
||||
const moreSummary = headerActions?.querySelector('.chat-header-more > summary');
|
||||
if (moreSummary) {
|
||||
const anyActive = !!data?.evolution_enabled || !!data?.bg_consciousness_enabled;
|
||||
moreSummary.classList.toggle('has-active', anyActive);
|
||||
}
|
||||
const ctxBtn = byId('context-mode');
|
||||
if (ctxBtn && typeof data?.context_mode === 'string') {
|
||||
ctxBtn.dataset.contextMode = data.context_mode === 'low' ? 'low' : 'max';
|
||||
}
|
||||
|
|
@ -467,8 +518,8 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
const budgetLabel = typeof data?.budget_text === 'string'
|
||||
? data.budget_text
|
||||
: `${formatUsdWhole(spent)} / ${formatUsdWhole(limit)}`;
|
||||
const budgetText = document.getElementById('chat-budget-text');
|
||||
const budgetFill = document.getElementById('chat-budget-bar-fill');
|
||||
const budgetText = byId('budget-text');
|
||||
const budgetFill = byId('budget-bar-fill');
|
||||
if (budgetText) budgetText.textContent = budgetLabel;
|
||||
if (budgetFill) budgetFill.style.width = `${Math.min(100, (spent / limit) * 100)}%`;
|
||||
}
|
||||
|
|
@ -484,7 +535,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
|
||||
function persistVisibleHistory() {
|
||||
try {
|
||||
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(persistedHistory.slice(-200)));
|
||||
sessionStorage.setItem(storeKey(CHAT_STORAGE_KEY), JSON.stringify(persistedHistory.slice(-200)));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
@ -502,7 +553,9 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
if (shouldStick) messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
return;
|
||||
}
|
||||
const typing = document.getElementById('typing-indicator');
|
||||
// Scope to THIS instance's column — a global id lookup would resolve to
|
||||
// the first panel's typing node and misplace project-thread messages.
|
||||
const typing = messagesDiv.querySelector('.typing-bubble');
|
||||
if (typing && typing.parentNode === messagesDiv) messagesDiv.insertBefore(node, typing);
|
||||
else messagesDiv.appendChild(node);
|
||||
if (shouldStick) messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
|
|
@ -669,6 +722,40 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
applyLiveCardState(summary, resolvedTaskId, ts, dedupeKey);
|
||||
}
|
||||
|
||||
async function turnTaskIntoProject(record) {
|
||||
if (!record || record.root?.dataset?.projectCreating === '1') return;
|
||||
const taskId = String(record.groupId || '').trim();
|
||||
const fallbackName = (record.titleEl?.textContent || record.lastHumanHeadline || taskId || 'New project')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 80);
|
||||
const name = window.prompt('Project name', fallbackName || 'New project');
|
||||
if (name === null) return;
|
||||
const displayName = String(name || '').trim() || fallbackName || `Project ${taskId}`;
|
||||
const projectId = projectIdFromTask(taskId);
|
||||
record.root.dataset.projectCreating = '1';
|
||||
if (record.turnProjectBtn) {
|
||||
record.turnProjectBtn.disabled = true;
|
||||
record.turnProjectBtn.textContent = 'Creating project...';
|
||||
}
|
||||
try {
|
||||
// Through the api_client boundary helper (Gateway Boundary pattern):
|
||||
// jsonPost throws on non-ok / {error}, returning the parsed payload.
|
||||
const payload = await apiClient.projectFromTask(taskId, projectId, displayName);
|
||||
const project = payload.project || { id: projectId, name: displayName };
|
||||
showToast(`Project created: ${project.name || project.id}`, 'ok');
|
||||
window.dispatchEvent(new CustomEvent('ouro:project-created', { detail: { project } }));
|
||||
if (record.turnProjectBtn) record.turnProjectBtn.textContent = 'Project created';
|
||||
} catch (exc) {
|
||||
showToast(`Project creation failed: ${exc.message || exc}`, 'error');
|
||||
delete record.root.dataset.projectCreating;
|
||||
if (record.turnProjectBtn) {
|
||||
record.turnProjectBtn.disabled = false;
|
||||
record.turnProjectBtn.textContent = 'Turn into project';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createLiveCardRecord(groupId = '', options = {}) {
|
||||
const normalizedGroupId = groupId || `task-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const timelineId = `chat-live-timeline-${normalizedGroupId.replace(/[^A-Za-z0-9_-]/g, '-')}`;
|
||||
|
|
@ -683,6 +770,9 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
}
|
||||
root.dataset.finished = '0';
|
||||
root.dataset.expanded = (options.isSubagent && nestedSubagentsExpanded) ? '1' : '0';
|
||||
const projectActionHtml = (isMain && !options.isSubagent)
|
||||
? `<div class="chat-live-actions"><button type="button" class="chat-live-project-btn" data-turn-into-project>Turn into project</button></div>`
|
||||
: '';
|
||||
root.innerHTML = `
|
||||
<button type="button" class="chat-live-summary-button" data-live-summary-button aria-expanded="false" aria-controls="${escapeHtmlAttr(timelineId)}">
|
||||
<div class="chat-live-summary">
|
||||
|
|
@ -703,6 +793,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
</div>
|
||||
<div class="chat-live-meta" data-live-meta></div>
|
||||
</button>
|
||||
${projectActionHtml}
|
||||
<div class="chat-live-timeline" data-live-timeline id="${escapeHtmlAttr(timelineId)}"></div>
|
||||
`;
|
||||
const record = {
|
||||
|
|
@ -715,6 +806,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
countEl: root.querySelector('[data-live-count]'),
|
||||
metaEl: root.querySelector('[data-live-meta]'),
|
||||
toggleEl: root.querySelector('[data-live-toggle]'),
|
||||
turnProjectBtn: root.querySelector('[data-turn-into-project]'),
|
||||
timelineEl: root.querySelector('[data-live-timeline]'),
|
||||
updates: 0,
|
||||
finished: false,
|
||||
|
|
@ -731,6 +823,10 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
record.summaryButtonEl?.addEventListener('click', () => {
|
||||
setLiveCardExpanded(record, record.root.dataset.expanded !== '1');
|
||||
});
|
||||
record.turnProjectBtn?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
turnTaskIntoProject(record);
|
||||
});
|
||||
record.timelineEl?.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-live-line-toggle]');
|
||||
if (!button) return;
|
||||
|
|
@ -886,8 +982,14 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
|
||||
function syncLiveCardLayout(record) {
|
||||
if (!record?.root) return;
|
||||
// Hidden SPA/browser tabs report zero geometry; defer to avoid collapsed cards.
|
||||
if (!record.root.closest('.page.active') || document.hidden) {
|
||||
// Hidden SPA/browser tabs report zero geometry; defer to avoid collapsed
|
||||
// cards. Generalized to panel instances: any visible host counts.
|
||||
const activePage = record.root.closest('.page.active');
|
||||
const panelHost = record.root.closest('.chat-instance-panel');
|
||||
// A panel counts as visible only when it is actually shown (not a
|
||||
// hidden/display:none secondary instance) — zero geometry otherwise.
|
||||
const visibleHost = activePage || (panelHost && panelHost.offsetParent !== null);
|
||||
if (!visibleHost || document.hidden) {
|
||||
record._needsLayoutSync = true;
|
||||
return;
|
||||
}
|
||||
|
|
@ -1572,6 +1674,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
}
|
||||
|
||||
function ensureWelcomeMessage() {
|
||||
if (!isMain) return;
|
||||
if (welcomeShown) return;
|
||||
const hasRealBubbles = Array.from(messagesDiv.querySelectorAll('.chat-bubble')).some(
|
||||
bubble => !bubble.classList.contains('typing-bubble')
|
||||
|
|
@ -1589,7 +1692,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
}
|
||||
historySyncPromise = (async () => {
|
||||
try {
|
||||
const resp = await apiFetch('/api/chat/history?limit=1000', { cache: 'no-store' });
|
||||
const resp = await apiFetch(`/api/chat/history?limit=1000${isMain ? '' : `&chat_id=${chatId}`}`, { cache: 'no-store' });
|
||||
if (!resp.ok) return false;
|
||||
const data = await resp.json();
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
|
|
@ -1612,7 +1715,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
// state so the rebuild below cannot produce duplicates even if
|
||||
// stale bubbles lingered in the DOM. Keep the typing indicator.
|
||||
for (const bubble of Array.from(messagesDiv.querySelectorAll('.chat-bubble'))) {
|
||||
if (bubble.id !== 'typing-indicator') bubble.remove();
|
||||
if (!bubble.classList.contains('typing-bubble')) bubble.remove();
|
||||
}
|
||||
seenMessageKeys.clear();
|
||||
messageKeyOrder.length = 0;
|
||||
|
|
@ -1810,7 +1913,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
await loadUiPreferences();
|
||||
if (await syncHistory({ includeUser: true })) return;
|
||||
try {
|
||||
const saved = JSON.parse(sessionStorage.getItem(CHAT_STORAGE_KEY) || '[]');
|
||||
const saved = JSON.parse(sessionStorage.getItem(storeKey(CHAT_STORAGE_KEY)) || '[]');
|
||||
for (const msg of saved) {
|
||||
addMessage(msg.text, msg.role, !!msg.markdown, msg.ts || null, false, {
|
||||
systemType: msg.systemType || '',
|
||||
|
|
@ -1924,6 +2027,8 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
content: text,
|
||||
sender_session_id: chatSessionId,
|
||||
force_plan: forcePlan,
|
||||
...(isMain ? {} : { chat_id: chatId }),
|
||||
...(projectId ? { project_id: projectId } : {}),
|
||||
...(attachmentMeta.length ? { attachments: attachmentMeta } : {}),
|
||||
}, hasAttachments ? { queue: false } : undefined);
|
||||
if (hasAttachments && result?.status !== 'sent') {
|
||||
|
|
@ -1951,11 +2056,11 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
}
|
||||
|
||||
// Send mode lives on DOM so CSS and click/Enter share one source.
|
||||
const sendGroup = document.querySelector('.chat-send-group');
|
||||
const sendGroup = page.querySelector('.chat-send-group');
|
||||
|
||||
// Consilium is a one-shot arm: the next send goes through plan_task multi-model
|
||||
// brainstorm/planning, then the pill auto-disarms so it never sticks.
|
||||
const consiliumBtn = document.getElementById('chat-consilium');
|
||||
const consiliumBtn = byId('consilium');
|
||||
function consiliumArmed() {
|
||||
return consiliumBtn?.dataset.armed === 'true';
|
||||
}
|
||||
|
|
@ -1979,7 +2084,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
|
||||
// Context-mode quick toggle (owner-only; applies on the next task). Posts to
|
||||
// the owner endpoint and reflects the current value from /api/state.
|
||||
const contextModeBtn = document.getElementById('chat-context-mode');
|
||||
const contextModeBtn = byId('context-mode');
|
||||
contextModeBtn?.addEventListener('click', async (event) => {
|
||||
const seg = event.target.closest('.chat-seg');
|
||||
if (!seg || contextModeBtn.dataset.disabled === 'true') return;
|
||||
|
|
@ -2074,6 +2179,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
headerActions?.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-chat-command]');
|
||||
if (!button) return;
|
||||
button.closest('details')?.removeAttribute('open');
|
||||
const command = button.dataset.chatCommand;
|
||||
if (command === 'evolve') {
|
||||
const next = !button.classList.contains('on');
|
||||
|
|
@ -2100,16 +2206,42 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
}
|
||||
});
|
||||
|
||||
// The More menu is a native <details> (no auto-dismiss): collapse it when a
|
||||
// click/tap lands outside it, or on Escape, so it never stays stuck open.
|
||||
if (!asPanel) {
|
||||
const collapseHeaderMenus = (predicate) => {
|
||||
page.querySelectorAll('details.chat-header-more[open]').forEach((details) => {
|
||||
if (predicate(details)) details.removeAttribute('open');
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', (event) => {
|
||||
collapseHeaderMenus((details) => !details.contains(event.target));
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') collapseHeaderMenus(() => true);
|
||||
});
|
||||
}
|
||||
|
||||
budgetPill?.addEventListener('click', () => {
|
||||
if (typeof openDashboardTab === 'function') openDashboardTab('costs');
|
||||
else if (typeof openSettingsTab === 'function') openSettingsTab('costs');
|
||||
});
|
||||
|
||||
refreshHeaderControlState(true);
|
||||
setInterval(refreshHeaderControlState, 3000);
|
||||
if (asPanel) {
|
||||
// The panel has no global controls/budget to poll; seed the status from
|
||||
// the live socket so a late-created panel never gets stuck on
|
||||
// "Connecting…" (the one-shot WS `open` already fired before it existed;
|
||||
// future reconnects still update it via the shared `open` handler).
|
||||
if (ws.isConnected?.()) setStatus('online', 'Online');
|
||||
} else {
|
||||
refreshHeaderControlState(true);
|
||||
setInterval(refreshHeaderControlState, 3000);
|
||||
}
|
||||
|
||||
const typingEl = document.createElement('div');
|
||||
typingEl.id = 'typing-indicator';
|
||||
// Per-instance id (main stays 'typing-indicator'; panels get a unique id) so
|
||||
// multiple open chat columns never collide on a duplicate DOM id.
|
||||
typingEl.id = idPrefix === 'chat' ? 'typing-indicator' : `${idPrefix}-typing-indicator`;
|
||||
typingEl.className = 'chat-bubble assistant typing-bubble';
|
||||
typingEl.style.display = 'none';
|
||||
typingEl.innerHTML = `<div class="typing-dots"><span></span><span></span><span></span></div>`;
|
||||
|
|
@ -2139,16 +2271,43 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
}
|
||||
|
||||
function incrementUnreadIfNeeded() {
|
||||
if (!isMain) return; // the global unread badge tracks the main chat
|
||||
if (state.activePage === 'chat') return;
|
||||
state.unreadCount++;
|
||||
updateUnreadBadge();
|
||||
}
|
||||
|
||||
ws.on('typing', () => {
|
||||
ws.on('typing', (msg) => {
|
||||
if (!isMyThread(msg)) return; // each column shows typing only for its own thread
|
||||
showTyping();
|
||||
});
|
||||
|
||||
// One socket, client-side fan-out: project instances take only their own
|
||||
// thread. The MAIN instance keeps ordinary non-project traffic AND mirrors
|
||||
// project progress/digests/logs as the "штаб", but never raw project chat
|
||||
// user/assistant messages.
|
||||
const isProjectMirrorFrame = (msg) => {
|
||||
if (!msg) return false;
|
||||
if (msg.type === 'log') return true;
|
||||
if (msg.is_progress) return true;
|
||||
if (msg.system_type === 'task_summary' || msg.system_type === 'project_digest') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isMyThread = (msg, { mirrorProject = false } = {}) => {
|
||||
const cid = Number(msg?.chat_id ?? 1);
|
||||
if (isMain) {
|
||||
const projectIds = state.projectChatIds instanceof Set ? state.projectChatIds : null;
|
||||
if (projectIds && projectIds.has(cid)) {
|
||||
return mirrorProject && isProjectMirrorFrame(msg);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return cid === chatId;
|
||||
};
|
||||
|
||||
ws.on('chat', (msg) => {
|
||||
if (!isMyThread(msg, { mirrorProject: true })) return;
|
||||
if (msg.role === 'user') {
|
||||
const clientMessageId = msg.client_message_id || '';
|
||||
const senderSessionId = msg.sender_session_id || '';
|
||||
|
|
@ -2199,6 +2358,12 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
|
||||
ws.on('log', (msg) => {
|
||||
if (!msg?.data) return;
|
||||
// Log frames now carry the task's chat_id (backend stamps it), so the
|
||||
// per-thread fan-out routes the full live card to its own column: a
|
||||
// project panel builds/animates/finalizes ITS card, while the main
|
||||
// chat mirrors project progress as штаб. Legacy frames without chat_id
|
||||
// default to the main chat.
|
||||
if (!isMyThread(msg, { mirrorProject: true })) return;
|
||||
updateLiveCardFromLogEvent(msg.data);
|
||||
});
|
||||
|
||||
|
|
@ -2207,6 +2372,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
});
|
||||
|
||||
ws.on('photo', (msg) => {
|
||||
if (!isMyThread(msg)) return;
|
||||
hideTyping();
|
||||
const role = msg.role === 'user' ? 'user' : 'assistant';
|
||||
const sender = role === 'user'
|
||||
|
|
@ -2241,6 +2407,7 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
});
|
||||
|
||||
ws.on('video', (msg) => {
|
||||
if (!isMyThread(msg)) return;
|
||||
hideTyping();
|
||||
const role = msg.role === 'user' ? 'user' : 'assistant';
|
||||
const sender = role === 'user'
|
||||
|
|
@ -2305,4 +2472,13 @@ export function initChat({ ws, state, updateUnreadBadge, openSettingsTab, openDa
|
|||
setStatus('offline', 'Reconnecting...');
|
||||
syncHeaderControlState({ spent_usd: 0, budget_limit: 10, budget_text: 'Connecting...' });
|
||||
});
|
||||
|
||||
return {
|
||||
page,
|
||||
chatId,
|
||||
projectId,
|
||||
destroy() {
|
||||
try { page.remove(); } catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@ export function initMarketplace(pane, controlsHost = null) {
|
|||
const summary = state.results.find((item) => item.slug === slug) || { slug };
|
||||
const installed = state.installedMap.get(slug);
|
||||
if (action === 'widgets') {
|
||||
document.querySelector('[data-page="widgets"]')?.click();
|
||||
document.querySelector('[data-nav-page="widgets"]')?.click();
|
||||
return;
|
||||
}
|
||||
if (action === 'disable' && installed) {
|
||||
|
|
@ -541,7 +541,7 @@ export function initMarketplace(pane, controlsHost = null) {
|
|||
});
|
||||
showStatus(pane, `${slug}: repair task queued`, 'ok');
|
||||
emitSkillLifecycle('repair', installed.name || slug);
|
||||
document.querySelector('.nav-btn[data-page="chat"]')?.click();
|
||||
document.querySelector('[data-nav-page="chat"]')?.click();
|
||||
return;
|
||||
}
|
||||
if (action === 'review' && installed) {
|
||||
|
|
|
|||
|
|
@ -4,28 +4,47 @@ function classAttr(parts) {
|
|||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function renderMobileNavToggle() {
|
||||
return `
|
||||
<button class="mobile-nav-toggle" type="button" data-mobile-nav-toggle aria-label="Open navigation" aria-controls="primary-sidebar" aria-expanded="false">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/></svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderPageHeader({
|
||||
title,
|
||||
icon = '',
|
||||
description = '',
|
||||
leadingHtml,
|
||||
toolbarHtml = '',
|
||||
trailingHtml = '',
|
||||
actionsHtml = '',
|
||||
tabsHtml = '',
|
||||
variant = '',
|
||||
className = '',
|
||||
showMobileNav = true,
|
||||
} = {}) {
|
||||
const variantClass = variant ? `app-page-header-${escapeHtml(variant)}` : '';
|
||||
const iconHtml = icon ? `<span class="app-page-icon" aria-hidden="true">${icon}</span>` : '';
|
||||
const leading = leadingHtml !== undefined
|
||||
? leadingHtml
|
||||
: (showMobileNav ? renderMobileNavToggle() : '');
|
||||
const descriptionHtml = description
|
||||
? `<p class="app-page-description">${escapeHtml(description)}</p>`
|
||||
: '';
|
||||
const actions = actionsHtml
|
||||
? `<div class="app-page-actions">${actionsHtml}</div>`
|
||||
const toolbar = (toolbarHtml || actionsHtml)
|
||||
? `<div class="app-page-toolbar app-page-actions">${toolbarHtml || actionsHtml}</div>`
|
||||
: '';
|
||||
const trailing = trailingHtml
|
||||
? `<div class="app-page-trailing">${trailingHtml}</div>`
|
||||
: '';
|
||||
const tabs = tabsHtml
|
||||
? `<div class="app-page-tabs">${tabsHtml}</div>`
|
||||
: '';
|
||||
return `
|
||||
<div class="${classAttr(['page-header', 'app-page-header', variantClass, className])}">
|
||||
<div class="app-page-leading">${leading}</div>
|
||||
<div class="app-page-title-block">
|
||||
<div class="app-page-title-row">
|
||||
${iconHtml}
|
||||
|
|
@ -33,7 +52,8 @@ export function renderPageHeader({
|
|||
</div>
|
||||
${descriptionHtml}
|
||||
</div>
|
||||
${actions}
|
||||
${toolbar}
|
||||
${trailing}
|
||||
${tabs}
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ export const PAGE_ICONS = {
|
|||
|
||||
export function hydrateNavIcons(root = document) {
|
||||
Object.entries(PAGE_ICONS).forEach(([page, svg]) => {
|
||||
const button = root.querySelector(`.nav-btn[data-page="${page}"]`);
|
||||
const button = root.querySelector(`[data-nav-page="${page}"]`);
|
||||
if (!button) return;
|
||||
const label = button.querySelector('span')?.outerHTML || `<span>${page}</span>`;
|
||||
const label = button.querySelector('.nav-row-label')?.outerHTML || `<span class="nav-row-label">${page}</span>`;
|
||||
button.innerHTML = `${svg}${label}`;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ function attachActionHandlers(container, renderFn, reviewingSkills, repairingSki
|
|||
async function triggerSkillAction(name, action, options = {}) {
|
||||
if (!name || !action) return;
|
||||
if (action === 'open_widgets') {
|
||||
document.querySelector('.nav-btn[data-page="widgets"]')?.click();
|
||||
document.querySelector('[data-nav-page="widgets"]')?.click();
|
||||
return;
|
||||
}
|
||||
const { skills } = await fetchSkills();
|
||||
|
|
@ -367,7 +367,7 @@ function attachActionHandlers(container, renderFn, reviewingSkills, repairingSki
|
|||
if (typeof ctx.showPage === 'function') {
|
||||
ctx.showPage('chat');
|
||||
} else {
|
||||
document.querySelector('.nav-btn[data-page="chat"]')?.click();
|
||||
document.querySelector('[data-nav-page="chat"]')?.click();
|
||||
}
|
||||
} finally {
|
||||
repairingSkills.delete(name);
|
||||
|
|
@ -395,7 +395,7 @@ function attachActionHandlers(container, renderFn, reviewingSkills, repairingSki
|
|||
if (typeof ctx.showPage === 'function') {
|
||||
ctx.showPage('chat');
|
||||
} else {
|
||||
document.querySelector('.nav-btn[data-page="chat"]')?.click();
|
||||
document.querySelector('[data-nav-page="chat"]')?.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,6 +212,10 @@ export class WS {
|
|||
return { status: 'queued', clientMessageId: payload.client_message_id || '' };
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
on(event, fn) {
|
||||
(this.listeners[event] ||= []).push(fn);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ouroboros-web",
|
||||
"version": "6.31.0",
|
||||
"version": "6.32.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Ouroboros browser UI package boundary",
|
||||
|
|
|
|||
862
web/style.css
862
web/style.css
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue