From dedc4189be2c091eda2665eb3f28a6836bab4ada Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:01:51 +0530 Subject: [PATCH 01/22] Design doc: active shared lab notebook (memory model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the brainstormed design for gently's memory substrate — the agent+human shared lab notebook (concept trace: gently-project/gently#52): - 3-kind model (Observation/Finding/Question) with author/status/confidence/ scope/links/artifacts as orthogonal fields, not kinds - working-memory vs durable-notebook lifespan split; Question as the hinge between the notebook (knowing) and the plan layer (doing) - two-faced presentation: Notebook tab in LIBRARY + Agent's View reborn as the ambient live edge - retrieval principle: structure→indexes, meaning→models, grounded structured output; embeddings as a rebuildable sidecar - what we keep from the old agent mind vs what we fix (dormant apply_updates loop, siloed types, no provenance/links/consolidation) - build decomposition into 5 increments; first slice = the foundation keystone Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-16-shared-lab-notebook-design.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md 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. From e89161fee45f649a98b418bc501f7a5fa6b71e40 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:07:12 +0530 Subject: [PATCH 02/22] =?UTF-8?q?plan:=20notebook=20foundation=20(Note=20m?= =?UTF-8?q?odel=20+=20NotebookStore)=20=E2=80=94=20increment=201=20keyston?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-16-notebook-foundation.md | 645 ++++++++++++++++++ 1 file changed, 645 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-notebook-foundation.md 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). ✓ From 60cd40a49cb9abf2c4528e669df74271fa099f15 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:12:56 +0530 Subject: [PATCH 03/22] feat(notebook): unified Note model + dict serialization --- gently/harness/memory/notebook.py | 109 ++++++++++++++++++++++++++++++ tests/test_notebook_store.py | 47 +++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 gently/harness/memory/notebook.py create mode 100644 tests/test_notebook_store.py diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py new file mode 100644 index 00000000..49e35b1c --- /dev/null +++ b/gently/harness/memory/notebook.py @@ -0,0 +1,109 @@ +""" +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 copy +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 + + +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"]), + ) diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py new file mode 100644 index 00000000..fa04a667 --- /dev/null +++ b/tests/test_notebook_store.py @@ -0,0 +1,47 @@ +"""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" From d15607dc62f81e76c46874c3f1661bb68fa4b20d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:15:26 +0530 Subject: [PATCH 04/22] feat(notebook): NotebookStore write_note/get_note with atomic YAML --- gently/harness/memory/notebook.py | 60 ++++++++++++++++++++++++++++++- tests/test_notebook_store.py | 26 ++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index 49e35b1c..f3ec01e5 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -8,7 +8,6 @@ from __future__ import annotations -import copy import os import re import uuid @@ -107,3 +106,62 @@ def note_from_dict(d: dict[str, Any]) -> Note: created_at=datetime.fromisoformat(d["created_at"]), updated_at=datetime.fromisoformat(d["updated_at"]), ) + + +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 diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index fa04a667..c157ea81 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -8,6 +8,7 @@ Note, NoteKind, NoteStatus, + NotebookStore, note_from_dict, note_to_dict, ) @@ -45,3 +46,28 @@ def test_round_trip_full(self): 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 From 5225811727179ba401690c92450fac8cf5a55262 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:17:39 +0530 Subject: [PATCH 05/22] feat(notebook): rebuildable reverse-indexes by strain/embryo/thread --- gently/harness/memory/notebook.py | 33 +++++++++++++++++++++++++++++++ tests/test_notebook_store.py | 24 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index f3ec01e5..3f776e68 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -112,12 +112,44 @@ 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 @@ -157,6 +189,7 @@ def write_note(self, note: Note) -> str: 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: diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index c157ea81..75b85381 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -71,3 +71,27 @@ def test_get_note_round_trip(self, tmp_path): 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] From 9ba23c9d3e34f3bfae0e85cf8827f6ea93c1a9bc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:19:30 +0530 Subject: [PATCH 06/22] feat(notebook): query_notes by kind/author/status/scope --- gently/harness/memory/notebook.py | 46 +++++++++++++++++++++++++++++++ tests/test_notebook_store.py | 38 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index 3f776e68..0678d9f4 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -198,3 +198,49 @@ def get_note(self, note_id: str) -> Note | 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 diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index 75b85381..492aa7fd 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -95,3 +95,41 @@ def test_rebuild_index_from_disk(self, tmp_path): # 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) From 458810ad36e0d99f6615b1174cd601d8c9497935 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:22:09 +0530 Subject: [PATCH 07/22] feat(notebook): link_notes + supersede_note (append-only history) --- gently/harness/memory/notebook.py | 22 ++++++++++++++++++++++ tests/test_notebook_store.py | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index 0678d9f4..f6e23b4d 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -244,3 +244,25 @@ def query_notes( 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/tests/test_notebook_store.py b/tests/test_notebook_store.py index 492aa7fd..3a7b5294 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -133,3 +133,24 @@ def test_query_all_sorted_newest_first(self, tmp_path): 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 From fed68218b868c8622dd14846295ae525cb212ab9 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:33:35 +0530 Subject: [PATCH 08/22] plan: notebook producer bridge (apply_updates -> notebook) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-16-notebook-producer-bridge.md | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md 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`). ✓ From 9ca55930ae7e910ac340a8b2efd78de4f79d20e9 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:36:02 +0530 Subject: [PATCH 09/22] feat(notebook): Observation/Learning -> Note converters --- gently/harness/memory/notebook.py | 32 ++++++++++++++++++++++++++++++- tests/test_notebook_store.py | 32 ++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py index f6e23b4d..7f3176d9 100644 --- a/gently/harness/memory/notebook.py +++ b/gently/harness/memory/notebook.py @@ -19,7 +19,7 @@ import yaml -from .model import Confidence +from .model import Confidence, Learning, Observation class NoteKind(str, Enum): @@ -108,6 +108,36 @@ def note_from_dict(d: dict[str, Any]) -> Note: ) +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).""" diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index 3a7b5294..07867b5f 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -2,15 +2,17 @@ from datetime import datetime -from gently.harness.memory.model import Confidence +from gently.harness.memory.model import Confidence, Learning, Observation from gently.harness.memory.notebook import ( Author, Note, NoteKind, NoteStatus, NotebookStore, + learning_to_note, note_from_dict, note_to_dict, + observation_to_note, ) @@ -154,3 +156,31 @@ def test_supersede_marks_old_and_points_new(self, tmp_path): 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 From 19c2ff4d1dcfc6207a0229d10fdb3a36b04aff5b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:37:43 +0530 Subject: [PATCH 10/22] feat(notebook): FileContextStore.notebook lazy property --- gently/harness/memory/file_store.py | 11 +++++++++++ tests/test_notebook_store.py | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index c19f785b..71752461 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -2175,6 +2175,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) diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index 07867b5f..f0f48219 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -184,3 +184,12 @@ def test_learning_to_note(self): 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 From 9224b0fbffd01f5574e46499fa0d737d529cad2f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:39:25 +0530 Subject: [PATCH 11/22] feat(notebook): apply_updates mirrors observations & learnings into notebook --- gently/harness/memory/file_store.py | 12 ++++++++++++ tests/test_notebook_store.py | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 71752461..7c153073 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -2213,6 +2213,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 # ================================================================== diff --git a/tests/test_notebook_store.py b/tests/test_notebook_store.py index f0f48219..4fdd48f7 100644 --- a/tests/test_notebook_store.py +++ b/tests/test_notebook_store.py @@ -193,3 +193,26 @@ def test_notebook_property_rooted_under_agent_dir(self, file_context_store): 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() == [] From 03ea7c621128679e1a966f2867646f9c5a11a35f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:44:54 +0530 Subject: [PATCH 12/22] plan: notebook read API Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-16-notebook-read-api.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-notebook-read-api.md 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`. ✓ From 7d51e20a8fb93f04677b61849e1b4ae9aab34635 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:47:34 +0530 Subject: [PATCH 13/22] =?UTF-8?q?feat(notebook):=20read=20API=20=E2=80=94?= =?UTF-8?q?=20GET=20/api/notebook/notes=20+=20/notes/{id}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/routes/__init__.py | 2 + gently/ui/web/routes/notebook.py | 61 ++++++++++++++++++++++++++++ tests/test_notebook_api.py | 69 ++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 gently/ui/web/routes/notebook.py create mode 100644 tests/test_notebook_api.py diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index bdbf3db7..9f8cd903 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -14,6 +14,7 @@ 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 @@ -35,6 +36,7 @@ def register_all_routes(server): 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/notebook.py b/gently/ui/web/routes/notebook.py new file mode 100644 index 00000000..a285a3d4 --- /dev/null +++ b/gently/ui/web/routes/notebook.py @@ -0,0 +1,61 @@ +"""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 diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py new file mode 100644 index 00000000..a6e088f1 --- /dev/null +++ b/tests/test_notebook_api.py @@ -0,0 +1,69 @@ +"""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 From 1a0121df46e2e420ac74e4e147caed42cff84c59 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 16:48:56 +0530 Subject: [PATCH 14/22] =?UTF-8?q?feat(notebook):=20read=20API=20=E2=80=94?= =?UTF-8?q?=20GET=20/api/notebook/threads=20with=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/routes/notebook.py | 12 ++++++++++++ tests/test_notebook_api.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py index a285a3d4..a0176630 100644 --- a/gently/ui/web/routes/notebook.py +++ b/gently/ui/web/routes/notebook.py @@ -58,4 +58,16 @@ async def get_note(note_id: str): 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} + return router diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py index a6e088f1..7510d5b7 100644 --- a/tests/test_notebook_api.py +++ b/tests/test_notebook_api.py @@ -67,3 +67,19 @@ 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}] From 0760578df5ae820b8b9c39ce2089120d352e497d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 17:58:13 +0530 Subject: [PATCH 15/22] feat(notebook): Notebook tab (read view) in LIBRARY rail Thread rail + kind filter + note cards (color-coded by kind, with author, status, and scope chips), consuming /api/notebook. Wired into the v2 rail and the legacy tab bar, lazy-init via switchTab, live-refresh on CONTEXT_UPDATED. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/css/notebook.css | 62 +++++++++++ gently/ui/web/static/js/app.js | 7 +- gently/ui/web/static/js/notebook.js | 142 ++++++++++++++++++++++++++ gently/ui/web/static/js/utils.js | 2 +- gently/ui/web/templates/_navbar.html | 2 + gently/ui/web/templates/index.html | 22 ++++ 6 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 gently/ui/web/static/css/notebook.css create mode 100644 gently/ui/web/static/js/notebook.js diff --git a/gently/ui/web/static/css/notebook.css b/gently/ui/web/static/css/notebook.css new file mode 100644 index 00000000..14810510 --- /dev/null +++ b/gently/ui/web/static/css/notebook.css @@ -0,0 +1,62 @@ +/* ── 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; } + +.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/js/app.js b/gently/ui/web/static/js/app.js index a8c6450c..41bad885 100644 --- a/gently/ui/web/static/js/app.js +++ b/gently/ui/web/static/js/app.js @@ -100,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(); } @@ -651,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/notebook.js b/gently/ui/web/static/js/notebook.js new file mode 100644 index 00000000..2fe94c6c --- /dev/null +++ b/gently/ui/web/static/js/notebook.js @@ -0,0 +1,142 @@ +/** + * 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(); + }); + }); + } + + function refresh() { loadThreads(); loadNotes(); } + + function init() { + if (inited) { refresh(); return; } + inited = true; + setupFilters(); + 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/utils.js b/gently/ui/web/static/js/utils.js index 8b4a3d79..b321506c 100644 --- a/gently/ui/web/static/js/utils.js +++ b/gently/ui/web/static/js/utils.js @@ -3,7 +3,7 @@ // ══════════════════════════════════════════════════════════ // 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 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 cd0b1fba..242e5441 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -18,6 +18,7 @@ + {% if ux_v2 %} @@ -122,6 +123,7 @@

