diff --git a/.gitignore b/.gitignore index 935470d1..f9a50443 100644 --- a/.gitignore +++ b/.gitignore @@ -146,5 +146,6 @@ electron/ gently/ui/tui/node_modules/ gently/ui/tui/dist/ -# Runtime storage accidentally created on Linux when GENTLY_STORAGE_PATH="D:/" resolves literally -D:/ +# Stray local storage: GENTLY_STORAGE_PATH default (D:\Gently3) resolves +# literally to ./D:/ under the repo on Linux. Not data we track. +/D:/ diff --git a/docs/HEURISTICS-AUDIT.md b/docs/HEURISTICS-AUDIT.md new file mode 100644 index 00000000..249b27cc --- /dev/null +++ b/docs/HEURISTICS-AUDIT.md @@ -0,0 +1,112 @@ +# Heuristics audit — where to use the model (as a typed-output function) instead + +Codebase sweep (5 parallel scanners + synthesis) for heuristics that **fake +judgment** an LLM would do better — in the spirit of the genotype→channel +refactor (drop the lookup table, let the model infer, keep a typed provenance +record + confirm-when-unsure). The flip side — logic that **must stay +deterministic** (safety, math, calibration, transport) — is listed at the end so +we don't mistakenly LLM-ify it. + +The unifying move for every candidate: **LLM with a typed structured-output +schema + provenance + a confirm/UNCERTAIN escape**, never free-text-then-parse. + +## Model candidates (ranked) + +### High value + +1. **Hatching / time-to-stage prediction** — `organisms/celegans/developmental_tracker.py` + *(the closest twin of genotype→channel; medium effort)* + Three hardcoded 20 °C lookup tables (`STAGE_TIMING_20C`, `TIME_TO_HATCHING`, + `TIMING_VARIABILITY`) plus magic `{HIGH:1.0, MEDIUM:1.5, LOW:2.0}` uncertainty + fudge factors. Structurally **can't use the rig's actual temperature** (we run + a TEC), the strain, or the embryo's observed progression rate. Let the model + produce a calibrated, explained interval; **keep the literature table as a + deterministic sanity bracket** and flag when the estimate falls outside it. + → `{ predicted_minutes_to_hatching, low, high, basis, assumptions{temperature_c,strain,used_observed_rate}, confidence, reasoning }` + +2. **Citation → PubMed query** — `harness/plan_mode/tools/research.py` (`_search_pmid`) + A regex that only handles "Surname et al YEAR …" + six hand-rolled query- + relaxation strategies + a stopword/word-position ladder that drops load-bearing + nouns. The model parses the sloppy citation and proposes relaxed queries; **code + keeps the deterministic esearch call and never fabricates a PMID.** + → `{ author_last, year, journal, topic_keywords[], organism, pubmed_query, alt_queries[], confidence }` + +3. **Lab-history retrieval** — `harness/plan_mode/tools/lab_context.py`, `harness/memory/interface.py` + Semantic recall faked by substring-OR over query tokens (matches "we"/"before", + misses every paraphrase). Feed the model the candidate records and have it + **rank/select from provided ids only** (no fabrication). Read-only, no + acquisition risk. + → `{ matches:[{kind,id,summary,relevance,why_relevant}], answer }` + +4. **Stage-label parse via 22-entry synonym dict** — `developmental_tracker.py` (`_parse_stage_name`) + *(small effort, pure robustness win)* The Vision call already classifies; the + brittleness is a plain-text `STAGE:/CONFIDENCE:` block scraped line-by-line, with + off-vocabulary phrasings silently collapsing to `UNKNOWN` (which kills the + downstream hatching prediction). Constrained-enum structured output deletes the + parser + synonym table. + → `{ stage: enum(...), confidence: enum(high|medium|low), is_transitional, reasoning }` + +### Medium value (mostly small — fix the output contract, not the judgment) + +5. **Calibration Vision calls** — `hardware/dispim/claude_client.py` + Four Vision calls return positional free text recovered by `'yes' in first_line` + / `re.search(r'\d+')` / first-valid-letter, with silent defaults (so "no, this is + not yes…" reads as *yes*). Typed output deletes the parse + silent-default layer. + +6. **ML architecture ranking** — `ml/architectures.py` (`get_suitable_architectures`) + Hard feasibility gates (VRAM / dataset) are correct **and stay**; the `+2/+1/+1` + point-score ranking that follows discards the per-arch prose. Let the model rank + the *pre-filtered feasible set* (ids constrained to that set). + +7. **Training label normalization** — `ml/data_loader.py` (`build_labels_from_store`) + Class space built by exact-string identity over free-text human annotations — + "1.5-fold" and "1.5 fold" become different classes. Model normalizes to the + canonical staging vocabulary, flags novel/ambiguous ones. + +### Lower value + +8. **"Plan has a control?"** — `plan_mode/tools/validation.py` — substring scan of a + 6-word keyword set; a scientific judgment over the whole plan. Non-blocking + warning → safe for the model. +9. **CGC HTML scraping** — `research.py` (`_cgc_search`) — positional multi-group + regex over fetched HTML; structured extraction the model does better (HTTP GET + stays code; **mark strain names low-confidence to avoid sending someone to order + a hallucinated strain**). + +### Cross-cutting batch (small each): typed output for the detector/verifier cluster +`harness/detection/verifier.py`, `app/detectors/hatching.py`, +`app/detectors/dopaminergic_signal.py`, `hardware/dispim/sam_detection.py` — all +already make the right model call but reconstruct the verdict via +`startswith`/regex-JSON-scraping with silent defaults. A batch move to native +structured output **strictly reduces parse-induced false negatives** without +touching the deterministic vote-tally/consensus/enum-dispatch downstream. + +**Reference implementations already in the repo (imitate, don't change):** +`dopaminergic_signal`'s perceiver→classifier rubric (typed enums, UNCERTAIN +escape, conservative-on-tie) and onboarding's `_extract_with_llm` (typed +extraction, degrade-to-verbatim fallback). + +## Keep deterministic (do NOT LLM-ify) +Safety, math, calibration, and transport — where a hallucinated value is unsafe +or breaks reproducibility: +- Laser-power safety limits + wavelength→MM-property map (`hardware/dispim/devices/optical.py`) +- SPIM trigger-timing arithmetic, piezo–galvo calibration, MM framing (`dispim/config.py`) +- Calibration prior EMA + R²≥0.75 slope-lock gate (`dispim/calibration.py`) +- SwitchBot GATT byte commands / status decoding (`hardware/switchbot.py`) +- Temperature setpoint bound [0,99.9] °C + stabilization I/O (`hardware/temperature.py`) +- Autofocus signal-processing, curve fitting, adaptive-sweep stop rules (`analysis/core.py`, `analysis/focus.py`) +- Classical-CV ROI detection + pixel→stage coordinate transforms (`detection.py`, `sam_detection.py` geometry) +- Timelapse rule dispatch + `confirm_timepoints` debounce + monotonic power ramp (`app/orchestration/timelapse.py`) +- Volume→b64 dark/flat calibration + fixed brightness scaling (`dopaminergic_signal._volume_to_b64` — deliberately non-adaptive) +- Wake-router debounce/throttle/stage-transition gate (`app/wake_router.py`) +- Plan hardware limits, detector-preset membership, dependency-cycle DFS, stage-order normalization (`plan_mode/tools/validation.py`) +- Ensemble vote tally + 0.70 quorum / unanimity consensus (`detection/verifier.py`) +- ML metric/aggregation math: confusion matrix, F1, federated averaging (`ml/evaluation.py`, `federated.py`) +- Core imaging geometry (max-projection, crop bounds, Euler rotations) + UI event reduction/routing/security (`core/imaging.py`, `ui/web/*`) +- Device-state SSE watchdog/staleness timers (`app/device_state_monitor.py`) +- Reference-type dispatch (PMID/DOI/URL by canonical syntax), `os.path.isfile` checks (`research.py`) + +## Note +`gap_assessment.conversation_weight` (the 0.25/0.1/0.05 readiness scalar) is now +largely **vestigial** — it only returns 'heavy' (lab onboarding) or 'none' — so +it's not worth an API call. Left off the candidate list. diff --git a/docs/superpowers/plans/2026-06-16-notebook-foundation.md b/docs/superpowers/plans/2026-06-16-notebook-foundation.md new file mode 100644 index 00000000..4d6a480c --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-foundation.md @@ -0,0 +1,645 @@ +# Notebook Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the unit-testable foundation of the shared lab notebook — the unified `Note` model and a file-backed `NotebookStore` (write / read / scope-query / rebuildable reverse-indexes / link & supersede) — with no UI, API, or agent wiring. + +**Architecture:** A new self-contained module `gently/harness/memory/notebook.py`. One `Note` dataclass (three kinds: observation/finding/question) with author, status, confidence, scope facets (strains/embryos/sessions/threads), typed links, basis, and artifact pointers — orthogonal fields, not subtypes. `NotebookStore` persists one YAML per note under `notebook/notes/{id}_{slug}.yaml` (atomic write, mirroring `FileContextStore`), maintains rebuildable reverse-indexes by strain/embryo/thread, and answers scope+kind+status queries. This is Increment 1's keystone from the design doc (`docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md`). + +**Tech Stack:** Python 3.11, dataclasses, `str`-Enums, PyYAML, pytest (fixtures in `tests/conftest.py`, flat `tests/` layout). + +**Follow-on plans (NOT in scope here):** producer wiring (`apply_updates` → notebook), `/api/notebook` + Notebook tab (UI), retrieval/embeddings + brainstorm. Each ships on top of this foundation. + +--- + +### Task 1: The `Note` model + +**Files:** +- Create: `gently/harness/memory/notebook.py` +- Test: `tests/test_notebook_store.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_notebook_store.py +"""Tests for the shared lab notebook: Note model + NotebookStore.""" + +from datetime import datetime + +from gently.harness.memory.model import Confidence +from gently.harness.memory.notebook import ( + Author, + Note, + NoteKind, + NoteStatus, + note_from_dict, + note_to_dict, +) + + +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" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNoteModel -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'gently.harness.memory.notebook'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/harness/memory/notebook.py +""" +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 + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any + +from .model import Confidence + + +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"]), + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNoteModel -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): unified Note model + dict serialization" +``` + +--- + +### Task 2: `NotebookStore` — write & read a note + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (append `NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +from gently.harness.memory.notebook import NotebookStore + + +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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookStoreReadWrite -v` +Expected: FAIL — `ImportError: cannot import name 'NotebookStore'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# append to gently/harness/memory/notebook.py +import copy +import os +import re +import uuid +from pathlib import Path + +import yaml + + +class NotebookStore: + """File-backed store for notebook Notes. One YAML per note under notes/; + flat pool, rebuildable reverse-indexes (added in Task 3).""" + + 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) + + # ---- 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)) + 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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookStoreReadWrite -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): NotebookStore write_note/get_note with atomic YAML" +``` + +--- + +### Task 3: Reverse-indexes (by strain / embryo / thread) + rebuild + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (`NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +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] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookIndex -v` +Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'ids_for_strain'` + +- [ ] **Step 3: Write minimal implementation** + +Modify `NotebookStore.__init__` to add index state + rebuild, extend `write_note` to update the index, and add the index methods. Replace the existing `__init__` and `write_note` with these versions and add the new methods: + +```python + # --- replace __init__ --- + 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() + + # --- add: facet extraction + index maintenance --- + _FACETS = {"strain": "strains", "embryo": "embryos", "thread": "threads"} + + 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/ (the notes are authoritative; + indexes are disposable caches).""" + 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, [])) +``` + +Then add an index-update at the end of `write_note` (just before `return note.id`): + +```python + self._index_note(note) + return note.id +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookIndex -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): rebuildable reverse-indexes by strain/embryo/thread" +``` + +--- + +### Task 4: `query_notes` — filter by kind / author / status / scope + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (`NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookQuery -v` +Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'query_notes'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to NotebookStore + 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))) + if scope_sets: + candidate_ids: set[str] | None = set.intersection(*scope_sets) + else: + candidate_ids = None # means "all" + + # 2. load + filter + results: list[Note] = [] + 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 + ] + 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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookQuery -v` +Expected: PASS (5 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): query_notes by kind/author/status/scope" +``` + +--- + +### Task 5: `link_notes` and `supersede_note` + +**Files:** +- Modify: `gently/harness/memory/notebook.py` (`NotebookStore`) +- Test: `tests/test_notebook_store.py` (append) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_notebook_store.py +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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_notebook_store.py::TestNotebookLinkSupersede -v` +Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'link_notes'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# add to NotebookStore + 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) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_notebook_store.py -v` +Expected: PASS (all tests across all classes pass) + +- [ ] **Step 5: Commit** + +```bash +git add gently/harness/memory/notebook.py tests/test_notebook_store.py +git commit -m "feat(notebook): link_notes + supersede_note (append-only history)" +``` + +--- + +## Self-Review + +**Spec coverage (against the design doc §2 data model):** +- Three kinds (Observation/Finding/Question) → Task 1 `NoteKind`. ✓ +- Orthogonal fields (author/status/confidence/scope/links/artifacts) → Task 1 `Note`. ✓ +- Flat note pool + rebuildable reverse-indexes (strain/embryo/thread) → Tasks 2-3. ✓ +- "By question + by strain + links coexist over flat YAML, no DB" → Tasks 3-4 (indexes + scope-intersect query). ✓ +- Append-only / supersede-never-overwrite → Task 5 `supersede_note`. ✓ +- Typed links graph → Tasks 1 (`links`) + 5 (`link_notes`). ✓ +- *Deferred to follow-on plans (correctly out of scope):* inquiry-thread object, working-memory split, producer wiring, API/tab, embeddings/retrieval, consolidation. Noted in header. + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; commands have expected output. ✓ + +**Type consistency:** `Note`, `NoteKind`, `Author`, `NoteStatus`, `note_to_dict`/`note_from_dict`, and `NotebookStore.{write_note,get_note,rebuild_index,ids_for_strain,ids_for_embryo,ids_for_thread,query_notes,link_notes,supersede_note}` are named identically across all tasks and tests. `Confidence` is imported from `.model` (confirmed to exist). ✓ diff --git a/docs/superpowers/plans/2026-06-16-notebook-live-edge.md b/docs/superpowers/plans/2026-06-16-notebook-live-edge.md new file mode 100644 index 00000000..9265d769 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-notebook-live-edge.md @@ -0,0 +1,188 @@ +# Notebook Live Edge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the Home "Agent's view" panel surface recent notebook activity — the ambient "live edge" of the notebook (design §3, two-faced presentation), complementing the Notebook tab (the reading room). + +**Architecture:** Add an optional `limit` to the notes read API (TDD). Extend `context-surface.js` to also fetch recent notes and render a "From the notebook" section whose rows click through to the Notebook tab. Reuse the existing `cx-dot` colors (amber/blue/green) for kinds — no new CSS. + +**Tech Stack:** FastAPI, pytest + TestClient (venv), vanilla JS. + +**Out of scope:** retrieval/"Ask the notebook" (next increment); proactive surfacing. + +--- + +### Task 1: `limit` param on `GET /api/notebook/notes` + +**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 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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_notebook_api.py::TestLimit -v` +Expected: FAIL — returns 2 notes, not 1. + +- [ ] **Step 3: Write minimal implementation** + +In `gently/ui/web/routes/notebook.py`, add a `limit` param to `list_notes` and slice. Replace the `list_notes` signature and the final return: + +```python + @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]} +``` + +- [ ] **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): limit param on GET /api/notebook/notes" +``` + +--- + +### Task 2: "From the notebook" section in the Agent's-view panel + +**Files:** +- Modify: `gently/ui/web/static/js/context-surface.js` + +Verification is browser-based (chrome-devtools), not pytest. + +- [ ] **Step 1: Extend `fetchAndRender` to also pull recent notes** + +Replace `fetchAndRender` with a version that fetches both endpoints and passes notes to `render`: + +```javascript + 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; } + } +``` + +- [ ] **Step 2: Render the notebook section** + +Replace `render(data)` with `render(data, notes)`. Add the notebook section and include notes in the empty-state check. Replace the whole `render` function body: + +```javascript + 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) { + el.innerHTML = '
Agent’s view
' + + '
Nothing yet — the agent’s notes, expectations, and open questions appear here as it works.
'; + return; + } + + const qHtml = questions.map(it => ` +
+ + ${esc(it.content)} + ${hc ? '' : ''} + ${hc ? '' : ''} +
`).join(''); + const wHtml = watchpoints.map(it => ` +
+ + ${esc(it.target)}${it.condition ? ' — ' + esc(it.condition) : ''} + ${hc ? '' : ''} +
`).join(''); + const eHtml = expectations.map(it => ` +
+ + ${esc(it.target)}${it.prediction ? ': ' + esc(it.prediction) : ''} + ${hc ? '' : ''} +
`).join(''); + // kind → existing cx-dot color: observation=blue, finding=green, question=amber + const dotFor = (k) => k === 'finding' ? 'cx-e' : (k === 'question' ? 'cx-q' : 'cx-w'); + const nHtml = notes.map(n => ` +
+ + ${esc(n.title || n.body)} +
`).join(''); + + el.innerHTML = '
Agent’s view
' + + 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 `
${title}
${content}
`; +function section(title, content, headerAction) { + const extra = headerAction ? `${headerAction}` : ''; + const titleCls = headerAction ? 'detail-section-title detail-section-title--row' : 'detail-section-title'; + return `
${title}${extra}
${content}
`; } 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.
'; + return; + } + + const qHtml = questions.map(it => ` +
+ + ${esc(it.content)} + ${hc ? '' : ''} + ${hc ? '' : ''} +
`).join(''); + const wHtml = watchpoints.map(it => ` +
+ + ${esc(it.target)}${it.condition ? ' — ' + esc(it.condition) : ''} + ${hc ? '' : ''} +
`).join(''); + const eHtml = expectations.map(it => ` +
+ + ${esc(it.target)}${it.prediction ? ': ' + esc(it.prediction) : ''} + ${hc ? '' : ''} +
`).join(''); + + // kind → existing cx-dot color: observation=blue, finding=green, question=amber + const dotFor = (k) => k === 'finding' ? 'cx-e' : (k === 'question' ? 'cx-q' : 'cx-w'); + const nHtml = notes.map(n => ` +
+ + ${esc(n.title || n.body)} +
`).join(''); + + el.innerHTML = '
Agent’s view
' + + section('Open questions', qHtml) + section('Watching', wHtml) + + section('Expectations', eHtml) + section('From the notebook', nHtml); + wire(); + } + + function wire() { + el.querySelectorAll('.cx-item').forEach(item => { + const kind = item.dataset.kind, id = item.dataset.id; + const actBtn = item.querySelector('.cx-act'); + if (!actBtn) return; + const act = actBtn.dataset.act; + if (act === 'answer') { + const box = item.querySelector('.cx-answer'); + const input = item.querySelector('.cx-answer-input'); + const submit = () => resolve(kind, id, { resolution: input.value.trim() }); + actBtn.addEventListener('click', () => { box.classList.toggle('hidden'); if (!box.classList.contains('hidden')) input.focus(); }); + item.querySelector('.cx-answer-go').addEventListener('click', submit); + input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }); + } else if (act === 'resolve') { + actBtn.addEventListener('click', () => resolve(kind, id, {})); + } else if (act === 'confirm') { + actBtn.addEventListener('click', () => resolve(kind, id, { status: 'confirmed' })); + } + }); + el.querySelectorAll('.cx-note').forEach(row => { + row.style.cursor = 'pointer'; + row.addEventListener('click', () => { + if (typeof switchTab === 'function') switchTab('notebook'); + }); + }); + } + + async function resolve(kind, id, body) { + try { + await fetch(`/api/context/${kind}/${encodeURIComponent(id)}/resolve`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), + }); + } catch (e) { /* ignore; surface stays as-is */ } + fetchAndRender(); // CONTEXT_UPDATED also re-fetches for every client + } + + function init() { + el = document.getElementById('context-surface'); + if (!el) return; // ux_v2 off → no-op + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('CONTEXT_UPDATED', () => fetchAndRender()); + ClientEventBus.on('AGENT_CONTROL', () => fetchAndRender()); // re-render with/without resolve controls + } + fetchAndRender(); + } + + document.addEventListener('DOMContentLoaded', init); + return { refresh: fetchAndRender }; +})(); diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index caa1bf48..040b11d1 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -14,7 +14,7 @@ */ const DevicesManager = (function () { const STALE_AFTER_MS = 4000; - const VIEWS = ['map', 'details']; + const VIEWS = ['map', 'details', 'optical3d']; const SVG_NS = 'http://www.w3.org/2000/svg'; // Status / details DOM @@ -1500,6 +1500,12 @@ const DevicesManager = (function () { if (typeof updateViewButtons === 'function') { updateViewButtons('devices-view-switcher', viewName); } + // The 3D optical-space view owns its own WebGL module. Build it lazily + // on first activation (the panel was display:none, so its container had + // no size until now); init() is idempotent and resizes on re-entry. + if (viewName === 'optical3d' && typeof Occupancy3DManager !== 'undefined') { + Occupancy3DManager.init(); + } } function setupViewSwitcher() { @@ -1511,6 +1517,7 @@ const DevicesManager = (function () { if (e.target.matches('input, textarea, select, [contenteditable]')) return; if (e.key === 'm') { e.preventDefault(); switchView('map'); } else if (e.key === 'd') { e.preventDefault(); switchView('details'); } + else if (e.key === 'v') { e.preventDefault(); switchView('optical3d'); } }); } diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 33032a40..9e7fc221 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -1,147 +1,12 @@ /** - * Experiment Overview Tab — vector-graphics view of the planned timelapse. + * Experiment Overview Tab — vector-graphics view of the live imaging tactics + * (cadence patterns + reactive-monitoring rules) for the running experiment. * - * Data source priority: - * 1. GET /api/experiments/current/strategy — live snapshot from FileStore. - * 2. STUB_STRATEGY below — used when the live fetch - * fails or no session exists. - * - * The render path is data-shape-driven and doesn't care which source the - * snapshot came from — only ``ExperimentOverview.isLive`` differs so the - * header badge can say "live" or "mockup · stubbed data". + * Data source: GET /api/experiments/current/strategy — the live snapshot from + * FileStore. When there is no active experiment (or the fetch isn't ready), the + * view shows a calm empty state; it never renders stubbed/mock data. */ -const STUB_STRATEGY = { - session_id: "20260522_1430_dopaminergic_demo_a3f8e1c2", - session_name: "dopaminergic-reporter demo", - started_at: "2026-05-22T14:30:00", - now_offset_s: 8100, // 2h 15min into the run - horizon_s: 14400, // 4h total view window (past + projected) - base_interval_s: 120, - dose_budget_base_ms: 50000, - per_timepoint_ms: 500, // 50 slices × 10ms - monitoring_modes: [ - { - name: "expression_monitoring", - description: "Anticipating fluorescent-reporter onset on Test embryos: accelerate to 60s on signal, ramp 488 down on saturation.", - applies_to_roles: ["test"], - params: { - fast_interval: 60, - rampdown_step_pct: 1.0, - rampdown_floor_pct: 2.0, - rampdown_ceiling_pct: 6.0 - } - }, - { - name: "pre_terminal_monitoring", - description: "Anticipating organism pre-terminal stage (pretzel): accelerate to 30s on detection.", - applies_to_roles: ["test"], - params: { fast_interval: 30 } - } - ], - triggers: [ - { id: "t1", kind: "interval_rule", label: "signal onset", - when_text: "dopaminergic ≥ WEAK", then_text: "120s → 60s", - applies_to: ["test"], one_time: true }, - { id: "t2", kind: "power_rule", label: "488 ramp down", - when_text: "intensity = SATURATING (×3)", then_text: "488 ↓ 1%/step, floor 2%", - applies_to: ["test"] }, - { id: "t3", kind: "burst", label: "structure-triggered burst", - when_text: "structure_quality = GOOD", then_text: "burst 200 frames @ 20 Hz", - applies_to: ["test"] }, - { id: "t4", kind: "interval_rule", label: "pre-terminal speedup", - when_text: "stage = pretzel", then_text: "60s → 30s", - applies_to: ["test"], one_time: true } - ], - embryos: [ - { - id: "E1", role: "test", color: "#ff66cc", icon: "★", - dose_used_ms: 12500, dose_budget_ms: 50000, - tp_acquired: 25, - stop_condition: "hatching+3 OR 24h duration", - stop_kind: "bounded", - laser_488_pct_now: 3.0, - phases: [ - { mode: "base", start: 0, end: 1800, cadence_s: 120 }, - { mode: "fast", start: 1800, end: 3600, cadence_s: 60 }, - { mode: "burst", start: 3600, end: 3610, frames: 200, hz: 20 }, - { mode: "cooldown", start: 3610, end: 3640, cadence_s: 60 }, - { mode: "fast", start: 3640, end: 8100, cadence_s: 60 } - ], - trigger_events: [ - { trigger_id: "t1", at: 1800 }, - { trigger_id: "t3", at: 3600 }, - { trigger_id: "t2", at: 5400, count: 3 } - ], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 5400, pct: 4.0 }, - { at: 5460, pct: 3.0 }, - { at: 8100, pct: 3.0 } - ], - // Future projection at current cadence (60s, fast). Hatching not - // deterministic so projected_end_s is null — render fades to ∞. - projected_cadence_s: 60, - projected_end_s: null - }, - { - id: "E2", role: "test", color: "#ff66cc", icon: "★", - dose_used_ms: 6500, dose_budget_ms: 50000, - tp_acquired: 13, - stop_condition: "hatching+3 OR 24h duration", - stop_kind: "bounded", - laser_488_pct_now: 5.0, - phases: [ - { mode: "base", start: 0, end: 8100, cadence_s: 120 } - ], - trigger_events: [], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 8100, pct: 5.0 } - ], - projected_cadence_s: 120, - projected_end_s: null - }, - { - id: "E3", role: "test", color: "#ff66cc", icon: "★", - dose_used_ms: 38000, dose_budget_ms: 50000, - tp_acquired: 76, - stop_condition: "manual", - stop_kind: "open_ended", - laser_488_pct_now: 5.0, - phases: [ - { mode: "base", start: 0, end: 8100, cadence_s: 120 } - ], - trigger_events: [], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 8100, pct: 5.0 } - ], - // Projected dose-exhaust horizon = 4.0h from now (warning condition) - projected_cadence_s: 120, - projected_end_s: null, - dose_exhaust_at_s: 12000 // budget will run out at this elapsed time - }, - { - id: "C1", role: "calibration", color: "#22d3ee", icon: "◆", - dose_used_ms: 33500, dose_budget_ms: 500000, // 10× multiplier - tp_acquired: 67, - stop_condition: "manual", - stop_kind: "open_ended", - laser_488_pct_now: 5.0, - phases: [ - { mode: "base", start: 0, end: 8100, cadence_s: 120 } - ], - trigger_events: [], - power_history_488: [ - { at: 0, pct: 5.0 }, - { at: 8100, pct: 5.0 } - ], - projected_cadence_s: 120, - projected_end_s: null - } - ] -}; const ExperimentOverview = { initialized: false, @@ -164,23 +29,19 @@ const ExperimentOverview = { cache: 'no-store' }); if (!resp.ok) { - console.warn( - '[ExperimentOverview] strategy fetch returned', - resp.status, '- falling back to stub' - ); + // No active experiment / not ready yet — show the empty state, + // never stubbed data. + console.warn('[ExperimentOverview] strategy fetch returned', resp.status); this.isLive = false; - return STUB_STRATEGY; + return null; } const data = await resp.json(); this.isLive = true; return data; } catch (e) { - console.warn( - '[ExperimentOverview] strategy fetch error - falling back to stub:', - e - ); + console.warn('[ExperimentOverview] strategy fetch error:', e); this.isLive = false; - return STUB_STRATEGY; + return null; } }, @@ -193,7 +54,7 @@ const ExperimentOverview = { }); // Re-render against the last fetched strategy (no re-fetch on tab // switch — refresh happens on tab activation in the bootstrap). - this.render(this.activeStrategy || STUB_STRATEGY); + this.render(this.activeStrategy); }, render(s) { @@ -204,6 +65,12 @@ const ExperimentOverview = { } // Tear down any prior ticker before we blow away the SVG it pointed at. this._stopNowTicker(); + if (!s) { + // No active experiment — a calm empty state, never stubbed data. + root.innerHTML = '
' + + 'No active experiment — the imaging tactics (cadence, reactive rules) will appear here once a run is live.
'; + return; + } try { root.innerHTML = ''; if (this.activeView === 'rules') { @@ -301,7 +168,6 @@ const ExperimentOverview = { const metaRow = el('div', 'expov-header-row expov-header-row-meta'); metaRow.appendChild(elText('span', 'expov-session-name', s.session_name)); metaRow.appendChild(elText('span', 'expov-session-id', s.session_id)); - metaRow.appendChild(elText('span', 'expov-mockup-badge', 'mockup · stubbed data')); header.appendChild(metaRow); root.appendChild(header); @@ -330,11 +196,7 @@ const ExperimentOverview = { if (s.session_name && s.session_name !== s.session_id) { metaRow.appendChild(elText('span', 'expov-session-name', s.session_name)); } - if (this.isLive) { - metaRow.appendChild(elText('span', 'expov-live-badge', 'live')); - } else { - metaRow.appendChild(elText('span', 'expov-mockup-badge', 'mockup · stubbed data')); - } + metaRow.appendChild(elText('span', 'expov-live-badge', 'live')); wrap.appendChild(metaRow); // Compact key-metric strip diff --git a/gently/ui/web/static/js/home.js b/gently/ui/web/static/js/home.js index 089d7de3..bf5cad17 100644 --- a/gently/ui/web/static/js/home.js +++ b/gently/ui/web/static/js/home.js @@ -136,7 +136,13 @@ const HomeApp = (() => { function updateStatus() { const el = document.getElementById('home-status'); if (!el) return; - const connected = (typeof state !== 'undefined' && state.connected); + // Read the shared ConnectionStatus store, not a one-shot snapshot of + // state.connected — the latter was read once at tab init (before the + // /ws handshake) and never corrected, showing "Offline" while the + // header pill said "Online". + const connected = (typeof ConnectionStatus !== 'undefined') + ? ConnectionStatus.get().gentlyConnected + : (typeof state !== 'undefined' && state.connected); const n = (typeof state !== 'undefined' && Array.isArray(state.embryos)) ? state.embryos.length : 0; el.textContent = connected ? `Connected · ${n} embryo${n === 1 ? '' : 's'} in view` @@ -162,6 +168,12 @@ const HomeApp = (() => { if (AgentChat.runCommand) setTimeout(() => AgentChat.runCommand('/wizard'), 250); } }); + // Re-render the status line on every connection change. subscribe() + // replays the current snapshot immediately, so a late init still + // renders correct state. Registered once (inside the _inited guard). + if (typeof ConnectionStatus !== 'undefined') { + ConnectionStatus.subscribe(() => updateStatus()); + } } refresh(); // re-fetch on every entry to the tab } diff --git a/gently/ui/web/static/js/landing.js b/gently/ui/web/static/js/landing.js new file mode 100644 index 00000000..6d058ebe --- /dev/null +++ b/gently/ui/web/static/js/landing.js @@ -0,0 +1,876 @@ +/** + * V2Landing (ux_v2): the agent-first welcome AND the in-place plan wizard. + * + * Clicking "Plan an experiment" switches the landing to a plan screen, enters + * plan mode, and renders the agent's work IN THE WIZARD — not the chat REPL: + * - the agent's reasoning + tool calls render as a tidy, claude.ai-style + * collapsible activity feed (#v2-plan-activity), fed by the AGENT_ACTIVITY + * event that agent-chat.js mirrors off the /ws/agent stream; + * - the agent's ask_user_choice questions render as button cards + * (#v2-plan-ask) via AgentChat.buildAskCard; + * - "THE PLAN" panel (#v2-plan-summary) mirrors the REAL plan (phases→tasks) + * fetched from /api/campaigns once a turn settles. + * Chat is the last resort (the escape pill / "Open conversation"). + * + * No-ops unless #v2-landing is present (flag off → v1 untouched, overlay absent). + */ +const V2Landing = (() => { + let el = null; + let current = null; // the ask currently in #v2-plan-ask + let feedTextEl = null; // current accumulating prose paragraph in the feed + let feedThinkingEl = null; // current accumulating reasoning (thinking) block + let runningTools = {}; // tool name -> stack of running card elements + let feedHadContent = false; // did this turn surface anything in the feed? + let capturedCampaignId = null; // best-effort id scraped from tool results + let planProposed = false; // propose_plan ran → plan is ready to commit + + const $ = (id) => document.getElementById(id); + + function greet() { + const g = $('v2-landing-greeting'); + if (!g) return; + const h = new Date().getHours(); + const t = h < 5 ? 'Still here.' : h < 12 ? 'Good morning.' + : h < 18 ? 'Good afternoon.' : 'Good evening.'; + g.innerHTML = t + '
What are we doing today?'; + } + + function setScreen(name) { if (el) el.dataset.screen = name; } + function planActive() { + return !!el && el.dataset.screen === 'plan' && !el.classList.contains('dismissed') + && el.style.display !== 'none'; + } + + function dismiss() { + if (!el || el.classList.contains('dismissed')) return; + el.classList.add('dismissed'); + let done = false; + const finish = () => { + if (done) return; + done = true; + el.style.display = 'none'; + el.setAttribute('aria-hidden', 'true'); + }; + el.addEventListener('transitionend', finish, { once: true }); + setTimeout(finish, 650); + } + + // ── status / error helpers ──────────────────────────────────────── + function setThinkingLabel(text) { + const l = document.querySelector('#v2-plan-thinking .v2-plan-thinking-label'); + if (l && text) l.textContent = text; + } + // Elapsed-time counter so a long think reads as progress, not a hang. Starts + // when the thinking indicator first shows and runs until it's hidden (turn end). + let _thinkTimer = null; + let _thinkStart = 0; + function _thinkTick() { + const t = $('v2-plan-thinking'); + if (!t) return; + let el = t.querySelector('.v2-plan-elapsed'); + if (!el) { + el = document.createElement('span'); + el.className = 'v2-plan-elapsed'; + el.style.cssText = 'margin-left:6px;opacity:.55;font-variant-numeric:tabular-nums;'; + t.appendChild(el); + } + const s = Math.round((Date.now() - _thinkStart) / 1000); + el.textContent = s > 0 ? s + 's' : ''; + } + function showThinking(on, label) { + const t = $('v2-plan-thinking'); + if (t) t.classList.toggle('hidden', !on); + if (on && label) setThinkingLabel(label); + if (on) { + if (!_thinkTimer) { + _thinkStart = Date.now(); + _thinkTick(); + _thinkTimer = setInterval(_thinkTick, 1000); + } + } else if (_thinkTimer) { + clearInterval(_thinkTimer); + _thinkTimer = null; + const el = t && t.querySelector('.v2-plan-elapsed'); + if (el) el.textContent = ''; + } + } + // Human-readable "what's happening right now" from a tool activity event, + // so the status line names the live operation instead of a static string. + function prettyTool(act) { + const raw = (act && (act.label || act.name)) || 'the next step'; + const s = String(raw).replace(/_/g, ' ').trim(); + return s.charAt(0).toUpperCase() + s.slice(1) + '…'; + } + function errorVisible() { const e = $('v2-plan-error'); return !!e && !e.classList.contains('hidden'); } + function showPlanError(msg) { + const e = $('v2-plan-error'); if (!e) return; + e.textContent = msg; e.classList.remove('hidden'); + showThinking(false); + } + function hidePlanError() { const e = $('v2-plan-error'); if (e) e.classList.add('hidden'); } + + function clearAsk() { const m = $('v2-plan-ask'); if (m) m.innerHTML = ''; } + function resetSummary() { + const list = $('v2-plan-summary'); + if (list) list.innerHTML = '
The plan will take shape here as Gently designs it.
'; + planPage = 0; planPages = []; planTitleText = ''; + } + + // ── activity feed: paginated, ONE agent step (turn) per page ─────── + // Instead of one ever-growing scroll, each agent turn — its reasoning + + // the tool calls it made — is a page you flip through with ‹ Prev / Next ›. + // The current question stays pinned below the feed (#v2-plan-ask). A new + // turn auto-advances to its page; you can flip back to review earlier steps. + let feedPages = []; // .v2-act-page elements, one per turn + let feedPage = 0; // index currently shown + let curPageEl = null; // page receiving this turn's content + let pendingNewPage = false; // a turn started; open a fresh page on first content + + function feedEl() { return $('v2-plan-activity'); } + function feedPagesWrap() { return feedEl()?.querySelector('.v2-feed-pages'); } + function clearActivity() { + const f = feedEl(); + if (f) { + f.innerHTML = + '' + + '
' + + ''; + } + feedPages = []; feedPage = 0; curPageEl = null; pendingNewPage = false; + feedTextEl = null; feedThinkingEl = null; runningTools = {}; feedHadContent = false; + capturedCampaignId = null; planProposed = false; + clearPlanReady(); + hidePlanError(); + } + function newFeedPage() { + const wrap = feedPagesWrap(); if (!wrap) return null; + const page = document.createElement('div'); + page.className = 'v2-act-page'; + wrap.appendChild(page); + feedPages.push(page); + curPageEl = page; + feedPage = feedPages.length - 1; // auto-advance to the live step + feedTextEl = null; + drawFeedPager(); + return page; + } + // Where this turn's prose/tool cards land. Opens a fresh page the first time + // content arrives after a turn_start (so content-less command turns don't + // leave empty pages), and lazily on the very first content. + function feedTarget() { + if (pendingNewPage || !curPageEl) { newFeedPage(); pendingNewPage = false; } + return curPageEl; + } + function viewingLatest() { return feedPage >= feedPages.length - 1; } + function drawFeedPager() { + const f = feedEl(); if (!f) return; + const n = feedPages.length; + const i = Math.min(Math.max(feedPage, 0), Math.max(n - 1, 0)); + feedPages.forEach((p, idx) => p.classList.toggle('active', idx === i)); + const bar = f.querySelector('.v2-feed-pager-bar'); + const dots = f.querySelector('.v2-feed-dots'); + if (!bar || !dots) return; + if (n <= 1) { bar.hidden = true; dots.hidden = true; return; } + bar.hidden = false; dots.hidden = false; + bar.innerHTML = ''; + const mkBtn = (txt, disabled, fn) => { + const b = document.createElement('button'); + b.className = 'v2-plan-pager-btn'; b.type = 'button'; b.textContent = txt; + b.disabled = disabled; b.addEventListener('click', fn); + return b; + }; + const pos = document.createElement('span'); + pos.className = 'v2-plan-pager-pos'; pos.textContent = `Step ${i + 1} of ${n}`; + bar.append( + mkBtn('‹ Prev', i === 0, () => { if (feedPage > 0) { feedPage--; drawFeedPager(); } }), + pos, + mkBtn('Next ›', i === n - 1, () => { if (feedPage < n - 1) { feedPage++; drawFeedPager(); } }), + ); + dots.innerHTML = ''; + for (let d = 0; d < n; d++) { + const dot = document.createElement('button'); + dot.className = 'v2-plan-dot' + (d === i ? ' active' : ''); + dot.type = 'button'; + dot.setAttribute('aria-label', `Step ${d + 1} of ${n}`); + dot.addEventListener('click', () => { feedPage = d; drawFeedPager(); }); + dots.appendChild(dot); + } + } + function scrollFeedIfNearBottom() { + if (!viewingLatest()) return; // don't yank the user off an earlier step + const m = document.querySelector('.v2-screen-plan .v2-plan-main'); + if (m && (m.scrollHeight - m.scrollTop - m.clientHeight) < 140) m.scrollTop = m.scrollHeight; + } + function clearFallback() { feedEl()?.querySelectorAll('.v2-plan-fallback').forEach(n => n.remove()); } + + // Render the agent's prose like the chat does (reuses AgentChat.mdToHtml — + // escapes then renders bold/italic/code/line-breaks), so the feed isn't raw + // markdown. Falls back to escaped text if the helper isn't available. + function renderMd(s) { + if (typeof AgentChat !== 'undefined' && AgentChat.mdToHtml) return AgentChat.mdToHtml(s); + const esc = (typeof escapeHtml === 'function') ? escapeHtml(String(s)) : String(s); + return esc.replace(/\n/g, '
'); + } + + // Plan-writing tools → refresh THE PLAN panel during the turn (debounced), + // not only at turn_end (ask_user_choice pauses the turn before it ends). + const PLAN_TOOLS = new Set([ + 'create_campaign', 'create_plan_item', 'link_plan_items', 'update_plan_item', + 'delete_plan_item', 'propose_plan', 'get_plan_status', 'validate_plan', + ]); + let planRefreshTimer = null; + function schedulePlanRefresh() { + if (planRefreshTimer) clearTimeout(planRefreshTimer); + planRefreshTimer = setTimeout(() => { planRefreshTimer = null; refreshPlanPanel(); }, 600); + } + + function safeStringify(v) { + try { + const s = (typeof v === 'string') ? v : JSON.stringify(v, null, 2); + return s.length > 4000 ? s.slice(0, 4000) + '\n…' : s; + } catch (e) { return String(v); } + } + function fillToolBody(body, act) { + body.innerHTML = ''; + // grid-template-rows reveal (landing.css) needs ONE collapsible child — + // append blocks into a single inner wrapper, not directly onto body. + const inner = document.createElement('div'); + body.appendChild(inner); + const inputStr = (act.input != null) ? safeStringify(act.input) : ''; + const full = act.full || act.summary || ''; + const block = (label, text) => { + const l = document.createElement('div'); l.className = 'v2-act-block-label'; l.textContent = label; + const b = document.createElement('pre'); b.className = 'v2-act-block'; b.textContent = text; + inner.append(l, b); + }; + if (inputStr) block('input', inputStr); + if (full) block('result', full); + if (!inputStr && !full) { + const e = document.createElement('div'); e.className = 'v2-act-block-label'; e.textContent = 'no details'; + inner.append(e); + } + } + function buildToolCard(act, done) { + const card = document.createElement('div'); + card.className = 'v2-act-tool' + (done ? (act.is_error ? ' done err' : ' done') : ''); + const head = document.createElement('button'); + head.className = 'v2-act-tool-head'; head.type = 'button'; + head.setAttribute('aria-expanded', 'false'); + const ic = document.createElement('span'); ic.className = 'v2-act-ic'; + ic.innerHTML = done ? (act.is_error ? '⚠' : '✓') : ''; + const label = document.createElement('span'); label.className = 'v2-act-label'; + label.textContent = act.label || act.name || 'tool'; + const sum = document.createElement('span'); sum.className = 'v2-act-summary'; + sum.textContent = done ? (act.summary || '') : ''; + const chev = document.createElement('span'); chev.className = 'v2-act-chev'; chev.textContent = '›'; + head.append(ic, label, sum, chev); + const body = document.createElement('div'); body.className = 'v2-act-tool-body'; + fillToolBody(body, act); + head.addEventListener('click', () => { + const open = card.classList.toggle('open'); + head.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + card.append(head, body); + return card; + } + function updateToolCard(card, act) { + card.classList.add('done'); + if (act.is_error) card.classList.add('err'); + const ic = card.querySelector('.v2-act-ic'); if (ic) ic.textContent = act.is_error ? '⚠' : '✓'; + const sum = card.querySelector('.v2-act-summary'); if (sum) sum.textContent = act.summary || ''; + const body = card.querySelector('.v2-act-tool-body'); if (body) fillToolBody(body, act); + } + function captureCampaignId(text) { + if (!text) return; + const s = String(text); + const m = s.match(/campaign_id[=:\s]+([0-9a-f]{6,})/i) || s.match(/\(id:\s*([0-9a-f]{6,})/i); + if (m) capturedCampaignId = m[1]; + } + + function applyActivity(act) { + if (!planActive() || !act) return; + const f = feedEl(); if (!f) return; + switch (act.kind) { + case 'turn_start': + feedTextEl = null; feedThinkingEl = null; pendingNewPage = true; hidePlanError(); clearFallback(); + clearPlanReady(); // new work in flight — drop any "ready" state + showThinking(true, 'reviewing your campaign and plan…'); + break; + case 'thinking': { + // Stream the model's reasoning summary live into the feed as a dim + // block, so the wait shows what the agent is actually considering. + showThinking(true); + const chunk = act.text || ''; + if (!chunk) { setThinkingLabel('thinking through the next step…'); break; } + if (!feedThinkingEl) { + feedThinkingEl = document.createElement('div'); + feedThinkingEl.className = 'v2-act-think'; + feedThinkingEl.style.cssText = + 'font-style:italic;opacity:.7;white-space:pre-wrap;margin:2px 0 8px;font-size:12.5px;line-height:1.5;'; + feedThinkingEl._raw = ''; + feedTarget().appendChild(feedThinkingEl); + } + feedThinkingEl._raw += chunk; + feedThinkingEl.textContent = feedThinkingEl._raw; + feedHadContent = true; + setThinkingLabel('reasoning…'); + scrollFeedIfNearBottom(); + break; + } + case 'text': { + const chunk = act.text || ''; + if (!chunk) break; + // The reasoning that immediately precedes the spoken answer is + // wrap-up meta ("let me wrap this up concisely and offer to + // export…") — drop the block entirely so the feed keeps the + // answer, not the narration of getting there. Reasoning that + // precedes a TOOL is left in place (tool_start only nulls the + // pointer) as the rationale for that action. + if (feedThinkingEl) { feedThinkingEl.remove(); feedThinkingEl = null; } + if (!feedTextEl) { + feedTextEl = document.createElement('div'); + feedTextEl.className = 'v2-act-text'; + feedTextEl._raw = ''; + feedTarget().appendChild(feedTextEl); + } + feedTextEl._raw += chunk; + feedTextEl.innerHTML = renderMd(feedTextEl._raw); + feedHadContent = true; showThinking(true, 'composing the response…'); scrollFeedIfNearBottom(); + break; + } + case 'tool_start': { + // ask_user_choice IS the active question (rendered separately in + // #v2-plan-ask) — don't also show it as a feed card. + if (act.name === 'ask_user_choice') break; + feedTextEl = null; feedThinkingEl = null; + const card = buildToolCard(act, false); + feedTarget().appendChild(card); + (runningTools[act.name] = runningTools[act.name] || []).push(card); + feedHadContent = true; showThinking(true, prettyTool(act)); scrollFeedIfNearBottom(); + break; + } + case 'tool_result': { + captureCampaignId(act.summary); + captureCampaignId(act.full); + if (PLAN_TOOLS.has(act.name)) schedulePlanRefresh(); + if (act.name === 'propose_plan' && !act.is_error) planProposed = true; + if (act.name === 'ask_user_choice') break; + feedTextEl = null; feedThinkingEl = null; + const arr = runningTools[act.name] || []; + const card = arr.pop(); + if (card) updateToolCard(card, act); + else feedTarget().appendChild(buildToolCard(act, true)); + feedHadContent = true; setThinkingLabel('working through the next step…'); scrollFeedIfNearBottom(); + break; + } + case 'turn_end': + showThinking(false); feedTextEl = null; feedThinkingEl = null; + refreshPlanPanel(); + if (!current && !feedHadContent) showFallback(); + // Plan proposed and the agent has settled (no pending question) → + // the design is done. Surface a clear "ready" state instead of + // leaving the user parked on the last wizard step. + if (planProposed && !current) showPlanReady(); + break; + case 'turn_error': + showPlanError(act.error || 'Something went wrong — open the conversation for detail.'); + break; + } + } + + function showFallback() { + const f = feedEl(); if (!f || f.querySelector('.v2-plan-fallback')) return; + const d = document.createElement('div'); + d.className = 'v2-plan-fallback'; + d.innerHTML = 'Gently replied in prose — open the conversation to read it.'; + d.querySelector('a').addEventListener('click', openChat); + feedTarget().appendChild(d); + } + + // ── plan-ready state ─────────────────────────────────────────────── + // Once the agent has proposed the plan and gone quiet, the wizard is done. + // Mark the screen "ready": rename the header, count phases/items from the + // panel, and promote "open workspace" to the obvious primary action — so the + // finish line is signposted instead of looking like one more wizard step. + function planCounts() { + let phases = 0, items = 0; + planPages.forEach(p => { + if (p.name !== 'Tasks') phases++; + items += (p.items || []).length; + }); + return { phases, items }; + } + function showPlanReady() { + const sec = document.querySelector('.v2-screen-plan'); + if (!sec) return; + sec.classList.add('ready'); + showThinking(false); + const who = sec.querySelector('.v2-plan-who'); + const title = sec.querySelector('.v2-plan-title'); + if (who) who.textContent = 'Gently · plan ready'; + if (title) { + const { phases, items } = planCounts(); + title.textContent = items + ? `Your plan is ready — ${items} item${items === 1 ? '' : 's'} across ${phases} phase${phases === 1 ? '' : 's'}` + : 'Your plan is ready'; + } + const cont = $('v2-plan-continue'); + if (cont) cont.textContent = 'Open the workspace ›'; + const exp = $('v2-plan-export'); + if (exp) exp.hidden = false; // the plan is final → offer the download + } + function clearPlanReady() { + const sec = document.querySelector('.v2-screen-plan'); + if (!sec || !sec.classList.contains('ready')) return; + sec.classList.remove('ready'); + const who = sec.querySelector('.v2-plan-who'); + const title = sec.querySelector('.v2-plan-title'); + if (who) who.textContent = 'Gently · planning'; + if (title) title.textContent = "Let's design your run"; + const cont = $('v2-plan-continue'); + if (cont) cont.textContent = 'Continue in workspace ›'; + const exp = $('v2-plan-export'); + if (exp) exp.hidden = true; + } + + // ── export the finished plan as a shareable markdown doc ──────────── + // Replaces the agent's end-of-plan "want me to export this?" prose with a + // real action: pull the enriched plan tree (/export) and render it to + // markdown client-side so the biologist can drop it in a doc or share it. + function specToLines(spec) { + let s = spec; + if (typeof s === 'string') { try { s = JSON.parse(s); } catch { return s ? ['- ' + s] : []; } } + if (!s || typeof s !== 'object') return []; + const out = []; + const fmt = (v) => Array.isArray(v) ? v.join(', ') : (typeof v === 'object' ? JSON.stringify(v) : String(v)); + const pick = (k, label) => { if (s[k] != null && s[k] !== '') out.push(`- **${label}:** ${fmt(s[k])}`); }; + pick('strain', 'Strain'); pick('goal', 'Goal'); + if (Array.isArray(s.channels) && s.channels.length) { + out.push('- **Channels:** ' + s.channels.map(c => `${c.name || '?'} (${c.excitation_nm || '?'} nm${c.exposure_ms ? `, ${c.exposure_ms} ms` : ''})`).join(', ')); + } + pick('num_slices', 'Slices'); pick('interval_s', 'Interval (s)'); pick('temperature_c', 'Temperature (°C)'); + pick('num_embryos', 'Embryos'); pick('start_stage', 'Start stage'); pick('stop_condition', 'Stop condition'); + pick('criteria', 'Criteria'); pick('success_criteria', 'Success criteria'); + return out; + } + function buildPlanMarkdown(tree) { + const L = []; + L.push(`# ${tree.description || tree.shorthand || 'Experimental plan'}`, ''); + if (tree.target) L.push(`**Goal:** ${tree.target}`, ''); + if (tree.shorthand) L.push(`**Plan ID:** \`${tree.shorthand}\``, ''); + L.push(`_Exported from Gently — ${new Date().toLocaleString()}_`, ''); + const renderItems = (items, prefix) => { + (items || []).slice().sort((a, b) => (a.phase_order || 0) - (b.phase_order || 0)).forEach((it, idx) => { + L.push(`### ${prefix}${idx + 1} ${it.title || '(task)'} \`${it.type || 'task'}\``, ''); + if (it.description) L.push(it.description, ''); + const sl = specToLines(it.spec); + if (sl.length) L.push(...sl, ''); + const refs = it.references || []; + if (refs.length) { + L.push('**References:**'); + refs.forEach((r, i) => L.push(`${i + 1}. ${r.citation || r.id || ''}${r.source ? ` _(${r.source})_` : ''}`)); + L.push(''); + } + }); + }; + if ((tree.items || []).length) { L.push('## Tasks', ''); renderItems(tree.items, ''); } + (tree.children || []).forEach((ph, pi) => { + if (!ph) return; + L.push(`## ${ph.display_name || ph.description || ph.shorthand || `Phase ${pi + 1}`}`, ''); + if (ph.target) L.push(ph.target, ''); + renderItems(ph.items, `${pi + 1}.`); + }); + return L.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; + } + async function resolveCampaignId() { + if (capturedCampaignId) return capturedCampaignId; + try { + const r = await fetch('/api/campaigns'); + if (r.ok) { const d = await r.json(); const t = (d.campaigns || [])[0]; return (t && t.campaign && t.campaign.id) || null; } + } catch (e) { /* offline */ } + return null; + } + async function exportPlan() { + const btn = $('v2-plan-export'); + const id = await resolveCampaignId(); + if (!id) { showPlanError('No plan to export yet.'); return; } + if (btn) { btn.disabled = true; btn.textContent = '↓ Exporting…'; } + try { + const r = await fetch(`/api/campaigns/${encodeURIComponent(id)}/export`); + if (!r.ok) throw new Error(`export ${r.status}`); + const tree = await r.json(); + const md = buildPlanMarkdown(tree); + const blob = new Blob([md], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${(tree.shorthand || 'plan').replace(/[^\w.-]+/g, '_')}.md`; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + showPlanError('Could not export the plan — open the conversation to export it manually.'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '↓ Export plan'; } + } + } + + // ── THE PLAN panel: mirror the real campaign tree ────────────────── + async function refreshPlanPanel() { + try { + let tree = null; + if (capturedCampaignId) { + const r = await fetch(`/api/campaigns/${encodeURIComponent(capturedCampaignId)}/tree`); + if (r.ok) tree = await r.json(); + } + if (!tree) { + const r = await fetch('/api/campaigns'); + if (r.ok) { const d = await r.json(); tree = (d.campaigns || [])[0] || null; } + } + if (tree) renderPlanTree(tree); + } catch (e) { /* keep whatever is shown */ } + } + function planName(c) { + c = c || {}; + return c.shorthand || c.display_name || c.description || 'Plan'; + } + // Phases read better by their human name ("Phase 1 — Reporter validation") + // than by their code shorthand ("nrp-p1"), which looks like a machine id. + function phaseName(c) { + c = c || {}; + return c.display_name || c.description || c.shorthand || 'Phase'; + } + // THE PLAN renders as a pager — one phase per page with ‹ Prev / Next ›, + // a position label, and dots — instead of one long scroll. planPage is held + // across re-renders (the panel refetches on every plan-writing tool) so the + // page you're reading doesn't snap back to the start mid-design. + let planPage = 0; + let planPages = []; // [{ name, items }] + let planTitleText = ''; + + function renderPlanTree(tree) { + if (!tree) return; + const phases = tree.children || []; + const rootItems = tree.items || []; + if (!phases.length && !rootItems.length) return; // nothing to show yet — keep placeholder + const pages = []; + if (rootItems.length) pages.push({ name: 'Tasks', items: rootItems }); + phases.forEach(phase => { + if (!phase) return; + pages.push({ name: phaseName(phase.campaign), items: phase.items || [] }); + }); + planPages = pages; + planTitleText = planName(tree.campaign); + if (planPage >= pages.length) planPage = pages.length - 1; + if (planPage < 0) planPage = 0; + drawPlanPage(); + } + + function drawPlanPage() { + const list = $('v2-plan-summary'); + if (!list) return; + const pages = planPages; + const n = pages.length; + if (!n) return; + const i = Math.min(Math.max(planPage, 0), n - 1); + const page = pages[i]; + + list.innerHTML = ''; + const title = document.createElement('div'); + title.className = 'v2-plan-title-row'; + title.textContent = planTitleText; + list.appendChild(title); + + if (n > 1) { + const bar = document.createElement('div'); + bar.className = 'v2-plan-pager'; + const prev = document.createElement('button'); + prev.className = 'v2-plan-pager-btn'; prev.type = 'button'; prev.textContent = '‹ Prev'; + prev.disabled = i === 0; + prev.addEventListener('click', () => { if (planPage > 0) { planPage--; drawPlanPage(); } }); + const pos = document.createElement('span'); + pos.className = 'v2-plan-pager-pos'; + pos.textContent = page.name; // position shown by the dots below + pos.title = `${page.name} · ${i + 1} of ${n}`; + const next = document.createElement('button'); + next.className = 'v2-plan-pager-btn'; next.type = 'button'; next.textContent = 'Next ›'; + next.disabled = i === n - 1; + next.addEventListener('click', () => { if (planPage < n - 1) { planPage++; drawPlanPage(); } }); + bar.append(prev, pos, next); + list.appendChild(bar); + } else { + const h = document.createElement('div'); + h.className = 'v2-plan-phase-h'; + h.textContent = page.name; + list.appendChild(h); + } + + const tasks = document.createElement('div'); + tasks.className = 'v2-plan-phase'; + const items = page.items || []; + // phase ordinal (1-based) for "P.I" numbering; the rootItems "Tasks" page + // isn't a phase, so it numbers items bare (1, 2, …). + const phaseOrd = pages.slice(0, i + 1).filter(p => p.name !== 'Tasks').length; + if (items.length) { + items.forEach((it, idx) => { + const type = String(it.type || '').toLowerCase(); + const t = document.createElement('div'); + t.className = 'v2-plan-task type-' + (type || 'other'); + const num = document.createElement('span'); + num.className = 'v2-task-num'; + num.textContent = phaseOrd ? `${phaseOrd}.${idx + 1}` : `${idx + 1}`; + const dot = document.createElement('span'); + dot.className = 'v2-task-dot'; + dot.title = type || 'task'; + const ttl = document.createElement('span'); + ttl.className = 'v2-task-ttl'; + ttl.textContent = it.title || it.shorthand || '(task)'; + t.append(num, dot, ttl); + if (it.estimated_days) { + const d = document.createElement('span'); + d.className = 'v2-task-days'; + d.textContent = `${it.estimated_days}d`; + t.append(d); + } + tasks.appendChild(t); + }); + } else { + const e = document.createElement('div'); + e.className = 'v2-plan-task v2-plan-task-empty'; + e.textContent = 'no items in this phase yet'; + tasks.appendChild(e); + } + list.appendChild(tasks); + + if (n > 1) { + const dots = document.createElement('div'); + dots.className = 'v2-plan-dots'; + for (let d = 0; d < n; d++) { + const dot = document.createElement('button'); + dot.className = 'v2-plan-dot' + (d === i ? ' active' : ''); + dot.type = 'button'; + dot.setAttribute('aria-label', `Go to ${pages[d].name} (${d + 1} of ${n})`); + dot.addEventListener('click', () => { planPage = d; drawPlanPage(); }); + dots.appendChild(dot); + } + list.appendChild(dots); + } + } + + // ── ask rendering (the active question) ──────────────────────────── + function labelFor(data, sel) { + const opts = (data && data.options) || []; + const one = (s) => { + const o = opts.find(o => o && (o.id === s || o.value === s || o.label === s)); + return o ? o.label : String(s); + }; + return Array.isArray(sel) ? sel.map(one).join(', ') : one(sel); + } + function recordPick(data, sel) { + const list = $('v2-plan-summary'); + if (!list) return; + const empty = list.querySelector('.v2-plan-side-empty'); + if (empty) empty.remove(); + const matched = (data && data.options || []).some(o => o && (o.id === sel || o.value === sel || o.label === sel)); + const row = document.createElement('div'); + row.className = 'v2-plan-row' + (matched ? '' : ' v2-plan-row-freetext'); + row.innerHTML = ''; + row.querySelector('.k').textContent = (data && data.question) || 'Choice'; + row.querySelector('.v').textContent = labelFor(data, sel); + list.appendChild(row); + } + function renderAsk() { + const mount = $('v2-plan-ask'); + if (!mount || !current || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) return; + showThinking(false); hidePlanError(); clearFallback(); + const data = current.data, reqId = current.reqId; + const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true; + const card = AgentChat.buildAskCard(data, { + reqId, isWake: current.isWake, hasControl, + onPick: (sel) => { + recordPick(data, sel); + AgentChat.answerChoice(reqId, sel); + current = null; clearAsk(); showThinking(true); + }, + }); + mount.innerHTML = ''; + mount.appendChild(card); + const first = mount.querySelector('button:not([disabled])'); + if (first) setTimeout(() => first.focus(), 30); + } + + let planKickedOff = false; // guard: design-kickoff fires once per session + async function startPlan() { + setScreen('plan'); + // Re-entering the wizard (Back → Plan again) must NOT re-fire the + // kickoff — that stacked duplicate "/plan" + design turns. Just show + // the wizard with its existing state. + if (planKickedOff) return; + planKickedOff = true; + resetSummary(); clearAsk(); clearActivity(); + current = null; + showThinking(true); + // Campaigns are persistent agent memory (not session state), so the + // agent always builds on an existing one — which leaves a user wanting a + // fresh plan stuck. So if an active campaign exists, ask up front: + // continue it (the default) or start a brand-new one. With NO campaign + // there's nothing to continue, so skip the gate and design straight away + // (that path is fresh anyway). + let campaign = null; + try { + const r = await fetch('/api/campaigns'); + if (r.ok) { const d = await r.json(); campaign = (d.campaigns || [])[0] || null; } + } catch (e) { /* offline / no API — just design */ } + if (campaign) renderCampaignChoice(campaign); + else kickoffDesign('continue'); + } + + // Enter plan mode, then prompt design. The prompt differs by intent: build + // on the active campaign, or set it aside and create a new one. A free-typed + // answer from the choice card becomes the design brief directly. + function kickoffDesign(mode) { + showThinking(true); + if (typeof AgentChat === 'undefined' || !AgentChat.runCommand) return; + AgentChat.runCommand('/plan'); + if (mode === 'fresh') { + AgentChat.runCommand( + "I want to start a brand-new experiment, not continue any existing " + + "campaign. Create a new campaign and let's design it from scratch — " + + "what should we capture?" + ); + } else if (mode === 'continue') { + AgentChat.runCommand("Let's design this run — what should it capture?"); + } else { + // free text from the choice card's "Something else…" escape + AgentChat.runCommand(String(mode)); + } + } + + // Continue-vs-fresh gate, shown only when an active campaign exists. Reuses + // the agent ask-card styling so it's visually identical to the agent's own + // questions; picking routes into kickoffDesign rather than the agent bridge. + function renderCampaignChoice(tree) { + const mount = $('v2-plan-ask'); + if (!mount || typeof AgentChat === 'undefined' || !AgentChat.buildAskCard) { + kickoffDesign('continue'); + return; + } + showThinking(false); hidePlanError(); clearFallback(); + const name = planName((tree && tree.campaign) || {}); + const data = { + question: `You have an active campaign — **${name}**. Design the next run inside it, or start something new?`, + options: [ + { id: 'continue', label: `Continue ${name}`, description: 'Design the next run inside your existing campaign' }, + { id: 'fresh', label: 'Start a brand-new campaign', description: 'Set the existing plan aside and plan from scratch' }, + ], + }; + const hasControl = AgentChat.hasControl ? AgentChat.hasControl() : true; + const card = AgentChat.buildAskCard(data, { + reqId: 'landing-campaign-choice', isWake: false, hasControl, + onPick: (sel) => { clearAsk(); kickoffDesign(sel); }, + }); + mount.innerHTML = ''; + mount.appendChild(card); + const first = mount.querySelector('button:not([disabled])'); + if (first) setTimeout(() => first.focus(), 30); + } + + function openScope() { + dismiss(); + if (typeof switchTab === 'function') switchTab('devices'); + } + function openChat() { + dismiss(); + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + setTimeout(() => AgentChat.togglePanel(true), 300); + } + } + function sendFreeform(text) { + const v = (text || '').trim(); + dismiss(); + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + AgentChat.togglePanel(true); + if (v && AgentChat.runCommand) setTimeout(() => AgentChat.runCommand(v), 300); + } + } + + function init() { + el = $('v2-landing'); + if (!el || typeof ClientEventBus === 'undefined') return; // flag off → no-op + greet(); + + el.querySelectorAll('[data-landing]').forEach(btn => btn.addEventListener('click', () => { + const kind = btn.dataset.landing; + if (kind === 'plan') startPlan(); + else if (kind === 'standalone') openScope(); + })); + + const esc = $('v2-escape'), escToggle = $('v2-escape-toggle'), + escInput = $('v2-escape-input'), escSend = $('v2-escape-send'); + if (escToggle && esc && escInput) { + escToggle.addEventListener('click', () => { + const open = esc.classList.toggle('open'); + escToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + if (open) setTimeout(() => escInput.focus(), 120); + }); + const submit = () => sendFreeform(escInput.value); + if (escSend) escSend.addEventListener('click', submit); + escInput.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); submit(); } + else if (e.key === 'Escape') { e.stopPropagation(); esc.classList.remove('open'); escToggle.setAttribute('aria-expanded', 'false'); } + }); + } + + const skip = $('v2-landing-skip'); + if (skip) skip.addEventListener('click', dismiss); + + const back = $('v2-plan-back'); + if (back) back.addEventListener('click', () => setScreen('welcome')); + const planChat = $('v2-plan-chat'); + if (planChat) planChat.addEventListener('click', openChat); + const cont = $('v2-plan-continue'); + if (cont) cont.addEventListener('click', dismiss); + const exp = $('v2-plan-export'); + if (exp) exp.addEventListener('click', exportPlan); + + // The agent's questions + work render in the plan stage while it's active; + // once we've receded into the workspace, AskStage (#ask-stage) takes over. + ClientEventBus.on('AGENT_ASK', ({ request_id, choice_data, origin }) => { + if (!planActive()) return; + current = { reqId: request_id, data: choice_data || {}, isWake: origin === 'wake' }; + renderAsk(); + }); + ClientEventBus.on('ASK_CLEARED', ({ request_id }) => { + if (request_id === '*' || (current && request_id === current.reqId)) { + current = null; clearAsk(); + if (planActive() && !errorVisible()) showThinking(true); + // A question was just answered — the agent's continuation is the + // next step, so open a fresh feed page for it. (A turn stays one + // stream across an ask_user_choice pause, so turn_start alone + // would lump every step of the design into a single page.) + pendingNewPage = true; + } + }); + ClientEventBus.on('AGENT_CONTROL', () => { if (current && planActive()) renderAsk(); }); + ClientEventBus.on('AGENT_ACTIVITY', (act) => applyActivity(act)); + + document.addEventListener('keydown', e => { + if (e.key !== 'Escape' || !el || el.classList.contains('dismissed')) return; + if (el.dataset.screen === 'plan') setScreen('welcome'); // step back, don't bail + else dismiss(); + }); + } + + document.addEventListener('DOMContentLoaded', init); + + return { + dismiss, + show: () => { + if (!el) return; + el.style.display = ''; + el.removeAttribute('aria-hidden'); + el.classList.remove('dismissed'); + setScreen('welcome'); + greet(); + }, + }; +})(); diff --git a/gently/ui/web/static/js/notebook.js b/gently/ui/web/static/js/notebook.js new file mode 100644 index 00000000..1130a31a --- /dev/null +++ b/gently/ui/web/static/js/notebook.js @@ -0,0 +1,196 @@ +/** + * NotebookApp — the LIBRARY "Notebook" tab. + * + * The reading room for the shared lab notebook: a thread rail (the inquiry + * spine) + kind filter, rendering Notes from the read API (/api/notebook). + * Read-only for now; authoring/curation arrive in a later increment. + */ +const NotebookApp = (() => { + let inited = false; + let kindFilter = ''; // '' | observation | finding | question + let threadFilter = ''; // '' = all notes + + const $ = (id) => document.getElementById(id); + + async function fetchJSON(url) { + try { + const r = await fetch(url); + if (!r.ok) return null; + return await r.json(); + } catch (e) { + return null; + } + } + + function kindMeta(kind) { + return ({ + observation: { label: 'Observation', cls: 'nb-k-obs' }, + finding: { label: 'Finding', cls: 'nb-k-find' }, + question: { label: 'Question', cls: 'nb-k-q' }, + })[kind] || { label: kind || 'note', cls: '' }; + } + + async function loadThreads() { + const rail = $('nb-threads'); + if (!rail) return; + const data = await fetchJSON('/api/notebook/threads'); + const threads = (data && data.threads) || []; + rail.innerHTML = ''; + const mk = (id, label, count, active) => { + const b = document.createElement('button'); + b.className = 'nb-thread' + (active ? ' active' : ''); + b.textContent = label + (count != null ? ` ${count}` : ''); + b.addEventListener('click', () => { threadFilter = id; loadThreads(); loadNotes(); }); + return b; + }; + rail.appendChild(mk('', 'All notes', null, threadFilter === '')); + threads.forEach(t => rail.appendChild(mk(t.id, t.id, t.count, threadFilter === t.id))); + } + + function card(n) { + const km = kindMeta(n.kind); + const el = document.createElement('div'); + el.className = 'nb-card'; + + const head = document.createElement('div'); + head.className = 'nb-card-head'; + const badge = document.createElement('span'); + badge.className = 'nb-badge ' + km.cls; + badge.textContent = km.label; + const author = document.createElement('span'); + author.className = 'nb-author'; + author.textContent = n.author || ''; + const status = document.createElement('span'); + status.className = 'nb-status'; + status.textContent = n.status || ''; + head.append(badge, author, status); + + const body = document.createElement('div'); + body.className = 'nb-body-text'; + body.textContent = n.title || n.body || ''; + + el.append(head, body); + + const chips = [] + .concat((n.strains || []).map(s => '🧬 ' + s)) + .concat((n.embryos || []).map(e => '◌ ' + e)) + .concat((n.threads || []).map(t => '# ' + t)); + if (chips.length) { + const row = document.createElement('div'); + row.className = 'nb-chips'; + chips.forEach(text => { + const c = document.createElement('span'); + c.className = 'nb-chip'; + c.textContent = text; + row.appendChild(c); + }); + el.appendChild(row); + } + return el; + } + + async function loadNotes() { + const list = $('nb-notes'); + if (!list) return; + const params = new URLSearchParams(); + if (kindFilter) params.set('kind', kindFilter); + if (threadFilter) params.set('thread', threadFilter); + const qs = params.toString(); + const data = await fetchJSON('/api/notebook/notes' + (qs ? `?${qs}` : '')); + if (!data || data.available === false) { + list.innerHTML = '
Notebook unavailable.
'; + return; + } + const notes = data.notes || []; + if (!notes.length) { + list.innerHTML = + '
No notes yet — the notebook fills as the agent ' + + 'records observations, findings, and open questions.
'; + return; + } + list.innerHTML = ''; + notes.forEach(n => list.appendChild(card(n))); + } + + function setupFilters() { + document.querySelectorAll('#notebook-content [data-nb-kind]').forEach(btn => { + btn.addEventListener('click', () => { + kindFilter = btn.dataset.nbKind; + document.querySelectorAll('#notebook-content [data-nb-kind]') + .forEach(b => b.classList.toggle('active', b === btn)); + loadNotes(); + }); + }); + } + + // ── Ask the notebook ─────────────────────────────────────────────── + function renderAskResult(data) { + const box = $('nb-ask-result'); + if (!box) return; + box.hidden = false; + if (!data || data.available === false) { + box.innerHTML = '
The notebook is unavailable right now.
'; + return; + } + const cov = data.coverage || 'covered'; + const covLabel = { covered: 'Grounded in the notebook', partial: 'Partially covered', not_in_notebook: 'Not in the notebook yet' }[cov] || cov; + const points = (data.points || []).map(p => ` +
  • + ${esc(p.text)} + ${(p.note_ids || []).map(id => `${esc(id)}`).join('')} +
  • `).join(''); + const nexts = (data.suggested_next || []).map(s => `
  • ${esc(s)}
  • `).join(''); + box.innerHTML = + `
    ${esc(covLabel)}
    ` + + `
    ${esc(data.answer || '')}
    ` + + (points ? `
    Why
    ` : '') + + (nexts ? `
    Try next
    ` : ''); + } + + async function ask() { + const input = $('nb-ask-input'); + const box = $('nb-ask-result'); + const q = (input && input.value || '').trim(); + if (!q) return; + if (box) { box.hidden = false; box.innerHTML = '
    Thinking over the notebook…
    '; } + 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 @@
    Plans Sessions + 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

    + + +
    @@ -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
    +
    +
    Embryo 1on track
    4-cell · imaging normally
    +
    Embryo 2dividing
    sped up to every 30 s
    +
    Embryo 3stalled
    40 min quiet · flagged above
    +
    +
    +
    +
    + +
    +
    + + + +