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:
Ouroboros 2026-06-14 17:14:58 +03:00
parent eb33abd491
commit 5c0dcba60b
55 changed files with 4375 additions and 331 deletions

View file

@ -7,7 +7,7 @@
[![Linux](https://img.shields.io/badge/Linux-x86__64-orange.svg)](https://github.com/razzant/ouroboros/releases)
[![Windows](https://img.shields.io/badge/Windows-x64-blue.svg)](https://github.com/razzant/ouroboros/releases)
[![OuroborosHub](https://img.shields.io/badge/OuroborosHub-skills%20marketplace-8A2BE2.svg)](https://github.com/razzant/OuroborosHub)
[![Version 6.31.0](https://img.shields.io/badge/version-6.31.0-green.svg)](VERSION)
[![Version 6.32.0](https://img.shields.io/badge/version-6.32.0-green.svg)](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.
---

View file

@ -1 +1 @@
6.31.0
6.32.0

View file

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

View file

@ -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' })`

View file

@ -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",
)

View file

@ -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,
})

View file

@ -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",

View file

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

View file

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

View file

@ -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",
]

View file

@ -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",

View file

@ -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

View 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",
]

View file

@ -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"]),

View file

@ -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"]

View file

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

View file

@ -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

View file

@ -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"

View 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"]

View 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 loadmodifyatomic-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",
]

View file

@ -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,

View file

@ -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": (

View file

@ -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 "",

View 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"]

View file

@ -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",
]

View file

@ -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

View file

@ -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
View file

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

View file

@ -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,

View file

@ -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(

View file

@ -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),

View file

@ -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

View file

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

View file

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

View file

@ -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

View file

@ -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

View 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

View file

@ -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
View 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")

View 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

View file

@ -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",

View file

@ -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

View file

@ -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());

View file

@ -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>

View file

@ -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 }),
};

View file

@ -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';

View file

@ -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 {}
},
};
}

View file

@ -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) {

View file

@ -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>
`;

View file

@ -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}`;
});
}

View file

@ -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();
}
}
}

View file

@ -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);
}

View file

@ -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",

File diff suppressed because it is too large Load diff