Take a quick look

Library
+
System
@@ -409,6 +411,25 @@

Experiment

{% include '_sessions_panel.html' %}
+ +
+
+
+

Notebook

+
+ + + + +
+
+
+ +
+
+
+
+
@@ -785,6 +806,7 @@

Properties

+ From 1c87896382ecbce01dee31eb78a1f23c7821c7b1 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 18:02:38 +0530 Subject: [PATCH 16/22] plan: notebook live edge (Agent's view recent notes) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-16-notebook-live-edge.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-notebook-live-edge.md 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. ✓ From 9b28fa8b3d5a3b1e4fc2c733d274419953b2b306 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 16 Jun 2026 18:04:31 +0530 Subject: [PATCH 17/22] feat(notebook): limit param on GET /api/notebook/notes --- gently/ui/web/routes/notebook.py | 3 +++ tests/test_notebook_api.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py index a0176630..b1585ff8 100644 --- a/gently/ui/web/routes/notebook.py +++ b/gently/ui/web/routes/notebook.py @@ -34,6 +34,7 @@ async def list_notes( strain: str | None = None, embryo: str | None = None, thread: str | None = None, + limit: int | None = None, ): nb = _nb() if nb is None: @@ -46,6 +47,8 @@ async def list_notes( 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}") diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py index 7510d5b7..b24abbd9 100644 --- a/tests/test_notebook_api.py +++ b/tests/test_notebook_api.py @@ -83,3 +83,10 @@ def test_thread_counts(self, file_context_store): 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 From b6439724442c54e1a7a821ebb19bd85b3447bcd6 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:01:24 +0530 Subject: [PATCH 18/22] =?UTF-8?q?feat(notebook):=20Agent's-view=20live=20e?= =?UTF-8?q?dge=20=E2=80=94=20recent=20notes=20section=20=E2=86=92=20Notebo?= =?UTF-8?q?ok=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/static/js/context-surface.js | 33 ++++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/gently/ui/web/static/js/context-surface.js b/gently/ui/web/static/js/context-surface.js index 2e10000d..2c11e27e 100644 --- a/gently/ui/web/static/js/context-surface.js +++ b/gently/ui/web/static/js/context-surface.js @@ -19,23 +19,29 @@ const ContextSurface = (() => { async function fetchAndRender() { if (!el || loading) return; loading = true; - try { render(await (await fetch('/api/context')).json()); } - catch (e) { /* keep last render */ } + 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) { + 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) { + 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 expectations, watchpoints, and open questions appear here as it works.
'; + '
Nothing yet — the agent’s notes, expectations, and open questions appear here as it works.
'; return; } @@ -59,8 +65,17 @@ const ContextSurface = (() => { ${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('Open questions', qHtml) + section('Watching', wHtml) + + section('Expectations', eHtml) + section('From the notebook', nHtml); wire(); } @@ -83,6 +98,12 @@ const ContextSurface = (() => { 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) { From 814e322f9722cffdf58d3a342d965effd4181e9d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:17:39 +0530 Subject: [PATCH 19/22] plan: ask the notebook (increment 2 backend) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-17-notebook-ask.md | 408 ++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-17-notebook-ask.md 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. ✓ From 8928084f513916f7378f52418a10533fa9973024 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:19:33 +0530 Subject: [PATCH 20/22] =?UTF-8?q?feat(notebook):=20ask=20backend=20?= =?UTF-8?q?=E2=80=94=20select=5Fnotes=20retrieval=20+=20forced-tool=20grou?= =?UTF-8?q?nded=20synthesis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/harness/memory/notebook_ask.py | 112 ++++++++++++++++++++++++++ tests/test_notebook_ask.py | 87 ++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 gently/harness/memory/notebook_ask.py create mode 100644 tests/test_notebook_ask.py diff --git a/gently/harness/memory/notebook_ask.py b/gently/harness/memory/notebook_ask.py new file mode 100644 index 00000000..acfed322 --- /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/tests/test_notebook_ask.py b/tests/test_notebook_ask.py new file mode 100644 index 00000000..9f501090 --- /dev/null +++ b/tests/test_notebook_ask.py @@ -0,0 +1,87 @@ +"""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 From 13b4dd245c5b540d1cc1dd7d56ae63f854a563c8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:21:20 +0530 Subject: [PATCH 21/22] =?UTF-8?q?feat(notebook):=20POST=20/api/notebook/as?= =?UTF-8?q?k=20=E2=80=94=20grounded=20notebook=20Q&A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/routes/notebook.py | 25 +++++++++++++- tests/test_notebook_api.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py index b1585ff8..b9cb27da 100644 --- a/gently/ui/web/routes/notebook.py +++ b/gently/ui/web/routes/notebook.py @@ -4,7 +4,7 @@ Read-only here; authoring/curation come in a later increment. """ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Body, HTTPException from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict @@ -73,4 +73,27 @@ async def list_threads(): 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/tests/test_notebook_api.py b/tests/test_notebook_api.py index b24abbd9..1d18333b 100644 --- a/tests/test_notebook_api.py +++ b/tests/test_notebook_api.py @@ -90,3 +90,59 @@ 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} From c7be82a81bd87431c2fa50494d2450fe5e886308 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Wed, 17 Jun 2026 01:39:59 +0530 Subject: [PATCH 22/22] feat(notebook): 'Ask the notebook' box on the Notebook tab Question field -> POST /api/notebook/ask, rendering the grounded answer with a coverage badge, cited 'Why' points (note-id chips), and 'Try next' steps. Asks within the selected thread scope. Verified live against Opus 4.8. --- gently/ui/web/static/css/notebook.css | 42 +++++++++++++++++++++ gently/ui/web/static/js/notebook.js | 54 +++++++++++++++++++++++++++ gently/ui/web/templates/index.html | 6 +++ 3 files changed, 102 insertions(+) diff --git a/gently/ui/web/static/css/notebook.css b/gently/ui/web/static/css/notebook.css index 14810510..fa4cebab 100644 --- a/gently/ui/web/static/css/notebook.css +++ b/gently/ui/web/static/css/notebook.css @@ -19,6 +19,48 @@ .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; } } diff --git a/gently/ui/web/static/js/notebook.js b/gently/ui/web/static/js/notebook.js index 2fe94c6c..1130a31a 100644 --- a/gently/ui/web/static/js/notebook.js +++ b/gently/ui/web/static/js/notebook.js @@ -123,12 +123,66 @@ const NotebookApp = (() => { }); } + // ── 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
      ${points}
    ` : '') + + (nexts ? `
    Try next
      ${nexts}
    ` : ''); + } + + 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') { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 242e5441..8bc8bf06 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -423,6 +423,12 @@

    Notebook

    +
    + + +
    +