' +
+ section('Open questions', qHtml) + section('Watching', wHtml) +
+ section('Expectations', eHtml) + section('From the notebook', nHtml);
+ wire();
+ }
+```
+
+- [ ] **Step 3: Make notebook rows click through to the Notebook tab**
+
+In `wire()`, after the existing `.cx-item` loop, add a handler for note rows (append inside `wire`, before its closing brace):
+
+```javascript
+ el.querySelectorAll('.cx-note').forEach(row => {
+ row.style.cursor = 'pointer';
+ row.addEventListener('click', () => {
+ if (typeof switchTab === 'function') switchTab('notebook');
+ });
+ });
+```
+
+- [ ] **Step 4: Verify in the browser**
+
+Restart the server, open `http://localhost:8080`, confirm the Home "Agent's view" panel shows a "From the notebook" section with recent notes, and clicking a row switches to the Notebook tab. Use chrome-devtools (navigate, evaluate_script to click, take_screenshot, list_console_messages → zero errors).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/static/js/context-surface.js
+git commit -m "feat(notebook): Agent's-view live edge — recent notes section -> Notebook tab"
+```
+
+---
+
+## Self-Review
+**Spec coverage:** design §3 two-faced presentation — the ambient live edge now surfaces recent notebook notes on Home, clicking through to the tab. ✓ Reuses `/api/notebook/notes` (+ new `limit`) and existing `cx-dot` colors. ✓
+**Placeholder scan:** none. ✓
+**Type consistency:** `render(data, notes)`, `dotFor`, `limit` param, `switchTab('notebook')` all consistent; kinds map to existing cx classes. ✓
diff --git a/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md
new file mode 100644
index 00000000..d518f621
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md
@@ -0,0 +1,251 @@
+# Notebook Producer Bridge Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** Make the existing agent-memory write path actually populate the shared notebook — when `FileContextStore.apply_updates()` records observations and learnings, mirror them into the `NotebookStore` as Notes.
+
+**Architecture:** Pure converters (`observation_to_note`, `learning_to_note`) in `notebook.py`; a lazy `FileContextStore.notebook` property rooted at `agent_dir/notebook`; a guarded mirror step at the end of `apply_updates`. Builds on the foundation plan (`2026-06-16-notebook-foundation.md`). Backend-only, no UI/agent-loop changes. Transitional dual-write (legacy YAML + notebook) — legacy silos retire in a later increment.
+
+**Tech Stack:** Python 3.11, dataclasses, PyYAML, pytest (`file_context_store` fixture in `tests/conftest.py`).
+
+**Out of scope:** wiring the live loop to *call* `apply_updates` (separate increment); read API + Notebook tab; mapping expectations/watchpoints (they're working memory, not notebook entries — see design doc §2).
+
+---
+
+### Task 1: Converters — Observation/Learning → Note
+
+**Files:**
+- Modify: `gently/harness/memory/notebook.py`
+- Test: `tests/test_notebook_store.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_store.py
+from datetime import datetime as _dt
+
+from gently.harness.memory.model import Learning, Observation
+from gently.harness.memory.notebook import learning_to_note, observation_to_note
+
+
+class TestConverters:
+ def test_observation_to_note(self):
+ obs = Observation(
+ id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0), type="milestone",
+ content="nerve ring formed", embryo_id="e1", session_id="s1",
+ relates_to=["o0"], gently_refs={"kind": "projection", "t": 42},
+ )
+ n = observation_to_note(obs)
+ assert n.id == "o1"
+ assert n.kind == NoteKind.OBSERVATION
+ assert n.body == "nerve ring formed"
+ assert n.author == Author.AGENT
+ assert n.embryos == ["e1"]
+ assert n.sessions == ["s1"]
+ assert {"rel": "relates_to", "to": "o0"} in n.links
+ assert n.artifacts == [{"kind": "projection", "t": 42}]
+ assert n.created_at == _dt(2026, 6, 16, 9, 0, 0)
+
+ def test_learning_to_note(self):
+ lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH)
+ n = learning_to_note(lrn)
+ assert n.id == "l1"
+ assert n.kind == NoteKind.FINDING
+ assert n.body == "rings form by comma"
+ assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm
+ assert n.confidence == Confidence.HIGH
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestConverters -v`
+Expected: FAIL — `ImportError: cannot import name 'observation_to_note'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add the model imports and converters to `notebook.py`. Extend the existing model import line:
+
+```python
+from .model import Confidence, Learning, Observation
+```
+
+Append at end of `notebook.py` (module-level functions, after `note_from_dict`):
+
+```python
+def observation_to_note(obs: Observation) -> Note:
+ """Bridge a legacy Observation into a notebook Note (kind=observation)."""
+ return Note(
+ id=obs.id,
+ kind=NoteKind.OBSERVATION,
+ body=obs.content,
+ author=Author.AGENT,
+ embryos=[obs.embryo_id] if obs.embryo_id else [],
+ sessions=[obs.session_id] if obs.session_id else [],
+ links=[{"rel": "relates_to", "to": r} for r in (obs.relates_to or [])],
+ artifacts=[obs.gently_refs] if obs.gently_refs else [],
+ created_at=obs.timestamp,
+ updated_at=obs.timestamp,
+ )
+
+
+def learning_to_note(learning: Learning) -> Note:
+ """Bridge a legacy Learning into a notebook Note (kind=finding, proposed)."""
+ return Note(
+ id=learning.id,
+ kind=NoteKind.FINDING,
+ body=learning.content,
+ author=Author.AGENT,
+ status=NoteStatus.PROPOSED,
+ confidence=learning.confidence,
+ created_at=learning.created_at,
+ updated_at=learning.created_at,
+ )
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestConverters -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/harness/memory/notebook.py tests/test_notebook_store.py
+git commit -m "feat(notebook): Observation/Learning -> Note converters"
+```
+
+---
+
+### Task 2: `FileContextStore.notebook` property
+
+**Files:**
+- Modify: `gently/harness/memory/file_store.py` (add property near `apply_updates`)
+- Test: `tests/test_notebook_store.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_store.py
+class TestContextStoreNotebook:
+ def test_notebook_property_rooted_under_agent_dir(self, file_context_store):
+ nb = file_context_store.notebook
+ assert nb.root == file_context_store.agent_dir / "notebook"
+
+ def test_notebook_property_is_cached(self, file_context_store):
+ assert file_context_store.notebook is file_context_store.notebook
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestContextStoreNotebook -v`
+Expected: FAIL — `AttributeError: 'FileContextStore' object has no attribute 'notebook'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `gently/harness/memory/file_store.py`, add this property immediately **before** `def apply_updates(self, updates: ContextUpdates):` (line ~2178):
+
+```python
+ @property
+ def notebook(self):
+ """The shared lab notebook, rooted at agent_dir/notebook (lazy)."""
+ nb = getattr(self, "_notebook", None)
+ if nb is None:
+ from .notebook import NotebookStore
+ nb = NotebookStore(self.agent_dir / "notebook")
+ self._notebook = nb
+ return nb
+
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestContextStoreNotebook -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/harness/memory/file_store.py tests/test_notebook_store.py
+git commit -m "feat(notebook): FileContextStore.notebook lazy property"
+```
+
+---
+
+### Task 3: Mirror observations & learnings in `apply_updates`
+
+**Files:**
+- Modify: `gently/harness/memory/file_store.py` (`apply_updates`)
+- Test: `tests/test_notebook_store.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_store.py
+class TestApplyUpdatesMirror:
+ def test_apply_updates_mirrors_observations_and_learnings(self, file_context_store):
+ from gently.harness.memory.model import ContextUpdates
+
+ cs = file_context_store
+ obs = Observation(id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0),
+ type="milestone", content="ring formed", embryo_id="e1")
+ lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH)
+ cs.apply_updates(ContextUpdates(new_observations=[obs], new_learnings=[lrn]))
+
+ bodies = {n.body for n in cs.notebook.query_notes()}
+ assert "ring formed" in bodies
+ assert "rings form by comma" in bodies
+ assert cs.notebook.ids_for_embryo("e1") == ["o1"]
+
+ def test_apply_updates_empty_is_noop_for_notebook(self, file_context_store):
+ from gently.harness.memory.model import ContextUpdates
+
+ cs = file_context_store
+ cs.apply_updates(ContextUpdates())
+ assert cs.notebook.query_notes() == []
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestApplyUpdatesMirror -v`
+Expected: FAIL — `assert "ring formed" in set()` (notebook not populated yet)
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `gently/harness/memory/file_store.py`, at the END of `apply_updates` (after the `if updates.new_focus is not None:` block), append:
+
+```python
+ # Mirror new observations & learnings into the shared notebook
+ # (best-effort — a notebook failure never breaks the legacy write).
+ from .notebook import learning_to_note, observation_to_note
+
+ try:
+ for obs in updates.new_observations:
+ self.notebook.write_note(observation_to_note(obs))
+ for learning in updates.new_learnings:
+ self.notebook.write_note(learning_to_note(learning))
+ except Exception:
+ logger.warning("notebook mirror failed", exc_info=True)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestApplyUpdatesMirror -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 5: Run full notebook suite + commit**
+
+Run: `python -m pytest tests/test_notebook_store.py -q`
+Expected: all pass
+
+```bash
+git add gently/harness/memory/file_store.py tests/test_notebook_store.py
+git commit -m "feat(notebook): apply_updates mirrors observations & learnings into notebook"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** Producer wiring (design doc increment 1b) — `apply_updates` now populates the notebook. ✓ Converters honor the model (Observation→observation note, Learning→finding/proposed). ✓ Working-memory types (expectation/watchpoint) intentionally not mirrored (design §2). ✓
+**Placeholder scan:** none; complete code + commands throughout. ✓
+**Type consistency:** `observation_to_note`/`learning_to_note`, `FileContextStore.notebook`, `NoteKind`/`Author`/`NoteStatus`/`Confidence` match the foundation module and `model.py` (`Observation`, `Learning`, `ContextUpdates` confirmed at `file_store.py:2178-2203`). ✓
diff --git a/docs/superpowers/plans/2026-06-16-notebook-read-api.md b/docs/superpowers/plans/2026-06-16-notebook-read-api.md
new file mode 100644
index 00000000..f4a5f20d
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-16-notebook-read-api.md
@@ -0,0 +1,265 @@
+# Notebook Read API Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or subagent-driven-development) to implement task-by-task. Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** Expose the shared notebook over HTTP so the frontend can read it — list/filter notes, fetch one note, and list inquiry-threads with counts.
+
+**Architecture:** A new route module `gently/ui/web/routes/notebook.py` exposing `create_router(server)` (the established pattern), reading `server.context_store.notebook` (the `NotebookStore` added in the producer-bridge plan) and serializing via `note_to_dict`. Registered in `routes/__init__.py`. Read-only; authoring/curation is a later increment.
+
+**Tech Stack:** FastAPI `APIRouter`, pytest + `fastapi.testclient.TestClient`, the `file_context_store` fixture (`tests/conftest.py`).
+
+**Out of scope:** the Notebook tab UI (next plan, browser-verified); the Agent's-View rewire; retrieval/embeddings.
+
+---
+
+### Task 1: Route module — list & get notes
+
+**Files:**
+- Create: `gently/ui/web/routes/notebook.py`
+- Modify: `gently/ui/web/routes/__init__.py`
+- Test: `tests/test_notebook_api.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_notebook_api.py
+"""Tests for the notebook read API."""
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from gently.harness.memory.notebook import Note, NoteKind, NoteStatus
+
+
+def _make_app(context_store):
+ from gently.ui.web.routes.notebook import create_router
+
+ app = FastAPI()
+
+ class _Server:
+ pass
+
+ server = _Server()
+ server.context_store = context_store
+ app.include_router(create_router(server))
+ return app
+
+
+def _seed(cs):
+ nb = cs.notebook
+ nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"]))
+ nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="rings by comma",
+ status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"]))
+ return cs
+
+
+class TestListNotes:
+ def test_no_store_available_false(self):
+ client = TestClient(_make_app(None))
+ data = client.get("/api/notebook/notes").json()
+ assert data == {"available": False, "notes": []}
+
+ def test_list_all(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes").json()
+ assert data["available"] is True
+ assert {n["id"] for n in data["notes"]} == {"o1", "f1"}
+
+ def test_filter_by_kind(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?kind=finding").json()
+ assert {n["id"] for n in data["notes"]} == {"f1"}
+
+ def test_filter_by_strain(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?strain=N2").json()
+ assert {n["id"] for n in data["notes"]} == {"o1", "f1"}
+
+ def test_invalid_kind_is_ignored(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?kind=bogus").json()
+ assert {n["id"] for n in data["notes"]} == {"o1", "f1"}
+
+
+class TestGetNote:
+ def test_get_existing(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes/o1").json()
+ assert data["id"] == "o1"
+ assert data["body"] == "ring formed"
+
+ def test_get_missing_404(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ resp = client.get("/api/notebook/notes/nope")
+ assert resp.status_code == 404
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_api.py -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'gently.ui.web.routes.notebook'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `gently/ui/web/routes/notebook.py`:
+
+```python
+"""Notebook (shared lab notebook) read routes.
+
+Exposes the notebook's Notes for the Notebook tab + Agent's-View live edge.
+Read-only here; authoring/curation come in a later increment.
+"""
+
+from fastapi import APIRouter, HTTPException
+
+from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict
+
+
+def _coerce(enum_cls, value):
+ """Parse a query-param string into an enum; invalid/None → None (no filter)."""
+ if value is None:
+ return None
+ try:
+ return enum_cls(value)
+ except ValueError:
+ return None
+
+
+def create_router(server) -> APIRouter:
+ router = APIRouter()
+
+ def _nb():
+ cs = getattr(server, "context_store", None)
+ return cs.notebook if cs is not None else None
+
+ @router.get("/api/notebook/notes")
+ async def list_notes(
+ kind: str | None = None,
+ author: str | None = None,
+ status: str | None = None,
+ strain: str | None = None,
+ embryo: str | None = None,
+ thread: str | None = None,
+ ):
+ nb = _nb()
+ if nb is None:
+ return {"available": False, "notes": []}
+ notes = nb.query_notes(
+ kind=_coerce(NoteKind, kind),
+ author=_coerce(Author, author),
+ status=_coerce(NoteStatus, status),
+ strain=strain,
+ embryo=embryo,
+ thread=thread,
+ )
+ return {"available": True, "notes": [note_to_dict(n) for n in notes]}
+
+ @router.get("/api/notebook/notes/{note_id}")
+ async def get_note(note_id: str):
+ nb = _nb()
+ if nb is None:
+ raise HTTPException(status_code=404, detail="notebook unavailable")
+ note = nb.get_note(note_id)
+ if note is None:
+ raise HTTPException(status_code=404, detail="note not found")
+ return note_to_dict(note)
+
+ return router
+```
+
+Then register it in `gently/ui/web/routes/__init__.py`. Add the import after the `images` import line:
+
+```python
+from .notebook import create_router as create_notebook_router
+```
+
+And add `create_notebook_router,` to the factory tuple in `register_all_routes` (after `create_context_router,`):
+
+```python
+ create_context_router,
+ create_notebook_router,
+ ):
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_api.py -v`
+Expected: PASS (7 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/routes/notebook.py gently/ui/web/routes/__init__.py tests/test_notebook_api.py
+git commit -m "feat(notebook): read API — GET /api/notebook/notes + /notes/{id}"
+```
+
+---
+
+### Task 2: `GET /api/notebook/threads`
+
+**Files:**
+- Modify: `gently/ui/web/routes/notebook.py`
+- Test: `tests/test_notebook_api.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_api.py
+class TestThreads:
+ def test_no_store(self):
+ client = TestClient(_make_app(None))
+ assert client.get("/api/notebook/threads").json() == {"available": False, "threads": []}
+
+ def test_thread_counts(self, file_context_store):
+ cs = file_context_store
+ nb = cs.notebook
+ nb.write_note(Note(id="a", kind=NoteKind.QUESTION, body="q", threads=["t1"]))
+ nb.write_note(Note(id="b", kind=NoteKind.FINDING, body="f", threads=["t1", "t2"]))
+ client = TestClient(_make_app(cs))
+ data = client.get("/api/notebook/threads").json()
+ assert data["available"] is True
+ assert data["threads"] == [{"id": "t1", "count": 2}, {"id": "t2", "count": 1}]
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_api.py::TestThreads -v`
+Expected: FAIL — 404 (route not defined)
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add this endpoint inside `create_router`, just before `return router`:
+
+```python
+ @router.get("/api/notebook/threads")
+ async def list_threads():
+ nb = _nb()
+ if nb is None:
+ return {"available": False, "threads": []}
+ counts: dict[str, int] = {}
+ for n in nb.query_notes():
+ for t in n.threads:
+ counts[t] = counts.get(t, 0) + 1
+ threads = [{"id": t, "count": c} for t, c in sorted(counts.items())]
+ return {"available": True, "threads": threads}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_api.py -q`
+Expected: all pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py
+git commit -m "feat(notebook): read API — GET /api/notebook/threads with counts"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** read surface API for the notebook (design increment 1c, backend half) — query notes by kind/author/status/scope, fetch one, list threads. ✓ Reuses `NotebookStore.query_notes` + `note_to_dict` from the foundation. ✓ Follows `create_router(server)` + `server.context_store` convention (`context.py`). ✓
+**Placeholder scan:** none — complete code + commands. ✓
+**Type consistency:** `create_router`, `note_to_dict`, `NoteKind`/`Author`/`NoteStatus`, `server.context_store.notebook`, `nb.query_notes`/`get_note` all match the foundation + producer-bridge modules. Registration matches the existing tuple in `routes/__init__.py`. ✓
diff --git a/docs/superpowers/plans/2026-06-17-notebook-ask.md b/docs/superpowers/plans/2026-06-17-notebook-ask.md
new file mode 100644
index 00000000..c03ea526
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-17-notebook-ask.md
@@ -0,0 +1,408 @@
+# "Ask the Notebook" Implementation Plan (Increment 2, backend)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** Let the notebook be *reasoned with* — given a question (+ optional scope), retrieve relevant Notes, ask Claude to synthesize a **grounded, cited** answer, and return it as a validated structured object.
+
+**Architecture:** A new module `gently/harness/memory/notebook_ask.py`: structural retrieval (`select_notes`) + a forced-`tool_choice` synthesis call (`answer_question`) that reuses gently's conventions — `anthropic.AsyncAnthropic` (per `chat.py:198`), `settings.models.main` (Opus 4.8), structured output via a pinned tool (per `verifier.py`), and **no self-rated confidence** (per the lab rule). A `POST /api/notebook/ask` route wires retrieval → synthesis. The Claude client is injected so everything is unit-testable with a fake.
+
+**Tech Stack:** Python 3.11, `anthropic` SDK, FastAPI, pytest + TestClient (venv).
+
+**Out of scope (later plans):** embeddings/semantic recall (structural-only here); the "Ask" UI box on the Notebook tab; proactive surfacing.
+
+---
+
+### Task 1: Structural retrieval — `select_notes`
+
+**Files:**
+- Create: `gently/harness/memory/notebook_ask.py`
+- Test: `tests/test_notebook_ask.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_notebook_ask.py
+"""Tests for 'Ask the notebook' — retrieval + grounded synthesis."""
+
+from gently.harness.memory.notebook import Note, NoteKind
+from gently.harness.memory.notebook_ask import select_notes
+
+
+def _seed(cs):
+ nb = cs.notebook
+ nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"]))
+ nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"]))
+ nb.write_note(Note(id="x1", kind=NoteKind.OBSERVATION, body="unrelated", strains=["OH904"]))
+ return nb
+
+
+class TestSelectNotes:
+ def test_scope_by_thread(self, file_context_store):
+ nb = _seed(file_context_store)
+ ids = {n.id for n in select_notes(nb, thread="t1")}
+ assert ids == {"o1", "f1"}
+
+ def test_scope_by_strain(self, file_context_store):
+ nb = _seed(file_context_store)
+ ids = {n.id for n in select_notes(nb, strain="OH904")}
+ assert ids == {"x1"}
+
+ def test_no_scope_returns_recent_capped(self, file_context_store):
+ nb = _seed(file_context_store)
+ notes = select_notes(nb, limit=2)
+ assert len(notes) == 2 # newest-first, capped
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestSelectNotes -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'gently.harness.memory.notebook_ask'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# gently/harness/memory/notebook_ask.py
+"""Ask the notebook — retrieve relevant Notes and synthesize a grounded,
+cited answer with Claude. Structural retrieval only (semantic recall is a
+later increment). See docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md §4.
+"""
+
+from __future__ import annotations
+
+from .notebook import Note, NotebookStore
+
+
+def select_notes(
+ store: NotebookStore,
+ *,
+ thread: str | None = None,
+ strain: str | None = None,
+ limit: int = 12,
+) -> list[Note]:
+ """Structural narrowing: scope by thread/strain when given, else recent.
+ Returns newest-first, capped at `limit`."""
+ notes = store.query_notes(thread=thread, strain=strain)
+ return notes[:limit]
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestSelectNotes -v`
+Expected: PASS (3 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/harness/memory/notebook_ask.py tests/test_notebook_ask.py
+git commit -m "feat(notebook): select_notes — structural retrieval for ask"
+```
+
+---
+
+### Task 2: Grounded synthesis — `answer_question` (forced tool)
+
+**Files:**
+- Modify: `gently/harness/memory/notebook_ask.py`
+- Test: `tests/test_notebook_ask.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_ask.py
+import asyncio
+
+from gently.harness.memory.notebook_ask import ASK_TOOL, answer_question, build_ask_messages
+
+
+class _FakeBlock:
+ def __init__(self, inp):
+ self.type = "tool_use"
+ self.input = inp
+
+
+class _FakeResp:
+ def __init__(self, inp):
+ self.content = [_FakeBlock(inp)]
+ self.stop_reason = "tool_use"
+
+
+class _FakeMessages:
+ def __init__(self, captured, inp):
+ self._captured, self._inp = captured, inp
+
+ async def create(self, **kwargs):
+ self._captured.update(kwargs)
+ return _FakeResp(self._inp)
+
+
+class _FakeClient:
+ def __init__(self, inp):
+ self.captured = {}
+ self.messages = _FakeMessages(self.captured, inp)
+
+
+class TestAnswerQuestion:
+ def test_build_messages_embeds_note_ids(self):
+ notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")]
+ msgs = build_ask_messages("what formed?", notes)
+ text = msgs[0]["content"]
+ assert "o1" in text and "ring formed" in text and "what formed?" in text
+
+ def test_answer_returns_structured_and_forces_tool(self):
+ canned = {"answer": "A ring formed.", "points": [{"text": "ring formed", "note_ids": ["o1"]}],
+ "suggested_next": [], "coverage": "covered"}
+ client = _FakeClient(canned)
+ notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")]
+ out = asyncio.run(answer_question(client, "m", "what formed?", notes))
+ assert out == canned
+ # tool_choice is pinned to the ask tool (forced structured output)
+ assert client.captured["tool_choice"] == {"type": "tool", "name": ASK_TOOL["name"]}
+ assert client.captured["model"] == "m"
+
+ def test_answer_no_notes_short_circuits_without_api(self):
+ client = _FakeClient({"should": "not be used"})
+ out = asyncio.run(answer_question(client, "m", "anything?", []))
+ assert out["coverage"] == "not_in_notebook"
+ assert client.captured == {} # no API call when nothing to ground on
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestAnswerQuestion -v`
+Expected: FAIL — `ImportError: cannot import name 'ASK_TOOL'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `gently/harness/memory/notebook_ask.py`:
+
+```python
+# ASK_TOOL pins the structured output. No confidence field — we don't ask the
+# model to self-rate (lab rule); "coverage" is a factual grounding classification.
+ASK_TOOL = {
+ "name": "answer_from_notebook",
+ "description": "Return a grounded answer built ONLY from the provided notebook entries.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "answer": {"type": "string", "description": "Direct synthesis grounded in the notes."},
+ "points": {
+ "type": "array",
+ "description": "Supporting points, each citing the note ids it rests on.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "text": {"type": "string"},
+ "note_ids": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["text", "note_ids"],
+ },
+ },
+ "suggested_next": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Concrete next experiments/moves if the question asks what to do; else empty.",
+ },
+ "coverage": {
+ "type": "string",
+ "enum": ["covered", "partial", "not_in_notebook"],
+ "description": "How well the provided notes cover the question.",
+ },
+ },
+ "required": ["answer", "points", "coverage"],
+ },
+}
+
+_SYSTEM = (
+ "You reason over a shared lab notebook. Answer ONLY from the notebook entries "
+ "provided — every claim must cite the note id(s) it rests on. If the notes do "
+ "not contain the answer, say so plainly and set coverage to 'not_in_notebook'. "
+ "Never invent facts not in the notes. Call the answer_from_notebook tool."
+)
+
+
+def _render_notes(notes: list[Note]) -> str:
+ lines = []
+ for n in notes:
+ scope = []
+ if n.strains:
+ scope.append("strains=" + ",".join(n.strains))
+ if n.embryos:
+ scope.append("embryos=" + ",".join(n.embryos))
+ tag = f" [{'; '.join(scope)}]" if scope else ""
+ lines.append(f"[{n.id}] ({n.kind.value}){tag} {n.body}")
+ return "\n".join(lines)
+
+
+def build_ask_messages(question: str, notes: list[Note]) -> list[dict]:
+ body = (
+ "Notebook entries:\n"
+ + _render_notes(notes)
+ + f"\n\nQuestion: {question}\n\n"
+ "Answer using only these entries, citing note ids."
+ )
+ return [{"role": "user", "content": body}]
+
+
+async def answer_question(client, model: str, question: str, notes: list[Note]) -> dict:
+ """Force the ask tool and return its validated input dict. Short-circuits
+ (no API call) when there are no notes to ground on."""
+ if not notes:
+ return {
+ "answer": "The notebook doesn't cover this yet.",
+ "points": [],
+ "suggested_next": [],
+ "coverage": "not_in_notebook",
+ }
+ resp = await client.messages.create(
+ model=model,
+ max_tokens=2048,
+ system=_SYSTEM,
+ tools=[ASK_TOOL],
+ tool_choice={"type": "tool", "name": ASK_TOOL["name"]},
+ messages=build_ask_messages(question, notes),
+ )
+ for block in resp.content:
+ if getattr(block, "type", None) == "tool_use":
+ return block.input
+ return {"answer": "", "points": [], "suggested_next": [], "coverage": "not_in_notebook"}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py -q`
+Expected: all pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/harness/memory/notebook_ask.py tests/test_notebook_ask.py
+git commit -m "feat(notebook): answer_question — forced-tool grounded synthesis"
+```
+
+---
+
+### Task 3: `POST /api/notebook/ask`
+
+**Files:**
+- Modify: `gently/ui/web/routes/notebook.py`
+- Test: `tests/test_notebook_api.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_api.py
+class _AskBlock:
+ def __init__(self, inp):
+ self.type = "tool_use"
+ self.input = inp
+
+
+class _AskResp:
+ def __init__(self, inp):
+ self.content = [_AskBlock(inp)]
+ self.stop_reason = "tool_use"
+
+
+class _AskMessages:
+ def __init__(self, inp):
+ self._inp = inp
+
+ async def create(self, **kwargs):
+ return _AskResp(self._inp)
+
+
+class _AskClient:
+ def __init__(self, inp):
+ self.messages = _AskMessages(inp)
+
+
+def _make_app_with_client(context_store, client):
+ from gently.ui.web.routes.notebook import create_router
+
+ app = FastAPI()
+
+ class _Server:
+ pass
+
+ server = _Server()
+ server.context_store = context_store
+ server.claude_async = client
+ app.include_router(create_router(server))
+ return app
+
+
+class TestAsk:
+ def test_ask_returns_grounded_answer(self, file_context_store):
+ cs = _seed(file_context_store)
+ canned = {"answer": "A ring formed.", "points": [{"text": "ring", "note_ids": ["o1"]}],
+ "suggested_next": [], "coverage": "covered"}
+ client = TestClient(_make_app_with_client(cs, _AskClient(canned)))
+ resp = client.post("/api/notebook/ask", json={"question": "what happened?"})
+ assert resp.status_code == 200
+ assert resp.json()["coverage"] == "covered"
+
+ def test_ask_no_store(self):
+ client = TestClient(_make_app(None))
+ resp = client.post("/api/notebook/ask", json={"question": "x"})
+ assert resp.json() == {"available": False}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_api.py::TestAsk -v`
+Expected: FAIL — 404/405 (route not defined)
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `gently/ui/web/routes/notebook.py`, add `Body` to the fastapi import and add the route inside `create_router`, before `return router`:
+
+```python
+ @router.post("/api/notebook/ask")
+ async def ask(
+ question: str = Body(..., embed=True),
+ thread: str | None = Body(None, embed=True),
+ strain: str | None = Body(None, embed=True),
+ ):
+ nb = _nb()
+ if nb is None:
+ return {"available": False}
+ from gently.harness.memory.notebook_ask import answer_question, select_notes
+ from gently.settings import settings
+
+ notes = select_notes(nb, thread=thread, strain=strain)
+ client = getattr(server, "claude_async", None)
+ if client is None:
+ import anthropic
+
+ client = anthropic.AsyncAnthropic()
+ result = await answer_question(client, settings.models.main, question, notes)
+ result["available"] = True
+ result["note_ids"] = [n.id for n in notes]
+ return result
+```
+
+Update the import line at the top of the file:
+
+```python
+from fastapi import APIRouter, Body, HTTPException
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_api.py -q`
+Expected: all pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py
+git commit -m "feat(notebook): POST /api/notebook/ask — grounded notebook Q&A"
+```
+
+---
+
+## Self-Review
+**Spec coverage (design §4):** structural retrieval (`select_notes`) → grounded synthesis (`answer_question`, forced tool, cited, "not_in_notebook" valid) → endpoint. ✓ No self-rated confidence (`coverage` is grounding, not correctness-confidence). ✓ Reuses `settings.models.main`, `anthropic.AsyncAnthropic`, the `verifier.py` forced-tool pattern. ✓ Client injected → unit-testable without real API. ✓
+**Deferred (correct):** embeddings/semantic recall; the "Ask" UI; proactive surfacing.
+**Placeholder scan:** none — complete code + commands. ✓
+**Type consistency:** `select_notes`, `answer_question`, `ASK_TOOL`, `build_ask_messages` named consistently across tasks/tests; route uses `server.claude_async` (tests inject) with a real `AsyncAnthropic` fallback. ✓
diff --git a/docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md b/docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md
new file mode 100644
index 00000000..c715d57d
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md
@@ -0,0 +1,162 @@
+# Design: The Active, Shared Lab Notebook (agent + human memory)
+
+Status: design approved in brainstorm (2026-06-16). Concept trace: gently-project/gently#52.
+Branch: `feature/memory-model` (off `feature/ux-v2`, which it depends on for UI).
+
+## 1. Overview
+
+Gently should have one **active, shared lab notebook** — a living memory the agent and
+the scientist co-author. The agent writes from doing the work; the human adds notes,
+literature, thoughts, and annotations; both read it and *think with* it. It accumulates
+across timepoints, sessions, and (eventually) systems.
+
+This replaces today's "agent mind" — a clean conceptual model (`Context`: intentions,
+understanding, observations, expectations, attention) that was never engineered into a
+working system. We keep its good bones and redesign the structure.
+
+### What we keep from the old agent mind
+- The **file-based, human-browsable YAML store** (`FileContextStore`) — auditable, no DB lock-in.
+- **`Observation` as the entry template** (`model.py:379`) — content + refs + significance + embryo/session is already ~90% of a notebook entry.
+- **`basis` + derived `confidence`** on knowledge (`Learning`, `model.py:329`).
+- **`gently_refs`** for pointing at artifacts (`model.py:394`).
+- The **sense → believe → predict → attend** cognitive framing, as the *meaning* of entries.
+- The **event bus + `CONTEXT_UPDATED` → browser** path and the existing `/api/context` surface and "Agent's View" panel (`context-surface.js`).
+
+### What was wrong (and we fix)
+- The write loop is **dormant**: `apply_updates()` (`file_store.py:2178`) is never called anywhere. The mind was designed but never wired to fill.
+- **Six parallel silos, no links** — `Observation.relates_to` exists but is never populated. A set of disconnected lists, not a connected mind.
+- **No author/provenance dimension** — everything is implicitly agent-authored, so it can't be shared/curated.
+- **Working and durable memory are mixed** — live per-embryo predictions sit beside things you'd keep forever.
+- **No consolidation** — raw never becomes distilled on its own.
+- **Type proliferation** — distinctions that should be *fields* (author, status) were encoded as separate *kinds*.
+
+## 2. The data model
+
+### Principle
+A **kind** exists only if the system stores, reasons about, or *shows* it differently.
+Otherwise it is a **field**. This prevents re-proliferation.
+
+### Three kinds
+- **Observation** — a record of something seen, done, read, or noted. *Immutable.* Timestamped evidence.
+- **Finding** — a believed claim / conclusion / insight. *Revisable and supersedable*, with a `basis` (the observations it rests on) and a confidence *derived from evidence*. Can be contradicted — a signal, not an error.
+- **Question** — an open inquiry being chased. Open→answered lifecycle. The large, long-lived ones **are the organizing spine** (the inquiry thread).
+
+### Orthogonal fields (shared by all kinds; NOT new kinds)
+- `author` — human | agent | perception (a human note, a literature ref, and an agent observation are all Observations with different authors).
+- `status` — proposed | confirmed | superseded | answered | … (lifecycle).
+- `confidence` — derived from supporting/contradicting evidence; never self-rated by the model.
+- `scope` — strain / embryo / session / thread (the cross-cut indexes).
+- `links` — typed edges to other entries (supports / contradicts / refines / answers / produced-by). The graph that makes it a wiki, not a log.
+- `artifacts` — pointers into `FileStore` (images, volumes, projections, traces, sessions) by reference, never copied.
+
+### The inquiry thread (the spine)
+A `Question` that grew a body: holds the question, a rolling synthesis, status
+(open/investigating/resolved), and denormalized scope. Membership lives on the entries
+(`threads: [...]`), not in the thread — a flat note pool, derived reverse-indexes
+(`by_thread`, `by_strain`, `by_embryo`) that are rebuildable caches. This lets "by question"
++ "by strain" + links coexist over the flat YAML store with no database.
+
+### Working memory vs. notebook (the lifespan split)
+- **Working memory** (transient, the live loop): the agent's predictions and things-to-watch
+ during a run. A thin runtime layer, *not* notebook entries. When one matters (a violated
+ prediction), it **graduates** into an Observation.
+- **Notebook** (durable): Observations, Findings, Questions — what accumulates.
+
+### The Question is the hinge between knowing and doing
+"What experiments next?" is a Question, but it resolves into **plan items (action)** in the
+existing campaign/plan layer, whereas a scientific question resolves into a **Finding
+(knowledge)**. Same kind, different exit — the system tells them apart by *what they link to*
+(plan items vs findings), no hardcoded subtype. The loop:
+
+```
+Question ("what next?") → brainstorm over Observations + Findings
+ → plan items (a campaign) → run them
+ → Observations → Findings → may answer the scientific Question → thread converges
+```
+
+Knowledge → action → knowledge. The plan/campaign layer (and its export) is the "action" arm.
+
+### Mapping the old six types
+| Old type | New representation |
+|---|---|
+| `Observation` | `Observation` (near-identity; `embryo_id/session_id` → `scope`, `gently_refs` → `artifacts`, `relates_to` → `links`) |
+| `Learning` | `Finding` (`content`→body, `basis`/`confidence` kept) |
+| `Expectation` | working memory; graduates to `Observation` when violated |
+| `Watchpoint` | working memory (live monitoring) |
+| `Question` | `Question` (passing) or inquiry thread (recurring) |
+| `EmbryoUnderstanding` | `Finding`(s) scoped to an embryo; supersession gives stage history |
+
+## 3. Presentation (two faces)
+
+The notebook is **active**, so it can't live only in a tab (the write-only-junk-drawer failure).
+It has two faces:
+
+1. **The Notebook tab** (LIBRARY group, beside Plans and Sessions): the reading room.
+ *Plans = what we'll do · Sessions = what we did · Notebook = what we know.* Landing view
+ organized by the **inquiry-thread spine**, with strain/embryo **filters** and **search +
+ link-graph** navigation. A thread page reads like a living entry — the question, its rolling
+ synthesis, Findings and Observations beneath, and **links out to its campaign(s) in Plans and
+ runs in Sessions**. The three library tabs interlink rather than duplicate.
+
+2. **The ambient edge** — the notebook coming to you, in context (Home, mid-session, planning):
+ the session-start brief, the one-thing surfacing, the brainstorm reply. **This is what
+ "Agent's View" becomes** — repurposed from near-empty transient cognition into the notebook's
+ live edge.
+
+## 4. Retrieval & extraction
+
+Principle: **structure → indexes (deterministic); meaning & judgment → models.**
+(Exact structural retrieval is not a heuristic — it's an index lookup, and a model must not do it.)
+
+Three-layer pipeline:
+1. **Narrow — structural (instant).** Fields/indexes filter by strain, embryo, thread, time,
+ status, link-traversal. Cuts thousands → dozens.
+2. **Recall + rank — semantic.** Embeddings (computed once per entry, kept in a rebuildable
+ sidecar vector cache; YAML stays source of truth) pull semantically-near candidates; an LLM
+ **re-ranks/selects** with reasons. Recall cheap, precision is judgment.
+3. **Synthesize — generation (model).** Thread summaries, session-start briefs, brainstorm
+ answers, contradiction calls, consolidated Findings. Non-negotiable: **grounded + cited**
+ (uncited claim = bug; "not in notebook" is valid), **forced structured output** (validated
+ objects, never re-scraped prose), **ranking from structural facts not self-rated confidence**.
+
+Two query faces:
+- **Agent**: full pipeline feeding its reasoning.
+- **Human search**: *Filter* mode (instant, structural, as-you-type) + *Ask* mode (full grounded
+ LLM answer with citations). No model in the typeahead path.
+
+"Unlimited API credits" removes **cost** (lean on models for judgment/synthesis, frequent
+consolidation, regenerate thread summaries on change) but not **latency** (keep interactive
+filtering model-free) or **hallucination** (grounding + citation + structured output remain the
+guardrails).
+
+## 5. Trust principles
+- Append-only evidence; **supersede, never overwrite**, conclusions (the chain is the history).
+- Agent and human are **separate, attributed voices**; the agent appends/proposes/links, never
+ silently rewrites the human's words.
+- Every claim **carries its basis** and cites the entries it rests on.
+- Confidence is **derived from evidence**, never self-rated.
+- **Revisiting is designed in** — stale findings/questions/unrevisited results resurface.
+
+## 6. Build decomposition (increments)
+
+The full vision is several independently-shippable increments. Ordered by value-vs-risk:
+
+1. **Foundation / keystone — make memory accumulate and be browsable.**
+ Unified `Note` (3 kinds + fields) + store + indexes; wire the dormant producer loop
+ (`apply_updates`) so a session actually writes Observations/Findings; surface a read-only
+ **Notebook tab** (thread-organized) + repurpose Agent's View as the live edge.
+ *Produce (perception/agent) → store (notebook) → consume (tab + ambient read).*
+2. **Retrieval & brainstorm** — structural indexes + embeddings sidecar + grounded LLM
+ synthesis; the "Ask the notebook" + agent brainstorm pipeline.
+3. **Human authorship & curation** — human-authored entries/annotations (on images/embryos),
+ proposed→confirmed curation, supersession UI.
+4. **Active surfacing & consolidation** — structural triggers (contradiction / answered-question
+ / violated-expectation / echo), throttled one-at-a-moment; scheduled reflection passes.
+5. **Cross-system** — sharing/sync of the notebook across instances ("shared brain").
+
+**First slice = Increment 1** (the keystone): it proves the model end-to-end and turns the
+"wired but starving" problem into the first visible win. Increment 1 gets its own
+implementation plan.
+
+## 7. Out of scope (for now)
+Cross-system sync (incr. 5), proactive surfacing tuning (incr. 4) — designed here, built later.
diff --git a/docs/ux-v2-flow-audit.md b/docs/ux-v2-flow-audit.md
new file mode 100644
index 00000000..00bba3e7
--- /dev/null
+++ b/docs/ux-v2-flow-audit.md
@@ -0,0 +1,106 @@
+# UX v2 — interaction-flow / IA audit
+
+**Branch:** `feature/ux-v2` (now includes the 3D optical-space view).
+**Scope:** the *flow* of the agent-first UI — clicks, how each step renders, how the
+workspace is unveiled, moving back/forth between views, resume — **not** the visual look
+(the look is fine). Plus where the 3D optical-space view belongs in the new workspace IA.
+
+**Method:** live click-audit driven through a real browser as a *dev biologist* would use
+it, with the agent **live** (Opus 4.8, `--offline` hardware, `GENTLY_NO_AUTH=1` single
+controller), cross-checked against the code. Screenshots from the run are in `screenshots/audit-*.png`.
+
+> Correction to an earlier automated pass: the plan-wizard helpers
+> (`buildAskCard`/`answerChoice`/`togglePanel`) are **not** missing — `agent-chat.js`
+> exports them and the module loads; the plan wizard works. The real issues are below.
+
+---
+
+## What works (keep it)
+
+- **The forward path is good.** Entry → one calm choice (Plan / Quick look / "just tell me")
+ → overlay dismisses to reveal the workspace → grouped rail (NOW / LIBRARY / SYSTEM) drives
+ everything through one chokepoint (`app.js switchTab`). The welcome→workspace unveil is genuinely nice.
+- **The agent-driven plan wizard is strong.** Live, it asked a well-framed scientific question
+ ("What's the core scientific question this run should capture?") with real C. elegans options,
+ ran a `query_lab_history` tool with visible provenance, and **assembled THE PLAN panel as each
+ answer landed** (strain → wavelengths, etc.). The "plan builds as you answer" feel is excellent.
+- **The dual-render** (ask shows in the plan stage *and* the chat transcript) is implemented.
+
+---
+
+## Findings (prioritized)
+
+| # | Pri | Symptom (felt) | Root cause / evidence | Fix |
+|---|-----|----------------|------------------------|-----|
+| 1 | **P0** | First plan step sat on "working through the next step…" for **~90s** with a static spinner — feels hung. | The wait is the model *thinking*. The streaming call requests **no thinking config** and the stream loop reads only text deltas. `conversation.py:272-275` (only `output_config.effort`), `conversation.py:654-657` (only `event.delta.text`). | Set `thinking={"type":"adaptive","display":"summarized"}` on the stream (`conversation.py:552`); handle `thinking_delta` in the loop (`:654`) and emit as a `thinking` activity; render it live + add an elapsed timer. See §1. |
+| 2 | **P1** | Agent's first line renders as **"'d love to help…"** — leading "I" dropped. | Plan-feed streaming path drops the first character of the turn's first text block; the chat transcript renders it correctly (`12_41` vs `12_3` in the run). Plan feed: `landing.js applyActivity` `'text'` case (`:269`). | Most likely the first `AGENT_ACTIVITY`/`text` delta is missed by `landing.js`'s listener (subscribed after the first delta) or coalesced wrong. Confirm with a 1-line repro; the transcript path is the reference. |
+| 3 | **P1** | Clicked the primary "Plan an experiment" → plan stage spun forever; the *real* blocker ("Viewing only — control is held by another client / sign in to control") was **hidden in the chat panel**. | Control/auth state isn't surfaced on the landing/plan surface — only in the chat dock. A viewer can enter the plan flow and dead-end. | Surface control/sign-in state on the landing **before** the primary CTA; gate or relabel "Plan an experiment" when `!hasControl`; show the wall on the plan stage, not just chat. |
+| 4 | **P1** | (Structural) The same ask renders in **two** stage mounts plus the transcript. | `#v2-plan-ask` **and** `#ask-stage` both render the ask (the overlay covers the workspace copy, so only cosmetic/perf today). Two live regions seen in the run (`12_10` + `12_24`). | One stage mount at a time — suppress `#ask-stage` while the landing overlay owns the ask. |
+| 5 | **P1** | Cross-surface clear can desync. | `ASK_CLEARED` is **listened for but emitted nowhere** (`landing.js:624`, `ask-stage.js:43` listen; no emit in repo). Answering works locally because `renderAsk.onPick` clears directly, but stage↔transcript sync relies on the missing signal. | Emit `ASK_CLEARED` the instant a `choice_response` is sent (per the migration plan's Phase-1 blocker), plus on cancel/control-loss/socket-close. |
+| 6 | **P1** | **No way back.** Once the landing dismisses, there's no path back to welcome / "start a new plan" from the workspace — must reload. | `dismiss()` is one-way (`landing.js:42-54`); `V2Landing.show()` exists but is never called from the workspace. | Add a "New plan" / "Talk to Gently" entry in the rail or header that re-summons the welcome/plan surface. |
+| 7 | **P2** | Browser **Back / refresh don't mean anything**; refresh mid-plan loses state and may re-show the landing. | Entry hash is consumed (`app.js` → `replaceState('/')`, ~`:650-662`); no deliberate URL/state sync; in-memory plan state (`planKickedOff`, feed pages) resets on reload. | Real routing: sync screen/tab to URL/History so Back/forward/refresh resolve; persist or re-hydrate plan progress. |
+| 8 | **P2** | **Resume = full page reload** — jarring, re-shows landing, drops chat position. | `session_changed` → `window.location.href='/'` (`websocket.js:147`; `review.js resumeSession ~:101-116`). Flagged in the migration plan. | In-place re-hydration on `session_changed` instead of a hard reload. |
+| 9 | **P1 (IA)** | The **3D optical-space view is buried**: SYSTEM → Devices → (Map / Details / **3D**) — a sub-sub-toggle. | It was integrated into the *legacy* Devices tab structure; the ux-v2 grouped rail doesn't surface it. | Promote "the scope in space" to a first-class run-time surface (NOW tier), reconciled with the grouped rail. See §2. |
+| 10 | **P2** | Offline / agent-silent dead-ends the wizard at "working…". | `startPlan` campaign fetch falls through silently if offline (`landing.js ~:502-508`); no error path. | Timeout + inline error/retry on the plan stage. |
+
+---
+
+## §1 — Make the loading state legible (P0, the one the user wants first)
+
+The 90s "working…" is the agent reasoning. The Claude streaming API exposes this on three
+channels; gently currently surfaces none of the reasoning:
+
+- **Thinking** — `content_block_delta` → `thinking_delta`. **Opus 4.8 defaults to
+ `display:"omitted"` (empty thinking text)**, and gently doesn't set the thinking config at
+ all on the stream, so there's nothing to show. Unlock: `thinking={"type":"adaptive","display":"summarized"}`.
+- **Tool activity** — `input_json_delta` + tool start/stop. **Already flowing** — the plan feed
+ renders tool cards (saw the `query_lab_history` card with input/result).
+- **Text** — `text_delta`. Already flowing (this is the path with the bug #2 truncation).
+
+**Backend (`gently/harness/conversation.py`):**
+1. `:552` `self.claude.messages.stream(...)` — add `thinking={"type":"adaptive","display":"summarized"}`
+ (keep `output_config.effort`).
+2. `:654` event loop — currently only `if hasattr(event.delta, "text")`. Add a branch for
+ `event.delta.type == "thinking_delta"` → `yield {"type":"thinking","text": event.delta.thinking}`.
+
+**Frontend (`gently/ui/web/static/js/landing.js`):** `applyActivity` already has a `thinking`
+case (`:266`) that only sets a static label — render the streamed thinking text instead, and add
+an elapsed timer to `#v2-plan-thinking` so a long think reads as progress, not a hang.
+
+Net: the reasoning summary + current tool + a timer fill the wait. Only the backend `display`
+flag is a new capability; the rest is surfacing data gently already receives.
+
+---
+
+## §2 — Workspace organization & where the 3D view belongs (P1, IA)
+
+The ux-v2 workspace is organized differently from the old flat tab bar: a **grouped rail**
+(NOW: Home/Experiment/Embryos · LIBRARY: Plans/Sessions · SYSTEM: Devices/Calibration/Logs),
+a **session-context strip**, and the **AGENT'S VIEW** surface. The 3D optical-space view,
+however, lives in the *legacy* Devices structure (`devices.js switchView`, VIEWS =
+`['map','details','optical3d']`; `index.html` devices-content Map/Details/3D switcher).
+
+During an actual run, "where the scope is in space" + the live experiment + the agent's view are
+**NOW-tier** concerns, not a System utility three clicks deep. Proposal (to design next):
+- Promote the 3D optical-space + live experiment to a first-class run-time surface in the rail
+ (or make it the default workspace view while a run is active).
+- Keep the Devices Map/Details as the System-tier hardware utility; the 3D "scope in space"
+ graduates out of that toggle.
+
+---
+
+## Recommended sequencing
+
+1. **P0 loading state** (§1) — highest felt value, mostly surfacing existing data.
+2. **P1 quick correctness**: #2 truncation, #3 control-wall surfacing, #4 single ask mount, #5 `ASK_CLEARED` emit.
+3. **P1 reachability**: #6 "new plan"/back entry; then #9 the workspace-IA / 3D-placement redesign (its own design pass).
+4. **P2 navigation**: #7 real routing, #8 resume re-hydration, #10 offline error path.
+
+---
+
+## Notes / housekeeping
+
+- Findings 1–5, 10 verified live with the agent on; 6–9 verified from code + the live rail.
+- `screenshots/audit-*.png` (live run) and `screenshots/uxv2-*.png` are local evidence (untracked).
+- The earlier visual-design exploration (`docs/superpowers/mockups/`, `screenshots/dir-*.png`) is
+ superseded — the look is staying as-is — and can be deleted.
diff --git a/gently/app/agent.py b/gently/app/agent.py
index 4a5f6602..f68d67a7 100644
--- a/gently/app/agent.py
+++ b/gently/app/agent.py
@@ -27,16 +27,13 @@
if TYPE_CHECKING:
from ..ui.web.server import VisualizationServer
+
from gently_perception import Perceiver
from ..core import EventType, emit, get_event_bus
from ..core.file_store import FileStore
from ..harness.conversation import ConversationManager
-from ..harness.orchestration.plan_synthesis import (
- PlanLibrary,
- PlanSynthesizer,
- PlanValidator,
-)
+from ..harness.orchestration.plan_synthesis import PlanLibrary, PlanSynthesizer, PlanValidator
from ..harness.prompts.manager import PromptManager
from ..harness.session.interaction_logger import InteractionLogger
from ..harness.session.manager import SessionManager
@@ -112,12 +109,13 @@ def __init__(
# the message entry points refuse to call Claude.
self.api_enabled = not no_api
- # API client with interleaved thinking support
+ # Shared API client. No interleaved-thinking beta header: it's GA on the
+ # 4.6+ models and obsolete on Fable 5 (always-on thinking); the header is
+ # dropped so it can't conflict with the new model family.
self.claude = anthropic.Anthropic(
api_key=api_key
or os.getenv("ANTHROPIC_API_KEY")
or ("no-api-mode" if no_api else None),
- default_headers={"anthropic-beta": "interleaved-thinking-2025-05-14"},
)
self.model = model
@@ -380,11 +378,7 @@ def enter_plan_mode(self) -> str:
import gently.harness.plan_mode.tools # noqa: F401
self._update_system_prompt()
- emit(
- EventType.STATUS_CHANGED,
- {"field": "agent_mode", "value": "plan"},
- source="agent",
- )
+ emit(EventType.STATUS_CHANGED, {"field": "agent_mode", "value": "plan"}, source="agent")
logger.info("Entered plan mode")
return "Switched to plan mode. I'm now your experimental design collaborator."
@@ -480,11 +474,7 @@ def exit_plan_mode(self) -> str:
self.prompts.invalidate_context_cache()
self._update_system_prompt()
- emit(
- EventType.STATUS_CHANGED,
- {"field": "agent_mode", "value": "run"},
- source="agent",
- )
+ emit(EventType.STATUS_CHANGED, {"field": "agent_mode", "value": "run"}, source="agent")
logger.info("Exited plan mode")
return result
@@ -760,10 +750,7 @@ def on_perception(event):
self.invalidate_context_cache()
self._auto_save()
logger.info(
- "Perception: %s -> stage %s (t%s)",
- embryo_id,
- stage,
- data.get("timepoint"),
+ "Perception: %s -> stage %s (t%s)", embryo_id, stage, data.get("timepoint")
)
except Exception as e:
logger.warning(f"Error handling perception event: {e}")
@@ -1615,8 +1602,8 @@ async def check_blank_image(
img.save(buffer, format="PNG")
b64_image = base64.b64encode(buffer.getvalue()).decode()
- prompt = """Look at this microscopy image. Is this a VALID microscopy image or a
-BLANK/CORRUPTED image?
+ prompt = """\
+Look at this microscopy image. Is this a VALID microscopy image or a BLANK/CORRUPTED image?
A BLANK or CORRUPTED image shows:
- Mostly uniform gray/black with no structure
diff --git a/gently/app/detectors/dopaminergic_signal.py b/gently/app/detectors/dopaminergic_signal.py
index 7fb09408..9e9edbd9 100644
--- a/gently/app/detectors/dopaminergic_signal.py
+++ b/gently/app/detectors/dopaminergic_signal.py
@@ -273,7 +273,9 @@ async def _call_perceiver(
}
],
)
- raw = response.content[0].text if response.content else ""
+ if response.stop_reason == "refusal" or not response.content:
+ return "(perception model declined the request)", ""
+ raw = response.content[0].text
return raw.strip(), raw
async def _call_classifier(
@@ -290,7 +292,9 @@ async def _call_classifier(
max_tokens=300,
messages=[{"role": "user", "content": prompt}],
)
- raw = response.content[0].text if response.content else ""
+ if response.stop_reason == "refusal" or not response.content:
+ return dict(_DEFAULT_FINDINGS), "", "Safety refusal"
+ raw = response.content[0].text
findings, parse_err = _parse_response(raw)
return findings, raw, parse_err
diff --git a/gently/app/detectors/hatching.py b/gently/app/detectors/hatching.py
index 62dd31cb..6dc0705d 100644
--- a/gently/app/detectors/hatching.py
+++ b/gently/app/detectors/hatching.py
@@ -5,6 +5,10 @@
pipeline trains on. The dopaminergic-signal detector already returns
``has_hatched`` as part of its richer schema; this is a lighter-weight
yes/no for use cases where structure / intensity assessment isn't needed.
+
+The verdict comes back as a forced tool call (``tool_choice`` pins the model
+to ``record_hatching``), so the structured fields arrive already parsed as
+``block.input`` — no JSON-from-prose scraping, no silent-default parse layer.
"""
import asyncio
@@ -20,8 +24,9 @@
logger = logging.getLogger(__name__)
-_HATCHING_PROMPT = """You are observing a C. elegans embryo on a microscope. Decide whether
-the embryo has HATCHED.
+_HATCHING_PROMPT = """\
+You are observing a C. elegans embryo on a microscope. Decide whether the embryo has HATCHED,
+then record your decision with the record_hatching tool.
A HATCHED embryo:
- Has visibly broken out of the eggshell
@@ -32,20 +37,37 @@
- Is still contained within an intact eggshell
- May be at any pre-hatching stage (bean, comma, 1.5-fold, 2-fold, pretzel)
-Respond with ONLY a JSON object exactly matching this schema:
+Default to has_hatched=false unless you are confident. Don't over-call hatching.
+"""
-{
- "has_hatched": true|false,
- "confidence": "LOW|MEDIUM|HIGH",
- "reasoning": "..."
-}
-Default to false unless you are confident. Don't over-call hatching.
-"""
+# Forced tool schema — the model is pinned to this via tool_choice, so the
+# fields come back as a validated dict on the tool_use block. The conservative
+# "default to false" guidance lives in the prompt. We deliberately do NOT ask
+# the model to self-rate confidence — that's a heuristics-era artifact; the
+# has_hatched judgment is the signal.
+_HATCHING_TOOL = {
+ "name": "record_hatching",
+ "description": "Record whether the C. elegans embryo has hatched, with brief reasoning.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "has_hatched": {
+ "type": "boolean",
+ "description": "True only if the embryo has visibly broken out of the eggshell.",
+ },
+ "reasoning": {
+ "type": "string",
+ "description": "One short sentence citing the visual evidence for the call.",
+ },
+ },
+ "required": ["has_hatched", "reasoning"],
+ },
+}
class HatchingDetector(Detector):
- """Claude-vision hatching yes/no, with confidence."""
+ """Claude-vision hatching yes/no."""
name = "hatching"
@@ -59,7 +81,6 @@ async def run(
context: dict[str, Any],
) -> DetectorResult:
import json
- import re
import anthropic
@@ -84,7 +105,7 @@ async def run(
detector_name=self.name,
embryo_id=embryo_id,
timepoint=timepoint,
- findings={"has_hatched": False, "confidence": "LOW"},
+ findings={"has_hatched": False},
reasoning="Empty / unreadable volume",
elapsed_ms=(time.time() - start) * 1000,
)
@@ -93,7 +114,9 @@ async def run(
response = await asyncio.to_thread(
claude.messages.create,
model=self._model or settings.models.fast,
- max_tokens=200,
+ max_tokens=256,
+ tools=[_HATCHING_TOOL],
+ tool_choice={"type": "tool", "name": _HATCHING_TOOL["name"]},
messages=[
{
"role": "user",
@@ -111,23 +134,24 @@ async def run(
}
],
)
- raw = response.content[0].text if response.content else ""
- findings = {"has_hatched": False, "confidence": "LOW"}
+ # Forced tool_choice guarantees a tool_use block; read its parsed
+ # input directly. No regex, no JSON-from-prose fallback.
+ tool_input = next(
+ (b.input for b in response.content if getattr(b, "type", None) == "tool_use"),
+ None,
+ )
+
+ findings = {"has_hatched": False}
reasoning = None
err = None
- try:
- m = re.search(r"\{.*?\}", raw, re.DOTALL)
- blob = m.group(0) if m else raw.strip()
- parsed = json.loads(blob)
- findings["has_hatched"] = bool(parsed.get("has_hatched", False))
- confidence = str(parsed.get("confidence", "LOW")).upper()
- if confidence not in {"LOW", "MEDIUM", "HIGH"}:
- confidence = "LOW"
- findings["confidence"] = confidence
- reasoning = parsed.get("reasoning")
- except (json.JSONDecodeError, AttributeError) as e:
- err = f"parse error: {e}"
+ if isinstance(tool_input, dict):
+ findings["has_hatched"] = bool(tool_input.get("has_hatched", False))
+ reasoning = tool_input.get("reasoning")
+ else:
+ # Shouldn't happen with forced tool_choice — keep the
+ # conservative default and record why.
+ err = "no tool_use block in response"
return DetectorResult(
detector_name=self.name,
@@ -135,7 +159,7 @@ async def run(
timepoint=timepoint,
findings=findings,
reasoning=reasoning,
- raw_response=raw,
+ raw_response=json.dumps(tool_input) if isinstance(tool_input, dict) else None,
elapsed_ms=(time.time() - start) * 1000,
error=err,
)
diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py
index f4a2692a..6b140922 100644
--- a/gently/app/orchestration/timelapse.py
+++ b/gently/app/orchestration/timelapse.py
@@ -231,6 +231,13 @@ async def start(
# Parse stop condition
stop_cond = self._parse_stop_condition(stop_condition, condition_value)
+ # Tolerate a comma-separated string: some agent tool calls pass
+ # embryo_ids as "embryo_1,embryo_2" rather than a JSON list. Without
+ # this, the membership check below iterates the string character by
+ # character and reports every letter as a missing embryo.
+ if isinstance(embryo_ids, str):
+ embryo_ids = [e.strip() for e in embryo_ids.split(",") if e.strip()]
+
# Get embryo list
if not embryo_ids:
embryo_ids = [e.id for e in self.experiment.embryos.values() if not e.should_skip]
diff --git a/gently/app/tools/acquisition_tools.py b/gently/app/tools/acquisition_tools.py
index 8efcef79..79f69732 100644
--- a/gently/app/tools/acquisition_tools.py
+++ b/gently/app/tools/acquisition_tools.py
@@ -6,6 +6,8 @@
import asyncio
import logging
+import time
+from typing import Any
import numpy as np
@@ -15,6 +17,62 @@
logger = logging.getLogger(__name__)
+def _publish_scan_geometry(
+ agent: Any,
+ *,
+ embryo_id: str,
+ stage_position: dict | None,
+ num_slices: int,
+ exposure_ms: float,
+ galvo_amplitude: float,
+ galvo_center: float,
+ piezo_amplitude: float,
+ piezo_center: float,
+) -> None:
+ """Emit SCAN_GEOMETRY_UPDATE describing the cuboid being acquired.
+
+ Drives the 3D optical-space view (the addressable volume + the scan cuboid
+ and light-sheet mode). Telemetry only — callers guard against exceptions so
+ this never interferes with an acquisition. The payload is also stashed on
+ the agent for REST bootstrap (``/api/devices/scan_geometry``).
+ """
+ from gently.core import EventType, get_event_bus
+
+ z_extent_um = 2.0 * piezo_amplitude
+ slice_spacing_um = z_extent_um / (num_slices - 1) if num_slices > 1 else 0.0
+ sx = stage_position.get("x") if stage_position else None
+ sy = stage_position.get("y") if stage_position else None
+
+ payload: dict[str, Any] = {
+ "embryo_id": embryo_id,
+ "stage_position_um": {"x": sx, "y": sy},
+ "scan": {
+ "num_slices": num_slices,
+ "exposure_ms": exposure_ms,
+ "galvo_amplitude_deg": galvo_amplitude,
+ "galvo_center_deg": galvo_center,
+ "piezo_amplitude_um": piezo_amplitude,
+ "piezo_center_um": piezo_center,
+ },
+ "derived": {
+ "z_extent_um": z_extent_um,
+ "slice_spacing_um": slice_spacing_um,
+ "z_min_um": piezo_center - piezo_amplitude,
+ "z_max_um": piezo_center + piezo_amplitude,
+ },
+ # diSPIM here is scanned-light-sheet only; a future pencil/beam tool
+ # would emit "pencil". See the 3D optical-space view notes.
+ "mode": "sheet",
+ "ts": time.time(),
+ }
+ agent.last_scan_geometry = payload
+ get_event_bus().publish(
+ event_type=EventType.SCAN_GEOMETRY_UPDATE,
+ data=payload,
+ source="acquisition-tools",
+ )
+
+
@tool(
name="acquire_volume",
description="""Acquire a single 3D lightsheet volume for a specific embryo. Moves to embryo
@@ -88,6 +146,23 @@ async def acquire_volume(
piezo_amplitude = piezo_amplitude + (additional_buffer_um * abs(slope) / 100.0)
z_buffer_applied = z_buffer_um
+ # Publish the resolved scan geometry for the 3D optical-space view.
+ # Telemetry only — must never break the acquisition.
+ try:
+ _publish_scan_geometry(
+ agent,
+ embryo_id=embryo_id,
+ stage_position=pos,
+ num_slices=num_slices,
+ exposure_ms=exposure_ms,
+ galvo_amplitude=galvo_amplitude,
+ galvo_center=galvo_center,
+ piezo_amplitude=piezo_amplitude,
+ piezo_center=piezo_center,
+ )
+ except Exception:
+ logger.debug("SCAN_GEOMETRY_UPDATE publish failed", exc_info=True)
+
result = await client.acquire_volume(
num_slices=num_slices,
exposure_ms=exposure_ms,
diff --git a/gently/app/tools/memory_tools.py b/gently/app/tools/memory_tools.py
index d58d3e5d..401d0bec 100644
--- a/gently/app/tools/memory_tools.py
+++ b/gently/app/tools/memory_tools.py
@@ -129,3 +129,57 @@ async def recall_context(
if not memory:
return "No memory available (context store not connected)"
return memory.recall_full_context(campaign_id=campaign_id)
+
+
+@tool(
+ name="record_note",
+ description=(
+ "Record a note from the researcher into the shared lab notebook. Use this when the "
+ "user says 'note that…', 'add a note…', 'remember that…'. First tidy the phrasing for "
+ "clarity — keep their meaning and any specifics (numbers, strains, stages) — then save. "
+ "The note is attributed to the human and tagged to the current session."
+ ),
+ category=ToolCategory.UTILITY,
+ examples=[
+ ToolExample(
+ user_query="Note that the 4-embryo test ran clean — 329 timepoints, system nominal.",
+ tool_input={
+ "text": "Test run on 4 calibration embryos was clean: 329 timepoints, "
+ "system behaved as expected."
+ },
+ ),
+ ],
+)
+async def record_note(
+ text: str,
+ embryos: list[str] | None = None,
+ strains: list[str] | None = None,
+ context: dict | None = None,
+) -> str:
+ """Write a human-authored note into the notebook, tagged to the current session."""
+ agent = context.get("agent") if context else None
+ cs = getattr(agent, "context_store", None) if agent else None
+ if cs is None:
+ return "No notebook available (context store not connected)"
+ from gently.harness.memory.notebook import Author, Note, NoteKind
+
+ session_id = getattr(agent, "session_id", None)
+ note = Note(
+ id="",
+ kind=NoteKind.OBSERVATION,
+ body=text,
+ author=Author.HUMAN,
+ sessions=[session_id] if session_id else [],
+ embryos=embryos or [],
+ strains=strains or [],
+ )
+ note_id = cs.notebook.write_note(note)
+ # Refresh the Notebook tab + Agent's-view live edge (both ride CONTEXT_UPDATED).
+ try:
+ from gently.core.event_bus import EventType, emit
+
+ emit(EventType.CONTEXT_UPDATED, {"kind": "note"}, source="record_note")
+ except Exception:
+ pass
+ scope = "this session" if session_id else "the notebook (no active session)"
+ return f"Noted (id {note_id}) — saved to {scope}, attributed to you."
diff --git a/gently/app/tools/plan_execution_tools.py b/gently/app/tools/plan_execution_tools.py
index f07e8af0..6fba52e1 100644
--- a/gently/app/tools/plan_execution_tools.py
+++ b/gently/app/tools/plan_execution_tools.py
@@ -107,24 +107,9 @@ async def execute_plan_item(
except Exception as e:
actions.append(f"detector '{det_name}' failed: {e}")
- # 6. Link session to campaign
- session_id = getattr(agent, "session_id", None)
- if session_id:
- try:
- cs.link_session_campaign(session_id, item.campaign_id)
- actions.append("session linked to campaign")
- except Exception:
- pass
-
- # 7. Update plan item status
- cs.update_plan_item(
- item_id=item.id,
- status=PlanItemStatus.IN_PROGRESS,
- session_id=session_id,
- )
- actions.append("plan item status → in_progress")
-
- # 8. Start timelapse via orchestrator
+ # 6. Start timelapse via orchestrator — this is what activates the session, so the
+ # plan↔session link must happen AFTER it (step 7), not before (the old bug:
+ # agent.session_id was still None here, so the link silently dropped).
orchestrator = getattr(agent, "timelapse_orchestrator", None)
if orchestrator:
try:
@@ -158,7 +143,22 @@ async def execute_plan_item(
except Exception as e:
actions.append(f"timelapse start error: {e}")
- # 10. Summary
+ # 7. Link this run to the plan item + campaign — AFTER start, so the session exists.
+ # Appends (an item may run several sessions). Surface failures, don't swallow.
+ session_id = getattr(agent, "session_id", None)
+ if session_id:
+ try:
+ cs.link_session_campaign(session_id, item.campaign_id)
+ cs.link_plan_item_session(item.id, session_id)
+ actions.append(
+ f"linked session {session_id[:8]} → plan item + campaign (status → in_progress)"
+ )
+ except Exception as e:
+ actions.append(f"⚠ link failed: {e}")
+ else:
+ actions.append("⚠ no active session — could not link this run to the plan item")
+
+ # Summary
lines = [f"Executing plan item: {item.title}"]
if spec.strain:
lines.append(f" Strain: {spec.strain}")
diff --git a/gently/app/tools/resolution_tools.py b/gently/app/tools/resolution_tools.py
index c3a152da..3ae8b4cc 100644
--- a/gently/app/tools/resolution_tools.py
+++ b/gently/app/tools/resolution_tools.py
@@ -112,15 +112,17 @@ async def attach_session_to_plan(
# Two sources of truth for the active plan item
_set_active_plan_item(agent, item.id)
- # Link the session into the campaign's intent record
+ # Link the session into the campaign's intent record AND onto the plan item
+ # itself (item-level, appends — an item may have several sessions).
session_id = getattr(agent, "session_id", None)
linked = False
if session_id:
try:
cs.link_session_campaign(session_id, item.campaign_id)
+ cs.link_plan_item_session(item.id, session_id)
linked = True
except Exception as e:
- logger.warning(f"link_session_campaign failed: {e}")
+ logger.warning(f"session↔plan link failed: {e}")
# Invalidate prompt cache so the next system prompt picks up the
# active item (the memory awareness layer injects its spec).
diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py
index 3b98e616..ce3aac03 100644
--- a/gently/core/event_bus.py
+++ b/gently/core/event_bus.py
@@ -86,12 +86,21 @@ class EventType(Enum):
DEVICE_STATE_UPDATE = auto() # Periodic device-state snapshot from device layer
BOTTOM_CAMERA_FRAME = auto() # Live JPEG frame from the bottom camera stream
EMBRYOS_UPDATE = auto() # Full embryo list snapshot from agent.experiment
+ SCAN_GEOMETRY_UPDATE = auto() # Scan cuboid + light-sheet mode for the 3D optical-space view
# Python logging.LogRecord republished onto the bus so the Events page
# surfaces what would otherwise only land in the terminal. See
# gently/core/log_bridge.py — opt-in handler.
LOG_RECORD = auto()
+ # Agent context/mind updates (expectations / watchpoints / questions) —
+ # drives the shared-visibility surface in the v2 UI.
+ CONTEXT_UPDATED = auto()
+
+ # Plan/campaign mutated (item status, session link, new item, progress) —
+ # drives live refresh of the Plans UI.
+ PLAN_UPDATED = auto()
+
# Operator-action events. Distinct from EMBRYOS_UPDATE because they
# carry intent ("a human did this") rather than just state delta.
# Candidate orchestrators can subscribe and reason about what the
diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py
index 2e4567e1..56a9a33c 100644
--- a/gently/hardware/dispim/client.py
+++ b/gently/hardware/dispim/client.py
@@ -374,8 +374,18 @@ async def get_stage_position(self) -> tuple[float, float]:
events = docs.get("events", [])
if events:
data = events[0].get("data", {})
- # Look for stage coordinates
- for key in ["XY:31", "xy_stage", "stage"]:
+ # Look for stage coordinates. Keys must match what
+ # read_stage_plan (bp.count on the xy_stage device) actually
+ # emits — the device-layer's own handle_detect_embryos uses
+ # "XYStage:XY:31"/"xy_stage_position", so include those here too
+ # (the bare "XY:31" was stale and never matched).
+ for key in [
+ "xy_stage",
+ "XYStage:XY:31",
+ "xy_stage_position",
+ "XY:31",
+ "stage",
+ ]:
if key in data:
val = data[key]
if isinstance(val, (list, tuple)) and len(val) >= 2:
@@ -1244,7 +1254,8 @@ async def capture_for_marking(
self._ensure_connected()
try:
- snap = await self.capture_bottom_image(use_led=True, exposure_ms=exposure_ms)
+ # No LED ever — the bottom camera images under room light only.
+ snap = await self.capture_bottom_image(use_led=False, exposure_ms=exposure_ms)
image = snap["image"]
if image is None or (image.shape == (100, 100) and image.max() == 0):
diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py
index 6d606621..55e8225d 100644
--- a/gently/hardware/dispim/device_layer.py
+++ b/gently/hardware/dispim/device_layer.py
@@ -1428,6 +1428,26 @@ def _room_light_device(self):
return dev
return None
+ async def _set_room_light(self, state: str) -> bool:
+ """Drive the room-light SwitchBot to ``state`` ('on'/'off'/'press').
+
+ Blocks until the BLE command lands (or times out). Returns True on
+ success, False if no bot is configured or the command failed. Shared
+ by the HTTP handler and internal callers (e.g. detect_embryos, which
+ images under room light rather than the camera LED).
+ """
+ bot = self._room_light_device()
+ if bot is None:
+ return False
+ import time
+
+ status = bot.set(state)
+ timeout = float(getattr(bot, "timeout", 20.0)) + 5
+ start = time.time()
+ while not status.done and (time.time() - start) < timeout:
+ await asyncio.sleep(0.1)
+ return bool(status.done and status.success)
+
async def handle_get_room_light_status(self, request):
"""GET /api/room_light/status - cached on/off state of the room light.
@@ -1931,6 +1951,21 @@ async def handle_detect_embryos(self, request):
if bottom_camera:
bottom_camera.configure_exposure(exposure_ms)
+ # Detection always images under ROOM LIGHT, never the camera LED.
+ # use_led is a persistent device flag that other flows (e.g. manual
+ # marking via capture_for_marking) leave switched on, so force it
+ # off here and turn the room light on so there's illumination.
+ bottom_camera = self.devices.get("bottom_camera")
+ if bottom_camera is not None:
+ bottom_camera.use_led = False
+ if await self._set_room_light("on"):
+ logger.info("[detect_embryos] Room light ON, camera LED disabled")
+ else:
+ logger.warning(
+ "[detect_embryos] Could not turn room light on "
+ "(no SwitchBot configured?) — capturing under ambient light"
+ )
+
# Capture image via plan
logger.info("[detect_embryos] Capturing bottom camera image...")
capture_result = await self.submit_plan(
diff --git a/gently/hardware/dispim/devices/camera.py b/gently/hardware/dispim/devices/camera.py
index 5141d6d4..505dcf3b 100644
--- a/gently/hardware/dispim/devices/camera.py
+++ b/gently/hardware/dispim/devices/camera.py
@@ -388,7 +388,9 @@ def __init__(
self.pixel_size_um = pixel_size_um
self.magnification = magnification
self.effective_pixel_size = pixel_size_um / magnification
- self.use_led = False # Set to True to enable automatic LED control
+ # Retained for API compatibility but ignored: the bottom camera never
+ # drives the LED (see trigger()). Imaging uses room light only.
+ self.use_led = False
def pixel_to_um(self, pixels: float) -> float:
"""
@@ -408,14 +410,12 @@ def pixel_to_um(self, pixels: float) -> float:
def trigger(self):
"""
- Trigger image acquisition with optional LED management.
+ Trigger image acquisition.
- Overrides parent trigger() to add LED control (if use_led=True):
- 1. Turn LED on (if use_led=True)
- 2. Capture image
- 3. Turn LED off (if use_led=True, always even on error)
-
- Set self.use_led = False to capture without LED (ambient light only).
+ The bottom camera NEVER drives the LED — imaging is done under room
+ light only. The ``use_led`` flag is retained for API compatibility but
+ is intentionally ignored here so that no caller (manual marking,
+ detection, live preview, …) can ever flash the LED.
Returns
-------
@@ -426,28 +426,13 @@ def trigger(self):
def wait():
try:
- # Turn LED on for transmitted light imaging (if enabled)
- if self.use_led:
- self.led_control.set("Open").wait(timeout=5)
- time.sleep(0.1) # Allow LED to stabilize
-
- # Capture image
+ # LED is never used — capture under ambient/room light only.
self._ensure_active()
self.core.snapImage()
self._last_image = _safe_obtain(self.core.getImage())
self._last_image_time = time.time()
- # Turn LED off (if enabled - important to prevent sample heating!)
- if self.use_led:
- self.led_control.set("Closed").wait(timeout=5)
-
except Exception as exc:
- # Critical: always turn off LED even on error (if enabled)
- if self.use_led:
- try:
- self.led_control.set("Closed").wait(timeout=5)
- except Exception:
- pass
status.set_exception(exc)
else:
status.set_finished()
diff --git a/gently/hardware/dispim/sam_detection.py b/gently/hardware/dispim/sam_detection.py
index cea9990a..45355438 100644
--- a/gently/hardware/dispim/sam_detection.py
+++ b/gently/hardware/dispim/sam_detection.py
@@ -18,16 +18,15 @@
import numpy as np
from PIL import Image
-from gently.settings import settings
-
-logger = logging.getLogger(__name__)
-
-from gently.core.coordinates import ( # noqa: E402
+from gently.core.coordinates import (
DEFAULT_OBJECTIVE_MAG,
DEFAULT_PIXEL_SIZE_UM,
get_um_per_pixel,
pixel_to_stage_position,
)
+from gently.settings import settings
+
+logger = logging.getLogger(__name__)
class SAMEmbryoDetector:
@@ -756,8 +755,8 @@ async def _review_with_claude(
image_base64 = self._encode_image_base64(annotated)
- prompt = f"""You are a microscopy expert analyzing embryo detections from a bottom
-camera view.
+ prompt = f"""\
+You are a microscopy expert analyzing embryo detections from a bottom camera view.
CURRENT DETECTIONS: {len(embryos)} embryos labeled 0-{len(embryos) - 1} with colored bounding boxes.
@@ -789,7 +788,9 @@ async def _review_with_claude(
message = self.claude_client.messages.create(
model=settings.models.perception,
max_tokens=8000,
- thinking={"type": "enabled", "budget_tokens": 5000},
+ output_config={
+ "effort": "high"
+ }, # was thinking budget_tokens (Opus 4.8 rejects it)
messages=[
{
"role": "user",
@@ -859,7 +860,9 @@ async def _verify_with_claude(
message = self.claude_client.messages.create(
model=settings.models.perception,
max_tokens=6000,
- thinking={"type": "enabled", "budget_tokens": 4000},
+ output_config={
+ "effort": "high"
+ }, # was thinking budget_tokens (Opus 4.8 rejects it)
messages=[
{
"role": "user",
diff --git a/gently/harness/bridge.py b/gently/harness/bridge.py
index ddbfa9ca..fdd21040 100644
--- a/gently/harness/bridge.py
+++ b/gently/harness/bridge.py
@@ -251,9 +251,12 @@ def _candidate_to_option(self, item, spec, campaign) -> dict:
spec_dict: dict[str, Any] = {}
for field in (
"strain",
+ "genotype",
+ "reporter",
"temperature_c",
"num_slices",
"exposure_ms",
+ "laser_wavelength_nm",
"interval_s",
"stop_condition",
"success_criteria",
@@ -261,6 +264,11 @@ def _candidate_to_option(self, item, spec, campaign) -> dict:
val = getattr(spec, field, None)
if val is not None:
spec_dict[field] = val
+ # Carry per-field provenance so the UI can tag inferred values
+ # (e.g. "561 nm · inferred · medium") and show what to confirm.
+ prov = getattr(spec, "provenance", None)
+ if prov:
+ spec_dict["provenance"] = prov
if spec_dict:
meta["spec"] = spec_dict
diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py
index e154c724..a3955df0 100644
--- a/gently/harness/conversation.py
+++ b/gently/harness/conversation.py
@@ -12,6 +12,8 @@
import time
from typing import Any
+from ..settings import settings
+
logger = logging.getLogger(__name__)
@@ -158,15 +160,13 @@ def should_use_thinking(self, message: str, mode: str) -> bool:
if re.search(r"\b(plan|timelapse|time-lapse|acquisition)\b", msg_lower):
return True
if re.search(
- r"\b(analy[sz]e|look at|check|inspect|review).*(image|volume|embryo)",
- msg_lower,
+ r"\b(analy[sz]e|look at|check|inspect|review).*(image|volume|embryo)", msg_lower
):
return True
if re.search(r"\b(all|every|each)\s+(embryo|sample)", msg_lower):
return True
if re.search(
- r"\b(first|then|after|next|finally)\b.*\b(first|then|after|next|finally)\b",
- msg_lower,
+ r"\b(first|then|after|next|finally)\b.*\b(first|then|after|next|finally)\b", msg_lower
):
return True
if re.search(r"\b(why|problem|issue|error|wrong|fail|debug|troubleshoot)", msg_lower):
@@ -176,6 +176,33 @@ def should_use_thinking(self, message: str, mode: str) -> bool:
# ===== Non-Streaming API Call =====
+ async def _create_with_refusal_fallback(self, api_kwargs):
+ """messages.create with main-tier resilience: if the model rejects the
+ request with a 400 (e.g. Fable 5 under <30-day org data retention, or
+ unavailable) OR declines it (stop_reason="refusal", empty content), retry
+ the SAME request once on the fallback model (Opus 4.8) — so gently keeps
+ working whether or not Fable 5 is currently serviceable. The moment the
+ org retention is fixed, Fable 5 serves with no code change."""
+ from anthropic import BadRequestError
+
+ fb = settings.models.refusal_fallback
+ model = api_kwargs.get("model")
+ try:
+ response = await self._call_api_with_retry(self.claude.messages.create, **api_kwargs)
+ except BadRequestError:
+ if not fb or fb == model:
+ raise
+ logger.warning("Model %s rejected the request (400); falling back to %s", model, fb)
+ return await self._call_api_with_retry(
+ self.claude.messages.create, **{**api_kwargs, "model": fb}
+ )
+ if response.stop_reason == "refusal" and fb and fb != model:
+ logger.warning("Model %s declined the turn; retrying on %s", model, fb)
+ response = await self._call_api_with_retry(
+ self.claude.messages.create, **{**api_kwargs, "model": fb}
+ )
+ return response
+
async def call_claude(
self, user_message: str, system_prompt, tools, mode: str, auto_save_fn
) -> str:
@@ -243,10 +270,11 @@ async def call_claude(
"max_tokens": 16000 if use_thinking else 4096,
}
if use_thinking:
- budget = 30000 if mode == "plan" else 10000
- api_kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
+ # Fable 5 / Opus 4.8 reject thinking budget_tokens (400) — thinking
+ # is adaptive; control depth via effort instead of a token budget.
+ api_kwargs["output_config"] = {"effort": "high" if mode == "plan" else "medium"}
- response = await self._call_api_with_retry(self.claude.messages.create, **api_kwargs)
+ response = await self._create_with_refusal_fallback(api_kwargs)
self._track_token_usage(response)
_extend_tool_calls(tool_calls_collected, response.content)
@@ -258,17 +286,21 @@ async def call_claude(
self.conversation_history.append({"role": "user", "content": tool_results})
api_kwargs["messages"] = self.conversation_history
- response = await self._call_api_with_retry(
- self.claude.messages.create, **api_kwargs
- )
+ response = await self._create_with_refusal_fallback(api_kwargs)
self._track_token_usage(response)
_extend_tool_calls(tool_calls_collected, response.content)
- # Extract text response
- assistant_message = ""
- for block in response.content:
- if hasattr(block, "text"):
- assistant_message += block.text
+ # Extract text response. Fable 5 may refuse (stop_reason="refusal")
+ # with empty content — surface it instead of returning blank.
+ if response.stop_reason == "refusal":
+ assistant_message = (
+ "(The request was declined by the model's safety system. Try rephrasing.)"
+ )
+ else:
+ assistant_message = ""
+ for block in response.content:
+ if hasattr(block, "text"):
+ assistant_message += block.text
self.conversation_history.append({"role": "assistant", "content": response.content})
@@ -399,6 +431,9 @@ async def get_tool_call(self, user_message: str, system_prompt, tools) -> dict |
input_tokens = getattr(response.usage, "input_tokens", 0)
output_tokens = getattr(response.usage, "output_tokens", 0)
+ # A refusal returns empty content — treat as "no tool call".
+ if response.stop_reason == "refusal" or not response.content:
+ return None
for block in response.content:
if block.type == "tool_use":
return {
@@ -509,71 +544,164 @@ async def call_claude_stream(self, system_prompt, tools, tool_label_fn, auto_sav
dict
Chunks as they arrive from Claude
"""
- from anthropic import APIStatusError
-
- def stream_and_collect():
- events = []
- final_message = None
-
- with self.claude.messages.stream(
- model=self.model,
- system=system_prompt,
- messages=self.conversation_history,
- tools=tools,
- max_tokens=4096,
- ) as stream:
- for event in stream:
- events.append(event)
- final_message = stream.get_final_message()
-
- return events, final_message
+ from anthropic import APIStatusError, BadRequestError
+
+ # Live streaming: a worker thread drains the SDK's (blocking) stream and
+ # pushes each event onto an asyncio queue as it arrives, so this coroutine
+ # can yield text/thinking deltas in real time instead of collecting the
+ # whole turn first (which left the UI on a blank spinner for the entire
+ # turn). thinking=summarized surfaces the model's reasoning during the
+ # wait. The full assistant content (incl. thinking blocks) is replayed from
+ # final_message below, so the tool-loop continuation stays valid.
+ _DONE = object()
+
+ async def _stream_live(model, sink):
+ """Stream one attempt live: yield delta dicts as they arrive; record
+ events / final_message / error / full_text into `sink`."""
+ loop = asyncio.get_running_loop()
+ queue: asyncio.Queue = asyncio.Queue()
+ state: dict = {}
+
+ def worker():
+ try:
+ with self.claude.messages.stream(
+ model=model,
+ system=system_prompt,
+ messages=self.conversation_history,
+ tools=tools,
+ max_tokens=16000,
+ # Adaptive thinking with a streamed, human-readable summary —
+ # this is what fills the "working…" wait. Opus 4.8 defaults to
+ # display="omitted" (empty thinking text), so it must be set.
+ thinking={"type": "adaptive", "display": "summarized"},
+ output_config={"effort": "medium"},
+ ) as stream:
+ for event in stream:
+ loop.call_soon_threadsafe(queue.put_nowait, event)
+ state["final"] = stream.get_final_message()
+ except BaseException as exc: # noqa: BLE001 — re-raised to caller below
+ state["error"] = exc
+ finally:
+ loop.call_soon_threadsafe(queue.put_nowait, _DONE)
+
+ task = asyncio.create_task(asyncio.to_thread(worker))
+ events: list = []
+ full_text: list = []
+ while True:
+ item = await queue.get()
+ if item is _DONE:
+ break
+ events.append(item)
+ if item.type != "content_block_delta":
+ continue
+ delta = item.delta
+ dtype = getattr(delta, "type", None)
+ if dtype == "thinking_delta":
+ chunk = getattr(delta, "thinking", "") or ""
+ if chunk:
+ yield {"type": "thinking", "text": chunk}
+ elif dtype == "text_delta" or hasattr(delta, "text"):
+ chunk = getattr(delta, "text", "") or ""
+ if chunk:
+ full_text.append(chunk)
+ yield {"type": "text", "text": chunk}
+ await task
+ sink["events"] = events
+ sink["full_text"] = full_text
+ sink["final"] = state.get("final")
+ sink["error"] = state.get("error")
- # Run streaming in thread with retry logic
max_retries = 3
retry_delay = 1.0
+ fb = settings.models.refusal_fallback
+ model_in_use = self.model
+ sink: dict = {}
for attempt in range(max_retries):
- try:
- events, final_message = await asyncio.to_thread(stream_and_collect)
- self._track_token_usage(final_message)
+ sink = {}
+ yielded_any = False
+ async for chunk in _stream_live(model_in_use, sink):
+ yielded_any = True
+ yield chunk
+ err = sink.get("error")
+ if err is None:
break
- except APIStatusError as e:
- error_type = getattr(e, "body", {})
+ # Fable 5 under <30-day data retention (or unavailable) rejects with a
+ # 400 — fall back to Opus 4.8. Only safe before any partial was streamed.
+ if isinstance(err, BadRequestError) and fb and fb != model_in_use and not yielded_any:
+ logger.warning(
+ "Stream model %s rejected the request (400); falling back to %s",
+ model_in_use,
+ fb,
+ )
+ model_in_use = fb
+ continue
+ if isinstance(err, APIStatusError):
+ error_type = getattr(err, "body", {})
if isinstance(error_type, dict):
error_type = error_type.get("error", {}).get("type", "")
-
- if (
+ overloaded = (
error_type in ("overloaded_error", "rate_limit_error")
- or "overloaded" in str(e).lower()
- ):
- if attempt < max_retries - 1:
- wait_time = retry_delay * (2**attempt)
- logger.warning(
- f"API overloaded, retrying in {wait_time:.1f}s"
- f" (attempt {attempt + 1}/{max_retries})"
- )
- yield {
- "type": "text",
- "text": f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n",
- }
- await asyncio.sleep(wait_time)
- continue
- raise
+ or "overloaded" in str(err).lower()
+ )
+ if overloaded and attempt < max_retries - 1 and not yielded_any:
+ wait_time = retry_delay * (2**attempt)
+ logger.warning(
+ "API overloaded, retrying in %.1fs (attempt %d/%d)",
+ wait_time,
+ attempt + 1,
+ max_retries,
+ )
+ yield {
+ "type": "text",
+ "text": f"\n*[API busy, retrying in {wait_time:.0f}s...]*\n",
+ }
+ await asyncio.sleep(wait_time)
+ continue
+ raise err
else:
raise RuntimeError("API overloaded after multiple retries")
- # Diagnostic: log stop_reason and tool block counts
+ final_message = sink["final"]
+ full_text = sink["full_text"]
+ self._track_token_usage(final_message)
+
+ # Refusal → retry on the fallback model. Re-streaming live is only safe when
+ # the refusal came before any visible output (pre-output refusals carry empty
+ # content, so nothing was yielded); otherwise we keep the partial we showed.
+ if final_message.stop_reason == "refusal" and fb and model_in_use != fb and not full_text:
+ logger.warning("Model %s declined the streamed turn; retrying on %s", model_in_use, fb)
+ sink = {}
+ async for chunk in _stream_live(fb, sink):
+ yield chunk
+ model_in_use = fb
+ final_message = sink["final"]
+ full_text = sink["full_text"]
+ self._track_token_usage(final_message)
+
+ # Last resort: if even the fallback declined, surface it and stop.
+ if final_message.stop_reason == "refusal":
+ logger.warning("Claude declined the request (model=%s)", model_in_use)
+ yield {
+ "type": "text",
+ "text": "(The request was declined by the model's safety system. Try rephrasing.)",
+ }
+ return
+
+ # Diagnostic: per-response counts. DEBUG, not WARNING — stop_reason=tool_use
+ # with matching tool blocks is normal; the genuine anomaly is the
+ # logger.error below (tool blocks present but stop_reason != tool_use).
tool_block_count = sum(
1 for b in final_message.content if hasattr(b, "type") and b.type == "tool_use"
)
- logger.warning(
- "Claude response: stop_reason=%s, content_blocks=%d, tool_use_blocks=%d,"
- " tools_passed=%d, model=%s",
+ logger.debug(
+ "Claude response: stop_reason=%s, content_blocks=%d, "
+ "tool_use_blocks=%d, tools_passed=%d, model=%s",
final_message.stop_reason,
len(final_message.content),
tool_block_count,
len(tools),
- self.model,
+ model_in_use,
)
if tool_block_count > 0 and final_message.stop_reason != "tool_use":
logger.error(
@@ -582,14 +710,6 @@ def stream_and_collect():
final_message.stop_reason,
)
- # Process events and yield text
- full_text = []
- for event in events:
- if event.type == "content_block_delta":
- if hasattr(event.delta, "text"):
- full_text.append(event.delta.text)
- yield {"type": "text", "text": event.delta.text}
-
# Detect fake XML tool calls in text (Claude writing tool_use as text)
joined_text = "".join(full_text)
if "" in joined_text or "" in joined_text:
@@ -610,7 +730,94 @@ def stream_and_collect():
await asyncio.sleep(0.05)
tool_results = []
+
+ # Concurrency fast-path: run a turn's tool calls in parallel when ALL of
+ # them are non-hardware and non-interactive (e.g. several strain / paper /
+ # lab-history lookups). Any microscope action or ask_user_choice in the
+ # batch falls back to the serial path below, so we never race hardware or
+ # an interactive prompt, and ordering of stateful ops is preserved.
+ tool_blocks = [b for b in response_content if getattr(b, "type", None) == "tool_use"]
+ _interactive = {"ask_user_choice"}
+ # Only parallelize genuinely read-only tools (independent lookups). Mutating
+ # tools (create_/update_/delete_/set_…) must stay serial — they share state
+ # (e.g. a campaign's plan file) and are order-dependent, so concurrent runs
+ # could race or corrupt it.
+ _readonly_prefixes = (
+ "search_",
+ "read_",
+ "query_",
+ "get_",
+ "list_",
+ "recall_",
+ "find_",
+ "fetch_",
+ "lookup_",
+ )
+
+ def _parallel_safe(b):
+ td = self._tool_registry.get(b.name)
+ return (
+ td is not None
+ and not td.requires_microscope
+ and b.name not in _interactive
+ and b.name.startswith(_readonly_prefixes)
+ )
+
+ handled_parallel = False
+ if len(tool_blocks) > 1 and all(_parallel_safe(b) for b in tool_blocks):
+ handled_parallel = True
+ starts = {b.id: time.time() for b in tool_blocks}
+ for b in tool_blocks:
+ yield {
+ "type": "tool_start",
+ "tool_name": b.name,
+ "tool_input": b.input,
+ "tool_label": tool_label_fn(b.name, b.input),
+ }
+ gathered = await asyncio.gather(
+ *[self._execute_single_tool(b.name, b.input) for b in tool_blocks],
+ return_exceptions=True,
+ )
+ for b, res in zip(tool_blocks, gathered, strict=True):
+ if isinstance(res, BaseException):
+ is_error_flag = True
+ result_text = f"Error: {res}"
+ tool_results.append(
+ {
+ "type": "tool_result",
+ "tool_use_id": b.id,
+ "content": result_text,
+ "is_error": True,
+ }
+ )
+ else:
+ is_error_flag = False
+ result_text = res if isinstance(res, str) else str(res)
+ tool_results.append(
+ {"type": "tool_result", "tool_use_id": b.id, "content": res}
+ )
+ result_summary = next(
+ (ln.strip() for ln in (result_text or "").splitlines() if ln.strip()),
+ "",
+ )
+ if len(result_summary) > 140:
+ result_summary = result_summary[:139] + "…"
+ result_full = result_text or ""
+ if len(result_full) > 4000:
+ result_full = result_full[:4000] + "\n…(truncated)"
+ yield {
+ "type": "tool_call",
+ "tool_name": b.name,
+ "tool_input": b.input,
+ "duration": time.time() - starts[b.id],
+ "result_summary": result_summary,
+ "result_full": result_full,
+ "is_error": is_error_flag,
+ }
+
for block in response_content:
+ if handled_parallel:
+ break
if hasattr(block, "type") and block.type == "tool_use":
start_time = time.time()
@@ -628,9 +835,7 @@ def stream_and_collect():
if isinstance(tool_result, str):
try:
- from gently.app.tools.interaction_tools import (
- CHOICE_RESPONSE_TYPE,
- )
+ from gently.app.tools.interaction_tools import CHOICE_RESPONSE_TYPE
choice_data = json.loads(tool_result)
if (
@@ -649,11 +854,7 @@ def stream_and_collect():
tool_result if isinstance(tool_result, str) else str(tool_result)
)
tool_results.append(
- {
- "type": "tool_result",
- "tool_use_id": block.id,
- "content": tool_result,
- }
+ {"type": "tool_result", "tool_use_id": block.id, "content": tool_result}
)
except Exception as e:
is_error_flag = True
@@ -677,12 +878,21 @@ def stream_and_collect():
if len(result_summary) > 140:
result_summary = result_summary[:139] + "…"
+ # Full result (bounded) so the UI's expandable tool card can
+ # show what the tool actually returned — not just the 140-char
+ # one-liner. The web client caps/scrolls this further; keep the
+ # streamed payload sane.
+ result_full = result_text or ""
+ if len(result_full) > 4000:
+ result_full = result_full[:4000] + "\n…(truncated)"
+
yield {
"type": "tool_call",
"tool_name": block.name,
"tool_input": block.input,
"duration": time.time() - start_time,
"result_summary": result_summary,
+ "result_full": result_full,
"is_error": is_error_flag,
}
@@ -909,8 +1119,8 @@ async def _call_api_with_retry(self, api_func, *args, max_retries=3, **kwargs):
if is_retryable and attempt < max_retries - 1:
wait_time = retry_delay * (2**attempt)
logger.warning(
- f"API error ({error_type}), retrying in {wait_time:.1f}s"
- f" (attempt {attempt + 1}/{max_retries})"
+ f"API error ({error_type}), retrying in {wait_time:.1f}s "
+ f"(attempt {attempt + 1}/{max_retries})"
)
await asyncio.sleep(wait_time)
continue
diff --git a/gently/harness/detection/verifier.py b/gently/harness/detection/verifier.py
index 0370abb7..1a714bcd 100644
--- a/gently/harness/detection/verifier.py
+++ b/gently/harness/detection/verifier.py
@@ -27,13 +27,123 @@
logger = logging.getLogger(__name__)
+# Each verification strategy is pinned to its tool via tool_choice, so the
+# verdict arrives as a validated dict on the tool_use block — no
+# startswith()-scraping of a "FIELD: VALUE" plain-text format, no silent
+# defaults from a missed line. Downstream vote-tally / consensus logic is
+# untouched: these helpers still produce the same strategy dataclasses.
+#
+# We deliberately don't ask the model to self-rate confidence (a heuristics-era
+# artifact) — the boolean verdict is the signal, and the only confidence-like
+# measure we keep is the ensemble's agreement ratio, which is *derived* from
+# many independent votes rather than introspected by one call.
+_ADVERSARIAL_TOOL = {
+ "name": "record_adversarial_review",
+ "description": (
+ "Record the critical review verdict: whether counter-evidence "
+ "against the detection was found."
+ ),
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "found_counter_evidence": {
+ "type": "boolean",
+ "description": "True only if there is real evidence the detection is wrong.",
+ },
+ "concerns": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Specific doubts or alternative explanations; empty list if none.",
+ },
+ },
+ "required": ["found_counter_evidence", "concerns"],
+ },
+}
+
+_INDEPENDENT_TOOL = {
+ "name": "record_independent_assessment",
+ "description": (
+ "Record an unbiased fresh assessment of whether the event occurred in this image."
+ ),
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "detected": {
+ "type": "boolean",
+ "description": "True if the event is observed in this image.",
+ },
+ "key_evidence": {
+ "type": "string",
+ "description": "What specifically supports the conclusion.",
+ },
+ },
+ "required": ["detected", "key_evidence"],
+ },
+}
+
+_TEMPORAL_TOOL = {
+ "name": "record_temporal_comparison",
+ "description": (
+ "Record whether a real change consistent with the event occurred "
+ "between the previous and current frames."
+ ),
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "change_detected": {
+ "type": "boolean",
+ "description": (
+ "True if a clear change consistent with the event is visible across frames."
+ ),
+ },
+ "description": {
+ "type": "string",
+ "description": "The specific change observed between previous and current frames.",
+ },
+ },
+ "required": ["change_detected", "description"],
+ },
+}
+
+_HARDWARE_CONTEXT_TOOL = {
+ "name": "record_hardware_context",
+ "description": "Record whether hardware errors could have caused a false-positive detection.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "suspicious": {
+ "type": "boolean",
+ "description": (
+ "True if hardware errors could have affected image quality "
+ "or positioning for this embryo."
+ ),
+ },
+ "concerns": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Specific hardware concerns; empty list if none.",
+ },
+ "reasoning": {"type": "string", "description": "Brief explanation of the analysis."},
+ },
+ "required": ["suspicious", "concerns", "reasoning"],
+ },
+}
+
+
+def _tool_input(response) -> dict[str, Any] | None:
+ """Return the parsed input of the first tool_use block, or None."""
+ for block in getattr(response, "content", None) or []:
+ if getattr(block, "type", None) == "tool_use":
+ return block.input
+ return None
+
+
@dataclass
class AdversarialResult:
"""Result of adversarial verification strategy"""
found_counter_evidence: bool
concerns: list[str]
- confidence_in_original: ConfidenceLevel | None
raw_response: str
@@ -42,7 +152,6 @@ class IndependentResult:
"""Result of independent verification strategy"""
detected: bool
- confidence: ConfidenceLevel | None
key_evidence: str
raw_response: str
@@ -53,7 +162,6 @@ class TemporalResult:
change_detected: bool
description: str
- confidence: ConfidenceLevel | None
raw_response: str
@@ -111,21 +219,14 @@ def to_dict(self) -> dict[str, Any]:
"adversarial": {
"found_counter_evidence": self.adversarial.found_counter_evidence,
"concerns": self.adversarial.concerns,
- "confidence_in_original": self.adversarial.confidence_in_original.value
- if self.adversarial.confidence_in_original
- else None,
},
"independent": {
"detected": self.independent.detected,
- "confidence": self.independent.confidence.value
- if self.independent.confidence
- else None,
"key_evidence": self.independent.key_evidence,
},
"temporal": {
"change_detected": self.temporal.change_detected,
"description": self.temporal.description,
- "confidence": self.temporal.confidence.value if self.temporal.confidence else None,
},
"consensus": self.consensus,
"consensus_reasoning": self.consensus_reasoning,
@@ -327,19 +428,10 @@ async def verify_with_context(
ensemble_result,
hardware_result,
) = await asyncio.gather(
- adversarial_task,
- independent_task,
- temporal_task,
- ensemble_task,
- hardware_task,
+ adversarial_task, independent_task, temporal_task, ensemble_task, hardware_task
)
else:
- (
- adversarial,
- independent,
- temporal,
- ensemble_result,
- ) = await asyncio.gather(
+ adversarial, independent, temporal, ensemble_result = await asyncio.gather(
adversarial_task, independent_task, temporal_task, ensemble_task
)
else:
@@ -354,6 +446,11 @@ async def verify_with_context(
# Adversarial result
strategies_complete += 1
+ adversarial_summary = (
+ "YES - " + ", ".join(adversarial.concerns)
+ if adversarial.found_counter_evidence
+ else "None found"
+ )
self._emit_event(
EventType.VERIFICATION_STRATEGY,
{
@@ -361,17 +458,7 @@ async def verify_with_context(
"detector_name": detector.name,
"strategy": "adversarial",
"passed": not adversarial.found_counter_evidence,
- "summary": (
- "Counter-evidence: "
- + (
- "YES - " + ", ".join(adversarial.concerns)
- if adversarial.found_counter_evidence
- else "None found"
- )
- ),
- "confidence": adversarial.confidence_in_original.value
- if adversarial.confidence_in_original
- else None,
+ "summary": f"Counter-evidence: {adversarial_summary}",
},
)
self._emit_event(
@@ -396,7 +483,6 @@ async def verify_with_context(
f"Independent detection: {'YES' if independent.detected else 'NO'}"
f" - {independent.key_evidence}"
),
- "confidence": independent.confidence.value if independent.confidence else None,
},
)
self._emit_event(
@@ -421,7 +507,6 @@ async def verify_with_context(
f"Change detected: {'YES' if temporal.change_detected else 'NO'}"
f" - {temporal.description}"
),
- "confidence": temporal.confidence.value if temporal.confidence else None,
},
)
self._emit_event(
@@ -464,6 +549,11 @@ async def verify_with_context(
# Hardware context result (if applicable)
if hardware_result:
strategies_complete += 1
+ hardware_summary = (
+ "YES - " + ", ".join(hardware_result.concerns)
+ if hardware_result.suspicious
+ else "No"
+ )
self._emit_event(
EventType.VERIFICATION_STRATEGY,
{
@@ -471,14 +561,7 @@ async def verify_with_context(
"detector_name": detector.name,
"strategy": "hardware_context",
"passed": not hardware_result.suspicious,
- "summary": (
- "Hardware errors suspicious: "
- + (
- "YES - " + ", ".join(hardware_result.concerns)
- if hardware_result.suspicious
- else "No"
- )
- ),
+ "summary": f"Hardware errors suspicious: {hardware_summary}",
"reasoning": hardware_result.reasoning,
},
)
@@ -493,12 +576,7 @@ async def verify_with_context(
# Determine consensus (with hardware context)
consensus, reasoning = self._evaluate_consensus_with_hardware(
- original_result,
- adversarial,
- independent,
- temporal,
- ensemble_result,
- hardware_result,
+ original_result, adversarial, independent, temporal, ensemble_result, hardware_result
)
duration = (datetime.now() - start_time).total_seconds()
@@ -577,8 +655,8 @@ async def _run_hardware_context_analysis(
Analysis result
"""
try:
- prompt = f"""You are analyzing hardware error context for a microscopy detection
-verification.
+ prompt = f"""\
+You are analyzing hardware error context for a microscopy detection verification.
GLOBAL ERROR LOG:
{global_error_context}
@@ -595,24 +673,31 @@ async def _run_hardware_context_analysis(
(stage drift, hardware instability)
- Multiple errors in quick succession suggests hardware problems
-If ANY errors occurred that could have affected the image quality or positioning for
-{embryo_id}, report as SUSPICIOUS.
+If ANY errors occurred that could have affected the image quality or positioning
+for {embryo_id}, mark it suspicious.
-Respond in EXACTLY this format:
-SUSPICIOUS: [YES/NO]
-CONCERNS: [list specific concerns, separated by semicolons]
-REASONING: [brief explanation of your analysis]
+Record your analysis with the record_hardware_context tool.
"""
response = await asyncio.to_thread(
self.claude.messages.create,
model=self.ensemble_model, # Use Haiku for speed
max_tokens=300,
+ tools=[_HARDWARE_CONTEXT_TOOL],
+ tool_choice={"type": "tool", "name": _HARDWARE_CONTEXT_TOOL["name"]},
messages=[{"role": "user", "content": prompt}],
)
- response_text = response.content[0].text
- return self._parse_hardware_context_response(response_text)
+ data = _tool_input(response)
+ if not isinstance(data, dict):
+ raise ValueError("no tool_use block in response")
+ concerns = data.get("concerns") or []
+ return HardwareContextResult(
+ suspicious=bool(data.get("suspicious", True)),
+ concerns=[str(c) for c in concerns],
+ reasoning=str(data.get("reasoning", "")),
+ raw_response=str(data),
+ )
except Exception as e:
logger.error(f"Hardware context analysis failed: {e}")
@@ -623,30 +708,6 @@ async def _run_hardware_context_analysis(
raw_response="",
)
- def _parse_hardware_context_response(self, response: str) -> HardwareContextResult:
- """Parse hardware context analysis response"""
- suspicious = False
- concerns = []
- reasoning = ""
-
- for line in response.split("\n"):
- line = line.strip()
- if line.startswith("SUSPICIOUS:"):
- value = line.split(":", 1)[1].strip().upper()
- suspicious = value == "YES"
- elif line.startswith("CONCERNS:"):
- concerns_str = line.split(":", 1)[1].strip()
- concerns = [c.strip() for c in concerns_str.split(";") if c.strip()]
- elif line.startswith("REASONING:"):
- reasoning = line.split(":", 1)[1].strip()
-
- return HardwareContextResult(
- suspicious=suspicious,
- concerns=concerns,
- reasoning=reasoning,
- raw_response=response,
- )
-
def _evaluate_consensus_with_hardware(
self,
original: DetectionResult,
@@ -753,7 +814,6 @@ async def _run_adversarial(
return AdversarialResult(
found_counter_evidence=False,
concerns=["No images available for verification"],
- confidence_in_original=None,
raw_response="",
)
@@ -771,11 +831,10 @@ async def _run_adversarial(
else:
specific_guidance = ""
- prompt = f"""You are reviewing a detection result for a C. elegans embryo
-(diSPIM max projection).
+ prompt = f"""\
+You are reviewing a detection result for a C. elegans embryo (diSPIM max projection).
The system detected: {detector.name}
-Original confidence: {original_result.confidence.value if original_result.confidence else "unknown"}
Original reasoning: {original_result.reasoning or "not provided"}
NOW ACT AS A CRITICAL REVIEWER. Your job is to find reasons why this detection might be INCORRECT:
@@ -784,10 +843,7 @@ async def _run_adversarial(
- Is the evidence actually conclusive, or could it be interpreted differently?
- Are there alternative explanations for what is observed?
{specific_guidance}
-Analyze the image(s) carefully and respond in EXACTLY this format:
-COUNTER_EVIDENCE_FOUND: [YES/NO]
-CONCERNS: [list specific doubts or alternative explanations, separated by semicolons]
-CONFIDENCE_IN_ORIGINAL: [HIGH/MEDIUM/LOW]
+Analyze the image(s) carefully and record your review with the record_adversarial_review tool.
"""
content = [{"type": "text", "text": prompt}] + images
@@ -796,18 +852,26 @@ async def _run_adversarial(
self.claude.messages.create,
model=self.model,
max_tokens=500,
+ tools=[_ADVERSARIAL_TOOL],
+ tool_choice={"type": "tool", "name": _ADVERSARIAL_TOOL["name"]},
messages=[{"role": "user", "content": content}],
)
- response_text = response.content[0].text
- return self._parse_adversarial_response(response_text)
+ data = _tool_input(response)
+ if not isinstance(data, dict):
+ raise ValueError("no tool_use block in response")
+ concerns = data.get("concerns") or []
+ return AdversarialResult(
+ found_counter_evidence=bool(data.get("found_counter_evidence", False)),
+ concerns=[str(c) for c in concerns],
+ raw_response=str(data),
+ )
except Exception as e:
logger.error(f"Adversarial verification failed: {e}")
return AdversarialResult(
found_counter_evidence=False,
concerns=[f"Verification error: {str(e)}"],
- confidence_in_original=None,
raw_response="",
)
@@ -829,7 +893,6 @@ async def _run_independent(
if not images:
return IndependentResult(
detected=False,
- confidence=None,
key_evidence="No images available",
raw_response="",
)
@@ -849,8 +912,8 @@ async def _run_independent(
criteria = detector.description
# Use a neutral prompt that doesn't reveal the previous detection
- prompt = f"""Analyze this C. elegans embryo image (diSPIM max projection) at
-timepoint {timepoint}.
+ prompt = f"""\
+Analyze this C. elegans embryo image (diSPIM max projection) at timepoint {timepoint}.
Question: Has '{detector.name}' occurred in this embryo?
@@ -859,10 +922,7 @@ async def _run_independent(
Provide an independent assessment based SOLELY on what you observe in this image.
Do not assume any prior state - analyze only what is visible now.
-Respond in EXACTLY this format:
-DETECTED: [YES/NO]
-CONFIDENCE: [HIGH/MEDIUM/LOW]
-KEY_EVIDENCE: [what specifically do you observe that supports your conclusion?]
+Record your assessment with the record_independent_assessment tool.
"""
content = [{"type": "text", "text": prompt}] + images
@@ -871,17 +931,24 @@ async def _run_independent(
self.claude.messages.create,
model=self.model,
max_tokens=400,
+ tools=[_INDEPENDENT_TOOL],
+ tool_choice={"type": "tool", "name": _INDEPENDENT_TOOL["name"]},
messages=[{"role": "user", "content": content}],
)
- response_text = response.content[0].text
- return self._parse_independent_response(response_text)
+ data = _tool_input(response)
+ if not isinstance(data, dict):
+ raise ValueError("no tool_use block in response")
+ return IndependentResult(
+ detected=bool(data.get("detected", False)),
+ key_evidence=str(data.get("key_evidence", "")),
+ raw_response=str(data),
+ )
except Exception as e:
logger.error(f"Independent verification failed: {e}")
return IndependentResult(
detected=False,
- confidence=None,
key_evidence=f"Verification error: {str(e)}",
raw_response="",
)
@@ -904,7 +971,6 @@ async def _run_temporal_check(
return TemporalResult(
change_detected=True, # Can't disprove without history
description="Insufficient temporal history for comparison",
- confidence=ConfidenceLevel.LOW,
raw_response="",
)
@@ -930,7 +996,6 @@ async def _run_temporal_check(
return TemporalResult(
change_detected=True,
description="No previous images available",
- confidence=ConfidenceLevel.LOW,
raw_response="",
)
@@ -947,8 +1012,8 @@ async def _run_temporal_check(
- Not just a static state that could have existed before
- Clear evidence of progression or event occurrence"""
- prompt = f"""Compare these sequential timepoints of a C. elegans embryo
-(diSPIM max projection).
+ prompt = f"""\
+Compare these sequential timepoints of a C. elegans embryo (diSPIM max projection).
PREVIOUS TIMEPOINTS (shown first):
These are from t={timepoint - 2} to t={timepoint - 1}
@@ -960,10 +1025,7 @@ async def _run_temporal_check(
{temporal_criteria}
-Respond in EXACTLY this format:
-CHANGE_DETECTED: [YES/NO]
-DESCRIPTION: [what specific change do you see between the previous and current frames?]
-CONFIDENCE: [HIGH/MEDIUM/LOW]
+Record your comparison with the record_temporal_comparison tool.
"""
# Combine: previous images first, then prompt, then current
@@ -973,18 +1035,25 @@ async def _run_temporal_check(
self.claude.messages.create,
model=self.model,
max_tokens=400,
+ tools=[_TEMPORAL_TOOL],
+ tool_choice={"type": "tool", "name": _TEMPORAL_TOOL["name"]},
messages=[{"role": "user", "content": content}],
)
- response_text = response.content[0].text
- return self._parse_temporal_response(response_text)
+ data = _tool_input(response)
+ if not isinstance(data, dict):
+ raise ValueError("no tool_use block in response")
+ return TemporalResult(
+ change_detected=bool(data.get("change_detected", True)),
+ description=str(data.get("description", "")),
+ raw_response=str(data),
+ )
except Exception as e:
logger.error(f"Temporal verification failed: {e}")
return TemporalResult(
change_detected=True, # Don't block on error
description=f"Verification error: {str(e)}",
- confidence=None,
raw_response="",
)
@@ -1038,10 +1107,10 @@ async def _run_ensemble_hatching(self, embryo_state: EmbryoState) -> EnsembleRes
Answer ONE question: Has the embryo HATCHED?
-HATCHED means: The worm body is OUTSIDE the eggshell (free-floating, elongated, or field is
-empty because worm left).
-NOT HATCHED means: The worm is still INSIDE the eggshell (coiled/pretzel-shaped, even if
-shell looks expanded).
+HATCHED means: The worm body is OUTSIDE the eggshell (free-floating, elongated,
+or field is empty because worm left).
+NOT HATCHED means: The worm is still INSIDE the eggshell (coiled/pretzel-shaped,
+even if shell looks expanded).
Respond with ONLY: YES or NO"""
@@ -1062,8 +1131,8 @@ async def single_vote() -> str:
# Run all votes in parallel
logger.info(
- f"[ENSEMBLE] Running {self.ensemble_size} parallel Haiku calls"
- " for hatching verification"
+ f"[ENSEMBLE] Running {self.ensemble_size} parallel Haiku calls "
+ "for hatching verification"
)
tasks = [single_vote() for _ in range(self.ensemble_size)]
responses = await asyncio.gather(*tasks)
@@ -1113,88 +1182,6 @@ async def single_vote() -> str:
raw_responses=[f"Error: {str(e)}"],
)
- def _parse_adversarial_response(self, response: str) -> AdversarialResult:
- """Parse adversarial strategy response"""
- found_counter = False
- concerns = []
- confidence = None
-
- for line in response.split("\n"):
- line = line.strip()
- if line.startswith("COUNTER_EVIDENCE_FOUND:"):
- value = line.split(":", 1)[1].strip().upper()
- found_counter = value == "YES"
- elif line.startswith("CONCERNS:"):
- concerns_str = line.split(":", 1)[1].strip()
- concerns = [c.strip() for c in concerns_str.split(";") if c.strip()]
- elif line.startswith("CONFIDENCE_IN_ORIGINAL:"):
- value = line.split(":", 1)[1].strip().upper()
- try:
- confidence = ConfidenceLevel(value)
- except ValueError:
- pass
-
- return AdversarialResult(
- found_counter_evidence=found_counter,
- concerns=concerns,
- confidence_in_original=confidence,
- raw_response=response,
- )
-
- def _parse_independent_response(self, response: str) -> IndependentResult:
- """Parse independent strategy response"""
- detected = False
- confidence = None
- evidence = ""
-
- for line in response.split("\n"):
- line = line.strip()
- if line.startswith("DETECTED:"):
- value = line.split(":", 1)[1].strip().upper()
- detected = value == "YES"
- elif line.startswith("CONFIDENCE:"):
- value = line.split(":", 1)[1].strip().upper()
- try:
- confidence = ConfidenceLevel(value)
- except ValueError:
- pass
- elif line.startswith("KEY_EVIDENCE:"):
- evidence = line.split(":", 1)[1].strip()
-
- return IndependentResult(
- detected=detected,
- confidence=confidence,
- key_evidence=evidence,
- raw_response=response,
- )
-
- def _parse_temporal_response(self, response: str) -> TemporalResult:
- """Parse temporal strategy response"""
- change_detected = False
- description = ""
- confidence = None
-
- for line in response.split("\n"):
- line = line.strip()
- if line.startswith("CHANGE_DETECTED:"):
- value = line.split(":", 1)[1].strip().upper()
- change_detected = value == "YES"
- elif line.startswith("DESCRIPTION:"):
- description = line.split(":", 1)[1].strip()
- elif line.startswith("CONFIDENCE:"):
- value = line.split(":", 1)[1].strip().upper()
- try:
- confidence = ConfidenceLevel(value)
- except ValueError:
- pass
-
- return TemporalResult(
- change_detected=change_detected,
- description=description,
- confidence=confidence,
- raw_response=response,
- )
-
def _evaluate_consensus(
self,
original: DetectionResult,
@@ -1253,8 +1240,8 @@ def _evaluate_consensus(
f"All verification strategies agree ({total_strategies}/{total_strategies}): "
f"no counter-evidence found, independent analysis confirms detection, "
f"temporal change observed, ensemble voting confirms "
- f"({ensemble.votes_yes}/{ensemble.total_votes}"
- f" = {ensemble.agreement_ratio:.0%} YES)."
+ f"({ensemble.votes_yes}/{ensemble.total_votes} = "
+ f"{ensemble.agreement_ratio:.0%} YES)."
)
else:
reasoning = (
diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py
index 0531dcce..ececeba2 100644
--- a/gently/harness/memory/file_store.py
+++ b/gently/harness/memory/file_store.py
@@ -503,6 +503,7 @@ def update_campaign_progress(self, campaign_id: str, progress: str):
data["progress"] = progress
data["updated_at"] = self._now()
self._write_yaml(folder / "campaign.yaml", data)
+ self._notify_plan_change(campaign_id)
def update_campaign_status(self, campaign_id: str, status: Status):
folder = self._campaign_folder(campaign_id)
@@ -582,6 +583,11 @@ def get_subcampaigns(self, campaign_id: str) -> list[Campaign]:
return children
def get_nth_subcampaign(self, parent_id: str, n: int) -> Campaign | None:
+ # Tolerate n arriving as a numeric string (tool args are often stringified).
+ try:
+ n = int(n)
+ except (ValueError, TypeError):
+ return None
phases = self.get_subcampaigns(parent_id)
if 1 <= n <= len(phases):
return phases[n - 1]
@@ -827,6 +833,7 @@ def link_session_campaign(self, session_id: str, campaign_id: str):
cids.append(campaign_id)
data["campaign_ids"] = cids
self._write_yaml(path, data)
+ self._notify_plan_change(campaign_id)
def unlink_session_campaign(self, session_id: str, campaign_id: str):
path = self.agent_dir / "session_intents" / f"{session_id}.yaml"
@@ -1140,6 +1147,7 @@ def create_plan_item(
"inherit_from": inherit_from,
"planned_session_id": planned_session_id,
"session_id": None,
+ "session_ids": [],
"estimated_days": estimated_days,
"phase_order": phase_order,
"references": references,
@@ -1151,6 +1159,7 @@ def create_plan_item(
}
items.append(item_data)
self._write_plan_items(campaign_id, items)
+ self._notify_plan_change(campaign_id)
logger.info(f"Created plan item {pid} [{type}] #{phase_order}: {title}")
return pid
@@ -1331,10 +1340,12 @@ def update_plan_item(
new_items = self._read_plan_items_raw(campaign_id)
new_items.append(item)
self._write_plan_items(campaign_id, new_items)
+ self._notify_plan_change(campaign_id)
return
item["updated_at"] = self._now()
self._write_plan_items(old_campaign_id, items)
+ self._notify_plan_change(old_campaign_id)
def complete_plan_item(self, item_id: str, outcome: str):
self.update_plan_item(
@@ -1343,6 +1354,31 @@ def complete_plan_item(self, item_id: str, outcome: str):
outcome=outcome,
)
+ def link_plan_item_session(
+ self, item_id: str, session_id: str, set_in_progress: bool = True
+ ) -> bool:
+ """Attach a session to a plan item — APPENDS (an item may run several times:
+ re-runs, multi-sitting, more embryos later). Records the session as the latest
+ `session_id` (back-compat), flips a PLANNED item to IN_PROGRESS, emits PLAN_UPDATED.
+ Returns False if the item isn't found."""
+ loc = self._find_plan_item_location(item_id)
+ if not loc:
+ return False
+ campaign_id, items, idx = loc
+ item = items[idx]
+ sids = item.get("session_ids") or ([item["session_id"]] if item.get("session_id") else [])
+ if session_id and session_id not in sids:
+ sids.append(session_id)
+ item["session_ids"] = sids
+ if sids:
+ item["session_id"] = sids[-1] # most recent run; back-compat for older readers
+ if set_in_progress and item.get("status") == "planned":
+ item["status"] = PlanItemStatus.IN_PROGRESS.value
+ item["updated_at"] = self._now()
+ self._write_plan_items(campaign_id, items)
+ self._notify_plan_change(campaign_id)
+ return True
+
def skip_plan_item(self, item_id: str, reason: str | None = None):
self.update_plan_item(
item_id,
@@ -1908,6 +1944,26 @@ def get_observations_for_embryo(self, embryo_id: str, limit: int = 20) -> list[O
# Expectations
# ==================================================================
+ def _notify_context_change(self, kind: str = "context") -> None:
+ """Emit CONTEXT_UPDATED on the global bus so the shared-visibility
+ surface refreshes live. Best-effort — a bus failure never breaks a write."""
+ try:
+ from gently.core.event_bus import EventType, emit
+
+ emit(EventType.CONTEXT_UPDATED, {"kind": kind}, source="context_store")
+ except Exception:
+ pass
+
+ def _notify_plan_change(self, campaign_id: str | None = None) -> None:
+ """Emit PLAN_UPDATED so the Plans UI refreshes live when a plan item or
+ campaign changes (status, session link, new item, progress). Best-effort."""
+ try:
+ from gently.core.event_bus import EventType, emit
+
+ emit(EventType.PLAN_UPDATED, {"campaign_id": campaign_id}, source="context_store")
+ except Exception:
+ pass
+
def add_expectation(self, exp: Expectation):
path = self.agent_dir / "active" / "expectations.yaml"
items = self._read_yaml(path) or []
@@ -1925,6 +1981,7 @@ def add_expectation(self, exp: Expectation):
}
)
self._write_yaml(path, items)
+ self._notify_context_change("expectation")
def get_pending_expectations(self) -> list[Expectation]:
path = self.agent_dir / "active" / "expectations.yaml"
@@ -1956,6 +2013,7 @@ def resolve_expectation(self, exp_id: str, status: ExpectationStatus):
item["resolved_at"] = now
break
self._write_yaml(path, items)
+ self._notify_context_change("expectation")
# ==================================================================
# Watchpoints
@@ -1975,6 +2033,7 @@ def add_watchpoint(self, wp: Watchpoint):
}
)
self._write_yaml(path, items)
+ self._notify_context_change("watchpoint")
def get_active_watchpoints(self) -> list[Watchpoint]:
path = self.agent_dir / "active" / "watchpoints.yaml"
@@ -2002,6 +2061,7 @@ def resolve_watchpoint(self, wp_id: str):
item["status"] = "resolved"
break
self._write_yaml(path, items)
+ self._notify_context_change("watchpoint")
# ==================================================================
# Questions
@@ -2021,6 +2081,7 @@ def add_question(self, q: Question):
}
)
self._write_yaml(path, items)
+ self._notify_context_change("question")
def get_open_questions(self) -> list[Question]:
path = self.agent_dir / "active" / "questions.yaml"
@@ -2042,6 +2103,7 @@ def resolve_question(self, q_id: str, resolution: str):
item["resolved_at"] = now
break
self._write_yaml(path, items)
+ self._notify_context_change("question")
# ==================================================================
# Learnings
@@ -2154,6 +2216,17 @@ def set_state(self, key: str, value: str):
# Batch Updates
# ==================================================================
+ @property
+ def notebook(self):
+ """The shared lab notebook, rooted at agent_dir/notebook (lazy)."""
+ nb = getattr(self, "_notebook", None)
+ if nb is None:
+ from .notebook import NotebookStore
+
+ nb = NotebookStore(self.agent_dir / "notebook")
+ self._notebook = nb
+ return nb
+
def apply_updates(self, updates: ContextUpdates):
for obs in updates.new_observations:
self.add_observation(obs)
@@ -2181,6 +2254,18 @@ def apply_updates(self, updates: ContextUpdates):
if updates.new_focus is not None:
self.set_state("current_focus", updates.new_focus)
+ # Mirror new observations & learnings into the shared notebook
+ # (best-effort — a notebook failure never breaks the legacy write).
+ from .notebook import learning_to_note, observation_to_note
+
+ try:
+ for obs in updates.new_observations:
+ self.notebook.write_note(observation_to_note(obs))
+ for learning in updates.new_learnings:
+ self.notebook.write_note(learning_to_note(learning))
+ except Exception:
+ logger.warning("notebook mirror failed", exc_info=True)
+
# ==================================================================
# ML Pipelines
# ==================================================================
@@ -2522,6 +2607,14 @@ def _dict_to_plan_item(d: dict) -> PlanItem:
imaging_spec = None
bench_spec = None
+ # Tolerate specs persisted as JSON strings (older tool calls that passed
+ # spec as a string instead of an object) so read-back never crashes.
+ if isinstance(spec_data, str):
+ try:
+ spec_data = json.loads(spec_data)
+ except (json.JSONDecodeError, TypeError):
+ spec_data = None
+
if spec_data:
if item_type == PlanItemType.IMAGING:
valid = {f.name for f in dataclasses.fields(ImagingSpec)}
@@ -2531,6 +2624,11 @@ def _dict_to_plan_item(d: dict) -> PlanItem:
bench_spec = BenchSpec(**{k: v for k, v in spec_data.items() if k in valid})
references = d.get("references") or []
+ if isinstance(references, str):
+ try:
+ references = json.loads(references) or []
+ except (json.JSONDecodeError, TypeError):
+ references = []
return PlanItem(
id=d["id"],
@@ -2548,6 +2646,7 @@ def _dict_to_plan_item(d: dict) -> PlanItem:
bench_spec=bench_spec,
planned_session_id=d.get("planned_session_id"),
session_id=d.get("session_id"),
+ session_ids=d.get("session_ids") or ([d["session_id"]] if d.get("session_id") else []),
inherit_from=d.get("inherit_from"),
estimated_days=d.get("estimated_days"),
phase_order=d.get("phase_order", 0),
diff --git a/gently/harness/memory/interface.py b/gently/harness/memory/interface.py
index 2e27db8d..d6824a69 100644
--- a/gently/harness/memory/interface.py
+++ b/gently/harness/memory/interface.py
@@ -215,15 +215,42 @@ def get_awareness_summary(self) -> str:
spec = self.store.resolve_imaging_spec(item)
campaign = self.store.get_campaign(item.campaign_id)
campaign_name = _short_name(campaign) if campaign else "?"
+ # Walk to the root campaign for the overall goal (the item's
+ # campaign may be a phase under it).
+ root = campaign
+ seen_ids: set[str] = set()
+ while root and root.parent_id and root.parent_id not in seen_ids:
+ seen_ids.add(root.id)
+ root = self.store.get_campaign(root.parent_id)
lines.append(f"\n## Active Plan Item: {item.title}")
- lines.append(f"Campaign: {campaign_name}")
+ if root and root.target:
+ lines.append(f"Goal of the investigation: {root.target}")
+ if campaign and root and campaign.id != root.id:
+ lines.append(f"Phase: {campaign_name}")
+ lines.append(f"Campaign: {_short_name(root) if root else campaign_name}")
lines.append(f"Status: {item.status.value}")
if spec:
lines.append(self.format_imaging_spec_block(spec))
+ # What's next — the items/gates this run unblocks.
+ try:
+ root_id = root.id if root else item.campaign_id
+ nxt = [
+ u
+ for u in self.store.get_unblocked_plan_items(root_id)
+ if u.id != item.id
+ ][:3]
+ if nxt:
+ bits = []
+ for u in nxt:
+ is_dp = u.type.value == "decision_point"
+ bits.append(u.title + (" (decision point)" if is_dp else ""))
+ lines.append("Next up: " + "; ".join(bits))
+ except Exception:
+ pass
lines.append(
- "\nUse this spec when configuring embryos and "
- "starting the timelapse. The user expects these "
- "settings from their experimental plan."
+ "\nYou're executing this item within the plan above — use the "
+ "spec to configure and run, and keep the goal and what's next in "
+ "mind (you can make go/no-go calls). The user expects these settings."
)
except Exception:
pass
diff --git a/gently/harness/memory/model.py b/gently/harness/memory/model.py
index 2a164176..e9e6532d 100644
--- a/gently/harness/memory/model.py
+++ b/gently/harness/memory/model.py
@@ -240,6 +240,11 @@ class ImagingSpec:
success_criteria: str | None = None
comparison_to: str | None = None # "Compare to WT session 1"
+ # Per-field provenance for INFERRED values — field name -> {source, confidence}.
+ # e.g. {"laser_wavelength_nm": {"source": "inferred:genotype", "confidence": "medium"}}
+ # Lets the UI tag each value with where it came from and what to confirm.
+ provenance: dict[str, dict[str, str]] = field(default_factory=dict)
+
@dataclass
class BenchSpec:
@@ -287,7 +292,8 @@ class PlanItem:
# Linking
planned_session_id: str | None = None # → PlannedSession (for imaging items)
- session_id: str | None = None # → Actual session (once executed)
+ session_id: str | None = None # → most recent actual session (back-compat / "primary")
+ session_ids: list[str] = field(default_factory=list) # all sessions that ran this item (1→many)
inherit_from: str | None = None # PlanItem ID to inherit spec from
# Scheduling — relative timeline from Day 0
diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py
new file mode 100644
index 00000000..88271fa8
--- /dev/null
+++ b/gently/harness/memory/notebook.py
@@ -0,0 +1,294 @@
+"""
+The shared lab notebook — unified memory entry (Note) and file-backed store.
+
+One Note kind taxonomy (observation / finding / question); everything else
+(author, status, confidence, scope, links) is an orthogonal field. See
+docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from .model import Confidence, Learning, Observation
+
+
+class NoteKind(str, Enum):
+ OBSERVATION = "observation" # immutable record of what was seen/done/read/noted
+ FINDING = "finding" # revisable, supersedable believed claim
+ QUESTION = "question" # open inquiry; large ones are the thread spine
+
+
+class Author(str, Enum):
+ HUMAN = "human"
+ AGENT = "agent"
+ PERCEPTION = "perception"
+
+
+class NoteStatus(str, Enum):
+ OPEN = "open" # questions not yet answered
+ PROPOSED = "proposed" # agent-drafted finding awaiting human confirm
+ CONFIRMED = "confirmed" # accepted observation/finding (default)
+ ANSWERED = "answered" # question resolved
+ SUPERSEDED = "superseded" # replaced by a newer note
+
+
+@dataclass
+class Note:
+ id: str
+ kind: NoteKind
+ body: str
+ author: Author = Author.AGENT
+ title: str | None = None
+ status: NoteStatus = NoteStatus.CONFIRMED
+ confidence: Confidence | None = None
+ strains: list[str] = field(default_factory=list)
+ embryos: list[str] = field(default_factory=list)
+ sessions: list[str] = field(default_factory=list)
+ threads: list[str] = field(default_factory=list)
+ basis: list[str] = field(default_factory=list) # note ids this rests on
+ links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}]
+ artifacts: list[dict] = field(default_factory=list) # FileStore pointers
+ superseded_by: str | None = None
+ created_at: datetime = field(default_factory=datetime.now)
+ updated_at: datetime = field(default_factory=datetime.now)
+
+
+def note_to_dict(n: Note) -> dict[str, Any]:
+ return {
+ "id": n.id,
+ "kind": n.kind.value,
+ "body": n.body,
+ "author": n.author.value,
+ "title": n.title,
+ "status": n.status.value,
+ "confidence": n.confidence.value if n.confidence else None,
+ "strains": list(n.strains),
+ "embryos": list(n.embryos),
+ "sessions": list(n.sessions),
+ "threads": list(n.threads),
+ "basis": list(n.basis),
+ "links": list(n.links),
+ "artifacts": list(n.artifacts),
+ "superseded_by": n.superseded_by,
+ "created_at": n.created_at.isoformat(),
+ "updated_at": n.updated_at.isoformat(),
+ }
+
+
+def note_from_dict(d: dict[str, Any]) -> Note:
+ conf = d.get("confidence")
+ return Note(
+ id=d["id"],
+ kind=NoteKind(d["kind"]),
+ body=d.get("body", ""),
+ author=Author(d.get("author", "agent")),
+ title=d.get("title"),
+ status=NoteStatus(d.get("status", "confirmed")),
+ confidence=Confidence(conf) if conf else None,
+ strains=list(d.get("strains") or []),
+ embryos=list(d.get("embryos") or []),
+ sessions=list(d.get("sessions") or []),
+ threads=list(d.get("threads") or []),
+ basis=list(d.get("basis") or []),
+ links=list(d.get("links") or []),
+ artifacts=list(d.get("artifacts") or []),
+ superseded_by=d.get("superseded_by"),
+ created_at=datetime.fromisoformat(d["created_at"]),
+ updated_at=datetime.fromisoformat(d["updated_at"]),
+ )
+
+
+def observation_to_note(obs: Observation) -> Note:
+ """Bridge a legacy Observation into a notebook Note (kind=observation)."""
+ return Note(
+ id=obs.id,
+ kind=NoteKind.OBSERVATION,
+ body=obs.content,
+ author=Author.AGENT,
+ embryos=[obs.embryo_id] if obs.embryo_id else [],
+ sessions=[obs.session_id] if obs.session_id else [],
+ links=[{"rel": "relates_to", "to": r} for r in (obs.relates_to or [])],
+ artifacts=[obs.gently_refs] if obs.gently_refs else [],
+ created_at=obs.timestamp,
+ updated_at=obs.timestamp,
+ )
+
+
+def learning_to_note(learning: Learning) -> Note:
+ """Bridge a legacy Learning into a notebook Note (kind=finding, proposed)."""
+ return Note(
+ id=learning.id,
+ kind=NoteKind.FINDING,
+ body=learning.content,
+ author=Author.AGENT,
+ status=NoteStatus.PROPOSED,
+ confidence=learning.confidence,
+ created_at=learning.created_at,
+ updated_at=learning.created_at,
+ )
+
+
+class NotebookStore:
+ """File-backed store for notebook Notes. One YAML per note under notes/;
+ flat pool, rebuildable reverse-indexes (added in Task 3)."""
+
+ _FACETS = {"strain": "strains", "embryo": "embryos", "thread": "threads"}
+
+ def __init__(self, notebook_dir: Path):
+ self.root = Path(notebook_dir)
+ self.notes_dir = self.root / "notes"
+ self.index_dir = self.root / "index"
+ self.notes_dir.mkdir(parents=True, exist_ok=True)
+ self.index_dir.mkdir(parents=True, exist_ok=True)
+ # reverse indexes: facet -> {value: [note_id, ...]}
+ self._index: dict[str, dict[str, list[str]]] = {"strain": {}, "embryo": {}, "thread": {}}
+ self.rebuild_index()
+
+ # ---- reverse indexes (notes are authoritative; indexes are caches) ----
+ def _index_note(self, note: Note) -> None:
+ for facet, attr in self._FACETS.items():
+ for value in getattr(note, attr):
+ bucket = self._index[facet].setdefault(value, [])
+ if note.id not in bucket:
+ bucket.append(note.id)
+
+ def rebuild_index(self) -> None:
+ """Rebuild reverse-indexes by scanning notes/."""
+ self._index = {"strain": {}, "embryo": {}, "thread": {}}
+ for f in sorted(self.notes_dir.glob("*.yaml")):
+ data = self._read_yaml(f)
+ if data:
+ self._index_note(note_from_dict(data))
+
+ def ids_for_strain(self, strain: str) -> list[str]:
+ return list(self._index["strain"].get(strain, []))
+
+ def ids_for_embryo(self, embryo: str) -> list[str]:
+ return list(self._index["embryo"].get(embryo, []))
+
+ def ids_for_thread(self, thread: str) -> list[str]:
+ return list(self._index["thread"].get(thread, []))
+
+ # ---- helpers (mirror FileContextStore conventions) ----
+ @staticmethod
+ def _gen_id() -> str:
+ return str(uuid.uuid4())[:8]
+
+ @staticmethod
+ def _slugify(text: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
+ return slug[:30]
+
+ def _write_yaml(self, path: Path, data: dict) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".tmp")
+ with open(tmp, "w", encoding="utf-8") as fh:
+ yaml.safe_dump(data, fh, default_flow_style=False, allow_unicode=True, sort_keys=False)
+ os.replace(str(tmp), str(path))
+
+ def _read_yaml(self, path: Path) -> dict | None:
+ try:
+ with open(path, encoding="utf-8") as fh:
+ return yaml.safe_load(fh)
+ except OSError:
+ return None
+
+ def _note_path(self, note_id: str) -> Path | None:
+ return next(self.notes_dir.glob(f"{note_id}_*.yaml"), None)
+
+ # ---- read/write ----
+ def write_note(self, note: Note) -> str:
+ if not note.id:
+ note.id = self._gen_id()
+ note.updated_at = datetime.now()
+ slug = self._slugify(note.title or note.body or note.kind.value)
+ # remove any stale file for this id (slug may have changed)
+ old = self._note_path(note.id)
+ if old is not None:
+ old.unlink()
+ self._write_yaml(self.notes_dir / f"{note.id}_{slug}.yaml", note_to_dict(note))
+ self._index_note(note)
+ return note.id
+
+ def get_note(self, note_id: str) -> Note | None:
+ path = self._note_path(note_id)
+ if path is None:
+ return None
+ data = self._read_yaml(path)
+ return note_from_dict(data) if data else None
+
+ def query_notes(
+ self,
+ *,
+ kind: NoteKind | None = None,
+ author: Author | None = None,
+ status: NoteStatus | None = None,
+ strain: str | None = None,
+ embryo: str | None = None,
+ thread: str | None = None,
+ ) -> list[Note]:
+ """Structural query: narrow by scope via the indexes, then filter by
+ kind/author/status. Returned newest-first. (No semantic ranking here —
+ that's a later increment.)"""
+ # 1. candidate ids — intersect any scope facets given, else all notes
+ scope_sets: list[set[str]] = []
+ if strain is not None:
+ scope_sets.append(set(self.ids_for_strain(strain)))
+ if embryo is not None:
+ scope_sets.append(set(self.ids_for_embryo(embryo)))
+ if thread is not None:
+ scope_sets.append(set(self.ids_for_thread(thread)))
+ candidate_ids: set[str] | None = set.intersection(*scope_sets) if scope_sets else None
+
+ # 2. load + filter
+ if candidate_ids is not None:
+ notes = [n for n in (self.get_note(i) for i in candidate_ids) if n]
+ else:
+ notes = [
+ note_from_dict(d)
+ for d in (self._read_yaml(f) for f in self.notes_dir.glob("*.yaml"))
+ if d
+ ]
+ results: list[Note] = []
+ for n in notes:
+ if kind is not None and n.kind != kind:
+ continue
+ if author is not None and n.author != author:
+ continue
+ if status is not None and n.status != status:
+ continue
+ results.append(n)
+ results.sort(key=lambda n: n.created_at, reverse=True)
+ return results
+
+ # ---- links + supersession (append-only history) ----
+ def link_notes(self, from_id: str, rel: str, to_id: str) -> None:
+ """Add a typed edge from one note to another (append-only)."""
+ note = self.get_note(from_id)
+ if note is None:
+ raise KeyError(from_id)
+ edge = {"rel": rel, "to": to_id}
+ if edge not in note.links:
+ note.links.append(edge)
+ self.write_note(note)
+
+ def supersede_note(self, old_id: str, new_id: str) -> None:
+ """Mark old as superseded (kept, never deleted) and link the new note
+ back to it as a refinement — the chain is the intellectual history."""
+ old = self.get_note(old_id)
+ if old is None:
+ raise KeyError(old_id)
+ old.status = NoteStatus.SUPERSEDED
+ old.superseded_by = new_id
+ self.write_note(old)
+ self.link_notes(new_id, "refines", old_id)
diff --git a/gently/harness/memory/notebook_ask.py b/gently/harness/memory/notebook_ask.py
new file mode 100644
index 00000000..e8f1228c
--- /dev/null
+++ b/gently/harness/memory/notebook_ask.py
@@ -0,0 +1,112 @@
+"""Ask the notebook — retrieve relevant Notes and synthesize a grounded,
+cited answer with Claude. Structural retrieval only (semantic recall is a
+later increment). See docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md §4.
+"""
+
+from __future__ import annotations
+
+from .notebook import Note, NotebookStore
+
+
+def select_notes(
+ store: NotebookStore,
+ *,
+ thread: str | None = None,
+ strain: str | None = None,
+ limit: int = 12,
+) -> list[Note]:
+ """Structural narrowing: scope by thread/strain when given, else recent.
+ Returns newest-first, capped at ``limit``."""
+ notes = store.query_notes(thread=thread, strain=strain)
+ return notes[:limit]
+
+
+# ASK_TOOL pins the structured output. No confidence field — we don't ask the
+# model to self-rate (lab rule); "coverage" is a factual grounding classification.
+ASK_TOOL = {
+ "name": "answer_from_notebook",
+ "description": "Return a grounded answer built ONLY from the provided notebook entries.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "answer": {"type": "string", "description": "Direct synthesis grounded in the notes."},
+ "points": {
+ "type": "array",
+ "description": "Supporting points, each citing the note ids it rests on.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "text": {"type": "string"},
+ "note_ids": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["text", "note_ids"],
+ },
+ },
+ "suggested_next": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": (
+ "Concrete next experiments/moves if the question asks what to do; else empty."
+ ),
+ },
+ "coverage": {
+ "type": "string",
+ "enum": ["covered", "partial", "not_in_notebook"],
+ "description": "How well the provided notes cover the question.",
+ },
+ },
+ "required": ["answer", "points", "coverage"],
+ },
+}
+
+_SYSTEM = (
+ "You reason over a shared lab notebook. Answer ONLY from the notebook entries "
+ "provided — every claim must cite the note id(s) it rests on. If the notes do "
+ "not contain the answer, say so plainly and set coverage to 'not_in_notebook'. "
+ "Never invent facts not in the notes. Call the answer_from_notebook tool."
+)
+
+
+def _render_notes(notes: list[Note]) -> str:
+ lines = []
+ for n in notes:
+ scope = []
+ if n.strains:
+ scope.append("strains=" + ",".join(n.strains))
+ if n.embryos:
+ scope.append("embryos=" + ",".join(n.embryos))
+ tag = f" [{'; '.join(scope)}]" if scope else ""
+ lines.append(f"[{n.id}] ({n.kind.value}){tag} {n.body}")
+ return "\n".join(lines)
+
+
+def build_ask_messages(question: str, notes: list[Note]) -> list[dict]:
+ body = (
+ "Notebook entries:\n" + _render_notes(notes) + f"\n\nQuestion: {question}\n\n"
+ "Answer using only these entries, citing note ids."
+ )
+ return [{"role": "user", "content": body}]
+
+
+async def answer_question(client, model: str, question: str, notes: list[Note]) -> dict:
+ """Force the ask tool and return its validated input dict. Short-circuits
+ (no API call) when there are no notes to ground on."""
+ if not notes:
+ return {
+ "answer": "The notebook doesn't cover this yet.",
+ "points": [],
+ "suggested_next": [],
+ "coverage": "not_in_notebook",
+ }
+ resp = await client.messages.create(
+ model=model,
+ max_tokens=2048,
+ system=_SYSTEM,
+ tools=[ASK_TOOL],
+ tool_choice={"type": "tool", "name": ASK_TOOL["name"]},
+ messages=build_ask_messages(question, notes),
+ )
+ for block in resp.content:
+ if getattr(block, "type", None) == "tool_use":
+ return block.input
+ return {"answer": "", "points": [], "suggested_next": [], "coverage": "not_in_notebook"}
diff --git a/gently/harness/plan_mode/prompt.py b/gently/harness/plan_mode/prompt.py
index 4228249c..055d0df2 100644
--- a/gently/harness/plan_mode/prompt.py
+++ b/gently/harness/plan_mode/prompt.py
@@ -21,8 +21,17 @@
6. Challenge assumptions — suggest controls the researcher might not have thought of
7. Suggest experiments outside of imaging where appropriate (bench assays, genetics, analysis)
-DO NOT rush to a plan. Gather information first. Ask questions. Search the literature.
-Understand the researcher's goals and constraints before proposing.
+Work INFERENCE-FIRST: arrive with a draft, don't interrogate. Infer what you
+reasonably can — read the reporters in the strain's genotype and set the
+excitation wavelengths from your knowledge of fluorophore spectra (e.g.
+TagRFP/mCherry ≈ 561 nm, GFP/GCaMP ≈ 488 nm), let the organism set sensible
+defaults, and let lab/campaign context fill the rest. Record each inferred
+value's source and confidence in the imaging spec's ``provenance``. State a
+wavelength only when you're confident; if a reporter is unfamiliar or ambiguous,
+mark it low-confidence and confirm via ask_user_choice rather than guessing a
+number. Then surface the draft for review, asking ONLY for genuine gaps,
+low-confidence guesses, or consequential choices. Search the literature to
+confirm, not to stall.
## How to Design an Experimental Plan
@@ -67,9 +76,38 @@
3. Set dependencies between items
4. Present the full plan for review with propose_plan
+After propose_plan, close with a short confirmation of what the plan contains
+(item/phase count, the critical path, anything notable) and stop there. Do NOT
+offer to export it, save it as a template, or ask "what would you like to do
+next?" — exporting and opening the workspace are handled by the interface, not
+this conversation. End on the summary, not an upsell.
+
IMPORTANT: ALWAYS use ask_user_choice when asking the researcher questions. Never
present options as text lists.
+## Communication style — keep it light to read
+
+You're talking to a working biologist, not a software user. Optimize every
+user-facing message for fast reading, not completeness:
+
+- **Lead with the ask or the finding.** The first sentence should be the question,
+ the decision, or what you found — supporting detail comes after, and only when it
+ changes what they'd do next.
+- **Short questions, short options.** Keep an ask_user_choice question to one line,
+ and each option to a few-word label plus at most a one-line rationale — never a
+ paragraph. Trust the biologist to know the domain; don't re-explain standard
+ concepts (what a histone marker is, why controls matter).
+- **Plain words, not process jargon.** Use the field's real terms (strain names,
+ stages, wavelengths) but drop software/workflow jargon and hedging.
+- **Give the short "why", not the survey.** One clause of rationale beats an
+ exhaustive list of everything you weighed. Put the full reasoning in the spec's
+ provenance and references, not in the message.
+- **One idea per message.** Don't stack caveats, alternatives, and next steps into
+ one dense block. If something is optional, say so briefly or leave it out.
+
+Readability and brevity are different — choose readability, but get there by
+saying less, not by compressing into fragments or abbreviations.
+
## Reading Papers
Use read_paper to retrieve and read scientific papers. It accepts:
@@ -115,8 +153,12 @@
PLAN_MODE_GUIDELINES = """\
# Behavior in Plan Mode
-1. **Ask before assuming**: Don't assume the researcher's constraints. Ask about
- available strains, timeline, equipment access, collaborators.
+1. **Infer, then confirm — don't interrogate**: Fill what you can from the strain
+ genotype, organism defaults, and lab/campaign context, and record where each
+ value came from (database citation, or your own fluorophore/biology knowledge)
+ in the spec's ``provenance``. Ask — via ask_user_choice — only for genuine
+ gaps, low-confidence guesses, or consequential choices, not for things you can
+ derive or look up.
2. **Think about the full story**: What would reviewers want to see? What controls
would strengthen the claims?
3. **Be realistic about timelines**: Genetic crosses take weeks. Behavioral assays
@@ -140,6 +182,14 @@
items, search to confirm strain availability, check the literature for recent
protocols, and attach references. Your built-in knowledge is a great starting
point for brainstorming — the databases are where you confirm before finalizing.
+11. **Batch independent lookups**: When you need several independent reads — multiple
+ strains, several papers, or a few lab-history queries — request them together in
+ one turn so they run in parallel. Don't fetch one, wait for it, then fetch the
+ next; that's slow. (The system runs same-turn read-only lookups concurrently.)
+12. **Build the plan in few turns**: Each turn is a model round-trip, so creating one
+ item per turn makes plan construction crawl. When writing a phase's items, emit
+ several create_plan_item calls in a single turn (then set any dependencies in a
+ follow-up). Fewer turns = a much faster plan.
"""
diff --git a/gently/harness/plan_mode/tools/planning.py b/gently/harness/plan_mode/tools/planning.py
index 34785e5f..87715acd 100644
--- a/gently/harness/plan_mode/tools/planning.py
+++ b/gently/harness/plan_mode/tools/planning.py
@@ -7,9 +7,34 @@
"""
import dataclasses
+import json
from ...tools.registry import ToolCategory, ToolExample, tool
+
+def _coerce_plan_args(spec, references, estimated_days):
+ """The model often serializes nested args (spec/references) as JSON strings
+ instead of objects — accept either so plan-item creation doesn't store a raw
+ string (which later breaks ImagingSpec/BenchSpec hydration). Returns the
+ normalized (spec, references, estimated_days)."""
+ if isinstance(spec, str):
+ try:
+ spec = json.loads(spec)
+ except (json.JSONDecodeError, TypeError):
+ spec = None
+ if isinstance(references, str):
+ try:
+ references = json.loads(references)
+ except (json.JSONDecodeError, TypeError):
+ references = None
+ if isinstance(estimated_days, str):
+ try:
+ estimated_days = int(estimated_days)
+ except (ValueError, TypeError):
+ estimated_days = None
+ return spec, references, estimated_days
+
+
# ---------------------------------------------------------------------------
# Campaign / Phase Management
# ---------------------------------------------------------------------------
@@ -132,6 +157,17 @@ async def create_plan_item(
return "Error: Context store not available"
store = agent.context_store
+ spec, references, estimated_days = _coerce_plan_args(spec, references, estimated_days)
+ if isinstance(phase_number, str):
+ try:
+ phase_number = int(phase_number)
+ except (ValueError, TypeError):
+ phase_number = None
+ if isinstance(phase_order, str):
+ try:
+ phase_order = int(phase_order)
+ except (ValueError, TypeError):
+ phase_order = -1
# Resolve phase_number → subcampaign ID
target_campaign_id = campaign_id
@@ -226,6 +262,7 @@ async def update_plan_item(
from gently.harness.memory.model import PlanItemStatus
status_enum = PlanItemStatus(status) if status else None
+ spec, references, estimated_days = _coerce_plan_args(spec, references, estimated_days)
store.update_plan_item(
item_id=resolved_id,
status=status_enum,
diff --git a/gently/settings.py b/gently/settings.py
index 68cebd38..9e7d2e68 100644
--- a/gently/settings.py
+++ b/gently/settings.py
@@ -56,14 +56,38 @@ class MeshSettings:
@dataclass(frozen=True)
class ModelSettings:
- """Claude model identifiers."""
-
- main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-opus-4-6"))
- perception: str = field(
- default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-5-20251101")
+ """Claude model identifiers — the single source of truth for every tier.
+
+ Tiers are split by role; capability-first per the latest models:
+ - main: Opus 4.8 ($5/$25). Per-user-turn reasoning + tool
+ orchestration (plan mode) and the dopaminergic classifier
+ stage. (Fable 5 was tried here but declined benign planning
+ turns — stop_reason="refusal" — forcing a fallback on every
+ turn; set MODEL_MAIN=claude-fable-5 to retry it.)
+ - perception: Opus 4.8 (high-res vision, $5/$25). Highest-frequency tier
+ (per timepoint); Opus-tier vision for perception accuracy.
+ - medium: Opus 4.8. Onboarding / wizard summaries.
+ - fast: Sonnet 4.6 ($3/$15). The cheaper/faster tier — drives the
+ verifier's parallel ensemble (ensemble_size calls per
+ verification) and blank-image / summary checks.
+
+ API note: Opus 4.8 rejects thinking budget_tokens and sampling params
+ (temperature/top_p/top_k) — adaptive thinking only, depth via effort.
+ Sonnet 4.6 supports adaptive thinking. No assistant prefills anywhere
+ (4.6+ family rejects them).
+ """
+
+ main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-opus-4-8"))
+ perception: str = field(default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-8"))
+ fast: str = field(default_factory=lambda: _env("MODEL_FAST", "claude-sonnet-4-6"))
+ medium: str = field(default_factory=lambda: _env("MODEL_MEDIUM", "claude-opus-4-8"))
+ # If the main tier declines a turn (stop_reason="refusal"), retry it on this
+ # model instead of surfacing the refusal. Inert while main is Opus 4.8 (the
+ # guard skips it when fallback == main); relevant if main is set to Fable 5.
+ # Empty disables the fallback.
+ refusal_fallback: str = field(
+ default_factory=lambda: _env("MODEL_REFUSAL_FALLBACK", "claude-opus-4-8")
)
- fast: str = field(default_factory=lambda: _env("MODEL_FAST", "claude-haiku-4-5-20251001"))
- medium: str = field(default_factory=lambda: _env("MODEL_MEDIUM", "claude-sonnet-4-5-20250929"))
@dataclass(frozen=True)
@@ -120,6 +144,17 @@ class TransferSettings:
)
+@dataclass(frozen=True)
+class UISettings:
+ """Web UI feature flags."""
+
+ # New agent-first UX paradigm (welcome→shell unfold, dual-rendered agent
+ # asks, inference-first plan mode, shared-visibility surface). Now ON by
+ # default; the v1 dashboard remains available as a fallback via
+ # GENTLY_UX_V2=0 until the v1 markup is removed in a later cleanup step.
+ ux_v2: bool = field(default_factory=lambda: _env("UX_V2", True))
+
+
@dataclass(frozen=True)
class Settings:
"""Top-level settings container."""
@@ -132,6 +167,7 @@ class Settings:
api: ApiSettings = field(default_factory=ApiSettings)
ml: MlSettings = field(default_factory=MlSettings)
transfer: TransferSettings = field(default_factory=TransferSettings)
+ ui: UISettings = field(default_factory=UISettings)
# Singleton — import this everywhere
diff --git a/gently/ui/web/connection_manager.py b/gently/ui/web/connection_manager.py
index 11cc3fe4..1d1f2a23 100644
--- a/gently/ui/web/connection_manager.py
+++ b/gently/ui/web/connection_manager.py
@@ -158,7 +158,12 @@ async def broadcast(self, message: dict):
try:
await connection.send_text(message_json)
except Exception as e:
- logger.warning(f"Failed to send to websocket: {e}")
+ # Expected when a client disconnects/reloads mid-broadcast
+ # (send after websocket.close). The connection is dropped
+ # below, so this is debug-level, not a warning.
+ logger.debug(
+ "Dropping a websocket that errored on send (client likely gone): %s", e
+ )
disconnected.append(connection)
# Remove disconnected clients
diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py
index ebd90770..9f8cd903 100644
--- a/gently/ui/web/routes/__init__.py
+++ b/gently/ui/web/routes/__init__.py
@@ -10,9 +10,11 @@
from .auth_routes import create_router as create_auth_router
from .campaigns import create_router as create_campaigns_router
from .chat import create_router as create_chat_router
+from .context import create_router as create_context_router
from .data import create_router as create_data_router
from .experiments import create_router as create_experiments_router
from .images import create_router as create_images_router
+from .notebook import create_router as create_notebook_router
from .pages import create_router as create_pages_router
from .sessions import create_router as create_sessions_router
from .volumes import create_router as create_volumes_router
@@ -33,6 +35,8 @@ def register_all_routes(server):
create_websocket_router,
create_agent_ws_router,
create_chat_router,
+ create_context_router,
+ create_notebook_router,
):
router = factory(server)
server.app.include_router(router)
diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py
index fdf2fc5f..887fdd25 100644
--- a/gently/ui/web/routes/agent_ws.py
+++ b/gently/ui/web/routes/agent_ws.py
@@ -14,6 +14,8 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+from gently.settings import settings
+
logger = logging.getLogger(__name__)
@@ -744,9 +746,18 @@ async def _run_resolution_bootstrap():
pass
if not wizard_ran:
- if bridge.should_enter_resolution():
+ enter_resolution = bridge.should_enter_resolution()
+ # Under ux_v2 the agent-first landing owns the session-entry
+ # decision ("Plan an experiment" / "Take a quick look"), so the
+ # legacy connect-time resolution picker would just duplicate it —
+ # and contradict it, by offering "Standalone" after the user has
+ # already chosen to plan. Stay quiet on connect for new sessions;
+ # the landing drives plan-mode (/plan) or standalone instead.
+ if enter_resolution and not settings.ui.ux_v2:
bootstrap_task = asyncio.create_task(_run_resolution_bootstrap())
- else:
+ elif not enter_resolution:
+ # Resume / already-resolved sessions still get their briefing
+ # (it sits behind the landing overlay until dismissed).
briefing = bridge.get_session_briefing()
if briefing:
await send_fn({"type": "stream_start"})
diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py
index a3fea08c..b0dbae11 100644
--- a/gently/ui/web/routes/campaigns.py
+++ b/gently/ui/web/routes/campaigns.py
@@ -321,6 +321,58 @@ async def _require(request: Request):
return _require
+ @router.patch(
+ "/api/campaigns/{campaign_id}/items/{item_id}",
+ dependencies=[Depends(_make_campaign_auth("campaigns"))],
+ )
+ async def update_item(campaign_id: str, item_id: str, request: Request):
+ """Edit plan-item fields and/or imaging-spec fields inline.
+
+ Send only the fields you're changing. Spec edits are *merged* into the
+ existing spec, so the UI can PATCH a single field (e.g. laser_power_pct)
+ without losing the rest. An empty string clears a spec field to null.
+ Persists via update_plan_item, which fires PLAN_UPDATED for live refresh.
+ """
+ cs = _get_store()
+ _resolve(cs, campaign_id)
+ item = cs.get_plan_item(item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="Plan item not found")
+
+ body = await request.json()
+ if not isinstance(body, dict):
+ raise HTTPException(status_code=400, detail="Body must be a JSON object")
+
+ kwargs: dict[str, Any] = {}
+ for f in ("title", "description", "outcome"):
+ if isinstance(body.get(f), str):
+ kwargs[f] = body[f]
+ if body.get("estimated_days") is not None:
+ kwargs["estimated_days"] = body["estimated_days"]
+
+ if body.get("status"):
+ try:
+ kwargs["status"] = PlanItemStatus(body["status"])
+ except ValueError as err:
+ raise HTTPException(
+ status_code=400, detail=f"Invalid status: {body['status']}"
+ ) from err
+
+ spec_patch = body.get("spec")
+ if isinstance(spec_patch, dict):
+ current = item.imaging_spec or item.bench_spec
+ merged = asdict(current) if current else {}
+ for k, v in spec_patch.items():
+ merged[k] = None if v == "" else v
+ kwargs["spec"] = merged
+
+ if not kwargs:
+ raise HTTPException(status_code=400, detail="No editable fields supplied")
+
+ cs.update_plan_item(item_id=item_id, **kwargs) # fires PLAN_UPDATED
+ updated = cs.get_plan_item(item_id)
+ return {"ok": True, "item": _serialize(updated)}
+
@router.post(
"/api/campaigns/{campaign_id}/share",
dependencies=[Depends(_make_campaign_auth("campaigns:admin"))],
diff --git a/gently/ui/web/routes/chat.py b/gently/ui/web/routes/chat.py
index 8c66dd45..e07d4501 100644
--- a/gently/ui/web/routes/chat.py
+++ b/gently/ui/web/routes/chat.py
@@ -19,11 +19,13 @@
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
+from gently.settings import settings
from gently.ui.web.auth import require_control
logger = logging.getLogger(__name__)
-CHAT_MODEL = "claude-opus-4-7"
+# Per-timepoint VLM chat → perception tier (Opus 4.8); centralized, not hardcoded.
+CHAT_MODEL = settings.models.perception
SYSTEM_PROMPT = (
"You are helping a biologist interpret a microscopy perception "
"assessment of a C. elegans embryo at a specific timepoint. You can "
diff --git a/gently/ui/web/routes/context.py b/gently/ui/web/routes/context.py
new file mode 100644
index 00000000..66c7f25c
--- /dev/null
+++ b/gently/ui/web/routes/context.py
@@ -0,0 +1,74 @@
+"""Context (shared-visibility) routes.
+
+Exposes the agent's "mind" — its open questions (uncertainty), active
+watchpoints (attention), and pending expectations (beliefs) — read by anyone,
+resolvable only by the control holder. Live updates ride the CONTEXT_UPDATED
+event the FileContextStore emits on the global bus, which the server already
+broadcasts to /ws; the client just re-fetches /api/context on it (no polling).
+"""
+
+from fastapi import APIRouter, Body, Depends
+
+from gently.ui.web.auth import require_control
+
+from .campaigns import _serialize
+
+
+def create_router(server) -> APIRouter:
+ router = APIRouter()
+
+ def _store():
+ # Defensive: the store is wired after construction; tolerate cold start.
+ return getattr(server, "context_store", None)
+
+ @router.get("/api/context")
+ async def get_context():
+ cs = _store()
+ empty = {"available": False, "expectations": [], "watchpoints": [], "questions": []}
+ if cs is None:
+ return empty
+ try:
+ return {
+ "available": True,
+ "questions": [_serialize(q) for q in cs.get_open_questions()],
+ "watchpoints": [_serialize(w) for w in cs.get_active_watchpoints()],
+ "expectations": [_serialize(e) for e in cs.get_pending_expectations()],
+ }
+ except Exception:
+ return empty
+
+ @router.post("/api/context/questions/{q_id}/resolve", dependencies=[Depends(require_control)])
+ async def resolve_question(q_id: str, resolution: str = Body("", embed=True)):
+ cs = _store()
+ if cs is None:
+ return {"ok": False, "error": "context store unavailable"}
+ cs.resolve_question(q_id, resolution or "")
+ return {"ok": True}
+
+ @router.post(
+ "/api/context/watchpoints/{wp_id}/resolve", dependencies=[Depends(require_control)]
+ )
+ async def resolve_watchpoint(wp_id: str):
+ cs = _store()
+ if cs is None:
+ return {"ok": False, "error": "context store unavailable"}
+ cs.resolve_watchpoint(wp_id)
+ return {"ok": True}
+
+ @router.post(
+ "/api/context/expectations/{exp_id}/resolve", dependencies=[Depends(require_control)]
+ )
+ async def resolve_expectation(exp_id: str, status: str = Body("confirmed", embed=True)):
+ cs = _store()
+ if cs is None:
+ return {"ok": False, "error": "context store unavailable"}
+ from gently.harness.memory.model import ExpectationStatus
+
+ try:
+ st = ExpectationStatus(status)
+ except ValueError:
+ st = ExpectationStatus.CONFIRMED
+ cs.resolve_expectation(exp_id, st)
+ return {"ok": True}
+
+ return router
diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py
index 93d49451..a609526a 100644
--- a/gently/ui/web/routes/data.py
+++ b/gently/ui/web/routes/data.py
@@ -214,6 +214,48 @@ async def get_coverslip():
}
}
+ @router.get("/api/devices/scan_geometry")
+ async def get_scan_geometry():
+ """Return the most recent scan geometry for the 3D optical-space view.
+
+ SCAN_GEOMETRY_UPDATE is published only when a volume is acquired, so a
+ page opened before the first acquisition would have no cuboid to draw.
+ This serves the last emitted payload (stashed on the agent by
+ acquisition_tools._publish_scan_geometry), or nominal defaults so the
+ scene is never empty.
+ """
+ bridge = getattr(server, "agent_bridge", None)
+ agent = bridge.agent if bridge is not None else None
+ last = getattr(agent, "last_scan_geometry", None) if agent else None
+ if isinstance(last, dict):
+ return last
+ # Nominal defaults (calibration defaults; no acquisition yet).
+ num_slices = 50
+ piezo_amplitude = 25.0
+ piezo_center = 50.0
+ z_extent = 2.0 * piezo_amplitude
+ return {
+ "embryo_id": None,
+ "stage_position_um": {"x": None, "y": None},
+ "scan": {
+ "num_slices": num_slices,
+ "exposure_ms": 10.0,
+ "galvo_amplitude_deg": 0.5,
+ "galvo_center_deg": 0.0,
+ "piezo_amplitude_um": piezo_amplitude,
+ "piezo_center_um": piezo_center,
+ },
+ "derived": {
+ "z_extent_um": z_extent,
+ "slice_spacing_um": z_extent / (num_slices - 1),
+ "z_min_um": piezo_center - piezo_amplitude,
+ "z_max_um": piezo_center + piezo_amplitude,
+ },
+ "mode": "sheet",
+ "ts": None,
+ "is_default": True,
+ }
+
@router.get("/api/devices/bottom_camera/status")
async def get_bottom_camera_status():
"""Return whether the bottom-camera stream bridge is running."""
diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py
new file mode 100644
index 00000000..b9cb27da
--- /dev/null
+++ b/gently/ui/web/routes/notebook.py
@@ -0,0 +1,99 @@
+"""Notebook (shared lab notebook) read routes.
+
+Exposes the notebook's Notes for the Notebook tab + Agent's-View live edge.
+Read-only here; authoring/curation come in a later increment.
+"""
+
+from fastapi import APIRouter, Body, HTTPException
+
+from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict
+
+
+def _coerce(enum_cls, value):
+ """Parse a query-param string into an enum; invalid/None → None (no filter)."""
+ if value is None:
+ return None
+ try:
+ return enum_cls(value)
+ except ValueError:
+ return None
+
+
+def create_router(server) -> APIRouter:
+ router = APIRouter()
+
+ def _nb():
+ cs = getattr(server, "context_store", None)
+ return cs.notebook if cs is not None else None
+
+ @router.get("/api/notebook/notes")
+ async def list_notes(
+ kind: str | None = None,
+ author: str | None = None,
+ status: str | None = None,
+ strain: str | None = None,
+ embryo: str | None = None,
+ thread: str | None = None,
+ limit: int | None = None,
+ ):
+ nb = _nb()
+ if nb is None:
+ return {"available": False, "notes": []}
+ notes = nb.query_notes(
+ kind=_coerce(NoteKind, kind),
+ author=_coerce(Author, author),
+ status=_coerce(NoteStatus, status),
+ strain=strain,
+ embryo=embryo,
+ thread=thread,
+ )
+ if limit is not None and limit >= 0:
+ notes = notes[:limit]
+ return {"available": True, "notes": [note_to_dict(n) for n in notes]}
+
+ @router.get("/api/notebook/notes/{note_id}")
+ async def get_note(note_id: str):
+ nb = _nb()
+ if nb is None:
+ raise HTTPException(status_code=404, detail="notebook unavailable")
+ note = nb.get_note(note_id)
+ if note is None:
+ raise HTTPException(status_code=404, detail="note not found")
+ return note_to_dict(note)
+
+ @router.get("/api/notebook/threads")
+ async def list_threads():
+ nb = _nb()
+ if nb is None:
+ return {"available": False, "threads": []}
+ counts: dict[str, int] = {}
+ for n in nb.query_notes():
+ for t in n.threads:
+ counts[t] = counts.get(t, 0) + 1
+ threads = [{"id": t, "count": c} for t, c in sorted(counts.items())]
+ return {"available": True, "threads": threads}
+
+ @router.post("/api/notebook/ask")
+ async def ask(
+ question: str = Body(..., embed=True),
+ thread: str | None = Body(None, embed=True),
+ strain: str | None = Body(None, embed=True),
+ ):
+ nb = _nb()
+ if nb is None:
+ return {"available": False}
+ from gently.harness.memory.notebook_ask import answer_question, select_notes
+ from gently.settings import settings
+
+ notes = select_notes(nb, thread=thread, strain=strain)
+ client = getattr(server, "claude_async", None)
+ if client is None:
+ import anthropic
+
+ client = anthropic.AsyncAnthropic()
+ result = await answer_question(client, settings.models.main, question, notes)
+ result["available"] = True
+ result["note_ids"] = [n.id for n in notes]
+ return result
+
+ return router
diff --git a/gently/ui/web/routes/pages.py b/gently/ui/web/routes/pages.py
index 3858f4e7..04a1a959 100644
--- a/gently/ui/web/routes/pages.py
+++ b/gently/ui/web/routes/pages.py
@@ -3,6 +3,8 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
+from gently.settings import settings
+
def create_router(server) -> APIRouter:
router = APIRouter()
@@ -16,7 +18,9 @@ async def index(request: Request):
chat window's "Sign in" affordance), not a gate on the page itself.
"""
return server.templates.TemplateResponse(
- request, "index.html", {"active_section": "embryos", "is_live": True}
+ request,
+ "index.html",
+ {"active_section": "embryos", "is_live": True, "ux_v2": settings.ui.ux_v2},
)
# Standalone URLs redirect to SPA with hash fragment for tab routing
diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py
index c16c548d..16641eae 100644
--- a/gently/ui/web/server.py
+++ b/gently/ui/web/server.py
@@ -676,7 +676,11 @@ async def wait_for_marking(self, session_id: str, timeout: float | None = None)
embryos.append(
{
"embryo_number": m["number"],
- "embryo_id": m.get("embryo_id") or f"embryo_{m['number']:03d}",
+ # Unpadded to match the live convention used everywhere else
+ # (detection_tools registers embryos as f"embryo_{n}"). A
+ # zero-padded fallback here produced ids like "embryo_002"
+ # that never matched the stored "embryo_2".
+ "embryo_id": m.get("embryo_id") or f"embryo_{m['number']}",
"pixel_position": (px, py),
"pixel_x": px,
"pixel_y": py,
@@ -784,13 +788,22 @@ async def on_start(self):
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ # Match uvicorn's own bind semantics. uvicorn sets SO_REUSEADDR before it
+ # binds, so a bare preflight bind WITHOUT it is *stricter* than the real
+ # server: when a previous instance has just exited, its browser/websocket
+ # connections linger in TIME_WAIT holding this local port, and a plain
+ # bind() fails with EADDRINUSE even though uvicorn would bind fine. That
+ # false positive was the recurring "port in use" on quick restarts. With
+ # SO_REUSEADDR the preflight now fails only on a genuine live listener
+ # (a real second instance) — exactly when uvicorn would also fail.
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((self.host, self.port))
except OSError:
raise OSError(
- f"Port {self.port} is already in use. "
- "Is another instance of the agent running? "
- "Close it first and try again."
+ f"Port {self.port} is already in use — another instance may be running. "
+ f"Free it with: fuser -k {self.port}/tcp "
+ f"(or: lsof -ti:{self.port} | xargs -r kill), then try again."
) from None
finally:
sock.close()
diff --git a/gently/ui/web/static/css/agent-chat.css b/gently/ui/web/static/css/agent-chat.css
index fdaaa8e3..29d64646 100644
--- a/gently/ui/web/static/css/agent-chat.css
+++ b/gently/ui/web/static/css/agent-chat.css
@@ -441,3 +441,42 @@ body.chat-docked .agent-chat:not(.open) {
color: var(--text-muted); cursor: pointer; font-size: 12px; line-height: 1;
}
.ac-queue-remove:hover { color: var(--color-danger, #f87171); }
+
+/* ── Rendered markdown (mdToHtml output, ac-md-* classes) ──────────────────
+ Shared by the chat transcript and the ux_v2 plan-wizard activity feed — the
+ same renderer feeds both, so these styles cover headings, lists, tables,
+ code blocks, quotes and links the agent emits. */
+.ac-md { line-height: 1.55; }
+.ac-md > :first-child { margin-top: 0; }
+.ac-md > :last-child { margin-bottom: 0; }
+.ac-md-h1, .ac-md-h2, .ac-md-h3, .ac-md-h4, .ac-md-h5, .ac-md-h6 {
+ margin: 14px 0 6px; font-weight: 650; line-height: 1.3; letter-spacing: -.01em; color: var(--text);
+}
+.ac-md-h1 { font-size: 1.25em; }
+.ac-md-h2 { font-size: 1.15em; }
+.ac-md-h3 { font-size: 1.05em; }
+.ac-md-h4, .ac-md-h5, .ac-md-h6 { font-size: 1em; }
+.ac-md-p { margin: 7px 0; }
+.ac-md-ul, .ac-md-ol { margin: 7px 0; padding-left: 22px; }
+.ac-md-li { margin: 3px 0; }
+.ac-md-quote {
+ margin: 8px 0; padding: 4px 12px; border-left: 3px solid var(--border, #e4e9f0);
+ color: var(--text-muted); font-style: italic;
+}
+.ac-md-hr { border: 0; border-top: 1px solid var(--border, #e4e9f0); margin: 12px 0; }
+.ac-md-link { color: var(--accent, #2f6df6); text-decoration: underline; text-underline-offset: 2px; }
+.ac-md-pre {
+ margin: 8px 0; padding: 10px 12px; border-radius: 8px; overflow-x: auto;
+ background: var(--bg, #f6f8fb); border: 1px solid var(--border, #e4e9f0);
+}
+.ac-md-pre .ac-md-code-block, .ac-md-pre code {
+ font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px;
+ color: var(--text); background: none; padding: 0; white-space: pre;
+}
+/* GFM tables — wrapped so a wide table scrolls instead of blowing out the column */
+.ac-md-table-wrap { margin: 9px 0; overflow-x: auto; border: 1px solid var(--border, #e4e9f0); border-radius: 8px; }
+.ac-md-table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
+.ac-md-table th, .ac-md-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border, #e4e9f0); border-right: 1px solid var(--border, #e4e9f0); }
+.ac-md-table th:last-child, .ac-md-table td:last-child { border-right: 0; }
+.ac-md-table tr:last-child td { border-bottom: 0; }
+.ac-md-table thead th { background: var(--bg, #f6f8fb); font-weight: 650; color: var(--text); }
diff --git a/gently/ui/web/static/css/ask-stage.css b/gently/ui/web/static/css/ask-stage.css
new file mode 100644
index 00000000..dc15c623
--- /dev/null
+++ b/gently/ui/web/static/css/ask-stage.css
@@ -0,0 +1,56 @@
+/* Main-stage ask surface (ux_v2): the agent's current pending ask, rendered
+ prominently outside the chat transcript. Reuses the .ac-choice card markup
+ from agent-chat.css; this file frames the stage container and adds the
+ shared free-text ("Something else…") escape styling. Only #ask-stage is
+ gated behind the flag, so loading this CSS unconditionally is harmless. */
+
+.ask-stage { margin: 14px 16px 0; }
+.ask-stage.hidden { display: none; }
+
+.ask-stage .ac-choice {
+ border: 1px solid var(--border, #e4e9f0);
+ border-radius: 14px;
+ padding: 16px 18px;
+ background: var(--surface, #fff);
+ box-shadow: 0 8px 28px rgba(15, 23, 42, .08);
+}
+.ask-stage .ac-choice-q {
+ font-size: 1.02rem;
+ font-weight: 600;
+ margin-bottom: 12px;
+}
+
+/* Free-text "Something else…" escape — present on ask cards in BOTH surfaces. */
+.ac-choice-otherwrap { margin-top: 6px; }
+.ac-choice-other.hidden,
+.ac-choice-otherform.hidden { display: none; }
+.ac-choice-otherform { display: flex; gap: 6px; align-items: center; margin-top: 4px; }
+.ac-choice-otherinput {
+ flex: 1; min-width: 0;
+ padding: 8px 10px;
+ border: 1px solid var(--border, #cbd5e1);
+ border-radius: 8px;
+ font: inherit;
+ background: var(--surface, #fff);
+ color: inherit;
+}
+.ac-choice-otherinput:focus { outline: none; border-color: var(--accent, #2f6df6); }
+.ac-choice-othergo {
+ border: 0; cursor: pointer;
+ background: var(--accent, #2f6df6); color: #fff;
+ border-radius: 8px; padding: 8px 12px; line-height: 1;
+}
+
+/* Per-field provenance tag on imaging-spec rows (Phase 3b): shows where an
+ inferred value came from, e.g. "inferred · medium". */
+.ac-spec-src {
+ margin-left: 6px;
+ font-size: 10px;
+ letter-spacing: .02em;
+ color: var(--text-muted, #94a3b8);
+ background: var(--bg-hover, #f1f5f9);
+ border-radius: 999px;
+ padding: 1px 7px;
+ white-space: nowrap;
+ vertical-align: middle;
+}
diff --git a/gently/ui/web/static/css/campaigns.css b/gently/ui/web/static/css/campaigns.css
index bc4f36c2..88690dec 100644
--- a/gently/ui/web/static/css/campaigns.css
+++ b/gently/ui/web/static/css/campaigns.css
@@ -1284,6 +1284,89 @@
font-size: 0.7rem;
}
+/* Section title with a right-aligned action (e.g. Edit) */
+.detail-section-title--row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.detail-section-action { display: inline-flex; }
+.spec-edit-btn {
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-muted);
+ border-radius: 6px;
+ padding: 2px 8px;
+ font: inherit;
+ font-size: 0.62rem;
+ letter-spacing: 0.3px;
+ cursor: pointer;
+}
+.spec-edit-btn:hover { color: var(--text); border-color: var(--text-muted); }
+
+/* Editable imaging-spec form */
+.spec-editor { display: flex; flex-direction: column; gap: 6px; }
+.spec-edit-row {
+ display: grid;
+ grid-template-columns: 38% 1fr;
+ align-items: center;
+ gap: 10px;
+}
+.spec-edit-label {
+ color: var(--text-muted);
+ font-size: 0.7rem;
+}
+.spec-edit-row--empty .spec-edit-label::after {
+ content: ' • set';
+ color: var(--accent-orange, #d98324);
+ font-size: 0.6rem;
+ opacity: 0.8;
+}
+.spec-edit-field { display: flex; align-items: center; gap: 5px; }
+.spec-edit-input {
+ flex: 1;
+ min-width: 0;
+ background: var(--bg, #fff);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 4px 7px;
+ color: var(--text);
+ font-family: 'SF Mono', 'Fira Code', monospace;
+ font-size: 0.7rem;
+}
+.spec-edit-input:focus {
+ outline: none;
+ border-color: var(--accent, #2f6df6);
+}
+.spec-edit-row--empty .spec-edit-input {
+ border-style: dashed;
+}
+.spec-edit-unit { color: var(--text-muted); font-size: 0.66rem; }
+.spec-edit-error {
+ color: var(--accent-orange, #d98324);
+ font-size: 0.68rem;
+ margin-top: 2px;
+}
+.spec-edit-actions { display: flex; gap: 8px; margin-top: 8px; }
+.spec-save-btn, .spec-cancel-btn {
+ border-radius: 7px;
+ padding: 5px 14px;
+ font: inherit;
+ font-size: 0.72rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+.spec-save-btn {
+ background: var(--accent, #2f6df6);
+ color: #fff;
+ border: 0;
+}
+.spec-cancel-btn {
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+}
+
/* Dependencies / Dependents chips */
.dep-list {
display: flex;
diff --git a/gently/ui/web/static/css/landing.css b/gently/ui/web/static/css/landing.css
new file mode 100644
index 00000000..046675de
--- /dev/null
+++ b/gently/ui/web/static/css/landing.css
@@ -0,0 +1,462 @@
+/* ux_v2 landing — the agent-first welcome that the prototype sketched, ported
+ into production. A full-bleed overlay shown on first entry that recedes into
+ the workspace once the user picks a path. Everything is scoped under
+ body.ux-v2 and the #v2-landing node only renders when the flag is on, so v1
+ is byte-for-byte untouched. Visual language mirrors ux-prototype/landing.html
+ but reuses production's CSS variables (with the prototype hexes as fallback)
+ so it tracks the app theme. */
+
+/* ux_v2 landing fills the gaps in the production token set (main.css defines
+ --bg-dark/-card/-hover, --border, --text, --text-muted, --accent, --accent-green
+ but NOT a page-bg alias, a secondary-text, or accent tints). Scope to
+ body.ux-v2 so v1 is untouched; both themes resolved here so landing.css can
+ reference these like any real token. dark is the default theme (main.css :root). */
+body.ux-v2 {
+ --bg: var(--bg-dark); /* page background, theme-aware */
+ --text-secondary: var(--text-muted);
+ --accent-soft: rgba(96,165,250,.15); /* tint of dark --accent #60a5fa */
+ --accent-green-soft: rgba(74,222,128,.15); /* tint of dark --accent-green */
+ /* one disciplined type scale for the landing/plan surface */
+ --v2-fs-body: 14px;
+ --v2-fs-sm: 13px;
+ --v2-fs-cap: 12px;
+ --v2-fs-eyebrow: 11px;
+}
+body.ux-v2[data-theme="light"] {
+ --accent-soft: rgba(59,130,246,.10); /* tint of light --accent #3b82f6 */
+ --accent-green-soft: rgba(34,197,94,.12); /* tint of light --accent-green */
+}
+/* accent-keyed glows can't put var() inside rgba channels, so the dark defaults
+ live on the elements (re-keyed off the dead #2f6df6 onto the real #60a5fa) and
+ light overrides ride here next to the tokens. */
+body.ux-v2[data-theme="light"] .v2-landing-orb { box-shadow: 0 6px 22px rgba(59,130,246,.35), inset 0 0 12px rgba(255,255,255,.6); }
+body.ux-v2[data-theme="light"] .v2-escape-field input:focus { box-shadow: 0 0 0 4px rgba(59,130,246,.12); }
+body.ux-v2[data-theme="light"] .v2-escape-send { box-shadow: 0 6px 16px rgba(59,130,246,.35); }
+
+.v2-landing {
+ position: fixed;
+ inset: 0;
+ z-index: 200;
+ display: flex;
+ align-items: flex-start; /* BOTH screens top-anchored — no discrete switch on swap */
+ justify-content: center;
+ padding: 24px;
+ overflow: hidden;
+ background:
+ radial-gradient(1100px 700px at 78% -8%, var(--accent-soft) 0%, transparent 55%),
+ radial-gradient(900px 600px at 8% 108%, var(--accent-green-soft) 0%, transparent 55%),
+ var(--bg);
+ transition: opacity .5s cubic-bezier(.22,1,.36,1), transform .5s cubic-bezier(.22,1,.36,1), visibility .5s;
+}
+/* The calm screen "unfolds" into the workspace: fade + slight scale-up, then
+ the node is pulled from the layout (display:none set by JS after the
+ transition) so it never traps clicks. */
+.v2-landing.dismissed {
+ opacity: 0;
+ visibility: hidden;
+ transform: scale(1.015);
+ pointer-events: none;
+}
+.v2-landing::before {
+ content: "";
+ position: absolute;
+ inset: -20vmax;
+ background: radial-gradient(closest-side, var(--accent-soft), transparent 70%);
+ filter: blur(30px);
+ animation: v2land-drift 26s cubic-bezier(.22,1,.36,1) infinite alternate;
+ will-change: transform;
+ pointer-events: none;
+}
+@keyframes v2land-drift {
+ 0% { transform: translate(-6vw,-4vh) scale(1); }
+ 100% { transform: translate(8vw,6vh) scale(1.15); }
+}
+
+.v2-landing-inner {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ max-width: 760px;
+ width: 100%;
+ margin-top: 7vh; /* shared anchor for welcome AND plan — orb stays put on swap */
+ margin-bottom: 5vh;
+}
+.v2-landing-rise { animation: v2land-rise .6s cubic-bezier(.22,1,.36,1) backwards; }
+.v2-landing-rise[data-i="1"] { animation-delay: .07s; }
+.v2-landing-rise[data-i="2"] { animation-delay: .14s; }
+.v2-landing-rise[data-i="3"] { animation-delay: .21s; }
+@keyframes v2land-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
+
+/* agent presence */
+.v2-landing-agent { display: flex; flex-direction: column; align-items: center; gap: 16px; }
+.v2-landing-orb {
+ width: 52px; height: 52px; border-radius: 50%;
+ background: radial-gradient(closest-side at 38% 34%, #ffffff, #bcd3ff 40%, var(--accent, #2f6df6) 100%);
+ box-shadow: 0 6px 22px rgba(96,165,250,.45), inset 0 0 12px rgba(255,255,255,.6);
+ animation: v2land-breathe 4s ease-in-out infinite;
+}
+@keyframes v2land-breathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.06); } }
+.v2-landing-say {
+ font-size: clamp(20px, 3vw, 28px); font-weight: 600; letter-spacing: -.02em;
+ text-align: center; max-width: 22ch; line-height: 1.25; color: var(--text, #0f172a);
+}
+.v2-landing-say .dim { color: var(--text-muted, #94a3b8); font-weight: 500; }
+
+/* choice cards */
+.v2-landing-choices {
+ display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 16px;
+ margin-top: 32px; width: min(720px, 92vw);
+}
+@media (max-width: 620px) { .v2-landing-choices { grid-template-columns: 1fr; } }
+.v2-choice {
+ text-align: left; cursor: pointer; position: relative; overflow: hidden;
+ border: 1px solid var(--border, #e4e9f0); background: var(--bg-card, #fff);
+ border-radius: 18px; padding: 20px;
+ box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06);
+ font: inherit; color: var(--text, #0f172a);
+ transition: transform .26s cubic-bezier(.22,1,.36,1), box-shadow .26s cubic-bezier(.22,1,.36,1), border-color .26s;
+}
+.v2-choice:hover {
+ transform: translateY(-4px);
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
+ box-shadow: 0 2px 6px rgba(15,23,42,.06),
+ 0 18px 50px color-mix(in srgb, var(--accent) 16%, transparent);
+}
+.v2-choice:active { transform: translateY(-1px) scale(.995); }
+
+/* Visible keyboard focus for every landing/plan control (mouse clicks get no ring) */
+#v2-landing :focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 12px; /* hug the pill/card corners */
+}
+#v2-landing .v2-choice:focus-visible { outline-offset: -2px; } /* inset: the card clips outside outlines */
+#v2-landing .v2-escape-field input:focus-visible { outline-offset: 0; }
+.v2-choice-ic {
+ width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center;
+ background: var(--accent-soft, #eaf1ff); color: var(--accent, #2f6df6); margin-bottom: 14px;
+}
+.v2-choice.alt .v2-choice-ic { background: var(--accent-green-soft, #e7f6ec); color: var(--accent-green, #16a34a); }
+.v2-choice h3 { margin: 0 0 6px; font-size: 17px; letter-spacing: -.01em; }
+.v2-choice p { margin: 0; color: var(--text-secondary, #475569); font-size: var(--v2-fs-sm); line-height: 1.5; }
+.v2-choice-tag {
+ position: absolute; top: 16px; right: 16px;
+ font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8);
+ border: 1px solid var(--border, #e4e9f0); border-radius: 999px; padding: 3px 9px;
+}
+.v2-choice-go {
+ margin-top: 16px; display: flex; align-items: center; gap: 6px;
+ color: var(--accent, #2f6df6); font-size: 13px; font-weight: 600;
+ opacity: 0; transform: translateX(-4px); transition: .26s cubic-bezier(.22,1,.36,1);
+}
+.v2-choice.alt .v2-choice-go { color: var(--accent-green, #16a34a); }
+.v2-choice:hover .v2-choice-go { opacity: 1; transform: none; }
+
+/* escape hatch — chat is the last resort, an obvious pill */
+.v2-escape { margin-top: 24px; display: flex; flex-direction: column; align-items: center; }
+.v2-escape-toggle {
+ display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font: inherit; font-size: 13px;
+ background: var(--bg-card, #fff); border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569);
+ padding: 11px 16px; border-radius: 999px;
+ box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.06);
+ transition: color .2s, border-color .2s, transform .2s cubic-bezier(.22,1,.36,1);
+}
+.v2-escape-toggle:hover { color: var(--text, #0f172a); border-color: var(--border-strong); transform: translateY(-1px); }
+.v2-escape-toggle .v2-escape-caret { display: inline-block; transition: transform .3s cubic-bezier(.22,1,.36,1); opacity: .55; }
+.v2-escape.open .v2-escape-toggle .v2-escape-caret { transform: rotate(180deg); }
+.v2-escape-field {
+ display: flex; align-items: center; gap: 8px; width: min(520px, 90vw);
+ max-height: 0; opacity: 0; overflow: hidden;
+ transition: max-height .4s cubic-bezier(.22,1,.36,1), opacity .4s cubic-bezier(.22,1,.36,1), margin .4s cubic-bezier(.22,1,.36,1);
+}
+.v2-escape.open .v2-escape-field { max-height: 64px; opacity: 1; margin-top: 12px; }
+.v2-escape-field input {
+ flex: 1; min-width: 0; border: 1px solid var(--border, #e4e9f0); background: var(--bg-card, #fff);
+ border-radius: 12px; padding: 12px 14px; font: inherit; font-size: var(--v2-fs-body); color: var(--text, #0f172a);
+ outline: none; box-shadow: 0 1px 2px rgba(15,23,42,.04);
+ transition: border-color .2s, box-shadow .2s;
+}
+.v2-escape-field input:focus { border-color: var(--accent, #2f6df6); box-shadow: 0 0 0 4px rgba(96,165,250,.18); }
+.v2-escape-send {
+ appearance: none; border: 0; cursor: pointer; flex: none; width: 42px; height: 42px; border-radius: 12px;
+ background: var(--accent, #2f6df6); color: #fff; display: grid; place-items: center;
+ box-shadow: 0 6px 16px rgba(96,165,250,.40); transition: transform .2s cubic-bezier(.22,1,.36,1), filter .2s;
+}
+.v2-escape-send:hover { transform: translateY(-1px); filter: brightness(1.05); }
+
+/* one-way skip into the workspace (e.g. a reload mid-session) */
+.v2-landing-skip {
+ margin-top: 24px; background: none; border: 0; cursor: pointer; font: inherit; font-size: var(--v2-fs-cap);
+ color: var(--text-muted, #94a3b8); padding: 10px 12px; border-radius: 8px;
+ transition: color .2s;
+}
+.v2-landing-skip:hover { color: var(--text-secondary, #475569); }
+
+/* Under ux_v2 the landing IS the welcome moment, so the legacy home hero
+ (static "Welcome to Gently" + start button) would be a duplicate behind it —
+ hide it. The recent-* cards and the context surface stay. */
+body.ux-v2 .home-hero { display: none; }
+
+/* ── Two-screen system: welcome ↔ in-place plan wizard ───────── */
+.v2-landing-inner { max-width: 980px; } /* widen for the plan layout */
+.v2-landing .v2-screen { display: none; width: 100%; }
+.v2-landing[data-screen="welcome"] .v2-screen-welcome {
+ display: flex; flex-direction: column; align-items: center;
+ max-width: 760px; margin: 0 auto;
+ animation: v2land-plan-in .42s cubic-bezier(.22,1,.36,1) backwards;
+}
+.v2-landing[data-screen="plan"] .v2-screen-plan {
+ display: flex; flex-direction: column;
+ animation: v2land-plan-in .42s cubic-bezier(.22,1,.36,1) backwards;
+}
+/* One swap motion shared by both screens: a soft opacity + rise + settle. The
+ scale .992→1 echoes the dismissed-state scale(1.015) so the surface feels like
+ one continuous fabric folding, not two slides swapping. */
+@keyframes v2land-plan-in {
+ from { opacity: 0; transform: translateY(10px) scale(.992); }
+ to { opacity: 1; transform: none; }
+}
+
+/* plan header */
+.v2-plan-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
+.v2-plan-orb { width: 40px; height: 40px; transition: width .42s cubic-bezier(.22,1,.36,1), height .42s cubic-bezier(.22,1,.36,1); }
+.v2-plan-who { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); }
+.v2-plan-title { font-size: 18px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); }
+.v2-plan-back {
+ margin-left: auto; background: none; border: 0; cursor: pointer; font: inherit; font-size: 13px;
+ color: var(--text-muted, #94a3b8); padding: 9px 12px; border-radius: 8px; transition: color .2s, background .2s;
+}
+.v2-plan-back:hover { color: var(--text, #0f172a); background: rgba(15,23,42,.05); }
+
+/* plan body: ask stage + assembling plan */
+.v2-plan-wrap { display: grid; grid-template-columns: 1.35fr .9fr; gap: 20px; align-items: start; }
+@media (max-width: 720px) {
+ .v2-plan-wrap { grid-template-columns: 1fr; }
+ /* single column: THE PLAN sits BELOW the feed — drop the internal scroll
+ and let the whole plan screen scroll as one document instead. The
+ descendant selector outranks the plain `.v2-plan-main { max-height }`
+ rule that appears later in the file (equal specificity → source order),
+ so the cap is genuinely lifted here, not silently re-applied. */
+ .v2-landing[data-screen="plan"] .v2-plan-main { height: auto; max-height: none; overflow-y: visible; padding-right: 0; }
+ .v2-landing[data-screen="plan"] { overflow-y: auto; }
+}
+.v2-plan-main { min-height: 220px; }
+.v2-plan-ask:empty { display: none; }
+.v2-plan-thinking { display: flex; align-items: center; gap: 9px; color: var(--text-muted, #94a3b8); font-size: var(--v2-fs-sm); padding: 20px 4px; }
+.v2-plan-thinking.hidden { display: none; }
+.v2-typing { display: inline-flex; gap: 5px; align-items: center; }
+.v2-typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #2f6df6); opacity: .4; animation: v2-blink 1.1s infinite; }
+.v2-typing i:nth-child(2) { animation-delay: .15s; }
+.v2-typing i:nth-child(3) { animation-delay: .3s; }
+@keyframes v2-blink { 0%,100% { opacity: .25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-3px); } }
+
+.v2-plan-side {
+ background: var(--bg-card, #fff); border: 1px solid var(--border, #e4e9f0); border-radius: 14px; padding: 14px 16px;
+ box-shadow: 0 1px 2px rgba(15,23,42,.04);
+}
+.v2-plan-side-h { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-bottom: 10px; }
+.v2-plan-side-empty { color: var(--text-muted, #94a3b8); font-size: var(--v2-fs-sm); font-style: italic; }
+.v2-plan-row { display: flex; flex-direction: column; gap: 2px; padding: 9px 0; border-top: 1px dashed var(--border, #e4e9f0); }
+.v2-plan-row:first-child { border-top: 0; }
+.v2-plan-row .k { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); }
+.v2-plan-row .v { font-size: var(--v2-fs-body); color: var(--text, #0f172a); font-weight: 600; }
+
+/* plan footer: "Open conversation" (quiet, left), a spacer, then "Continue in
+ workspace" demoted to a text link (right). The agent's recommended option in
+ the ask card is the real primary action now — the footer no longer competes. */
+.v2-plan-foot { display: flex; align-items: center; gap: 10px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border, #e4e9f0); }
+.v2-plan-foot-spacer { flex: 1; }
+.v2-plan-chat {
+ background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569);
+ border-radius: 999px; padding: 11px 16px; font: inherit; font-size: var(--v2-fs-sm); cursor: pointer; transition: border-color .2s, color .2s;
+}
+.v2-plan-chat:hover { border-color: var(--border-strong); color: var(--text, #0f172a); }
+.v2-plan-skip {
+ background: none; border: 0; cursor: pointer; font: inherit; font-size: var(--v2-fs-sm);
+ color: var(--text-muted, #94a3b8); padding: 11px 10px; border-radius: 8px; transition: color .2s;
+}
+.v2-plan-skip:hover { color: var(--text-secondary, #475569); }
+.v2-plan-export {
+ background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569);
+ border-radius: 999px; padding: 11px 16px; font: inherit; font-size: var(--v2-fs-sm);
+ font-weight: 600; cursor: pointer; transition: border-color .2s, color .2s, background .2s;
+}
+.v2-plan-export:hover { border-color: var(--border-strong); color: var(--text, #0f172a); background: var(--bg-hover); }
+.v2-plan-export:disabled { opacity: .6; cursor: default; }
+.v2-plan-export[hidden] { display: none; }
+
+/* ── Plan-ready state: the design is done, signpost the finish line ───────── */
+.v2-screen-plan.ready .v2-plan-orb {
+ background: radial-gradient(closest-side at 38% 34%, #ffffff, #c8f0d4 40%, var(--accent-green, #16a34a) 100%);
+}
+.v2-screen-plan.ready .v2-plan-who { color: var(--accent-green, #16a34a); }
+.v2-screen-plan.ready .v2-plan-foot { border-top-color: color-mix(in srgb, var(--accent-green) 35%, var(--border)); }
+/* promote "open the workspace" from a quiet skip link to the primary action */
+.v2-screen-plan.ready #v2-plan-continue {
+ background: var(--accent-green, #16a34a); color: #fff;
+ border-radius: 999px; padding: 11px 20px; font-weight: 600;
+}
+.v2-screen-plan.ready #v2-plan-continue:hover {
+ color: #fff; background: color-mix(in srgb, var(--accent-green) 88%, #000);
+}
+
+/* ── Agent-activity feed: claude.ai-style collapsible tool cards ──────────── */
+/* Both screens share the .v2-landing-inner top anchor (no per-screen align flip —
+ that was the welcome→plan lurch). The feed is a fixed-height viewport (height
+ set above) so the streaming activity column scrolls on its own without ever
+ reflowing the anchored header/footer around it. Short feeds stay compact
+ (min-height above); long feeds cap at 66vh and scroll internally. The header
+ never moves because the inner is top-anchored — only the footer rides down as
+ the feed grows, up to the cap. */
+.v2-plan-main { max-height: 66vh; overflow-y: auto; padding-right: 4px; }
+
+.v2-plan-activity { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
+.v2-plan-activity:empty { display: none; margin: 0; }
+
+/* Paginated feed — one agent step (turn) per page, flipped with ‹ Prev / Next ›.
+ The pager bar / dots reuse .v2-plan-pager / .v2-plan-dots styling. */
+.v2-feed-pages { display: flex; flex-direction: column; }
+.v2-act-page { display: none; flex-direction: column; gap: 8px; }
+.v2-act-page.active { display: flex; }
+/* beat .v2-plan-pager/.v2-plan-dots { display:flex } so [hidden] actually hides */
+.v2-plan-pager[hidden], .v2-plan-dots[hidden] { display: none; }
+.v2-feed-pager-bar { margin: 0 0 4px; }
+.v2-feed-dots { margin-top: 10px; }
+/* the current question, pinned below the paged feed, set off by a divider —
+ only once there are steps above it (no stray line on the first choice card) */
+#v2-plan-activity:has(.v2-act-page) + .v2-plan-ask:not(:empty) {
+ margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border, #e4e9f0);
+}
+
+/* agent prose between tool calls */
+.v2-act-text { font-size: var(--v2-fs-sm); line-height: 1.55; color: var(--text-secondary, #475569); white-space: pre-wrap; }
+
+/* collapsed-by-default tool card; the header toggles .open to reveal the body */
+.v2-act-tool { border: 1px solid var(--border, #e4e9f0); border-radius: 11px; background: var(--bg-card, #fff); overflow: hidden; }
+.v2-act-tool-head {
+ display: flex; align-items: center; gap: 8px; width: 100%;
+ background: none; border: 0; cursor: pointer; text-align: left; font: inherit;
+ padding: 11px 12px; color: var(--text, #0f172a);
+}
+.v2-act-tool-head:hover { background: var(--bg-hover, #f1f5f9); }
+.v2-act-ic { width: 16px; flex: none; text-align: center; font-size: 12px; }
+.v2-act-tool.done .v2-act-ic { color: var(--accent-green, #16a34a); }
+.v2-act-tool.err .v2-act-ic { color: #ea580c; }
+body.ux-v2[data-theme="dark"] .v2-act-tool.err .v2-act-ic { color: #fb923c; }
+.v2-act-spin {
+ display: inline-block; width: 11px; height: 11px; border-radius: 50%;
+ border: 2px solid var(--border, #e4e9f0); border-top-color: var(--accent, #2f6df6);
+ animation: v2-act-spin .7s linear infinite;
+}
+@keyframes v2-act-spin { to { transform: rotate(360deg); } }
+.v2-act-label { font-size: var(--v2-fs-sm); font-weight: 600; flex: none; }
+.v2-act-summary { font-size: var(--v2-fs-cap); color: var(--text-muted, #94a3b8); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.v2-act-chev { flex: none; color: var(--text-muted, #94a3b8); transition: transform .2s; font-size: 13px; }
+.v2-act-tool.open .v2-act-chev { transform: rotate(90deg); }
+/* animatable collapse: grid-template-rows 0fr→1fr eases in step with the chevron
+ (display:none isn't animatable). Needs exactly ONE min-height:0 child — landing.js
+ wraps the blocks in a single inner div for this. */
+.v2-act-tool-body {
+ display: grid; grid-template-rows: 0fr; opacity: 0;
+ padding: 0 12px 0 37px;
+ transition: grid-template-rows .26s cubic-bezier(.22,1,.36,1),
+ opacity .26s cubic-bezier(.22,1,.36,1),
+ padding-bottom .26s cubic-bezier(.22,1,.36,1);
+}
+.v2-act-tool-body > * { min-height: 0; overflow: hidden; }
+.v2-act-tool.open .v2-act-tool-body { grid-template-rows: 1fr; opacity: 1; padding-bottom: 11px; }
+.v2-act-tool.open .v2-act-summary { white-space: normal; }
+.v2-act-block-label { font-size: var(--v2-fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-top: 8px; }
+.v2-act-block {
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
+ background: var(--bg-hover); border: 1px solid var(--border, #e4e9f0); border-radius: 8px;
+ padding: 8px 10px; margin-top: 4px; white-space: pre-wrap; word-break: break-word;
+ color: var(--text-secondary, #475569); max-height: 220px; overflow: auto;
+}
+
+/* error + fallback states */
+.v2-plan-error {
+ font-size: var(--v2-fs-sm); color: #b91c1c;
+ background: rgba(239,68,68,.10); border: 1px solid rgba(239,68,68,.35);
+ border-radius: 11px; padding: 11px 13px;
+}
+body.ux-v2[data-theme="dark"] .v2-plan-error {
+ color: #fca5a5; background: rgba(239,68,68,.14); border-color: rgba(239,68,68,.40);
+}
+.v2-plan-error.hidden { display: none; }
+.v2-plan-fallback { font-size: 13px; color: var(--text-muted, #94a3b8); padding: 8px 2px; }
+.v2-plan-fallback a { color: var(--accent, #2f6df6); cursor: pointer; }
+
+/* plan-panel: phases + tasks (real plan), and a free-text-answer row variant */
+.v2-plan-phase { margin-top: 12px; }
+.v2-plan-phase:first-child { margin-top: 0; }
+.v2-plan-phase-h {
+ font-size: var(--v2-fs-eyebrow); font-weight: 700; letter-spacing: .06em;
+ text-transform: uppercase; color: var(--text-secondary, #475569); margin-bottom: 6px;
+}
+/* a plan item: "P.I" number · type-color dot · title · optional duration.
+ the type dot encodes the item kind (imaging/genetics/analysis/…) at a glance. */
+.v2-plan-task {
+ display: grid; grid-template-columns: auto 8px 1fr auto; align-items: baseline;
+ gap: 9px; font-size: var(--v2-fs-cap); color: var(--text-secondary, #475569);
+ padding: 6px 0; border-top: 1px solid color-mix(in srgb, var(--border, #e4e9f0) 55%, transparent);
+}
+.v2-plan-phase .v2-plan-task:first-child { border-top: 0; }
+.v2-task-num {
+ font: 600 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
+ color: var(--text-muted, #94a3b8); font-variant-numeric: tabular-nums;
+}
+.v2-task-dot { width: 8px; height: 8px; border-radius: 50%; align-self: center; background: var(--text-muted, #94a3b8); }
+.v2-task-ttl { min-width: 0; color: var(--text, #0f172a); line-height: 1.45; }
+.v2-task-days {
+ font-size: 10.5px; font-weight: 600; color: var(--text-muted, #94a3b8);
+ font-variant-numeric: tabular-nums; white-space: nowrap;
+}
+.v2-plan-task.type-imaging .v2-task-dot { background: var(--accent, #2f6df6); }
+.v2-plan-task.type-genetics .v2-task-dot { background: #8b5cf6; }
+.v2-plan-task.type-analysis .v2-task-dot { background: var(--accent-green, #16a34a); }
+.v2-plan-task.type-bench .v2-task-dot { background: #d97706; }
+/* decision points read as gates — a rotated square, not a round bead */
+.v2-plan-task.type-decision_point .v2-task-dot { background: #e11d48; border-radius: 1px; transform: rotate(45deg); }
+.v2-plan-title-row { font-size: var(--v2-fs-sm); font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin-bottom: 8px; }
+.v2-plan-row.v2-plan-row-freetext .v { font-style: italic; }
+.v2-plan-task-empty { grid-column: 1 / -1; color: var(--text-muted, #94a3b8); font-style: italic; }
+
+/* THE PLAN pager: ‹ Prev · "Phase · i of N" · Next › + dots, one phase per page */
+.v2-plan-pager { display: flex; align-items: center; gap: 8px; margin: 2px 0 12px; }
+.v2-plan-pager-btn {
+ flex: none; background: none; border: 0; cursor: pointer; font: inherit;
+ font-size: var(--v2-fs-cap); font-weight: 600; color: var(--accent, #2f6df6);
+ padding: 5px 7px; border-radius: 7px; transition: background .15s, color .15s, opacity .15s;
+}
+.v2-plan-pager-btn:hover:not(:disabled) { background: var(--accent-soft); }
+.v2-plan-pager-btn:disabled { color: var(--text-muted, #94a3b8); opacity: .45; cursor: default; }
+.v2-plan-pager-pos {
+ flex: 1; min-width: 0; text-align: center; font-size: var(--v2-fs-cap); font-weight: 600;
+ color: var(--text, #0f172a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.v2-plan-dots { display: flex; gap: 6px; justify-content: center; margin-top: 12px; }
+.v2-plan-dot {
+ width: 7px; height: 7px; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
+ background: var(--border, #e4e9f0); transition: background .2s, transform .2s;
+}
+.v2-plan-dot:hover { transform: scale(1.25); }
+.v2-plan-dot.active { background: var(--accent, #2f6df6); }
+
+@media (prefers-reduced-motion: reduce) {
+ .v2-landing, .v2-landing::before, .v2-landing-rise, .v2-landing-orb, .v2-plan-orb,
+ .v2-landing[data-screen="plan"] .v2-screen-plan,
+ .v2-landing[data-screen="welcome"] .v2-screen-welcome,
+ .v2-typing i, .v2-act-spin, .v2-act-chev,
+ .v2-act-tool-body, .v2-act-tool-head,
+ .v2-choice, .v2-escape-field, .v2-escape-toggle,
+ .v2-plan-pager-btn, .v2-plan-dot {
+ animation: none !important;
+ transition-duration: .12s !important;
+ }
+ /* keep the collapsible usable without the height tween */
+ .v2-act-tool-body { transition: none !important; }
+ .v2-act-tool.open .v2-act-tool-body { grid-template-rows: 1fr; opacity: 1; }
+}
diff --git a/gently/ui/web/static/css/notebook.css b/gently/ui/web/static/css/notebook.css
new file mode 100644
index 00000000..fa4cebab
--- /dev/null
+++ b/gently/ui/web/static/css/notebook.css
@@ -0,0 +1,104 @@
+/* ── Notebook tab (LIBRARY) ──────────────────────────────────────────────
+ The reading room for the shared lab notebook: thread rail + kind filter +
+ note cards. Tokens reuse the app theme (--accent, --accent-green, --border …). */
+
+.nb-container { max-width: 1100px; margin: 0 auto; padding: 24px 28px; }
+
+.nb-header {
+ display: flex; align-items: center; justify-content: space-between;
+ gap: 16px; margin-bottom: 18px; flex-wrap: wrap;
+}
+.nb-title { font-size: 22px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin: 0; }
+
+.nb-kinds { display: flex; gap: 6px; }
+.nb-kind {
+ background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569);
+ border-radius: 999px; padding: 6px 13px; font: inherit; font-size: 13px; cursor: pointer;
+ transition: border-color .15s, color .15s, background .15s;
+}
+.nb-kind:hover { color: var(--text, #0f172a); border-color: var(--border-strong, #cbd5e1); }
+.nb-kind.active { background: var(--accent, #2f6df6); border-color: var(--accent, #2f6df6); color: #fff; }
+
+/* Ask the notebook */
+.nb-ask { display: flex; gap: 9px; margin-bottom: 14px; }
+.nb-ask-input {
+ flex: 1; border: 1px solid var(--border, #e4e9f0); border-radius: 11px;
+ padding: 11px 14px; font: inherit; font-size: 14px; color: var(--text, #0f172a);
+ background: var(--bg-card, #fff);
+}
+.nb-ask-input:focus { outline: none; border-color: var(--accent, #2f6df6); box-shadow: 0 0 0 4px rgba(96, 165, 250, .15); }
+.nb-ask-go {
+ flex: none; border: 0; border-radius: 11px; padding: 11px 20px; font: inherit; font-size: 14px;
+ font-weight: 600; color: #fff; background: var(--accent, #2f6df6); cursor: pointer; transition: background .15s;
+}
+.nb-ask-go:hover { background: color-mix(in srgb, var(--accent) 88%, #000); }
+
+.nb-ask-result {
+ border: 1px solid var(--border, #e4e9f0); border-radius: 13px; padding: 16px 18px; margin-bottom: 18px;
+ background: var(--accent-soft, #f5f8ff);
+}
+.nb-ask-result[hidden] { display: none; }
+.nb-ask-loading, .nb-ask-empty { color: var(--text-muted, #94a3b8); font-size: 14px; font-style: italic; }
+.nb-ask-cov {
+ display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
+ padding: 2px 9px; border-radius: 999px; margin-bottom: 10px; color: #fff; background: var(--text-muted, #94a3b8);
+}
+.nb-cov-covered { background: var(--accent-green, #16a34a); }
+.nb-cov-partial { background: #d97706; }
+.nb-cov-not_in_notebook { background: var(--text-muted, #94a3b8); }
+.nb-ask-answer { font-size: 14.5px; line-height: 1.55; color: var(--text, #0f172a); }
+.nb-ask-h {
+ font-size: var(--v2-fs-eyebrow, 11px); font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
+ color: var(--text-secondary, #475569); margin: 14px 0 6px;
+}
+.nb-ask-points { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 7px; }
+.nb-ask-point { font-size: 13.5px; line-height: 1.45; color: var(--text, #0f172a); display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
+.nb-cite {
+ font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
+ color: var(--accent, #2f6df6); background: var(--bg-card, #fff);
+ border: 1px solid var(--border, #e4e9f0); border-radius: 6px; padding: 1px 6px;
+}
+.nb-ask-next { margin: 0; padding-left: 18px; font-size: 13.5px; line-height: 1.5; color: var(--text-secondary, #475569); }
+.nb-ask-next li { margin: 2px 0; }
+
+.nb-body-wrap { display: grid; grid-template-columns: 220px 1fr; gap: 22px; align-items: start; }
+@media (max-width: 760px) { .nb-body-wrap { grid-template-columns: 1fr; } }
+
+/* thread rail (the inquiry spine) */
+.nb-threads { display: flex; flex-direction: column; gap: 4px; position: sticky; top: 12px; }
+.nb-thread {
+ text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: 13px;
+ color: var(--text-secondary, #475569); padding: 8px 11px; border-radius: 9px;
+ transition: background .15s, color .15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.nb-thread:hover { background: var(--bg-hover, #f1f5f9); color: var(--text, #0f172a); }
+.nb-thread.active { background: var(--accent-soft, #eaf1ff); color: var(--accent, #2f6df6); font-weight: 600; }
+
+/* notes */
+.nb-notes { display: flex; flex-direction: column; gap: 12px; }
+.nb-empty { color: var(--text-muted, #94a3b8); font-size: 14px; font-style: italic; padding: 28px 4px; }
+
+.nb-card {
+ border: 1px solid var(--border, #e4e9f0); border-radius: 13px; padding: 14px 16px;
+ background: var(--bg-card, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
+}
+.nb-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
+.nb-badge {
+ font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
+ padding: 2px 9px; border-radius: 999px; color: #fff; background: var(--text-muted, #94a3b8);
+}
+.nb-badge.nb-k-obs { background: var(--accent, #2f6df6); }
+.nb-badge.nb-k-find { background: var(--accent-green, #16a34a); }
+.nb-badge.nb-k-q { background: #d97706; }
+.nb-author { font-size: 12px; color: var(--text-secondary, #475569); }
+.nb-status {
+ margin-left: auto; font-size: 11px; color: var(--text-muted, #94a3b8);
+ text-transform: uppercase; letter-spacing: .05em;
+}
+.nb-body-text { font-size: 14px; line-height: 1.5; color: var(--text, #0f172a); white-space: pre-wrap; }
+
+.nb-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+.nb-chip {
+ font-size: 11.5px; color: var(--text-secondary, #475569);
+ background: var(--bg-hover, #f1f5f9); border-radius: 7px; padding: 2px 8px;
+}
diff --git a/gently/ui/web/static/css/shell.css b/gently/ui/web/static/css/shell.css
new file mode 100644
index 00000000..c38ae89a
--- /dev/null
+++ b/gently/ui/web/static/css/shell.css
@@ -0,0 +1,105 @@
+/* ux_v2 shell chrome: grouped left-rail nav + session-context strip.
+ Everything is scoped under body.ux-v2 so the v1 dashboard is byte-for-byte
+ untouched — no consolidation of the existing duplicate .tab rulesets here
+ (that cleanup is deferred to the final phase). */
+
+/* Replace the flat 8-tab bar with the rail. */
+body.ux-v2 .tabs { display: none; }
+
+/* ── Left rail ─────────────────────────────────────────────── */
+body.ux-v2 .v2-rail {
+ flex: 0 0 212px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 14px 10px;
+ border-right: 1px solid var(--border, #e4e9f0);
+ background: var(--bg-card, #fff);
+ overflow-y: auto;
+ animation: v2-rise .45s ease backwards;
+}
+body.ux-v2 .v2-nav-group { margin-bottom: 6px; }
+body.ux-v2 .v2-nav-label {
+ font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
+ color: var(--text-muted, #94a3b8); padding: 10px 10px 4px;
+}
+body.ux-v2 .v2-nav-item {
+ display: flex; align-items: center; gap: 8px; width: 100%;
+ background: none; border: 0; cursor: pointer; text-align: left;
+ padding: 8px 10px; border-radius: 8px;
+ font: inherit; font-size: 13.5px;
+ color: var(--text-secondary, #475569);
+ transition: background .15s, color .15s;
+}
+body.ux-v2 .v2-nav-item:hover { background: var(--bg-hover, #f1f5f9); color: var(--text, #0f172a); }
+body.ux-v2 .v2-nav-item.active {
+ background: var(--accent-soft, #eaf1ff);
+ color: var(--accent, #2f6df6);
+ font-weight: 600;
+}
+body.ux-v2 .v2-rail-chat {
+ margin-top: auto;
+ display: flex; align-items: center; gap: 9px;
+ background: none; border: 1px solid var(--border, #e4e9f0); border-radius: 10px;
+ padding: 9px 12px; cursor: pointer;
+ font: inherit; font-size: 13px;
+ color: var(--text-secondary, #475569);
+ transition: border-color .15s, color .15s;
+}
+body.ux-v2 .v2-rail-chat:hover { border-color: var(--accent, #2f6df6); color: var(--accent, #2f6df6); }
+body.ux-v2 .v2-rail-orb {
+ width: 18px; height: 18px; border-radius: 50%; flex: none;
+ background: radial-gradient(closest-side at 38% 34%, #fff, #bcd3ff 42%, var(--accent, #2f6df6) 100%);
+}
+
+/* ── Session-context strip (top of main) ───────────────────── */
+body.ux-v2 .v2-strip {
+ flex: none;
+ display: flex; align-items: center; gap: 12px;
+ padding: 9px 16px;
+ border-bottom: 1px solid var(--border, #e4e9f0);
+ background: var(--bg-card, #fff);
+ font-size: 12.5px; color: var(--text-muted, #94a3b8);
+ animation: v2-rise .45s ease backwards .05s;
+}
+body.ux-v2 .v2-strip-live {
+ display: inline-flex; align-items: center; gap: 6px;
+ font-size: 10.5px; font-weight: 700; letter-spacing: .08em; color: #ef4444;
+}
+body.ux-v2 .v2-strip-dot {
+ width: 8px; height: 8px; border-radius: 50%; background: #ef4444;
+}
+body.ux-v2 .v2-strip-status { margin-left: auto; font-variant-numeric: tabular-nums; }
+
+body.ux-v2 .app-main { animation: v2-rise .5s ease backwards .1s; }
+
+@keyframes v2-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+@media (prefers-reduced-motion: reduce) {
+ body.ux-v2 .v2-rail, body.ux-v2 .v2-strip, body.ux-v2 .app-main { animation: none; }
+}
+
+/* ── Shared-visibility surface (the agent's view) ──────────── */
+body.ux-v2 .cx-surface {
+ margin: 0 0 16px;
+ border: 1px solid var(--border, #e4e9f0);
+ border-radius: 14px;
+ background: var(--bg-card, #fff);
+ padding: 14px 16px;
+}
+body.ux-v2 .cx-surface.hidden { display: none; }
+body.ux-v2 .cx-title { font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted, #94a3b8); margin-bottom: 8px; }
+body.ux-v2 .cx-lens { margin-bottom: 10px; }
+body.ux-v2 .cx-lens-h { font-size: 11px; font-weight: 600; color: var(--text-secondary, #475569); margin: 6px 0 4px; }
+body.ux-v2 .cx-item { display: flex; align-items: center; gap: 9px; padding: 5px 0; flex-wrap: wrap; }
+body.ux-v2 .cx-text { flex: 1; min-width: 0; font-size: 13px; color: var(--text, #0f172a); }
+body.ux-v2 .cx-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
+body.ux-v2 .cx-dot.cx-q { background: #d97706; }
+body.ux-v2 .cx-dot.cx-w { background: var(--accent, #2f6df6); }
+body.ux-v2 .cx-dot.cx-e { background: var(--accent-green, #16a34a); }
+body.ux-v2 .cx-act { flex: none; border: 1px solid var(--border, #e4e9f0); background: none; color: var(--text-secondary, #475569); border-radius: 8px; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer; }
+body.ux-v2 .cx-act:hover { border-color: var(--accent, #2f6df6); color: var(--accent, #2f6df6); }
+body.ux-v2 .cx-answer { display: flex; gap: 6px; align-items: center; flex: 1 0 100%; margin-top: 4px; }
+body.ux-v2 .cx-answer.hidden { display: none; }
+body.ux-v2 .cx-answer-input { flex: 1; min-width: 0; border: 1px solid var(--border, #cbd5e1); border-radius: 8px; padding: 6px 9px; font: inherit; font-size: 12px; }
+body.ux-v2 .cx-answer-go { border: 0; background: var(--accent, #2f6df6); color: #fff; border-radius: 8px; padding: 6px 10px; cursor: pointer; }
+body.ux-v2 .cx-empty { font-size: 12.5px; color: var(--text-muted, #94a3b8); font-style: italic; padding: 2px 0 4px; }
diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js
index 63339b92..58be6e5c 100644
Binary files a/gently/ui/web/static/js/agent-chat.js and b/gently/ui/web/static/js/agent-chat.js differ
diff --git a/gently/ui/web/static/js/app.js b/gently/ui/web/static/js/app.js
index d1e75a72..41bad885 100644
--- a/gently/ui/web/static/js/app.js
+++ b/gently/ui/web/static/js/app.js
@@ -60,6 +60,8 @@ function updateCalibrationCount() {
function switchTab(tabName) {
if (!tabName) return;
state.tab = tabName;
+ // ux_v2 grouped rail mirrors the active tab off this single chokepoint.
+ if (typeof ClientEventBus !== 'undefined') ClientEventBus.emit('TAB_CHANGED', tabName);
// Update tab styling
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -98,6 +100,11 @@ function switchTab(tabName) {
ExperimentOverview.init();
}
+ // Lazy-init Notebook tab
+ if (tabName === TABS.NOTEBOOK && typeof NotebookApp !== 'undefined') {
+ NotebookApp.init();
+ }
+
// Update statusbar for context
updateStatusbar();
}
@@ -538,11 +545,13 @@ function fetchDeviceStatus() {
.then(r => r.json())
.then(data => {
_microscopeConnected = data.microscope;
- _setBadge('status-microscope-badge', data.microscope, 'Online', 'Offline');
- updateTopLevelDot();
+ ConnectionStatus.setMicroscope(data.microscope);
})
.catch(() => {
- _setBadge('status-microscope-badge', false, '', '--');
+ // Transient poll failure: keep the last-known badge. The next
+ // successful poll re-renders via the store if the value changed
+ // (writing '--' here could stick, since the store only re-renders
+ // on an actual change, not on an unchanged success).
});
}
@@ -555,23 +564,26 @@ function _setBadge(id, isOn, onText, offText) {
}
function updateGentlyStatus(connected) {
- _setBadge('status-gently-badge', connected, 'Online', 'Offline');
- updateTopLevelDot();
+ // Feed the single source of truth; the header re-renders via the
+ // ConnectionStatus subscriber (renderConnectionUI).
+ ConnectionStatus.setGently(connected);
}
-function updateTopLevelDot() {
+// Single renderer for the header connection UI, driven by a ConnectionStatus
+// snapshot. Subscribed once at startup, so the pill, both popover badges, and
+// the dot always reflect the same shared state.
+function renderConnectionUI(s) {
+ _setBadge('status-gently-badge', s.gentlyConnected, 'Online', 'Offline');
+ _setBadge('status-microscope-badge', s.microscopeConnected, 'Online', 'Offline');
const dot = document.getElementById('status-dot');
const text = document.getElementById('status-text');
if (!dot || !text) return;
- const gentlyUp = state.connected;
- const scopeUp = _microscopeConnected;
-
dot.classList.remove('connected', 'partial');
- if (gentlyUp && scopeUp) {
+ if (s.gentlyConnected && s.microscopeConnected) {
dot.classList.add('connected');
text.textContent = 'Connected';
- } else if (gentlyUp) {
+ } else if (s.gentlyConnected) {
dot.classList.add('partial');
text.textContent = 'Online';
} else {
@@ -579,6 +591,11 @@ function updateTopLevelDot() {
}
}
+// Back-compat shim: any legacy caller re-renders from the current snapshot.
+function updateTopLevelDot() {
+ renderConnectionUI(ConnectionStatus.get());
+}
+
document.addEventListener('DOMContentLoaded', () => {
// Initialize presence manager (before WebSocket so ID is ready)
PresenceManager.init();
@@ -620,6 +637,11 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
+ // Connection status: one source of truth, three writers (this /ws, the
+ // device-status poll, and the agent /ws/agent). Subscribe the header
+ // renderer BEFORE connecting so the first handshake renders correctly.
+ ConnectionStatus.subscribe(renderConnectionUI);
+
// Start WebSocket connection
connectWebSocket();
@@ -634,7 +656,7 @@ document.addEventListener('DOMContentLoaded', () => {
const hash = window.location.hash.slice(1); // remove #
if (hash) {
const [tab, param] = hash.split(':');
- if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT) {
+ if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT || tab === TABS.NOTEBOOK) {
switchTab(tab);
if (tab === TABS.PLANS && param && typeof openCampaign === 'function') {
setTimeout(() => openCampaign(param), 200);
diff --git a/gently/ui/web/static/js/ask-stage.js b/gently/ui/web/static/js/ask-stage.js
new file mode 100644
index 00000000..b5e63db1
--- /dev/null
+++ b/gently/ui/web/static/js/ask-stage.js
@@ -0,0 +1,53 @@
+/**
+ * AskStage (ux_v2) — renders the agent's CURRENT pending ask prominently on the
+ * main stage, in addition to the chat transcript. One payload, two renderers:
+ * it reuses AgentChat.buildAskCard so the stage and the transcript can't drift,
+ * and answering from either surface clears both (via the ASK_CLEARED event that
+ * AgentChat fires off the CHOICE lifecycle — not stream_end, which arrives only
+ * after an in-turn answer and never for a cancelled turn).
+ *
+ * No-ops unless #ask-stage is present (gated behind GENTLY_UX_V2 in the
+ * template), so it never affects the v1 dashboard.
+ */
+const AskStage = (() => {
+ let stageEl = null;
+ let current = null; // { reqId, data, isWake }
+
+ function clear() {
+ current = null;
+ if (stageEl) { stageEl.innerHTML = ''; stageEl.classList.add('hidden'); }
+ }
+
+ function render() {
+ if (!stageEl || !current || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) return;
+ const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true;
+ const card = AgentChat.buildAskCard(current.data, {
+ reqId: current.reqId,
+ isWake: current.isWake,
+ hasControl,
+ onPick: (sel) => AgentChat.answerChoice(current.reqId, sel),
+ });
+ stageEl.innerHTML = '';
+ stageEl.appendChild(card);
+ stageEl.classList.remove('hidden');
+ }
+
+ function init() {
+ stageEl = document.getElementById('ask-stage');
+ if (!stageEl || typeof ClientEventBus === 'undefined') return; // ux_v2 off → no-op
+
+ ClientEventBus.on('AGENT_ASK', ({ request_id, choice_data, origin }) => {
+ current = { reqId: request_id, data: choice_data || {}, isWake: origin === 'wake' };
+ render();
+ });
+ ClientEventBus.on('ASK_CLEARED', ({ request_id }) => {
+ if (!current) return;
+ if (request_id === '*' || request_id === current.reqId) clear();
+ });
+ // Re-render read-only / actionable when control changes hands mid-ask.
+ ClientEventBus.on('AGENT_CONTROL', () => { if (current) render(); });
+ }
+
+ document.addEventListener('DOMContentLoaded', init);
+ return { clear };
+})();
diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js
index 36d29623..6a3c3799 100644
--- a/gently/ui/web/static/js/campaigns.js
+++ b/gently/ui/web/static/js/campaigns.js
@@ -40,6 +40,18 @@ const SPEC_UNITS = {
laser_power_pct: '%', interval_s: 's', estimated_duration_h: ' hrs',
estimated_days: ' days',
};
+// Imaging-spec fields the inspector lets you edit/fill inline (ordered for the form).
+// Empty ones still show \u2014 that's how you fill a TBD value like laser power.
+const IMAGING_SPEC_FIELDS = [
+ 'strain', 'genotype', 'reporter', 'sample_prep', 'temperature_c', 'num_embryos',
+ 'num_slices', 'exposure_ms', 'laser_wavelength_nm', 'laser_power_pct', 'interval_s',
+ 'target_window', 'start_stage', 'stop_condition', 'estimated_duration_h',
+ 'success_criteria', 'comparison_to',
+];
+const SPEC_NUMERIC = new Set([
+ 'temperature_c', 'num_embryos', 'num_slices', 'exposure_ms', 'laser_wavelength_nm',
+ 'laser_power_pct', 'interval_s', 'estimated_duration_h',
+]);
// ── State ────────────────────────────────────────────────
const state = {
@@ -52,6 +64,9 @@ const state = {
versions: [], // snapshots list
viewingSnapshotId: null,
allItemsFlat: {}, // id → item for quick lookup
+ editingSpec: false, // inspector imaging-spec edit mode
+ _inspectorData: null, // last item-detail payload (for re-render on edit toggle)
+ _specError: '', // inline save error in the spec editor
};
// ── DOM refs (cached on init) ────────────────────────────
@@ -117,11 +132,15 @@ function boot() {
case 'select-item': selectItem(id); break;
case 'open-campaign': openCampaign(id); break;
case 'navigate-item': e.stopPropagation(); navigateToItem(id); break;
+ case 'run-item': e.stopPropagation(); runPlanItem(id); break;
case 'filter-type': applyTypeFilter(el.dataset.filterType); break;
case 'view-version': viewVersion(el.dataset.versionId, el.dataset.isCurrent === 'true'); break;
case 'back-to-current': backToCurrent(); break;
case 'scroll-to': e.stopPropagation(); scrollCanvasTo(el.dataset.target); break;
case 'toggle-phase': toggleNavPhase(el); break;
+ case 'spec-edit': e.stopPropagation(); startSpecEdit(); break;
+ case 'spec-cancel': e.stopPropagation(); cancelSpecEdit(); break;
+ case 'spec-save': e.stopPropagation(); saveSpecEdit(); break;
}
});
@@ -131,6 +150,13 @@ function boot() {
// Plan view switcher
setupPlanViewSwitcher();
+ // Live refresh: re-fetch the active campaign when the plan changes (item status,
+ // session link, new item, progress). The store emits PLAN_UPDATED, the server
+ // broadcasts it to /ws, and websocket.js re-emits it on the client bus.
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.on('PLAN_UPDATED', () => scheduleCampaignRefresh());
+ }
+
// Load campaigns — auto-selects first, or the specified one
const initialId = window.INITIAL_CAMPAIGN_ID;
if (initialId) {
@@ -267,6 +293,26 @@ async function openCampaign(campaignId) {
renderAll();
}
+// Live refresh of the open campaign (debounced) — re-fetch its tree and re-render,
+// preserving the selected item so the inspector reflects the change without a reload.
+let _planRefreshTimer = null;
+function scheduleCampaignRefresh() {
+ if (_planRefreshTimer) clearTimeout(_planRefreshTimer);
+ _planRefreshTimer = setTimeout(() => {
+ _planRefreshTimer = null;
+ refreshActiveCampaign().catch(() => {});
+ }, 400);
+}
+async function refreshActiveCampaign() {
+ if (!state.activeCampaignId) return;
+ const keep = state.selectedItemId;
+ await loadDocument(state.activeCampaignId);
+ if (!state.docData) return;
+ renderAll();
+ // Don't clobber an in-progress spec edit with a re-fetch.
+ if (keep && !state.editingSpec) selectItem(keep).catch(() => {});
+}
+
// Handle browser back/forward
window.addEventListener('popstate', e => {
const s = e.state;
@@ -579,6 +625,12 @@ function renderVersionHistory() {
// ══════════════════════════════════════════════════════════
async function selectItem(itemId) {
+ // A re-fetch of the same item (e.g. after saving) keeps view mode; switching
+ // to a different item always lands in read-only.
+ if (itemId !== state.selectedItemId) {
+ state.editingSpec = false;
+ state._specError = '';
+ }
state.selectedItemId = itemId;
// Highlight in document
@@ -617,6 +669,7 @@ async function selectItem(itemId) {
}
function renderInspector(data) {
+ state._inspectorData = data;
const item = data.item;
const deps = data.dependencies || [];
const dnts = data.dependents || [];
@@ -639,6 +692,19 @@ function renderInspector(data) {
${item.id}
`;
+ // Run affordance — only for an actionable imaging item. Routes through the
+ // agent (it applies this item's spec via execute_plan_item), in keeping with
+ // the agent-first paradigm.
+ if (item.type === 'imaging' && item.status === 'planned') {
+ html += `
+
+ Hands it to the agent to apply the spec and start
+
`;
+ }
+
// Description
if (item.description) {
html += section('Description', `
${esc(item.description)}
`);
@@ -649,9 +715,20 @@ function renderInspector(data) {
html += section('Outcome', `
${esc(item.outcome)}
`);
}
- // Imaging spec
- if (item.imaging_spec) {
- html += section('Imaging Specification', `
${renderSpecTable(item.imaging_spec)}
`);
+ // Imaging spec — view, or edit/fill inline (the laser-power loop). Shown for any
+ // imaging item even when no spec is set yet, so empty fields can be filled.
+ if (item.type === 'imaging' || item.imaging_spec) {
+ const spec = item.imaging_spec || {};
+ if (state.editingSpec) {
+ html += section('Imaging Specification', renderSpecEditor(spec));
+ } else {
+ const rows = renderSpecTable(spec);
+ const content = rows
+ ? `
${rows}
`
+ : '
No parameters set yet
';
+ const editBtn = '';
+ html += section('Imaging Specification', content, editBtn);
+ }
}
// Bench spec
@@ -727,6 +804,113 @@ function renderInspector(data) {
if ($inspectorBody) $inspectorBody.innerHTML = html;
}
+// Editable imaging-spec form. Lists every fillable field — empty ones included,
+// flagged — so a TBD value (e.g. laser power) is obvious and one click away.
+function renderSpecEditor(spec) {
+ let rows = '';
+ for (const key of IMAGING_SPEC_FIELDS) {
+ const label = SPEC_LABELS[key] || key;
+ const val = spec[key];
+ const has = val != null && val !== '';
+ const numeric = SPEC_NUMERIC.has(key);
+ const unit = SPEC_UNITS[key]
+ ? `${esc(SPEC_UNITS[key].trim())}` : '';
+ const rowCls = has ? 'spec-edit-row' : 'spec-edit-row spec-edit-row--empty';
+ rows += `
+
+
+ ${unit}
+
+
`;
+ }
+ const err = state._specError
+ ? `
${esc(state._specError)}
` : '';
+ return `
+ ${rows}
+ ${err}
+
+
+
+
+
`;
+}
+
+function startSpecEdit() {
+ if (!state._inspectorData) return;
+ state.editingSpec = true;
+ state._specError = '';
+ renderInspector(state._inspectorData);
+}
+
+function cancelSpecEdit() {
+ state.editingSpec = false;
+ state._specError = '';
+ if (state._inspectorData) renderInspector(state._inspectorData);
+}
+
+// Collect changed/filled fields and PATCH them. The store fires PLAN_UPDATED,
+// which live-refreshes the plan; we also re-fetch the inspector for immediacy.
+async function saveSpecEdit() {
+ const data = state._inspectorData;
+ const item = data && data.item;
+ const campaignId = state.activeCampaignId;
+ if (!item || !campaignId) return;
+
+ const orig = item.imaging_spec || {};
+ const specPatch = {};
+ document.querySelectorAll('#inspector-body [data-spec-key]').forEach(inp => {
+ const key = inp.dataset.specKey;
+ const raw = inp.value.trim();
+ const hadVal = orig[key] != null && orig[key] !== '';
+ if (raw === '') {
+ if (hadVal) specPatch[key] = ''; // cleared an existing value → unset
+ return; // stayed empty → skip
+ }
+ let v = raw;
+ if (SPEC_NUMERIC.has(key)) {
+ const n = Number(raw);
+ if (!Number.isNaN(n)) v = n;
+ }
+ if (String(orig[key] ?? '') !== String(v)) specPatch[key] = v;
+ });
+
+ state.editingSpec = false;
+ state._specError = '';
+ if (Object.keys(specPatch).length === 0) {
+ selectItem(item.id).catch(() => {}); // nothing changed — just leave edit mode
+ return;
+ }
+
+ try {
+ const res = await fetch(
+ `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(item.id)}`,
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ spec: specPatch }),
+ },
+ );
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ selectItem(item.id).catch(() => {}); // refresh inspector now; PLAN_UPDATED refreshes the plan
+ } catch (err) {
+ console.error('Failed to save spec:', err);
+ state.editingSpec = true;
+ state._specError = 'Could not save — try again.';
+ renderInspector(data);
+ }
+}
+
+// Hand a planned imaging item to the agent to execute. The agent resolves the
+// item ref, applies its spec, and starts the timelapse (execute_plan_item). We
+// open the chat so the user sees it pick up and can confirm/adjust.
+function runPlanItem(id) {
+ if (typeof AgentChat === 'undefined' || !AgentChat.runCommand) return;
+ AgentChat.runCommand(`Start imaging for plan item ${id}`);
+ if (AgentChat.togglePanel) AgentChat.togglePanel(true);
+}
+
function closeInspector() {
state.selectedItemId = null;
$workspace?.classList.remove('inspector-open');
@@ -1020,14 +1204,19 @@ function hideLoading() {
$canvasLoading?.classList.add('hidden');
}
-function section(title, content) {
- return `
`;
}
function renderSpecTable(spec) {
let rows = '';
for (const [key, value] of Object.entries(spec)) {
if (value == null || key.startsWith('_')) continue;
+ // Skip nested objects (e.g. provenance metadata) — they'd render as
+ // "[object Object]". Field-level provenance isn't a spec value to show here.
+ if (typeof value === 'object' && !Array.isArray(value)) continue;
const label = SPEC_LABELS[key] || key;
let display = Array.isArray(value) ? value.join(', ') : String(value);
if (SPEC_UNITS[key]) display += SPEC_UNITS[key];
diff --git a/gently/ui/web/static/js/context-surface.js b/gently/ui/web/static/js/context-surface.js
new file mode 100644
index 00000000..2c11e27e
--- /dev/null
+++ b/gently/ui/web/static/js/context-surface.js
@@ -0,0 +1,130 @@
+/**
+ * ContextSurface (ux_v2): renders the agent's "mind" as a calm, always-visible
+ * panel — open questions (uncertainty), watchpoints (attention), expectations
+ * (beliefs) — read from /api/context and refreshed live on the CONTEXT_UPDATED
+ * event (the store emits it; the server broadcasts it to /ws; no polling).
+ *
+ * The control holder can resolve items inline (answer a question, resolve a
+ * watchpoint, confirm an expectation); observers see it read-only. No-ops
+ * unless #context-surface is present (flag off → v1 untouched).
+ */
+const ContextSurface = (() => {
+ let el = null, loading = false;
+
+ const esc = (s) => (typeof escapeHtml === 'function')
+ ? escapeHtml(String(s == null ? '' : s)) : String(s == null ? '' : s);
+ const hasControl = () =>
+ (typeof AgentChat !== 'undefined' && AgentChat.hasControl) ? AgentChat.hasControl() : true;
+
+ async function fetchAndRender() {
+ if (!el || loading) return;
+ loading = true;
+ try {
+ const [ctx, nb] = await Promise.all([
+ fetch('/api/context').then(r => r.json()).catch(() => ({})),
+ fetch('/api/notebook/notes?limit=5').then(r => r.json()).catch(() => ({})),
+ ]);
+ render(ctx || {}, (nb && nb.notes) || []);
+ } catch (e) { /* keep last render */ }
+ finally { loading = false; }
+ }
+
+ function section(label, html) { return html ? `
${label}
${html}
` : ''; }
+
+ function render(data, notes) {
+ if (!el) return;
+ notes = notes || [];
+ const hc = hasControl();
+ const questions = data.questions || [], watchpoints = data.watchpoints || [], expectations = data.expectations || [];
+ el.classList.remove('hidden');
+ if (!questions.length && !watchpoints.length && !expectations.length && !notes.length) {
+ // Show an empty-state rather than vanishing, so the surface is
+ // discoverable before the agent has formed any beliefs.
+ el.innerHTML = '
Agent’s view
' +
+ '
Nothing yet — the agent’s notes, expectations, and open questions appear here as it works.
'; }
+ const body = { question: q };
+ if (threadFilter) body.thread = threadFilter; // ask within the selected thread
+ try {
+ const r = await fetch('/api/notebook/ask', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
+ });
+ renderAskResult(await r.json());
+ } catch (e) {
+ if (box) box.innerHTML = '
Something went wrong asking the notebook.
';
+ }
+ }
+
+ function setupAsk() {
+ const go = $('nb-ask-go'), input = $('nb-ask-input');
+ if (go) go.addEventListener('click', ask);
+ if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); ask(); } });
+ }
+
+ function esc(s) {
+ return (typeof escapeHtml === 'function') ? escapeHtml(String(s == null ? '' : s))
+ : String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ }
+
+ function refresh() { loadThreads(); loadNotes(); }
+
+ function init() {
+ if (inited) { refresh(); return; }
+ inited = true;
+ setupFilters();
+ setupAsk();
+ refresh();
+ // Notebook writes ride the CONTEXT_UPDATED event — live-refresh if visible.
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.on('CONTEXT_UPDATED', () => {
+ if (typeof state !== 'undefined' && state.tab === 'notebook') refresh();
+ });
+ }
+ }
+
+ return { init };
+})();
diff --git a/gently/ui/web/static/js/occupancy3d.js b/gently/ui/web/static/js/occupancy3d.js
new file mode 100644
index 00000000..b9cfb7ee
--- /dev/null
+++ b/gently/ui/web/static/js/occupancy3d.js
@@ -0,0 +1,489 @@
+// ══════════════════════════════════════════════════════════════════════
+// 3D Optical Space — live digital-twin of the addressable imaging volume
+//
+// Renders the acquisition cuboid (the box of voxels being scanned) with the
+// live light-sheet plane inside it, plus a Z-neighbourhood reference frame.
+// An HTML overlay (mode badge + readouts + a top-down minimap) carries the
+// GLOBAL context: where in the addressable XY stage range this cuboid sits,
+// and the embryos around it.
+//
+// Why two representations: the addressable stage XY (~tens of mm), the cuboid
+// footprint (~hundreds of µm) and the piezo Z range (~µm) differ by ~100x, so
+// a single literal-scale 3D box would draw the cuboid invisibly small. The 3D
+// scene therefore stays in one µm scale around the cuboid; the minimap (2D)
+// handles the much larger stage extent. Some scales are local by design — see
+// FOV_UM / the outer-frame sizing below.
+//
+// Data:
+// DEVICE_STATE_UPDATE → live Piezo.Position (sheet Z), Galvo.A/B, XYStage,
+// and the firmware box (minimap extent).
+// SCAN_GEOMETRY_UPDATE → cuboid extents, num_slices, pencil/sheet mode.
+// EMBRYOS_UPDATE → minimap markers.
+// Bootstrap via /api/devices/scan_geometry + /api/embryos/current.
+//
+// Mirrors the DevicesManager IIFE pattern (devices.js) and reuses the
+// Three.js scaffold + drag-orbit from projection-viewer.js.
+// ══════════════════════════════════════════════════════════════════════
+
+const Occupancy3DManager = (function () {
+ 'use strict';
+
+ // --- Tunables / approximations (v1) --------------------------------
+ // Camera FOV footprint of the SPIM cuboid in µm. SPIM is 0.1625 µm/px;
+ // a ~2048px sCMOS ROI ≈ 333 µm. Not currently streamed, so we use a
+ // constant until SCAN_GEOMETRY_UPDATE carries fov_um. (Documented approx.)
+ const FOV_UM = 333.0;
+ const MAX_SLICE_LINES = 30; // cap drawn slice outlines for perf
+ const COLORS = {
+ outer: 0x33414d,
+ cuboid: 0x14b8c4,
+ cuboidFace: 0x14b8c4,
+ sheet: 0x39d0ff,
+ slice: 0x2a6f78,
+ beam: 0xffd166,
+ };
+
+ // --- Module state --------------------------------------------------
+ let _initialized = false;
+ let _scene = null, _camera = null, _renderer = null, _root = null;
+ let _animationId = null, _resizeObserver = null, _resizeRaf = null, _onLayoutChanged = null;
+ let _isDragging = false, _prevMouse = { x: 0, y: 0 };
+ const _rot = { x: -0.6, y: 0.6 };
+ let _zoom = 1.7;
+
+ // Live data caches
+ let _geom = null; // last SCAN_GEOMETRY_UPDATE.data
+ let _firmwareBox = null; // {x:[min,max], y:[min,max]} µm
+ let _stage = { x: null, y: null };
+ let _piezoZ = null; // live axial position (µm)
+ let _galvo = { a: null, b: null };
+ let _embryos = []; // [{x,y,role,id}]
+ let _scaler = null;
+
+ // Scene object handles (rebuilt as geometry changes)
+ let _outerBox = null, _cuboid = null, _cuboidEdges = null;
+ let _sheet = null, _beam = null, _sliceGroup = null;
+
+ // DOM
+ let _container = null, _modeEl = null, _readoutsEl = null, _minimapEl = null, _demoBtn = null;
+ let _demoTimer = null;
+
+ // ===================================================================
+ // Init / scene scaffold
+ // ===================================================================
+ function init() {
+ if (_initialized) { _resize(); return; }
+ if (typeof THREE === 'undefined') {
+ console.warn('[occupancy3d] THREE not loaded');
+ return;
+ }
+ _container = document.getElementById('occ3d-container');
+ _modeEl = document.getElementById('occ3d-mode');
+ _readoutsEl = document.getElementById('occ3d-readouts');
+ _minimapEl = document.getElementById('occ3d-minimap');
+ _demoBtn = document.getElementById('occ3d-demo-btn');
+ if (!_container) return;
+
+ _buildScene();
+ _wireInteraction();
+ if (_demoBtn) _demoBtn.addEventListener('click', toggleDemo);
+
+ // Subscribe to live data (mirror devices.js:1553-1559)
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.on('DEVICE_STATE_UPDATE', handleDeviceState);
+ ClientEventBus.on('SCAN_GEOMETRY_UPDATE', handleScanGeometry);
+ ClientEventBus.on('EMBRYOS_UPDATE', handleEmbryos);
+ }
+ _bootstrap();
+
+ _initialized = true;
+ _rebuildSceneObjects();
+ _renderReadouts();
+ _renderMinimap();
+ _animate();
+ // Container is 0×0 while the tab is hidden; size once it's visible.
+ requestAnimationFrame(_resize);
+ }
+
+ function _buildScene() {
+ const w = _container.clientWidth || 600;
+ const h = _container.clientHeight || 460;
+
+ _scene = new THREE.Scene();
+ _camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100);
+ _camera.position.set(0, 0, _zoom);
+
+ _renderer = new THREE.WebGLRenderer({ antialias: true });
+ _renderer.setSize(w, h);
+ _renderer.setClearColor(0x0a0e12);
+ _container.innerHTML = '';
+ _container.appendChild(_renderer.domElement);
+
+ _root = new THREE.Group();
+ _root.rotation.x = _rot.x;
+ _root.rotation.y = _rot.y;
+ _scene.add(_root);
+
+ // Keep the canvas in sync with its container (chat dock / window resize).
+ if (_resizeObserver) _resizeObserver.disconnect();
+ _resizeObserver = new ResizeObserver(() => {
+ if (_resizeRaf) cancelAnimationFrame(_resizeRaf);
+ _resizeRaf = requestAnimationFrame(_resize);
+ });
+ _resizeObserver.observe(_container);
+ if (!_onLayoutChanged) {
+ _onLayoutChanged = () => _resize();
+ window.addEventListener('gently:layout-changed', _onLayoutChanged);
+ }
+ }
+
+ function _resize() {
+ if (!_renderer || !_container) return;
+ const w = _container.clientWidth || 600;
+ const h = _container.clientHeight || 460;
+ if (w === 0 || h === 0) return;
+ _camera.aspect = w / h;
+ _camera.updateProjectionMatrix();
+ _renderer.setSize(w, h);
+ }
+
+ function _wireInteraction() {
+ const el = _renderer.domElement;
+ el.addEventListener('mousedown', (e) => {
+ _isDragging = true; _prevMouse = { x: e.clientX, y: e.clientY };
+ });
+ el.addEventListener('mousemove', (e) => {
+ if (!_isDragging) return;
+ _root.rotation.y += (e.clientX - _prevMouse.x) * 0.01;
+ _root.rotation.x += (e.clientY - _prevMouse.y) * 0.01;
+ _rot.x = _root.rotation.x; _rot.y = _root.rotation.y;
+ _prevMouse = { x: e.clientX, y: e.clientY };
+ });
+ window.addEventListener('mouseup', () => { _isDragging = false; });
+ el.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ _zoom = Math.max(0.4, Math.min(6, _zoom + e.deltaY * 0.002));
+ _camera.position.z = _zoom;
+ }, { passive: false });
+ el.addEventListener('dblclick', () => {
+ _rot.x = -0.6; _rot.y = 0.6; _zoom = 1.7;
+ _root.rotation.x = _rot.x; _root.rotation.y = _rot.y;
+ _camera.position.z = _zoom;
+ });
+ }
+
+ function _animate() {
+ _animationId = requestAnimationFrame(_animate);
+ if (_renderer && _scene && _camera) _renderer.render(_scene, _camera);
+ }
+
+ // ===================================================================
+ // Scene geometry (rebuilt when scan geometry changes)
+ // ===================================================================
+ function _disposeObj(obj) {
+ if (!obj) return;
+ _root.remove(obj);
+ obj.traverse?.((c) => {
+ c.geometry?.dispose?.();
+ if (c.material) (Array.isArray(c.material) ? c.material : [c.material]).forEach(m => m.dispose());
+ });
+ obj.geometry?.dispose?.();
+ if (obj.material) (Array.isArray(obj.material) ? obj.material : [obj.material]).forEach(m => m.dispose());
+ }
+
+ function _currentGeom() {
+ // Fall back to nominal defaults so the scene is never empty.
+ const g = _geom || {};
+ const scan = g.scan || {};
+ const derived = g.derived || {};
+ const piezoCenter = scan.piezo_center_um != null ? scan.piezo_center_um : 50.0;
+ const zExtent = derived.z_extent_um != null ? derived.z_extent_um : 50.0;
+ return {
+ numSlices: scan.num_slices != null ? scan.num_slices : 50,
+ piezoCenter,
+ zExtent,
+ mode: g.mode || 'sheet',
+ };
+ }
+
+ function _rebuildSceneObjects() {
+ if (!_root) return;
+ [_outerBox, _cuboid, _cuboidEdges, _sheet, _beam, _sliceGroup].forEach(_disposeObj);
+ _outerBox = _cuboid = _cuboidEdges = _sheet = _beam = _sliceGroup = null;
+
+ const g = _currentGeom();
+ const fov = FOV_UM;
+ // Outer Z neighbourhood centred on the cuboid so it's always framed.
+ const halfZ = Math.max(g.zExtent * 2.5, 75);
+ const zMin = g.piezoCenter - halfZ;
+ const zMax = g.piezoCenter + halfZ;
+ const halfXY = fov * 1.5;
+
+ _scaler = makeSceneScaler({
+ xRange: [-halfXY, halfXY],
+ yRange: [-halfXY, halfXY],
+ zRange: [zMin, zMax],
+ });
+ const L = (um) => _scaler.scaleLen(um);
+ const Z = (um) => _scaler.toScene(um, 'z');
+
+ // --- Outer reference frame (addressable Z × local XY) ----------
+ _outerBox = new THREE.LineSegments(
+ new THREE.EdgesGeometry(new THREE.BoxGeometry(L(2 * halfXY), L(zMax - zMin), L(2 * halfXY))),
+ new THREE.LineBasicMaterial({ color: COLORS.outer })
+ );
+ _outerBox.position.y = Z(g.piezoCenter); // box centred on its own midpoint == piezoCenter
+ _root.add(_outerBox);
+
+ // --- Acquisition cuboid (footprint × z-extent) -----------------
+ // Three.js Y is our axial (Z µm) axis; X/Z are the lateral footprint.
+ const cw = L(fov), cd = L(fov), ch = L(g.zExtent);
+ _cuboid = new THREE.Mesh(
+ new THREE.BoxGeometry(cw, ch, cd),
+ new THREE.MeshBasicMaterial({
+ color: COLORS.cuboidFace, transparent: true, opacity: 0.06,
+ depthWrite: false, side: THREE.DoubleSide,
+ })
+ );
+ _cuboid.position.y = Z(g.piezoCenter);
+ _root.add(_cuboid);
+ _cuboidEdges = new THREE.LineSegments(
+ new THREE.EdgesGeometry(new THREE.BoxGeometry(cw, ch, cd)),
+ new THREE.LineBasicMaterial({ color: COLORS.cuboid })
+ );
+ _cuboidEdges.position.y = Z(g.piezoCenter);
+ _root.add(_cuboidEdges);
+
+ // --- Slice planes (faint outlines through the cuboid) ----------
+ _sliceGroup = new THREE.Group();
+ const n = Math.max(1, Math.min(g.numSlices, MAX_SLICE_LINES));
+ const sliceMat = new THREE.LineBasicMaterial({ color: COLORS.slice, transparent: true, opacity: 0.5 });
+ for (let i = 0; i < n; i++) {
+ const frac = n === 1 ? 0.5 : i / (n - 1);
+ const zUm = (g.piezoCenter - g.zExtent / 2) + frac * g.zExtent;
+ const ring = new THREE.LineLoop(_rectXZ(cw, cd), sliceMat);
+ ring.position.y = Z(zUm);
+ _sliceGroup.add(ring);
+ }
+ _root.add(_sliceGroup);
+
+ // --- Light sheet / pencil beam ---------------------------------
+ if (g.mode === 'pencil') {
+ // Pencil: a thin beam along the lateral axis through cuboid centre.
+ _beam = new THREE.LineSegments(
+ new THREE.BufferGeometry().setFromPoints([
+ new THREE.Vector3(-cw / 2, 0, 0), new THREE.Vector3(cw / 2, 0, 0),
+ ]),
+ new THREE.LineBasicMaterial({ color: COLORS.beam })
+ );
+ _root.add(_beam);
+ } else {
+ _sheet = new THREE.Mesh(
+ new THREE.PlaneGeometry(cw, cd),
+ new THREE.MeshBasicMaterial({
+ color: COLORS.sheet, transparent: true, opacity: 0.35,
+ side: THREE.DoubleSide, depthWrite: false,
+ })
+ );
+ _sheet.rotation.x = -Math.PI / 2; // lie in the lateral (X-Z) plane
+ _root.add(_sheet);
+ }
+ _updateSheetPosition();
+ }
+
+ // A rectangle outline in the lateral (X-Z) plane, centred at origin.
+ function _rectXZ(w, d) {
+ const hw = w / 2, hd = d / 2;
+ return new THREE.BufferGeometry().setFromPoints([
+ new THREE.Vector3(-hw, 0, -hd), new THREE.Vector3(hw, 0, -hd),
+ new THREE.Vector3(hw, 0, hd), new THREE.Vector3(-hw, 0, hd),
+ ]);
+ }
+
+ // Move the sheet/beam to the live axial position (piezo µm), clamped to
+ // the cuboid extent. Falls back to the cuboid centre when no live value.
+ function _updateSheetPosition() {
+ if (!_scaler) return;
+ const g = _currentGeom();
+ const zMin = g.piezoCenter - g.zExtent / 2;
+ const zMax = g.piezoCenter + g.zExtent / 2;
+ let zUm = _piezoZ != null ? _piezoZ : g.piezoCenter;
+ zUm = Math.max(zMin, Math.min(zMax, zUm));
+ const y = _scaler.toScene(zUm, 'z');
+ if (_sheet) _sheet.position.y = y;
+ if (_beam) _beam.position.y = y;
+ }
+
+ // ===================================================================
+ // Event handlers
+ // ===================================================================
+ function handleDeviceState(payload) {
+ if (!payload) return;
+ const pos = payload.positions || {};
+ for (const name of Object.keys(pos)) {
+ const e = pos[name] || {};
+ if (e.kind === 'xy_stage') {
+ if (e.X != null) _stage.x = e.X;
+ if (e.Y != null) _stage.y = e.Y;
+ } else if (e.kind === 'piezo') {
+ if (e.Position != null) _piezoZ = e.Position;
+ } else if (e.kind === 'galvo') {
+ if (e.A != null) _galvo.a = e.A;
+ if (e.B != null) _galvo.b = e.B;
+ }
+ }
+ const box = extractFirmwareBox(payload.properties);
+ if (box) _firmwareBox = box;
+ _updateSheetPosition();
+ _renderReadouts();
+ _renderMinimap();
+ }
+
+ function handleScanGeometry(payload) {
+ if (!payload) return;
+ _geom = payload;
+ if (payload.stage_position_um) {
+ if (payload.stage_position_um.x != null) _stage.x = payload.stage_position_um.x;
+ if (payload.stage_position_um.y != null) _stage.y = payload.stage_position_um.y;
+ }
+ _rebuildSceneObjects();
+ _renderReadouts();
+ _renderMinimap();
+ }
+
+ function handleEmbryos(payload) {
+ if (!payload || !Array.isArray(payload.embryos)) return;
+ _embryos = payload.embryos.map((e) => {
+ const fine = e.position_fine || {};
+ const coarse = e.position_coarse || {};
+ const x = fine.x != null ? fine.x : coarse.x;
+ const y = fine.y != null ? fine.y : coarse.y;
+ return { x, y, role: e.role, id: e.id };
+ }).filter((e) => e.x != null && e.y != null);
+ _renderMinimap();
+ }
+
+ async function _bootstrap() {
+ try {
+ const r = await fetch('/api/devices/scan_geometry');
+ if (r.ok) handleScanGeometry(await r.json());
+ } catch (_) { /* offline — demo button covers it */ }
+ try {
+ const r = await fetch('/api/embryos/current');
+ if (r.ok) handleEmbryos(await r.json());
+ } catch (_) { /* ignore */ }
+ }
+
+ // ===================================================================
+ // HTML overlay: mode badge, readouts, minimap
+ // ===================================================================
+ function _fmt(v, digits = 1, unit = '') {
+ return v == null ? '—' : (Number(v).toFixed(digits) + unit);
+ }
+
+ function _renderReadouts() {
+ const g = _currentGeom();
+ if (_modeEl) {
+ _modeEl.textContent = g.mode === 'pencil' ? 'PENCIL' : 'SHEET';
+ _modeEl.classList.toggle('is-pencil', g.mode === 'pencil');
+ }
+ if (!_readoutsEl) return;
+ const scan = (_geom && _geom.scan) || {};
+ const derived = (_geom && _geom.derived) || {};
+ const rows = [
+ ['stage X', _fmt(_stage.x, 0, ' µm')],
+ ['stage Y', _fmt(_stage.y, 0, ' µm')],
+ ['piezo Z', _fmt(_piezoZ, 1, ' µm')],
+ ['galvo A/B', `${_fmt(_galvo.a, 3)} / ${_fmt(_galvo.b, 3)}°`],
+ ['slices', scan.num_slices != null ? String(scan.num_slices) : '—'],
+ ['Z extent', _fmt(derived.z_extent_um, 1, ' µm')],
+ ['slice step', _fmt(derived.slice_spacing_um, 3, ' µm')],
+ ];
+ _readoutsEl.innerHTML = rows
+ .map(([k, v]) => `
${k}${escapeHtml(v)}
`)
+ .join('');
+ }
+
+ function _renderMinimap() {
+ if (!_minimapEl) return;
+ const VB = { w: 200, h: 120, pad: 8 };
+ const box = _firmwareBox || { x: [-25000, 25000], y: [-12000, 12000] };
+ const bw = box.x[1] - box.x[0], bh = box.y[1] - box.y[0];
+ if (!(bw > 0 && bh > 0)) return;
+ const sx = (VB.w - 2 * VB.pad) / bw;
+ const sy = (VB.h - 2 * VB.pad) / bh;
+ const s = Math.min(sx, sy);
+ const ox = VB.pad + (VB.w - 2 * VB.pad - bw * s) / 2;
+ const oy = VB.pad + (VB.h - 2 * VB.pad - bh * s) / 2;
+ const px = (x) => ox + (x - box.x[0]) * s;
+ const py = (y) => oy + (box.y[1] - y) * s; // flip Y for screen
+
+ const parts = [];
+ parts.push(``);
+ for (const e of _embryos) {
+ parts.push(``);
+ }
+ if (_stage.x != null && _stage.y != null) {
+ const fovPx = FOV_UM * s;
+ parts.push(``);
+ parts.push(``);
+ }
+ _minimapEl.innerHTML = parts.join('');
+ }
+
+ // ===================================================================
+ // Demo driver — develop without live hardware (launch_gently.py --offline)
+ // ===================================================================
+ function toggleDemo() {
+ if (_demoTimer) {
+ clearInterval(_demoTimer); _demoTimer = null;
+ if (_demoBtn) _demoBtn.classList.remove('is-on');
+ return;
+ }
+ if (_demoBtn) _demoBtn.classList.add('is-on');
+ // Seed a firmware box, a scan geometry, and a few embryos.
+ _firmwareBox = { x: [-25000, 25000], y: [-12000, 12000] };
+ handleScanGeometry({
+ embryo_id: 'demo_2',
+ stage_position_um: { x: 4200, y: -1800 },
+ scan: {
+ num_slices: 60, exposure_ms: 5.0,
+ galvo_amplitude_deg: 0.5, galvo_center_deg: 0.0,
+ piezo_amplitude_um: 25.0, piezo_center_um: 50.0,
+ },
+ derived: { z_extent_um: 50.0, slice_spacing_um: 50 / 59, z_min_um: 25, z_max_um: 75 },
+ mode: 'sheet', ts: 0,
+ });
+ handleEmbryos({
+ embryos: [
+ { id: 'demo_1', role: 'test', position_coarse: { x: 4200, y: -1800 } },
+ { id: 'demo_2', role: 'control', position_coarse: { x: -8000, y: 5200 } },
+ { id: 'demo_3', role: 'test', position_coarse: { x: 12000, y: 2400 } },
+ ],
+ });
+ // Sweep the sheet in Z to animate the plane.
+ let t = 0;
+ _demoTimer = setInterval(() => {
+ t += 0.08;
+ const g = _currentGeom();
+ _piezoZ = g.piezoCenter + (g.zExtent / 2) * Math.sin(t);
+ _galvo.a = 0.5 * Math.sin(t);
+ _updateSheetPosition();
+ _renderReadouts();
+ }, 60);
+ }
+
+ function cleanup() {
+ if (_animationId) cancelAnimationFrame(_animationId);
+ if (_demoTimer) { clearInterval(_demoTimer); _demoTimer = null; }
+ if (_resizeObserver) _resizeObserver.disconnect();
+ if (_renderer) { _renderer.dispose(); }
+ }
+
+ return { init, cleanup, toggleDemo, handleDeviceState, handleScanGeometry, handleEmbryos };
+})();
+
+document.addEventListener('DOMContentLoaded', () => {
+ // Build lazily on first tab activation (container is 0×0 while hidden),
+ // so init() is invoked from app.js switchTab(), not here.
+});
diff --git a/gently/ui/web/static/js/shell.js b/gently/ui/web/static/js/shell.js
new file mode 100644
index 00000000..c85b2474
--- /dev/null
+++ b/gently/ui/web/static/js/shell.js
@@ -0,0 +1,58 @@
+/**
+ * Shell (ux_v2): the grouped left-rail nav (Now / Library / System) + the
+ * session-context strip that replace the flat 8-tab bar.
+ *
+ * CRITICAL: the rail ROUTES THROUGH switchTab(tabId) for every reveal — it
+ * never reimplements tab activation, so each tab's lazy-init side-effect
+ * (HomeApp.init, EmbryosManager.clearDetectionBadge, CampaignsApp.init, …)
+ * still fires. switchTab emits TAB_CHANGED, which keeps the rail's active
+ * state in sync no matter who switched (rail, keyboard shortcut, home card,
+ * hash route). No-ops unless body.ux-v2 is present (flag off → v1 untouched).
+ */
+const Shell = (() => {
+ let railItems = [];
+
+ function setActive(tabName) {
+ railItems.forEach(b => b.classList.toggle('active', b.dataset.tab === tabName));
+ }
+
+ function currentTab() {
+ const active = document.querySelector('.tab.active');
+ return (active && active.dataset.tab) ||
+ (typeof state !== 'undefined' && state.tab) || 'home';
+ }
+
+ function renderStrip(status) {
+ const el = document.getElementById('v2-strip-status');
+ if (!el) return;
+ const s = status || (typeof ConnectionStatus !== 'undefined' ? ConnectionStatus.get() : {});
+ const n = (typeof state !== 'undefined' && Array.isArray(state.embryos)) ? state.embryos.length : 0;
+ const conn = s.gentlyConnected ? (s.microscopeConnected ? 'Connected' : 'Online') : 'Offline';
+ el.textContent = `${n} embryo${n === 1 ? '' : 's'} · ${conn}`;
+ }
+
+ function init() {
+ if (!document.body.classList.contains('ux-v2')) return; // flag off → no-op
+
+ railItems = Array.from(document.querySelectorAll('.v2-nav-item'));
+ railItems.forEach(btn => btn.addEventListener('click', () => {
+ if (typeof switchTab === 'function') switchTab(btn.dataset.tab);
+ }));
+ setActive(currentTab());
+
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.on('TAB_CHANGED', (tabName) => setActive(tabName));
+ ClientEventBus.on('CONNECTION_STATUS', (s) => renderStrip(s));
+ }
+
+ const chatBtn = document.getElementById('v2-rail-chat');
+ if (chatBtn) chatBtn.addEventListener('click', () => {
+ if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) AgentChat.togglePanel(true);
+ });
+
+ renderStrip();
+ }
+
+ document.addEventListener('DOMContentLoaded', init);
+ return {};
+})();
diff --git a/gently/ui/web/static/js/status-store.js b/gently/ui/web/static/js/status-store.js
new file mode 100644
index 00000000..0e50dd30
--- /dev/null
+++ b/gently/ui/web/static/js/status-store.js
@@ -0,0 +1,54 @@
+/**
+ * ConnectionStatus — the single source of truth for connection liveness.
+ *
+ * Fixes the "three disagreeing indicators" bug where the header pill, the home
+ * landing line, and the agent dock each computed connection state from their
+ * own signal at their own time (home.js read state.connected ONCE at tab init,
+ * before the /ws handshake, and never corrected — showing "Offline" while the
+ * header showed "Online").
+ *
+ * Three genuinely distinct signals (kept separate, not flattened):
+ * - gentlyConnected : the main /ws telemetry socket (websocket.js)
+ * - microscopeConnected : the /api/device-status health poll (app.js)
+ * - agentConnected : the /ws/agent chat socket (agent-chat.js)
+ *
+ * Writers call set*(); readers subscribe(). The store is STICKY: subscribe()
+ * immediately replays the current snapshot to the new subscriber, so a late
+ * subscriber can never miss the initial state. Events only fire on real change.
+ */
+const ConnectionStatus = (() => {
+ const s = { gentlyConnected: false, microscopeConnected: false, agentConnected: false };
+
+ function emit() {
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.emit('CONNECTION_STATUS', { ...s });
+ }
+ }
+
+ function set(key, val) {
+ val = !!val;
+ if (s[key] === val) return; // only emit on actual change
+ s[key] = val;
+ emit();
+ }
+
+ return {
+ setGently(v) { set('gentlyConnected', v); },
+ setMicroscope(v) { set('microscopeConnected', v); },
+ setAgent(v) { set('agentConnected', v); },
+ get() { return { ...s }; },
+
+ /**
+ * Subscribe to status changes AND immediately receive the current
+ * snapshot (sticky replay). This is the guard against the original bug:
+ * a subscriber that registers after the first emit still renders from
+ * the correct current state instead of a stale default.
+ */
+ subscribe(handler) {
+ if (typeof ClientEventBus !== 'undefined') {
+ ClientEventBus.on('CONNECTION_STATUS', handler);
+ }
+ try { handler({ ...s }); } catch (e) { console.error('ConnectionStatus subscriber error', e); }
+ }
+ };
+})();
diff --git a/gently/ui/web/static/js/utils.js b/gently/ui/web/static/js/utils.js
index 4b8ff62b..b321506c 100644
--- a/gently/ui/web/static/js/utils.js
+++ b/gently/ui/web/static/js/utils.js
@@ -3,7 +3,67 @@
// ══════════════════════════════════════════════════════════
// Tab and view name constants
-const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment' };
+const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment', NOTEBOOK: 'notebook' };
+
+/**
+ * Extract the XY firmware fence (the addressable stage box) from a device-state
+ * properties map. The ASI adapter exposes LowerLimX/UpperLimX/LowerLimY/
+ * UpperLimY in mm; we convert to µm. Single source of truth for both the 2D
+ * devices map and the 3D optical-space view.
+ *
+ * @param {Object} propsByDevice - payload.properties from DEVICE_STATE_UPDATE
+ * @returns {{x:[number,number], y:[number,number]}|null}
+ */
+function extractFirmwareBox(propsByDevice) {
+ if (!propsByDevice) return null;
+ for (const name of Object.keys(propsByDevice)) {
+ const p = propsByDevice[name] || {};
+ const xMinMm = parseFloat(p['LowerLimX(mm)']);
+ const xMaxMm = parseFloat(p['UpperLimX(mm)']);
+ const yMinMm = parseFloat(p['LowerLimY(mm)']);
+ const yMaxMm = parseFloat(p['UpperLimY(mm)']);
+ if (isFinite(xMinMm) && isFinite(xMaxMm) &&
+ isFinite(yMinMm) && isFinite(yMaxMm)) {
+ return {
+ x: [xMinMm * 1000, xMaxMm * 1000], // mm → µm
+ y: [yMinMm * 1000, yMaxMm * 1000],
+ };
+ }
+ }
+ return null;
+}
+
+/**
+ * Build a coordinate mapper from device microns to Three.js scene units.
+ * Centers each axis on its range midpoint and divides by the LARGEST span so
+ * the whole scene fits a ~[-0.5, 0.5] cube while keeping axes proportional
+ * (anisotropic Z vs XY preserved). Returns helpers used by all scene objects
+ * so a single scale governs geometry and camera distance.
+ *
+ * @param {{xRange:[number,number], yRange:[number,number], zRange:[number,number]}} ranges (µm)
+ */
+function makeSceneScaler(ranges) {
+ const xc = (ranges.xRange[0] + ranges.xRange[1]) / 2;
+ const yc = (ranges.yRange[0] + ranges.yRange[1]) / 2;
+ const zc = (ranges.zRange[0] + ranges.zRange[1]) / 2;
+ const xs = Math.abs(ranges.xRange[1] - ranges.xRange[0]);
+ const ys = Math.abs(ranges.yRange[1] - ranges.yRange[0]);
+ const zs = Math.abs(ranges.zRange[1] - ranges.zRange[0]);
+ const maxExtent = Math.max(xs, ys, zs, 1e-6);
+ return {
+ maxExtent,
+ center: { x: xc, y: yc, z: zc },
+ // Map an absolute µm position on one axis into scene space.
+ toScene(um, axis) {
+ const c = axis === 'x' ? xc : axis === 'y' ? yc : zc;
+ return (um - c) / maxExtent;
+ },
+ // Map a µm length (span) into scene units (no centering).
+ scaleLen(um) {
+ return um / maxExtent;
+ },
+ };
+}
/**
* HTML-escape a string (safe for insertion into innerHTML).
diff --git a/gently/ui/web/templates/_navbar.html b/gently/ui/web/templates/_navbar.html
index 33d5f675..ed02ba49 100644
--- a/gently/ui/web/templates/_navbar.html
+++ b/gently/ui/web/templates/_navbar.html
@@ -23,6 +23,7 @@
Plans
Sessions
+
Notebook
{% else %}
{# Standalone pages — all tabs link back to the SPA #}
Home
@@ -34,5 +35,6 @@
PlansSessions
+ Notebook
{% endif %}
diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html
index 85a60345..8bc8bf06 100644
--- a/gently/ui/web/templates/index.html
+++ b/gently/ui/web/templates/index.html
@@ -15,19 +15,138 @@
+
+
+
+
-
+
+ {% if ux_v2 %}
+ {# Agent-first welcome — shown on entry, recedes into the workspace once the
+ user picks a path. Chat is the last resort (the escape pill), not the
+ first thing. Only rendered when the flag is on. #}
+
+
+
+ {# ── Screen 1: welcome ── #}
+
+
+
+
Hello. What are we doing today?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {# ── Screen 2: the plan wizard, hosted IN the landing. The agent's
+ ask_user_choice questions render here as button cards (#v2-plan-ask),
+ NOT in the chat panel. The plan assembles on the right as you pick. #}
+
+
+
+
+
Gently · planning
+
Let's design your run
+
+
+
+
+
+
+
+
working through the next step…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endif %}
{% include '_header.html' %}
{% include '_navbar.html' %}
+ {% if ux_v2 %}
+
+ {% endif %}
+ {% if ux_v2 %}
LIVE
{% endif %}
+
+ {# ux_v2: the agent's current pending ask, dual-rendered here + in the chat. #}
+ {% if ux_v2 %}{% endif %}
+
+ {% if ux_v2 %}{% endif %}
Welcome to Gently
@@ -292,6 +411,31 @@
Experiment
{% include '_sessions_panel.html' %}
+
+
+
+
+
Notebook
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -301,6 +445,7 @@
Device
+
@@ -532,6 +677,40 @@
Properties
+
+
+
+
+
+
+
+
SHEET
+
+
+
+
drag rotate · wheel zoom · dbl-click reset
+
+
+
@@ -616,6 +795,7 @@
Properties
+
@@ -631,10 +811,16 @@
Properties
+
+
+
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index 561326b8..56f6f2a3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,11 @@ dependencies = [
"tifffile>=2023.0.0",
"scipy>=1.10.0",
"scikit-image>=0.21.0",
+ # OpenCV is imported (unguarded) by detection (sam_detection), the device
+ # layer, analysis steps, and video_maker — so it's a true runtime dep, not
+ # optional. headless avoids the libGL.so requirement of full opencv-python
+ # on headless servers/agents.
+ "opencv-python-headless>=4.8.0",
"jinja2>=3.1.0",
"pyyaml>=6.0",
"matplotlib>=3.7.0",
diff --git a/screenshots/occupancy3d-demo.png b/screenshots/occupancy3d-demo.png
new file mode 100644
index 00000000..d7688572
Binary files /dev/null and b/screenshots/occupancy3d-demo.png differ
diff --git a/screenshots/ux-v2-landing.png b/screenshots/ux-v2-landing.png
new file mode 100644
index 00000000..62502ce3
Binary files /dev/null and b/screenshots/ux-v2-landing.png differ
diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py
new file mode 100644
index 00000000..408fc423
--- /dev/null
+++ b/tests/test_notebook_api.py
@@ -0,0 +1,160 @@
+"""Tests for the notebook read API."""
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from gently.harness.memory.notebook import Note, NoteKind, NoteStatus
+
+
+def _make_app(context_store):
+ from gently.ui.web.routes.notebook import create_router
+
+ app = FastAPI()
+
+ class _Server:
+ pass
+
+ server = _Server()
+ server.context_store = context_store
+ app.include_router(create_router(server))
+ return app
+
+
+def _seed(cs):
+ nb = cs.notebook
+ nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"]))
+ nb.write_note(
+ Note(
+ id="f1",
+ kind=NoteKind.FINDING,
+ body="rings by comma",
+ status=NoteStatus.PROPOSED,
+ strains=["N2"],
+ threads=["t1"],
+ )
+ )
+ return cs
+
+
+class TestListNotes:
+ def test_no_store_available_false(self):
+ client = TestClient(_make_app(None))
+ data = client.get("/api/notebook/notes").json()
+ assert data == {"available": False, "notes": []}
+
+ def test_list_all(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes").json()
+ assert data["available"] is True
+ assert {n["id"] for n in data["notes"]} == {"o1", "f1"}
+
+ def test_filter_by_kind(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?kind=finding").json()
+ assert {n["id"] for n in data["notes"]} == {"f1"}
+
+ def test_filter_by_strain(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?strain=N2").json()
+ assert {n["id"] for n in data["notes"]} == {"o1", "f1"}
+
+ def test_invalid_kind_is_ignored(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?kind=bogus").json()
+ assert {n["id"] for n in data["notes"]} == {"o1", "f1"}
+
+
+class TestGetNote:
+ def test_get_existing(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes/o1").json()
+ assert data["id"] == "o1"
+ assert data["body"] == "ring formed"
+
+ def test_get_missing_404(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ resp = client.get("/api/notebook/notes/nope")
+ assert resp.status_code == 404
+
+
+class TestThreads:
+ def test_no_store(self):
+ client = TestClient(_make_app(None))
+ assert client.get("/api/notebook/threads").json() == {"available": False, "threads": []}
+
+ def test_thread_counts(self, file_context_store):
+ cs = file_context_store
+ nb = cs.notebook
+ nb.write_note(Note(id="a", kind=NoteKind.QUESTION, body="q", threads=["t1"]))
+ nb.write_note(Note(id="b", kind=NoteKind.FINDING, body="f", threads=["t1", "t2"]))
+ client = TestClient(_make_app(cs))
+ data = client.get("/api/notebook/threads").json()
+ assert data["available"] is True
+ assert data["threads"] == [{"id": "t1", "count": 2}, {"id": "t2", "count": 1}]
+
+
+class TestLimit:
+ def test_limit_returns_newest(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?limit=1").json()
+ assert len(data["notes"]) == 1 # newest-first, capped
+
+
+class _AskBlock:
+ def __init__(self, inp):
+ self.type = "tool_use"
+ self.input = inp
+
+
+class _AskResp:
+ def __init__(self, inp):
+ self.content = [_AskBlock(inp)]
+ self.stop_reason = "tool_use"
+
+
+class _AskMessages:
+ def __init__(self, inp):
+ self._inp = inp
+
+ async def create(self, **kwargs):
+ return _AskResp(self._inp)
+
+
+class _AskClient:
+ def __init__(self, inp):
+ self.messages = _AskMessages(inp)
+
+
+def _make_app_with_client(context_store, client):
+ from gently.ui.web.routes.notebook import create_router
+
+ app = FastAPI()
+
+ class _Server:
+ pass
+
+ server = _Server()
+ server.context_store = context_store
+ server.claude_async = client
+ app.include_router(create_router(server))
+ return app
+
+
+class TestAsk:
+ def test_ask_returns_grounded_answer(self, file_context_store):
+ cs = _seed(file_context_store)
+ canned = {
+ "answer": "A ring formed.",
+ "points": [{"text": "ring", "note_ids": ["o1"]}],
+ "suggested_next": [],
+ "coverage": "covered",
+ }
+ client = TestClient(_make_app_with_client(cs, _AskClient(canned)))
+ resp = client.post("/api/notebook/ask", json={"question": "what happened?"})
+ assert resp.status_code == 200
+ assert resp.json()["coverage"] == "covered"
+
+ def test_ask_no_store(self):
+ client = TestClient(_make_app(None))
+ resp = client.post("/api/notebook/ask", json={"question": "x"})
+ assert resp.json() == {"available": False}
diff --git a/tests/test_notebook_ask.py b/tests/test_notebook_ask.py
new file mode 100644
index 00000000..0cf664d7
--- /dev/null
+++ b/tests/test_notebook_ask.py
@@ -0,0 +1,95 @@
+"""Tests for 'Ask the notebook' — retrieval + grounded synthesis."""
+
+import asyncio
+
+from gently.harness.memory.notebook import Note, NoteKind
+from gently.harness.memory.notebook_ask import (
+ ASK_TOOL,
+ answer_question,
+ build_ask_messages,
+ select_notes,
+)
+
+
+def _seed(cs):
+ nb = cs.notebook
+ nb.write_note(
+ Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"])
+ )
+ nb.write_note(
+ Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"])
+ )
+ nb.write_note(Note(id="x1", kind=NoteKind.OBSERVATION, body="unrelated", strains=["OH904"]))
+ return nb
+
+
+class TestSelectNotes:
+ def test_scope_by_thread(self, file_context_store):
+ nb = _seed(file_context_store)
+ ids = {n.id for n in select_notes(nb, thread="t1")}
+ assert ids == {"o1", "f1"}
+
+ def test_scope_by_strain(self, file_context_store):
+ nb = _seed(file_context_store)
+ ids = {n.id for n in select_notes(nb, strain="OH904")}
+ assert ids == {"x1"}
+
+ def test_no_scope_returns_recent_capped(self, file_context_store):
+ nb = _seed(file_context_store)
+ notes = select_notes(nb, limit=2)
+ assert len(notes) == 2 # newest-first, capped
+
+
+class _FakeBlock:
+ def __init__(self, inp):
+ self.type = "tool_use"
+ self.input = inp
+
+
+class _FakeResp:
+ def __init__(self, inp):
+ self.content = [_FakeBlock(inp)]
+ self.stop_reason = "tool_use"
+
+
+class _FakeMessages:
+ def __init__(self, captured, inp):
+ self._captured, self._inp = captured, inp
+
+ async def create(self, **kwargs):
+ self._captured.update(kwargs)
+ return _FakeResp(self._inp)
+
+
+class _FakeClient:
+ def __init__(self, inp):
+ self.captured = {}
+ self.messages = _FakeMessages(self.captured, inp)
+
+
+class TestAnswerQuestion:
+ def test_build_messages_embeds_note_ids(self):
+ notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")]
+ msgs = build_ask_messages("what formed?", notes)
+ text = msgs[0]["content"]
+ assert "o1" in text and "ring formed" in text and "what formed?" in text
+
+ def test_answer_returns_structured_and_forces_tool(self):
+ canned = {
+ "answer": "A ring formed.",
+ "points": [{"text": "ring formed", "note_ids": ["o1"]}],
+ "suggested_next": [],
+ "coverage": "covered",
+ }
+ client = _FakeClient(canned)
+ notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")]
+ out = asyncio.run(answer_question(client, "m", "what formed?", notes))
+ assert out == canned
+ assert client.captured["tool_choice"] == {"type": "tool", "name": ASK_TOOL["name"]}
+ assert client.captured["model"] == "m"
+
+ def test_answer_no_notes_short_circuits_without_api(self):
+ client = _FakeClient({"should": "not be used"})
+ out = asyncio.run(answer_question(client, "m", "anything?", []))
+ assert out["coverage"] == "not_in_notebook"
+ assert client.captured == {} # no API call when nothing to ground on
diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py
new file mode 100644
index 00000000..ff8cc715
--- /dev/null
+++ b/tests/test_notebook_store.py
@@ -0,0 +1,240 @@
+"""Tests for the shared lab notebook: Note model + NotebookStore."""
+
+from datetime import datetime
+
+from gently.harness.memory.model import Confidence, Learning, Observation
+from gently.harness.memory.notebook import (
+ Author,
+ Note,
+ NotebookStore,
+ NoteKind,
+ NoteStatus,
+ learning_to_note,
+ note_from_dict,
+ note_to_dict,
+ observation_to_note,
+)
+
+
+class TestNoteModel:
+ def test_round_trip_minimal(self):
+ n = Note(id="abc123", kind=NoteKind.OBSERVATION, body="dim rings at 10 ms")
+ d = note_to_dict(n)
+ assert d["kind"] == "observation"
+ assert d["author"] == "agent" # default
+ assert d["status"] == "confirmed" # default
+ back = note_from_dict(d)
+ assert back == n
+
+ def test_round_trip_full(self):
+ n = Note(
+ id="def456",
+ kind=NoteKind.FINDING,
+ body="temperature shifts timing ~12 min/degC",
+ author=Author.AGENT,
+ title="Temp shifts timing",
+ status=NoteStatus.PROPOSED,
+ confidence=Confidence.MEDIUM,
+ strains=["N2", "OH904"],
+ embryos=["emb_0007"],
+ sessions=["20260615_1432_x"],
+ threads=["q_division_temp"],
+ basis=["obs_1", "obs_2"],
+ links=[{"rel": "supports", "to": "q_division_temp"}],
+ artifacts=[{"kind": "projection", "session": "s1", "embryo": "emb_0007", "t": 42}],
+ created_at=datetime(2026, 6, 16, 11, 0, 0),
+ updated_at=datetime(2026, 6, 16, 11, 0, 0),
+ )
+ back = note_from_dict(note_to_dict(n))
+ assert back == n
+ assert note_to_dict(n)["confidence"] == "medium"
+
+
+class TestNotebookStoreReadWrite:
+ def test_write_assigns_id_and_persists(self, tmp_path):
+ store = NotebookStore(tmp_path / "notebook")
+ n = Note(id="", kind=NoteKind.OBSERVATION, body="bean stage at t40")
+ note_id = store.write_note(n)
+ assert note_id # non-empty id assigned
+ files = list((tmp_path / "notebook" / "notes").glob("*.yaml"))
+ assert len(files) == 1
+ assert files[0].name.startswith(note_id + "_")
+
+ def test_get_note_round_trip(self, tmp_path):
+ store = NotebookStore(tmp_path / "notebook")
+ n = Note(id="", kind=NoteKind.FINDING, body="x", strains=["N2"], threads=["t1"])
+ note_id = store.write_note(n)
+ got = store.get_note(note_id)
+ assert got is not None
+ assert got.id == note_id
+ assert got.kind == NoteKind.FINDING
+ assert got.strains == ["N2"]
+
+ def test_get_missing_returns_none(self, tmp_path):
+ store = NotebookStore(tmp_path / "notebook")
+ assert store.get_note("nope") is None
+
+
+class TestNotebookIndex:
+ def test_index_updated_on_write(self, tmp_path):
+ store = NotebookStore(tmp_path / "notebook")
+ a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"]))
+ b = store.write_note(
+ Note(id="", kind=NoteKind.OBSERVATION, body="b", strains=["N2", "OH904"])
+ )
+ assert set(store.ids_for_strain("N2")) == {a, b}
+ assert store.ids_for_strain("OH904") == [b]
+ assert store.ids_for_strain("missing") == []
+
+ def test_index_by_embryo_and_thread(self, tmp_path):
+ store = NotebookStore(tmp_path / "notebook")
+ a = store.write_note(
+ Note(id="", kind=NoteKind.FINDING, body="a", embryos=["e1"], threads=["t1"])
+ )
+ assert store.ids_for_embryo("e1") == [a]
+ assert store.ids_for_thread("t1") == [a]
+
+ def test_rebuild_index_from_disk(self, tmp_path):
+ store = NotebookStore(tmp_path / "notebook")
+ a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"]))
+ # a fresh store over the same dir must rebuild the index by scanning notes/
+ store2 = NotebookStore(tmp_path / "notebook")
+ assert store2.ids_for_strain("N2") == [a]
+
+
+class TestNotebookQuery:
+ def _seed(self, tmp_path):
+ store = NotebookStore(tmp_path / "notebook")
+ store.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="o", strains=["N2"]))
+ store.write_note(
+ Note(
+ id="f1",
+ kind=NoteKind.FINDING,
+ body="f",
+ status=NoteStatus.PROPOSED,
+ strains=["N2"],
+ threads=["t1"],
+ )
+ )
+ store.write_note(
+ Note(id="q1", kind=NoteKind.QUESTION, body="q", status=NoteStatus.OPEN, threads=["t1"])
+ )
+ return store
+
+ def test_query_by_kind(self, tmp_path):
+ store = self._seed(tmp_path)
+ ids = {n.id for n in store.query_notes(kind=NoteKind.FINDING)}
+ assert ids == {"f1"}
+
+ def test_query_by_thread_scope(self, tmp_path):
+ store = self._seed(tmp_path)
+ ids = {n.id for n in store.query_notes(thread="t1")}
+ assert ids == {"f1", "q1"}
+
+ def test_query_by_thread_and_kind(self, tmp_path):
+ store = self._seed(tmp_path)
+ ids = {n.id for n in store.query_notes(thread="t1", kind=NoteKind.QUESTION)}
+ assert ids == {"q1"}
+
+ def test_query_by_status(self, tmp_path):
+ store = self._seed(tmp_path)
+ ids = {n.id for n in store.query_notes(status=NoteStatus.OPEN)}
+ assert ids == {"q1"}
+
+ def test_query_all_sorted_newest_first(self, tmp_path):
+ store = self._seed(tmp_path)
+ notes = store.query_notes()
+ assert len(notes) == 3
+ ts = [n.created_at for n in notes]
+ assert ts == sorted(ts, reverse=True)
+
+
+class TestNotebookLinkSupersede:
+ def test_link_notes_adds_typed_edge(self, tmp_path):
+ store = NotebookStore(tmp_path / "notebook")
+ a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a"))
+ b = store.write_note(Note(id="", kind=NoteKind.QUESTION, body="b"))
+ store.link_notes(a, "supports", b)
+ got = store.get_note(a)
+ assert {"rel": "supports", "to": b} in got.links
+
+ def test_supersede_marks_old_and_points_new(self, tmp_path):
+ store = NotebookStore(tmp_path / "notebook")
+ old = store.write_note(Note(id="", kind=NoteKind.FINDING, body="old claim"))
+ new = store.write_note(Note(id="", kind=NoteKind.FINDING, body="better claim"))
+ store.supersede_note(old, new)
+ old_n = store.get_note(old)
+ new_n = store.get_note(new)
+ assert old_n.status == NoteStatus.SUPERSEDED
+ assert old_n.superseded_by == new
+ assert {"rel": "refines", "to": old} in new_n.links
+
+
+class TestConverters:
+ def test_observation_to_note(self):
+ obs = Observation(
+ id="o1",
+ timestamp=datetime(2026, 6, 16, 9, 0, 0),
+ type="milestone",
+ content="nerve ring formed",
+ embryo_id="e1",
+ session_id="s1",
+ relates_to=["o0"],
+ gently_refs={"kind": "projection", "t": 42},
+ )
+ n = observation_to_note(obs)
+ assert n.id == "o1"
+ assert n.kind == NoteKind.OBSERVATION
+ assert n.body == "nerve ring formed"
+ assert n.author == Author.AGENT
+ assert n.embryos == ["e1"]
+ assert n.sessions == ["s1"]
+ assert {"rel": "relates_to", "to": "o0"} in n.links
+ assert n.artifacts == [{"kind": "projection", "t": 42}]
+ assert n.created_at == datetime(2026, 6, 16, 9, 0, 0)
+
+ def test_learning_to_note(self):
+ lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH)
+ n = learning_to_note(lrn)
+ assert n.id == "l1"
+ assert n.kind == NoteKind.FINDING
+ assert n.body == "rings form by comma"
+ assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm
+ assert n.confidence == Confidence.HIGH
+
+
+class TestContextStoreNotebook:
+ def test_notebook_property_rooted_under_agent_dir(self, file_context_store):
+ nb = file_context_store.notebook
+ assert nb.root == file_context_store.agent_dir / "notebook"
+
+ def test_notebook_property_is_cached(self, file_context_store):
+ assert file_context_store.notebook is file_context_store.notebook
+
+
+class TestApplyUpdatesMirror:
+ def test_apply_updates_mirrors_observations_and_learnings(self, file_context_store):
+ from gently.harness.memory.model import ContextUpdates
+
+ cs = file_context_store
+ obs = Observation(
+ id="o1",
+ timestamp=datetime(2026, 6, 16, 9, 0, 0),
+ type="milestone",
+ content="ring formed",
+ embryo_id="e1",
+ )
+ lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH)
+ cs.apply_updates(ContextUpdates(new_observations=[obs], new_learnings=[lrn]))
+
+ bodies = {n.body for n in cs.notebook.query_notes()}
+ assert "ring formed" in bodies
+ assert "rings form by comma" in bodies
+ assert cs.notebook.ids_for_embryo("e1") == ["o1"]
+
+ def test_apply_updates_empty_is_noop_for_notebook(self, file_context_store):
+ from gently.harness.memory.model import ContextUpdates
+
+ cs = file_context_store
+ cs.apply_updates(ContextUpdates())
+ assert cs.notebook.query_notes() == []
diff --git a/tests/test_plan_context.py b/tests/test_plan_context.py
new file mode 100644
index 00000000..acf97ffe
--- /dev/null
+++ b/tests/test_plan_context.py
@@ -0,0 +1,29 @@
+"""Connection B: the running agent's awareness summary carries the plan narrative
+(goal + what's next), not just the active item's spec sheet."""
+
+from gently.harness.memory.interface import AgentMemory
+
+
+def test_awareness_includes_goal_and_next(file_context_store):
+ cs = file_context_store
+ cid = cs.create_campaign(
+ description="Pioneer guidance", target="how pioneers steer the nerve ring"
+ )
+ active = cs.create_plan_item(
+ campaign_id=cid,
+ type="imaging",
+ title="WT baseline",
+ spec={"strain": "N2", "num_slices": 50, "laser_wavelength_nm": 488, "laser_power_pct": 8},
+ )
+ cs.create_plan_item(campaign_id=cid, type="decision_point", title="Go/no-go gate")
+
+ mem = AgentMemory(cs, session_id="s1")
+ mem.active_plan_item_id = active
+ summary = mem.get_awareness_summary()
+
+ assert "Goal of the investigation: how pioneers steer the nerve ring" in summary
+ assert "Next up:" in summary
+ assert "Go/no-go gate (decision point)" in summary
+ # spec block still present, incl. laser power when the field is set
+ assert "WT baseline" in summary
+ assert "Laser: 488nm at 8%" in summary
diff --git a/tests/test_plan_events.py b/tests/test_plan_events.py
new file mode 100644
index 00000000..ca28f9a9
--- /dev/null
+++ b/tests/test_plan_events.py
@@ -0,0 +1,49 @@
+"""Plan writes emit PLAN_UPDATED so the Plans UI can refresh live."""
+
+from gently.core.event_bus import EventType, on
+from gently.harness.memory.model import PlanItemStatus
+
+
+class TestPlanUpdatedEvent:
+ def test_plan_writes_emit_plan_updated(self, file_context_store):
+ cs = file_context_store
+ seen = []
+ unsub = on(EventType.PLAN_UPDATED, lambda e: seen.append(e))
+ try:
+ cid = cs.create_campaign(description="test campaign")
+ iid = cs.create_plan_item(campaign_id=cid, type="imaging", title="pilot")
+ cs.update_plan_item(iid, status=PlanItemStatus.IN_PROGRESS, session_id="sess_1")
+ cs.complete_plan_item(iid, outcome="done")
+ finally:
+ unsub()
+ # create_plan_item + update_plan_item + complete_plan_item(→update) each fire it
+ assert len(seen) >= 3
+ assert any((e.data or {}).get("campaign_id") == cid for e in seen)
+
+ def test_session_campaign_link_emits(self, file_context_store):
+ cs = file_context_store
+ seen = []
+ unsub = on(EventType.PLAN_UPDATED, lambda e: seen.append(e))
+ try:
+ cid = cs.create_campaign(description="c")
+ cs.link_session_campaign("sess_9", cid)
+ finally:
+ unsub()
+ assert any((e.data or {}).get("campaign_id") == cid for e in seen)
+
+
+class TestLinkPlanItemSession:
+ def test_append_many_sessions(self, file_context_store):
+ cs = file_context_store
+ cid = cs.create_campaign(description="c")
+ iid = cs.create_plan_item(campaign_id=cid, type="imaging", title="x")
+ assert cs.link_plan_item_session(iid, "s1") is True
+ assert cs.link_plan_item_session(iid, "s2") is True
+ cs.link_plan_item_session(iid, "s1") # duplicate — no-op
+ item = cs.get_plan_item(iid)
+ assert item.session_ids == ["s1", "s2"] # appended, deduped
+ assert item.session_id == "s2" # latest, for back-compat readers
+ assert item.status == PlanItemStatus.IN_PROGRESS # PLANNED → IN_PROGRESS on first link
+
+ def test_missing_item_returns_false(self, file_context_store):
+ assert file_context_store.link_plan_item_session("nope", "s1") is False
diff --git a/tests/test_plan_item_patch.py b/tests/test_plan_item_patch.py
new file mode 100644
index 00000000..b6303f0c
--- /dev/null
+++ b/tests/test_plan_item_patch.py
@@ -0,0 +1,96 @@
+"""Connection D: inline edits to plan items / imaging specs via PATCH.
+
+The inspector PATCHes changed fields; the store fires PLAN_UPDATED so the
+Plans UI refreshes live. Spec edits merge into the existing spec.
+"""
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+
+def _make_app(context_store):
+ from gently.ui.web.routes.campaigns import create_router
+
+ app = FastAPI()
+
+ class _Server:
+ pass
+
+ server = _Server()
+ server.context_store = context_store
+ app.include_router(create_router(server))
+ return app
+
+
+def _seed_imaging(cs):
+ cid = cs.create_campaign(description="c", target="goal")
+ iid = cs.create_plan_item(
+ campaign_id=cid,
+ type="imaging",
+ title="WT baseline",
+ spec={"strain": "N2", "laser_wavelength_nm": 488},
+ )
+ return cid, iid
+
+
+class TestPatchItem:
+ def test_edit_title_and_status(self, file_context_store):
+ cid, iid = _seed_imaging(file_context_store)
+ client = TestClient(_make_app(file_context_store))
+ r = client.patch(
+ f"/api/campaigns/{cid}/items/{iid}",
+ json={"title": "WT baseline (rev)", "status": "in_progress"},
+ )
+ assert r.status_code == 200
+ assert r.json()["ok"] is True
+ item = file_context_store.get_plan_item(iid)
+ assert item.title == "WT baseline (rev)"
+ assert item.status.value == "in_progress"
+
+ def test_fill_laser_power_merges_spec(self, file_context_store):
+ cid, iid = _seed_imaging(file_context_store)
+ client = TestClient(_make_app(file_context_store))
+ r = client.patch(
+ f"/api/campaigns/{cid}/items/{iid}",
+ json={"spec": {"laser_power_pct": 8}},
+ )
+ assert r.status_code == 200
+ item = file_context_store.get_plan_item(iid)
+ # the filled field is set...
+ assert item.imaging_spec.laser_power_pct == 8
+ # ...and the pre-existing spec fields survive the merge
+ assert item.imaging_spec.strain == "N2"
+ assert item.imaging_spec.laser_wavelength_nm == 488
+
+ def test_empty_string_clears_field(self, file_context_store):
+ cid, iid = _seed_imaging(file_context_store)
+ client = TestClient(_make_app(file_context_store))
+ r = client.patch(f"/api/campaigns/{cid}/items/{iid}", json={"spec": {"strain": ""}})
+ assert r.status_code == 200
+ item = file_context_store.get_plan_item(iid)
+ assert item.imaging_spec.strain is None
+
+ def test_patch_fires_plan_updated(self, file_context_store):
+ from gently.core.event_bus import EventType, on
+
+ cid, iid = _seed_imaging(file_context_store)
+ client = TestClient(_make_app(file_context_store))
+ seen = []
+ unsub = on(EventType.PLAN_UPDATED, lambda e: seen.append(e))
+ try:
+ client.patch(f"/api/campaigns/{cid}/items/{iid}", json={"title": "x"})
+ finally:
+ unsub()
+ assert any((e.data or {}).get("campaign_id") == cid for e in seen)
+
+ def test_no_fields_is_400(self, file_context_store):
+ cid, iid = _seed_imaging(file_context_store)
+ client = TestClient(_make_app(file_context_store))
+ r = client.patch(f"/api/campaigns/{cid}/items/{iid}", json={})
+ assert r.status_code == 400
+
+ def test_missing_item_is_404(self, file_context_store):
+ cid, _ = _seed_imaging(file_context_store)
+ client = TestClient(_make_app(file_context_store))
+ r = client.patch(f"/api/campaigns/{cid}/items/nope", json={"title": "x"})
+ assert r.status_code == 404
diff --git a/tests/test_record_note.py b/tests/test_record_note.py
new file mode 100644
index 00000000..ddad3559
--- /dev/null
+++ b/tests/test_record_note.py
@@ -0,0 +1,39 @@
+"""Tests for the record_note tool — human notes into the shared notebook."""
+
+import asyncio
+from types import SimpleNamespace
+
+import gently.app.tools.memory_tools # noqa: F401 — registers record_note
+from gently.harness.tools.registry import get_tool_registry
+
+
+def _handler():
+ return get_tool_registry().get("record_note").handler
+
+
+class TestRecordNote:
+ def test_writes_human_note_tagged_to_session(self, file_context_store):
+ agent = SimpleNamespace(context_store=file_context_store, session_id="sess_1", memory=None)
+ out = asyncio.run(
+ _handler()(text="ring formed cleanly", embryos=["e1"], context={"agent": agent})
+ )
+ assert "Noted" in out
+ notes = file_context_store.notebook.query_notes()
+ assert len(notes) == 1
+ n = notes[0]
+ assert n.author.value == "human"
+ assert n.body == "ring formed cleanly"
+ assert n.sessions == ["sess_1"]
+ assert n.embryos == ["e1"]
+
+ def test_no_session_still_records(self, file_context_store):
+ agent = SimpleNamespace(context_store=file_context_store, session_id=None, memory=None)
+ asyncio.run(_handler()(text="general observation", context={"agent": agent}))
+ notes = file_context_store.notebook.query_notes()
+ assert len(notes) == 1
+ assert notes[0].sessions == []
+
+ def test_no_store_returns_message(self):
+ agent = SimpleNamespace(context_store=None, session_id=None, memory=None)
+ out = asyncio.run(_handler()(text="x", context={"agent": agent}))
+ assert "No notebook available" in out
diff --git a/uv.lock b/uv.lock
index 60a87123..56e9f456 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,15 +2,15 @@ version = 1
revision = 3
requires-python = ">=3.10, <3.13"
resolution-markers = [
- "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
conflicts = [[
{ package = "gently", extra = "torch-cpu" },
@@ -159,6 +159,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
+[[package]]
+name = "ast-serialize"
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" },
+ { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" },
+ { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" },
+ { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" },
+ { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" },
+ { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" },
+ { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" },
+ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
+]
+
[[package]]
name = "async-timeout"
version = "5.0.1"
@@ -242,6 +266,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
+[[package]]
+name = "cfgv"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
+]
+
[[package]]
name = "click"
version = "8.4.1"
@@ -268,10 +301,9 @@ name = "contourpy"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -321,11 +353,12 @@ name = "contourpy"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -399,6 +432,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/f8/1070ab5973dda406f735f6f73a4bc50debf7c43301f5e113fd3657bfca58/dbus_fast-5.0.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8b2c92f82af04d59c50cbccf65f995fa2e321ce7850e3c1b613e94680a8044e", size = 860033, upload-time = "2026-05-27T19:27:55.167Z" },
]
+[[package]]
+name = "distlib"
+version = "0.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
+]
+
[[package]]
name = "distro"
version = "1.9.0"
@@ -605,6 +647,7 @@ dependencies = [
{ name = "jinja2" },
{ name = "matplotlib" },
{ name = "numpy" },
+ { name = "opencv-python-headless" },
{ name = "ophyd" },
{ name = "pillow" },
{ name = "pymmcore" },
@@ -642,8 +685,11 @@ torch-gpu = [
[package.dev-dependencies]
dev = [
+ { name = "mypy" },
+ { name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
+ { name = "ruff" },
]
[package.metadata]
@@ -658,6 +704,7 @@ requires-dist = [
{ name = "matplotlib", specifier = ">=3.7.0" },
{ name = "numpy", specifier = ">=1.24.0,<2" },
{ name = "nvidia-ml-py", marker = "extra == 'torch-gpu'", specifier = ">=12.0.0" },
+ { name = "opencv-python-headless", specifier = ">=4.8.0" },
{ name = "ophyd", specifier = ">=1.9.0" },
{ name = "paho-mqtt", marker = "extra == 'device'", specifier = ">=1.6.0" },
{ name = "pillow", specifier = ">=10.0.0" },
@@ -679,8 +726,11 @@ provides-extras = ["sam", "torch-gpu", "torch-cpu", "device"]
[package.metadata.requires-dev]
dev = [
+ { name = "mypy", specifier = "==2.1.0" },
+ { name = "pre-commit", specifier = ">=3.7.0" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" },
+ { name = "ruff", specifier = ">=0.4.0" },
]
[[package]]
@@ -689,20 +739,62 @@ version = "0.1.0"
source = { editable = "../gently-perception" }
dependencies = [
{ name = "anthropic" },
+ { name = "moderngl" },
{ name = "numpy" },
{ name = "pillow" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
[package.metadata]
requires-dist = [
{ name = "anthropic", specifier = ">=0.40.0" },
{ name = "matplotlib", marker = "extra == 'benchmark'", specifier = ">=3.7.0" },
+ { name = "moderngl", specifier = ">=5.10" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "pillow", specifier = ">=10.0.0" },
+ { name = "scipy", specifier = ">=1.10" },
{ name = "tifffile", marker = "extra == 'benchmark'", specifier = ">=2024.1.30" },
]
provides-extras = ["benchmark"]
+[[package]]
+name = "glcontext"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3a/80/8238a0e6e972292061176141c1028b5e670aa8c94cf4c2f819bd730d314e/glcontext-3.0.0.tar.gz", hash = "sha256:57168edcd38df2fc0d70c318edf6f7e59091fba1cd3dadb289d0aa50449211ef", size = 16422, upload-time = "2024-08-10T20:01:20.004Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/44/f605689047cca15d5e5d31c0a4b9ca87dd1d52bfabf3c3a05d70a4636c08/glcontext-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b154c25a57e16dbb073478d0cbe2c0090649d135c4c9f87e753b6181b97ec848", size = 9329, upload-time = "2024-08-10T19:59:49.485Z" },
+ { url = "https://files.pythonhosted.org/packages/10/fd/461c9e37ad50514dac82fda04a7a0aabb1a5e6b0f76476538caa4e74a25a/glcontext-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa5a14778d13ecf4a0dd60a7825427bf6ac0383eacb3b8b1a9c23e4976aa0c8c", size = 9735, upload-time = "2024-08-10T19:59:51.329Z" },
+ { url = "https://files.pythonhosted.org/packages/db/86/c879bf61d4f52d8b0cd702db5f7180b5eae8804d29a798de8da0568a64b2/glcontext-3.0.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c229290a3a33004a59799b94a50bc5e6f8addd1b5bc5039ef9e78f86d888b31", size = 50476, upload-time = "2024-08-10T19:59:52.854Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/c4/8b45b1c910f4a9b4df1ee3449b4c1aaa798fc25d86390e8eb2a18bc59669/glcontext-3.0.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1445a03d8795113034e1f9ffa662f795df65ae69ee21b26ed3b1d66100ba3f8c", size = 51480, upload-time = "2024-08-10T19:59:54.149Z" },
+ { url = "https://files.pythonhosted.org/packages/91/00/159f7df65554f30e0cc6f7fb78869e95717ed5319729f386dcc81ae9ee3d/glcontext-3.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09247011c09c37b8d30eca9aa24659288de2febaeaa6a817b33b1498b5ef164c", size = 44902, upload-time = "2024-08-10T19:59:55.268Z" },
+ { url = "https://files.pythonhosted.org/packages/91/85/21d2b619576690d48e4967dce37fabe616aeee06b7c6a6c6ceb48d99d3ef/glcontext-3.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c8c1223f1cbcfc0b88428e1717baca829ee863ed5d88e9b5574c7ed6598249cd", size = 47035, upload-time = "2024-08-10T20:00:03.972Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/99/6c200c6d16b55d19dfd13bfed1ed5794e17e49ea8ccf9e4fab52e2d0e2d0/glcontext-3.0.0-cp310-cp310-win32.whl", hash = "sha256:c13dedb3636328b133c4d53c047ce69040ae784095e8f239432ad74d6f921712", size = 12218, upload-time = "2024-08-10T20:00:06.285Z" },
+ { url = "https://files.pythonhosted.org/packages/93/36/8a8f25366ab80dc4e6c9ded7b21f11152bba65fd6b8c0f28641ef3e133f5/glcontext-3.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:4817f4cd52c7fe5410c92ca12b6712435548918719373882ade76f8f75d80abd", size = 12973, upload-time = "2024-08-10T20:00:07.733Z" },
+ { url = "https://files.pythonhosted.org/packages/01/36/3d0d09f7352b179a7ecc5fc3322beb85c8995c66db780acf791853e49043/glcontext-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3a9e56fa3597cc709cfd0fdf2ae682cda36510a13faac2b3142f401e823b64f4", size = 9328, upload-time = "2024-08-10T20:00:09.43Z" },
+ { url = "https://files.pythonhosted.org/packages/44/9d/0c8fd9c660db000071ebace14a40bd381a41775c10faa262fedfae8227e3/glcontext-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a0484308af75e04b0e56066dc2324a8fb9f1443b76ddb98833439982322b2a39", size = 9738, upload-time = "2024-08-10T20:00:10.831Z" },
+ { url = "https://files.pythonhosted.org/packages/78/cf/7bcadb995830cdd6a1a31f0527a52b2c441a499feabed9749106f7e41e67/glcontext-3.0.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:983231394396aa2a1e2b96df49404cc8f8aa729d462ed40e605a74b079c46342", size = 50663, upload-time = "2024-08-10T20:00:12.301Z" },
+ { url = "https://files.pythonhosted.org/packages/43/fb/646c2773cb097b914afe1f06c95e65deb8a544d770389bf29c76a8f3a8fd/glcontext-3.0.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa413f4420abff2bbb5aa5770a3e1deffcdc13e0ef2f459b145fa79c36909e7", size = 51680, upload-time = "2024-08-10T20:00:13.646Z" },
+ { url = "https://files.pythonhosted.org/packages/89/b7/04aac6c50071b858cfd02a9bccafb42a21f567992fa448c8ad8aa62939b9/glcontext-3.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7d0ac35ac07fc91eccea093beb9d1c1a4eae250bc33836047deff01a3b5f4757", size = 45063, upload-time = "2024-08-10T20:00:15.137Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/6e/6e398492b55f3c453cc6a2ecc2886a01b2a465623aa0c476d3e115be85c4/glcontext-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7145d17a70adc5784ca59ebbe19a56435ba21816070b8b433f43aa2dfb8be71a", size = 47281, upload-time = "2024-08-10T20:00:16.679Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/fd/4f59118e5067a3c88217862d8672463aff29f29c1ea9f47971f8ca67e83c/glcontext-3.0.0-cp311-cp311-win32.whl", hash = "sha256:b31808ca2517fedcac8ca5b296ff46c8af012911eaa2080889a1f244d329ef9a", size = 12222, upload-time = "2024-08-10T20:00:18.108Z" },
+ { url = "https://files.pythonhosted.org/packages/20/1b/f402574ae4644e7fb389b9534de9d1be575b8a4da901a9023d1cec72c4aa/glcontext-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ef4b4ec35e2b720f4cd250bb92cf6417add445490bf780345596da5a796a0e6f", size = 12975, upload-time = "2024-08-10T20:00:19.236Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/61/d8f77d44bbf477b235ecbff8d421a5511d4b4f6dc676dacd84d012348516/glcontext-3.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:848f870a2bc72a29de7ab6756b9e8f2e6ce052e17873ebc6b3f25129b6e0d58a", size = 9360, upload-time = "2024-08-10T20:00:20.42Z" },
+ { url = "https://files.pythonhosted.org/packages/de/46/680a97d974cfe7af798542918b65bd8e65a5f8f7647edfd9fdb91a95df6c/glcontext-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b3b12a66f57379566dd4d36899ac265abdbe040f3fc3293f50cd6678a1dcc9b", size = 9733, upload-time = "2024-08-10T20:00:21.624Z" },
+ { url = "https://files.pythonhosted.org/packages/74/2c/be188c4eb63b4d0cc74c644a1519b5e3a37488da3cbda724570cd5fed8d3/glcontext-3.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:449eaefd89c0519900715b8363ead59ac4aa32457722ca521ce01297441edb34", size = 50409, upload-time = "2024-08-10T20:00:22.827Z" },
+ { url = "https://files.pythonhosted.org/packages/32/ba/9ccb80650e5bd61e739f16f33aec3bb290a80f314631be8f7dc0a2b22b5f/glcontext-3.0.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04921720740438ceea8fb8a38b5665963520c7c8f27bef03df8aeb3ea3cfbfb6", size = 51440, upload-time = "2024-08-10T20:00:23.987Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/99/f6c9a0e614809ba5b83bd8403c475b788bd574d694bd5bc6b6ae2e2cefdf/glcontext-3.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:25538bdb106f673638d70e8a16a0c037a92a24c4cf40a05f0d3fa14b483d6194", size = 44820, upload-time = "2024-08-10T20:00:25.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/29/fdbb94e4c9374390639b741774384f7413efcd7634cc9c4baacc2cf00a1f/glcontext-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d11f7701b900a5a34c994e1d91c547be1cc469b73f881471460fd905f69f9e4c", size = 47055, upload-time = "2024-08-10T20:00:26.723Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/fe/25b5348fe5e856697dacad34fc07e80f48eecfb38bd09806679fe0e62769/glcontext-3.0.0-cp312-cp312-win32.whl", hash = "sha256:5d2b567eaf34adb016aadce81fd2f1d4c8e4a39e3d6f2a395ce528e2a350dd3f", size = 12219, upload-time = "2024-08-10T20:00:27.76Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d3/6619693ddad97011ca1c9aaeb82216ab2bfd54757be752b12f4e9a2fc489/glcontext-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e80bb37ba727bd20c192f2754aea40c437a7665005c1001c10752f91913964e9", size = 12971, upload-time = "2024-08-10T20:00:29.575Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/84/8f6ca5b8005ec58adbf51e2e6c3b5a20f234ea8f7a2e1df52835c9b23274/glcontext-3.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f4e285e5d40e7a9bafeb0651c3d8f1a7c822525dec7091e97109ba87a70dbd1e", size = 9272, upload-time = "2024-08-10T20:01:00.133Z" },
+ { url = "https://files.pythonhosted.org/packages/39/a2/a0d1e7492f14727682820120062717a118f0196cda8cf6c1a6e6c4ab6d12/glcontext-3.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:96d1bbe62c5bc5315ca2f84a2abae6fa7b7d645dd368415a0cd1ee5ba6f3f8f7", size = 9581, upload-time = "2024-08-10T20:01:01.098Z" },
+ { url = "https://files.pythonhosted.org/packages/07/ba/2f093542218c9c38b293df0f501509fdfeb5dad975135a5467431aa9a774/glcontext-3.0.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a190b1cdb39110c7b56c33857dbff493a364633bfd0ff402a1ce205956c94ca", size = 17842, upload-time = "2024-08-10T20:01:02.308Z" },
+ { url = "https://files.pythonhosted.org/packages/01/e5/5209be64858f9d7f9861f03ed5662491642d8d31b51a6a8495f084869386/glcontext-3.0.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d467cce2dac8c3928847e90310eb6bdfdfa69f8df39b76a74734046faa15e688", size = 17226, upload-time = "2024-08-10T20:01:03.362Z" },
+ { url = "https://files.pythonhosted.org/packages/06/cb/0995eef99e56394b93bea3aa20bc269ae09125b66c0b7c1e3338f44c93cb/glcontext-3.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2b0c5240125d75498a8f14596484233e4effe98a6a035f566872dd2fdf472ceb", size = 12988, upload-time = "2024-08-10T20:01:04.728Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -778,6 +870,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
+[[package]]
+name = "identify"
+version = "2.6.19"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
+]
+
[[package]]
name = "idna"
version = "3.17"
@@ -992,6 +1093,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" },
]
+[[package]]
+name = "librt"
+version = "0.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" },
+ { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" },
+ { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" },
+ { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" },
+ { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" },
+ { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" },
+ { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" },
+ { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" },
+ { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" },
+ { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" },
+ { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" },
+ { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -1079,6 +1226,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" },
]
+[[package]]
+name = "moderngl"
+version = "5.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "glcontext" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/52/540e2f8c45060bb2709f56eb5a44ae828dfcc97ccecb342c1a7deb467889/moderngl-5.12.0.tar.gz", hash = "sha256:52936a98ccb2f2e1d6e3cb18528b2919f6831e7e3f924e788b5873badce5129b", size = 193232, upload-time = "2024-10-17T12:36:28.002Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/4b/988c5f4ef55bf0c62a0b63a4b32fe6d6dd755d12f5951e8fd56e8a30d3f6/moderngl-5.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:105cdee2ee29a7e6ebbc76cf178941503656a185c6945933bc7ef395ba8e65a7", size = 111803, upload-time = "2024-10-17T12:36:34.271Z" },
+ { url = "https://files.pythonhosted.org/packages/97/40/272d38d9d096d591db08e4aa1c983a8ed34cd1717b6524a88539b0df4341/moderngl-5.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:18deb8bebd0a4277d92c76dbedf8e4b4b68bf0a8a878404c6b26aed750890d3b", size = 109223, upload-time = "2024-10-17T12:36:35.957Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/5c/a6f2743c8ae1c95cd6531510bf76fa6be650a5e0a1dffdf138b5ed186f61/moderngl-5.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f0c4f7c42425177168938386a4fabd734ca3bbb5de2d1fd1176cfa6f980fc13", size = 291432, upload-time = "2024-10-17T12:36:37.122Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/2d/b6bfc4fe7df343a4f410030dbd8261566282049e56e9ffc3cbd2e626656e/moderngl-5.12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:878cdf593204d85c020305f21d306f979353a67c932b9df58ea936f6e5ad13e7", size = 265422, upload-time = "2024-10-17T12:36:38.767Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ae/35f8f4c833aca90870110509dddad641f5adf9597da15b3c9e7c75db319f/moderngl-5.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:00d94f9cb485d87c85088edad624201e152d8ac401793a024b16cd9e2bc4dbf6", size = 1342266, upload-time = "2024-10-17T12:36:40.405Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/4b/2e0b6d0e8a5273da93ed82c8eb91b54a37413b1b9d65bb223d67b595fc96/moderngl-5.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:edd91057b8d76beebac0d7b0c741466ee8f37eaf3c07856785c2872afe0b35ac", size = 1270055, upload-time = "2024-10-17T12:36:42.243Z" },
+ { url = "https://files.pythonhosted.org/packages/48/2f/b590f86d42630fbbaffd83f1890ef6278ec12d5cb336b6a2efcbb3c2ca21/moderngl-5.12.0-cp310-cp310-win32.whl", hash = "sha256:3d066eae2eb44e81bd7addf565adebc041bdee119e7ac6e4f95831d6f327a938", size = 101044, upload-time = "2024-10-17T12:36:43.94Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/6d/f9b77797d3a0f3e5b67a75092c6c63b26360042fd8e43be017c706e308a8/moderngl-5.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:76d966194d51852f48c42a6e786a4520f1e1be5f93e2626423d673663422d559", size = 108272, upload-time = "2024-10-17T12:36:44.995Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/ea/569c2c08bfef84f4acf633a8e6d956f4f75cfaa8832d7d812dbf2ff6843a/moderngl-5.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28cdba5dcf2d03c89bb25dc3b2f5770ac4104470ed5bbe680a15494fa52a537d", size = 111802, upload-time = "2024-10-17T12:36:47.377Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ef/98f36133ab010ce9831b75a16e75a627c12a4c1d6ef2e353eca1769a1e09/moderngl-5.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dad93893e3fcb2410bfd31e854f20e1370b4fbafa07a737f1046f5fbd29ba0f4", size = 109227, upload-time = "2024-10-17T12:36:49.24Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/bb/ab371acacd2497bddb5f02b209e3bfae452b2be59d0cf8fa728a3b87de1f/moderngl-5.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc0f8788bc84433d2124e9a4893adbe40f93c7d213abb8ad7b909540cb0161f", size = 293468, upload-time = "2024-10-17T12:36:50.8Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/9e/7ebf2b98da310c90c2b295e91b6c25f864f0f5583ce86bee72d387cb577a/moderngl-5.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6efd3fe0d2c9652af21e2c1f5a936a2b971abac5bdd777da7182a54962466cab", size = 267348, upload-time = "2024-10-17T12:36:52.526Z" },
+ { url = "https://files.pythonhosted.org/packages/61/0a/87fb24f4cd2aa07150b84fbf400edf4d8a8f71784cbf064f1ed92b756fea/moderngl-5.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6f3bd2d534fc081cde30545b84ebca63aef847ba8bd533217b9a37f565614ade", size = 1343953, upload-time = "2024-10-17T12:36:54.601Z" },
+ { url = "https://files.pythonhosted.org/packages/83/42/11b0306e630d9a38b8bac20563b326f6d5fba4dfc45cc90b0666ed3e7141/moderngl-5.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eaa3de9446c6febec4d5f888e6f1a4e9398bc5a5ea70b1570ea447213641d4a6", size = 1271832, upload-time = "2024-10-17T12:36:56.383Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/dd/74d300275fe4834e63b8d70450801f206d005f2047898d4eb2a30efc3913/moderngl-5.12.0-cp311-cp311-win32.whl", hash = "sha256:9fdb76f1fd890db67727c8cdee4db2ee6319068c7ce92be0308366f8745e28ab", size = 101043, upload-time = "2024-10-17T12:36:58.281Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/08/5f615a4605d343cd5c1112d2b175270e6a5586008bc10a85b822c340cf86/moderngl-5.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:0c210e8d52a60025f6586ca015c39feb1e57e6dc792c3ff44800f6493a541b1a", size = 108279, upload-time = "2024-10-17T12:37:01.125Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/66/31161e81bc85ca3cdbb9d94f703f21575e4ae9a2919e9d1af98fc7fdb1ba/moderngl-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2750547707c1ec3790dfbeb9c90fb808672ff13f61cac392c706ba09fda10db0", size = 112101, upload-time = "2024-10-17T12:37:02.267Z" },
+ { url = "https://files.pythonhosted.org/packages/84/b2/7229a89a40d33a95119a7c64c7ee36a6a6e376c57c39fb577ea513602f37/moderngl-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c2a5fe06c7021183d9274df798f25516409c8d55898c324dae8a0b2de10144", size = 109377, upload-time = "2024-10-17T12:37:03.843Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/96/bcb5141eae24474d80b8157b0c3055d25fa75f9804d4abb4a514695bbba9/moderngl-5.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6c4972f3ddd10a3de6c30311da2c25bc493d023796e16c5d4e0f8bd6d5770be", size = 296394, upload-time = "2024-10-17T12:37:04.967Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/79/a9998ddf6757f4f15888b0a106d80a64a8c8991a8ce5c14047830704b9e6/moderngl-5.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d497ec6a3f6afa9ebd0be816d9bfe2fe20fec2105acfb88d956619c3ed8eb4", size = 270548, upload-time = "2024-10-17T12:37:07.57Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f4/313dc301db936b231035b961e004f1914c2954bdcdf4985e24bff15e7ed5/moderngl-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2f3d240e9bc5d83257378bae59f8f35638b89d22bb003cf674b88fd7932161ce", size = 1345817, upload-time = "2024-10-17T12:37:09.105Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/6f/7b3587e7e3ae633b8c85038f035dfeb348ebf805de4beb01241c59c6b97c/moderngl-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6fa667d560d842e778e2a5968305fb78f9781616a11b1b93acd2562f97262ccf", size = 1274968, upload-time = "2024-10-17T12:37:11.092Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/e8/0f3b3cd1be7b0a93f33dc613f76a42021d1393b4949c5b6a1ca2a01c6772/moderngl-5.12.0-cp312-cp312-win32.whl", hash = "sha256:0a02fddd54dccee1ca6060bfed75a2e6a17dd3ee06920fac418506d8a8233849", size = 101221, upload-time = "2024-10-17T12:37:12.642Z" },
+ { url = "https://files.pythonhosted.org/packages/56/85/35498b1821cf31c731b1882168db8924207ff3c06d8f0da53e1cc373a89d/moderngl-5.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8698a59ad03539a2982125b7998efc1c107ba31d5d03437b6fcd72cb2c226922", size = 108525, upload-time = "2024-10-17T12:37:13.816Z" },
+]
+
[[package]]
name = "mpmath"
version = "1.3.0"
@@ -1201,15 +1383,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
+[[package]]
+name = "mypy"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ast-serialize" },
+ { name = "librt", marker = "platform_python_implementation != 'PyPy' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" },
+ { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" },
+ { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" },
+ { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" },
+ { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" },
+ { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
[[package]]
name = "networkx"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" }
wheels = [
@@ -1221,17 +1449,27 @@ name = "networkx"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
]
+[[package]]
+name = "nodeenv"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+]
+
[[package]]
name = "numpy"
version = "1.26.4"
@@ -1313,7 +1551,7 @@ name = "nvidia-cudnn-cu11"
version = "9.1.0.70"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "nvidia-cublas-cu11" },
+ { name = "nvidia-cublas-cu11", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/3b/0b776f04e364cd99e4cf152c2a9eadb5934c67c9a91429da55169a9447fd/nvidia_cudnn_cu11-9.1.0.70-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e6135ac63fe9d5b0b89cfb35c3fc1c1349f2b995becadf2e9dc21bca89d9633d", size = 663919573, upload-time = "2024-04-22T15:20:24.839Z" },
@@ -1347,7 +1585,7 @@ name = "nvidia-cusolver-cu11"
version = "11.4.1.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "nvidia-cublas-cu11" },
+ { name = "nvidia-cublas-cu11", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/ee/939ff0104991dd7bdabb4c9767994c612ba0e1c9a55672a1ddd42f5e5b16/nvidia_cusolver_cu11-11.4.1.48-py3-none-manylinux1_x86_64.whl", hash = "sha256:ca538f545645b7e6629140786d3127fe067b3d5a085bd794cde5bfe877c8926f", size = 128240842, upload-time = "2022-10-03T23:30:24.348Z" },
@@ -1395,6 +1633,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/3f/0e1dd2bc4d89f838b86c76956ffa514307d3be4d8b5ee0da4e9d12a8b54b/nvidia_nvtx_cu11-11.8.86-py3-none-win_amd64.whl", hash = "sha256:54031010ee38d774b2991004d88f90bbd7bbc1458a96bbc4b42662756508c252", size = 66297, upload-time = "2022-10-03T23:39:12.132Z" },
]
+[[package]]
+name = "opencv-python-headless"
+version = "4.11.0.86"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929, upload-time = "2025-01-16T13:53:40.22Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/53/2c50afa0b1e05ecdb4603818e85f7d174e683d874ef63a6abe3ac92220c8/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca", size = 37326460, upload-time = "2025-01-16T13:52:57.015Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/43/68555327df94bb9b59a1fd645f63fafb0762515344d2046698762fc19d58/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81", size = 56723330, upload-time = "2025-01-16T13:55:45.731Z" },
+ { url = "https://files.pythonhosted.org/packages/45/be/1438ce43ebe65317344a87e4b150865c5585f4c0db880a34cdae5ac46881/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb", size = 29487060, upload-time = "2025-01-16T13:51:59.625Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b", size = 49969856, upload-time = "2025-01-16T13:53:29.654Z" },
+ { url = "https://files.pythonhosted.org/packages/95/dd/ed1191c9dc91abcc9f752b499b7928aacabf10567bb2c2535944d848af18/opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b", size = 29324425, upload-time = "2025-01-16T13:52:49.048Z" },
+ { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386, upload-time = "2025-01-16T13:52:56.418Z" },
+]
+
[[package]]
name = "opentelemetry-api"
version = "1.42.1"
@@ -1443,6 +1698,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
]
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
[[package]]
name = "pillow"
version = "12.2.0"
@@ -1496,10 +1760,9 @@ name = "pint"
version = "0.24.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "flexcache", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -1517,11 +1780,12 @@ name = "pint"
version = "0.25.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "flexcache", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -1552,6 +1816,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "pre-commit"
+version = "4.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cfgv" },
+ { name = "identify" },
+ { name = "nodeenv" },
+ { name = "pyyaml" },
+ { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
+]
+
[[package]]
name = "propcache"
version = "0.5.2"
@@ -1741,7 +2021,7 @@ name = "pyobjc-framework-cocoa"
version = "12.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pyobjc-core" },
+ { name = "pyobjc-core", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/cc/927169225e72bab9c9b44285656768fb75052a0bc85fdbca62740e1ca43c/pyobjc_framework_cocoa-12.2.tar.gz", hash = "sha256:20b392e2b7241caad0538dfde12143343e5dfe48f72e7df660a7548e635903dc", size = 3125555, upload-time = "2026-05-30T12:35:09.273Z" }
wheels = [
@@ -1755,8 +2035,8 @@ name = "pyobjc-framework-corebluetooth"
version = "12.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pyobjc-core" },
- { name = "pyobjc-framework-cocoa" },
+ { name = "pyobjc-core", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
+ { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/df/bee7ba216f9fb513710aac1701b78c97b087b37fca8ec1806f8572e0bbb3/pyobjc_framework_corebluetooth-12.2.tar.gz", hash = "sha256:8b4e5ca99953c360c391a695b0782a5328fcecafd56fdf790ad709e932feb306", size = 37552, upload-time = "2026-05-30T12:35:33.863Z" }
wheels = [
@@ -1770,8 +2050,8 @@ name = "pyobjc-framework-libdispatch"
version = "12.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pyobjc-core" },
- { name = "pyobjc-framework-cocoa" },
+ { name = "pyobjc-core", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
+ { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/fe/e23be301e46c30450955cdb096f16f6a86e7609787a4b8225ec24d6fdc9d/pyobjc_framework_libdispatch-12.2.tar.gz", hash = "sha256:4a41879ef7716b73d70f2e40ff39353d686cbc59d48c93217ed362d2b2baf1ba", size = 40345, upload-time = "2026-05-30T12:39:09.474Z" }
wheels = [
@@ -1842,6 +2122,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
+[[package]]
+name = "python-discovery"
+version = "1.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -1907,10 +2200,9 @@ name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
@@ -1977,11 +2269,12 @@ name = "rpds-py"
version = "2026.5.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" }
wheels = [
@@ -2029,15 +2322,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" },
]
+[[package]]
+name = "ruff"
+version = "0.15.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
+ { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
+ { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
+ { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
+ { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
+ { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
+ { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
+ { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
+]
+
[[package]]
name = "scikit-image"
version = "0.25.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "imageio", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -2073,11 +2390,12 @@ name = "scikit-image"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "imageio", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -2114,10 +2432,9 @@ name = "scipy"
version = "1.15.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -2158,11 +2475,12 @@ name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -2257,10 +2575,9 @@ name = "tifffile"
version = "2025.5.10"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version < '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -2275,11 +2592,12 @@ name = "tifffile"
version = "2026.3.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.12' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version == '3.11.*' and extra != 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform != 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
- "python_full_version >= '3.11' and extra != 'extra-6-gently-torch-cpu' and extra != 'extra-6-gently-torch-gpu'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
@@ -2330,9 +2648,15 @@ name = "torch"
version = "2.7.1+cu118"
source = { registry = "https://download.pytorch.org/whl/cu118" }
resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version < '3.11'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "filelock" },
@@ -2370,7 +2694,8 @@ name = "torch"
version = "2.12.0"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
- "python_full_version >= '3.11' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version < '3.11' and sys_platform == 'darwin'",
]
dependencies = [
@@ -2394,8 +2719,12 @@ name = "torch"
version = "2.12.0+cpu"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
- "python_full_version >= '3.11' and sys_platform != 'darwin'",
- "python_full_version < '3.11' and sys_platform != 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "filelock", marker = "sys_platform != 'darwin'" },
@@ -2429,9 +2758,15 @@ name = "torchvision"
version = "0.22.1+cu118"
source = { registry = "https://download.pytorch.org/whl/cu118" }
resolution-markers = [
- "python_full_version >= '3.12'",
- "python_full_version == '3.11.*'",
- "python_full_version < '3.11'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "numpy" },
@@ -2452,7 +2787,8 @@ name = "torchvision"
version = "0.27.0"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
- "python_full_version >= '3.11' and sys_platform == 'darwin'",
+ "python_full_version >= '3.12' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version < '3.11' and sys_platform == 'darwin'",
]
dependencies = [
@@ -2471,8 +2807,12 @@ name = "torchvision"
version = "0.27.0+cpu"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
- "python_full_version >= '3.11' and sys_platform != 'darwin'",
- "python_full_version < '3.11' and sys_platform != 'darwin'",
+ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "numpy", marker = "sys_platform != 'darwin'" },
@@ -2508,7 +2848,7 @@ name = "triton"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "setuptools" },
+ { name = "setuptools", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/a9/549e51e9b1b2c9b854fd761a1d23df0ba2fbc60bd0c13b489ffa518cfcb7/triton-3.3.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b74db445b1c562844d3cfad6e9679c72e93fdfb1a90a24052b03bb5c49d1242e", size = 155600257, upload-time = "2025-05-29T23:39:36.085Z" },
@@ -2588,6 +2928,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
]
+[[package]]
+name = "virtualenv"
+version = "21.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+ { name = "python-discovery" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" },
+]
+
[[package]]
name = "watchfiles"
version = "1.2.0"
@@ -2690,7 +3046,7 @@ name = "winrt-runtime"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions" },
+ { name = "typing-extensions", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" }
wheels = [
@@ -2710,7 +3066,7 @@ name = "winrt-windows-devices-bluetooth"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "winrt-runtime" },
+ { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" }
wheels = [
@@ -2730,7 +3086,7 @@ name = "winrt-windows-devices-bluetooth-advertisement"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "winrt-runtime" },
+ { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" }
wheels = [
@@ -2750,7 +3106,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "winrt-runtime" },
+ { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" }
wheels = [
@@ -2770,7 +3126,7 @@ name = "winrt-windows-devices-enumeration"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "winrt-runtime" },
+ { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" }
wheels = [
@@ -2790,7 +3146,7 @@ name = "winrt-windows-devices-radios"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "winrt-runtime" },
+ { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" }
wheels = [
@@ -2810,7 +3166,7 @@ name = "winrt-windows-foundation"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "winrt-runtime" },
+ { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" }
wheels = [
@@ -2830,7 +3186,7 @@ name = "winrt-windows-foundation-collections"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "winrt-runtime" },
+ { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" }
wheels = [
@@ -2850,7 +3206,7 @@ name = "winrt-windows-storage-streams"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "winrt-runtime" },
+ { name = "winrt-runtime", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu') or (sys_platform == 'linux' and extra == 'extra-6-gently-torch-cpu' and extra == 'extra-6-gently-torch-gpu')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" }
wheels = [
diff --git a/ux-prototype/MIGRATION-PLAN.md b/ux-prototype/MIGRATION-PLAN.md
new file mode 100644
index 00000000..8e906515
--- /dev/null
+++ b/ux-prototype/MIGRATION-PLAN.md
@@ -0,0 +1,102 @@
+# Gently web UI → agent-first paradigm: migration plan
+
+Strangler-fig migration of the **existing** stack (FastAPI + Jinja2 + vanilla-JS, `gently/ui/web/`). **No SPA rewrite.** Everything new is gated behind a `GENTLY_UX_V2` flag and layered onto the seams that already exist. Target paradigm is the prototype in `ux-prototype/landing.html`.
+
+## Why this is cheap (the load-bearing discoveries)
+
+- **The structured-ask protocol already exists as data.** The agent emits `{type:'choice_request', request_id, choice_data:{question, options[], _type, allow_multiple}}` over `/ws/agent` (`conversation.py:617`, `bridge.stream_response:648`); the client replies `{type:'choice_response', request_id, selected}` (`agent_ws.py:769`). "One payload, two renderers" = add a `_kind` discriminator + factor `agent-chat.js renderChoice` (L356-390) into a pure `buildAskCard()` + a second mount. **Not a protocol rewrite.**
+- **There's already an inference-first precedent**: `bridge.bootstrap_resolution_picker` builds inferred pickers in-memory from candidates without persisting. Plan-mode's draft-first flow models on it.
+- **One init chokepoint**: `switchTab(name)` (`app.js:60-103`) is the *only* caller of every tab's manager init. The new shell **calls** it for each region reveal — never reimplements activation.
+- **Two separate sockets**: `/ws` (telemetry + server `EventBus` fan-out) and `/ws/agent` (chat + asks). They have different lifecycles — the context surface (Phase 4) rides `/ws`, the ask-stage rides `/ws/agent`.
+
+## Coexistence (how old + new run side by side)
+
+A Jinja2 flag: `pages.py GET /` passes `ux_v2` (new `GENTLY_UX_V2` setting, default off) into `index.html`. The template keeps **both** the current 8-tab markup and the new grouped-rail markup, mutually exclusive via `{% if ux_v2 %}` + a `body.ux-v2` class. New JS modules (`status-store.js`, `ask-stage.js`, `shell.js`, `context-surface.js`) load on every page but **no-op without `body.ux-v2`**, so they can't regress v1. Same URL, same shell, same uvicorn process. Flip default-on after a soak (Phase 6), then delete v1 markup. Both UIs read the same state objects and sockets, so v1/v2 can be compared side-by-side on identical live data.
+
+> CSS hazard: `main.css` has **duplicate** `.tab`/`.tab-content`/`.status-dot` rulesets (~L547 and ~L2892). Consolidate or strictly scope v2 under `body.ux-v2` **before** Phase 2 touches nav CSS.
+
+## Phase sequence
+
+| # | Phase | Ships | Flag | Depends |
+|---|-------|-------|------|---------|
+| 0 | Bug-fix beachhead: sticky status store + idle-telemetry | Status unification + quiet idle channel, **to prod now** | none | — |
+| 1 | Dual-render the ask protocol + correct clear-signal | The paradigm enabler | ux_v2 | 0 |
+| 2 | Shell unfold + grouped nav + session-context strip | The calm welcome→workspace | ux_v2 | 1 |
+| 3a | Inference-first plan mode **backend** (headless) | Draft-from-strain + per-field provenance | — | 2 |
+| 3b | Inference-first plan mode **UI** (`plan_confirm` renderer) | Draft renders with provenance | ux_v2 | 3a |
+| 4 | Co-editable FileContextStore surface + proactive cards | Shared visibility (beliefs/attention/uncertainty) | ux_v2 | 3b |
+| 5 | Carve per-embryo tactical Experiment view out of `embryos.js` | The tactical view mount (contents TBD) | ux_v2 | 4 |
+| 6 | Default-on flip + v1 deletion | Irreversible cutover, isolated/soaked | flip | 5 |
+
+## Phase detail
+
+### Phase 0 — Bug-fix beachhead (no flag, pure value)
+- **Single sticky/replaying `ConnectionStatus` store** (`status-store.js`) holding `{gentlyConnected, microscopeConnected, agentConnected}` and emitting `CONNECTION_STATUS`. Must **replay last state to late subscribers** or bug #1 just moves.
+- **Bug #1**: header pill (`updateTopLevelDot` `app.js:562`), home line (`home.js updateStatus:136` — today reads `state.connected` once, the literal "Offline while connected" bug), and dock dot all **subscribe** and re-render on every event.
+- **Bug #3 (measure first)**: code shows **15s** polls (not 1-2s); the real idle cost is likely the ~5Hz `DEVICE_STATE_UPDATE` WS stream. **Ship unconditionally:** decouple `#events-count` from `DEVICE_STATE_UPDATE`/`BOTTOM_CAMERA_FRAME`. **Gate polls only if** measurement implicates them; if it's the SSE stream, coalesce/backoff in `device_state_monitor.py`. Gate on a stable `DevicesManager.active` flag, **not** switchTab internals (Phase 2 rewrites those).
+- **Bug #2**: capture the prototype's correct multi-select contract (Continue mounts with disabled state *derived from current selection*) for `buildAskCard`.
+- Files: `status-store.js` (new), `websocket.js`, `app.js`, `home.js`, `agent-chat.js`, `devices.js`, `events.js`, `index.html`.
+- Verify: cold + reload + kill each socket independently — all three indicators agree within one handshake; kill device layer → microscope badge flips in 15s, gently stays Online.
+
+### Phase 1 — Dual-render the ask protocol (the enabler)
+- `_kind` always-present discriminator + additive `_surface` ∈ {transcript,stage,both} (default transcript so the Ink TUI/older clients are unaffected). Set on each payload the bridge builds.
+- Factor `renderChoice` → pure `buildAskCard()` + a module-level `answered` Set keyed by **opaque** `request_id` (never parse a prefix — ids mix `req_N` and `resolve_*_`). New `ask-stage.js` renders the same card into `#ask-stage`.
+- **BLOCKER fixed — clear signal**: fire `ASK_CLEARED` the instant a `choice_response` is sent (plus on cancel/error/control-loss/socket-close), **not** on `stream_end` — an in-turn ask suspends on `asend` (`bridge.py:657`) and `stream_end` only arrives *after* the answer; a cancelled turn emits none.
+- **BLOCKER fixed — dismiss vs control**: `agent_ws.py:772` silently drops non-holder responses. Stage renders read-only when `!hasControl`; only the holder answers/dismisses; a holder's escape posts a real empty `choice_response` so the turn-lock releases.
+- **BLOCKER fixed — leak cleanup**: pop orphaned `_choice_futures` on holder-change and answerer-disconnect, not only last-client (`agent_ws.py:889`).
+- Add the **free-text "Something else"** affordance web cards lack today (`bridge._dispatch_resolution_pick:462` already routes unknown selections to LLM resolution).
+- Verify: trigger the session-open bootstrap picker — appears in chat **and** `#ask-stage`; answering either clears both; cancel a turn mid-ask → stage clears and next turn works; lose control mid-ask → stage goes read-only.
+
+### Phase 2 — Shell unfold + grouped nav + session strip
+- `shell.js` screen-state machine sets `body[data-screen]` (welcome|plan|standalone|shell) and **calls `switchTab()`** for every reveal (+ a dev assertion if a region shows without its init).
+- Grouped left rail (Now / Library / System) → each item maps to an existing `data-tab` id via `switchTab`. Session-context strip reuses `ExperimentStrip` (fix stale `switchTab('tasks')` at `experiment-strip.js:176`), fed by `/api/experiments/current/strategy`, reading live status from the Phase 0 store.
+- **Real routing**: replace the consume-once hash (`app.js:633` `replaceState` to `/`) with deliberate URL/state sync so refresh/back-button + the `/review`→`/#sessions` redirects resolve correctly.
+- Decouple welcome→plan from the brittle `togglePanel(true)+setTimeout(250ms,'/wizard')` (`home.js:159`): the picker renders in `#ask-stage` on connect, driven by the bootstrap `choice_request`.
+- **Resume**: replace the `window.location.href='/'` hard reload on `session_changed` (`websocket.js:147`, `review.js:108`) with in-place re-hydration (jarring otherwise).
+- Verify: cold load → calm welcome; choosing a plan unfolds (no hard cut); each rail item fires its manager's init side-effect (verify the side-effect, not just visibility).
+
+### Phase 3a — Inference-first plan backend (headless-testable)
+- Flip the plan-mode prompt from ask-first to **infer-first** (`plan_mode_system_prompt.tex`, `harness/plan_mode/prompt.py`): arrive with a draft, ask only for genuine gaps / low-confidence / consequential confirmations.
+- Deterministic **strain→channel** inference in `research.py`: parse genotype from `search_strains` (TagRFP→561, GFP→488), attach source ref. **Must degrade to "confirm" — never fabricate a wavelength.** Network-dependent (WormBase REST + CGC scraping), so the degrade path is load-bearing.
+- **Per-field provenance** (`model.py:186`): `ImagingSpec` fields are flat scalars with no per-field source (`references[]` is on `PlanItem`, not the spec). Add a parallel `{field → {source, confidence, citation}}` map; reuse the existing `Confidence` enum.
+- **Drafts stay in-memory** (like `bootstrap_resolution_picker`), materialized via `create_campaign`/`create_plan_item` only on explicit confirm — avoids a `PlanItemStatus` enum change *and* orphan-folder cleanup.
+- Use `gap_assessment.assess_gaps()` to *select* which gaps to ask (don't re-enable the deliberately-disabled multi-question wizard; note `conversation_weight` short-circuits after onboarding).
+- Verify (no UI): known strain → draft with channels pre-filled + per-field source/confidence; unknown/offline → "confirm channel", never fabricated; reject → no folder written; confirm → materializes and `GET /api/campaigns/{id}/document` returns the tree.
+
+### Phase 3b — Inference-first plan UI
+- `_kind:'plan_confirm'` ask: bridge emits the in-memory draft as `choice_data`; `ask-stage.js` renders cards with per-field source tags + confidence + edit affordances; chat shows a **compact reference line** (not a duplicate) so the surfaces can't drift.
+- Extend `renderSpec` (`agent-chat.js:403`, today a flat key→value table) with a **source column**.
+- Confirm posts the same `choice_response`; bridge materializes; stage clears via `ASK_CLEARED`.
+
+### Phase 4 — Co-editable context surface + proactive cards
+- **BLOCKER fixed — real store change**: `FileContextStore` has no event bus. (1) add `CONTEXT_UPDATED` to the closed `EventType` enum (`core/event_bus.py`); (2) inject a bus/callback into the store; (3) emit a **single coalesced** event from each mutator (`add_expectation:1880`, `add_watchpoint:1933`, `add_question:1980`, `update_embryo_understanding:2049`, …); (4) wire in `launch_gently.py:497`.
+- New `routes/context.py`: **read** side models on `campaigns.py` (`_serialize`); **write** side uses `Depends(require_control)` from `data.py`/`sessions.py` (NOT the `campaigns.py` mesh/account auth) so a viewer can't mutate the agent's mind.
+- Live updates over `/ws` (telemetry socket) → `websocket.js:117` → `context-surface.js`. **Push on change, never poll** (`load_active` scans ~50 observations + YAML).
+- `context-surface.js` renders beliefs/attention/uncertainty as a calm panel in the Now region with inline edit/resolve (disabled when `!hasControl`).
+- **Proactive cards**: wire watchpoint/question creation + the existing wake-router `origin:'wake'` approvals (`agent.py:1079`) to surface prominent `#ask-stage` cards — real backing for the prototype's attention card, no new mechanism.
+
+### Phase 5 — Carve the tactical Experiment view (behind the flag, before the flip)
+- Make Experiment a distinct renderer over `EmbryosManager.state` + the strategy snapshot rather than overloading the 4556-line `embryos.js`. **Preserve `reconcileWithServerState`/`clearAllState` as the contract.** Don't over-specify contents yet — it's a mount point.
+- **Remove the `STUB_STRATEGY` fallback** (`experiment-overview.js:14` + the "mockup · stubbed data" badge) → real loading/empty state. Production must never render stubs.
+- Stays behind the flag through its own soak so a reconciliation regression is caught before the irreversible flip.
+
+### Phase 6 — Default-on flip + v1 cleanup (irreversible, isolated)
+- Flip `GENTLY_UX_V2` default-on after soak; delete v1 `{% else %}` nav markup, superseded v1 status writers, and the dead/duplicate `.tab` CSS. Isolated from Phase 5 so the high-regression carve-out never coincides with deletion.
+
+## Blockers the adversarial pass caught (now folded in)
+1. **Clear signal must follow the choice lifecycle, not the stream lifecycle** (asend suspension; cancelled turns emit no `stream_end`).
+2. **Dismiss vs control gate** — non-holder responses are silently dropped; only the holder dismisses; server cleans orphaned futures on holder-change/disconnect.
+3. **Phase 4 store change is real, not free** — new `EventType`, bus injection, coalesced emit from every mutator, launch wiring.
+4. Two answer paths (`_choice_futures` vs bridge-owned `_pending_import`) → client-authoritative `answered` Set + bridge idempotency guard.
+5. `switchTab` is the sole init chokepoint → shell calls it, never reimplements.
+6. Per-field provenance doesn't exist today → added in 3a.
+7. Phase 3 split into headless backend (3a) + UI (3b); embryos.js carve-out isolated in Phase 5.
+
+## Open decisions (yours)
+- **Measure bug #3 first**: is idle chatter the ~5Hz `DEVICE_STATE_UPDATE` stream or the 15s polls? Determines the lever (coalesce in `device_state_monitor.py` vs gate polls).
+- **Status**: client-computed sticky store (chosen for Phase 0) vs a single server-emitted status object over `/ws`.
+- **Routing**: History API vs hash-fragment for region state (keeping the `/review`→`/#sessions` redirects working without reload).
+- **Co-edit concurrency**: optimistic last-write-wins vs per-item version/lock, given the agent mutates the same YAML.
+- **Slash-command demotion**: re-render `/status`,`/embryos` rich content as affordances vs button-per-command.
+- **Per-field provenance schema**: parallel map on `ImagingSpec` vs sibling dataclass vs extending `PlanItem.references[]`.
+- **Experiment tactical view contents** — deferred to Phase 5 design.
diff --git a/ux-prototype/landing.html b/ux-prototype/landing.html
new file mode 100644
index 00000000..59e7bd94
--- /dev/null
+++ b/ux-prototype/landing.html
@@ -0,0 +1,674 @@
+
+
+
+
+
+Gently — entry paradigm sketch
+
+
+
+
+
+
Gently
+
+
+ Scope ready · 36.9 °C · stage idle
+
+
+
+
+
+
+
+
+
Good evening. What are we doing today?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Gently
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
The plan
+
assembling from your choices
+
+
Nothing yet — pick above and watch it fill in.
+
+
+
+
+
+
+
+
+
+
+
Quick look
+
I'll take one careful volume right where the stage is now — nothing scheduled, nothing committed. (We'll design this surface next — it's a stub for now.)
+
+
+
+
+
+
+
+
+ LIVE
+ run
+
+ 0h 12m elapsed · next 1:43
+
+
+
+
+
+
+
⚠ Needs you
+
Embryo 3 has been quiet for 40 min — past its expected division window. Keep waiting, or flag it for you?
+
+
+
+
+
+
+
+
Embryos · 3 tracked
representative — the embryo-wise tactical view is yours to define